mirror of
https://github.com/jech/galene.git
synced 2024-12-27 01:35:48 +01:00
ef0201c94d
We used to copy the RTCPFeeback field from the up track. It is more correct to regenerate it with the exact feedback types that we expect.
1237 lines
24 KiB
Go
1237 lines
24 KiB
Go
package group
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"log"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/pion/ice/v2"
|
|
"github.com/pion/sdp/v3"
|
|
"github.com/pion/webrtc/v3"
|
|
|
|
"github.com/jech/galene/token"
|
|
)
|
|
|
|
var Directory, DataDirectory string
|
|
var UseMDNS bool
|
|
var UDPMin, UDPMax uint16
|
|
|
|
type NotAuthorisedError struct {
|
|
err error
|
|
}
|
|
|
|
func (err *NotAuthorisedError) Error() string {
|
|
if err.err != nil {
|
|
return "not authorised: " + err.err.Error()
|
|
}
|
|
return "not authorised"
|
|
}
|
|
func (err *NotAuthorisedError) Unwrap() error {
|
|
return err.err
|
|
}
|
|
|
|
var ErrDuplicateUsername = &NotAuthorisedError{
|
|
errors.New("this username is taken"),
|
|
}
|
|
|
|
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 != nil && *err.Username != "" {
|
|
m += " by " + *err.Username
|
|
}
|
|
return m
|
|
}
|
|
|
|
type ProtocolError string
|
|
|
|
func (err ProtocolError) Error() string {
|
|
return string(err)
|
|
}
|
|
|
|
type ChatHistoryEntry struct {
|
|
Id string
|
|
Source string
|
|
User *string
|
|
Time time.Time
|
|
Kind string
|
|
Value interface{}
|
|
}
|
|
|
|
const (
|
|
LowBitrate = 100 * 1024
|
|
MinBitrate = LowBitrate * 2
|
|
MaxBitrate = 1024 * 1024 * 1024
|
|
)
|
|
|
|
type Group struct {
|
|
name string
|
|
|
|
mu sync.Mutex
|
|
description *Description
|
|
locked *string
|
|
clients map[string]Client
|
|
history []ChatHistoryEntry
|
|
timestamp time.Time
|
|
data map[string]interface{}
|
|
}
|
|
|
|
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()
|
|
if locked {
|
|
g.locked = &message
|
|
} else {
|
|
g.locked = nil
|
|
}
|
|
clients := g.getClientsUnlocked(nil)
|
|
g.mu.Unlock()
|
|
|
|
for _, c := range clients {
|
|
c.Joined(g.Name(), "change")
|
|
}
|
|
}
|
|
|
|
func (g *Group) Data() map[string]interface{} {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
return g.data
|
|
}
|
|
|
|
func (g *Group) UpdateData(d map[string]interface{}) {
|
|
g.mu.Lock()
|
|
if g.data == nil {
|
|
g.data = make(map[string]interface{})
|
|
}
|
|
for k, v := range d {
|
|
if v == nil {
|
|
delete(g.data, k)
|
|
} else {
|
|
g.data[k] = v
|
|
}
|
|
}
|
|
clients := g.getClientsUnlocked(nil)
|
|
g.mu.Unlock()
|
|
|
|
for _, c := range clients {
|
|
c.Joined(g.Name(), "change")
|
|
}
|
|
}
|
|
|
|
func (g *Group) Description() *Description {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
return g.description
|
|
}
|
|
|
|
func (g *Group) ClientCount() int {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
return len(g.clients)
|
|
}
|
|
|
|
func (g *Group) mayExpire() bool {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
|
|
if g.description.Public {
|
|
return false
|
|
}
|
|
if len(g.clients) > 0 {
|
|
return false
|
|
}
|
|
return time.Since(g.timestamp) > maxHistoryAge(g.description)
|
|
}
|
|
|
|
var groups struct {
|
|
mu sync.Mutex
|
|
groups map[string]*Group
|
|
}
|
|
|
|
func (g *Group) API() (*webrtc.API, error) {
|
|
g.mu.Lock()
|
|
codecs := g.description.Codecs
|
|
g.mu.Unlock()
|
|
|
|
return APIFromNames(codecs)
|
|
}
|
|
|
|
func fmtpValue(fmtp, key string) string {
|
|
fields := strings.Split(fmtp, ";")
|
|
for _, f := range fields {
|
|
k, v, found := strings.Cut(f, "=")
|
|
if found && k == key {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func CodecPayloadType(codec webrtc.RTPCodecCapability) (webrtc.PayloadType, error) {
|
|
switch strings.ToLower(codec.MimeType) {
|
|
case "video/vp8":
|
|
return 96, nil
|
|
case "video/vp9":
|
|
profile := fmtpValue(codec.SDPFmtpLine, "profile-id")
|
|
switch profile {
|
|
case "", "0":
|
|
return 98, nil
|
|
case "2":
|
|
return 100, nil
|
|
default:
|
|
return 0, fmt.Errorf("unknown VP9 profile %v", profile)
|
|
|
|
}
|
|
case "video/av1":
|
|
return 35, nil
|
|
case "video/h264":
|
|
profile := fmtpValue(codec.SDPFmtpLine, "profile-level-id")
|
|
if profile == "" {
|
|
return 102, nil
|
|
}
|
|
if len(profile) < 4 {
|
|
return 0, errors.New("malforned H.264 profile")
|
|
}
|
|
switch strings.ToLower(profile[:4]) {
|
|
case "4200":
|
|
return 102, nil
|
|
case "42e0":
|
|
return 108, nil
|
|
default:
|
|
return 0, fmt.Errorf(
|
|
"unknown H.264 profile %v", profile,
|
|
)
|
|
}
|
|
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, fmt.Errorf("unknown codec %v", codec.MimeType)
|
|
}
|
|
}
|
|
|
|
// VideoRTCPFeedback are the RTCP feedback types that we expect for video
|
|
// tracks.
|
|
var VideoRTCPFeedback = []webrtc.RTCPFeedback{
|
|
{"goog-remb", ""},
|
|
{"nack", ""},
|
|
{"nack", "pli"},
|
|
{"ccm", "fir"},
|
|
}
|
|
|
|
// AudioRTCPFeedback is like VideoRTCPFeedback but for audio tracks.
|
|
var AudioRTCPFeedback = []webrtc.RTCPFeedback(nil)
|
|
|
|
func codecsFromName(name string) ([]webrtc.RTPCodecParameters, error) {
|
|
var codecs []webrtc.RTPCodecCapability
|
|
|
|
switch name {
|
|
case "vp8":
|
|
codecs = []webrtc.RTPCodecCapability{
|
|
{
|
|
"video/VP8", 90000, 0,
|
|
"",
|
|
VideoRTCPFeedback,
|
|
},
|
|
}
|
|
case "vp9":
|
|
codecs = []webrtc.RTPCodecCapability{
|
|
{
|
|
"video/VP9", 90000, 0,
|
|
"profile-id=0",
|
|
VideoRTCPFeedback,
|
|
},
|
|
{
|
|
"video/VP9", 90000, 0,
|
|
"profile-id=2",
|
|
VideoRTCPFeedback,
|
|
},
|
|
}
|
|
case "av1":
|
|
codecs = []webrtc.RTPCodecCapability{
|
|
{
|
|
"video/AV1", 90000, 0,
|
|
"",
|
|
VideoRTCPFeedback,
|
|
},
|
|
}
|
|
case "h264":
|
|
codecs = []webrtc.RTPCodecCapability{
|
|
{
|
|
"video/H264", 90000, 0,
|
|
"level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f",
|
|
VideoRTCPFeedback,
|
|
},
|
|
}
|
|
case "opus":
|
|
codecs = []webrtc.RTPCodecCapability{
|
|
{
|
|
"audio/opus", 48000, 2,
|
|
"minptime=10;useinbandfec=1;stereo=1;sprop-stereo=1",
|
|
AudioRTCPFeedback,
|
|
},
|
|
}
|
|
case "g722":
|
|
codecs = []webrtc.RTPCodecCapability{
|
|
{
|
|
"audio/G722", 8000, 1,
|
|
"",
|
|
AudioRTCPFeedback,
|
|
},
|
|
}
|
|
case "pcmu":
|
|
codecs = []webrtc.RTPCodecCapability{
|
|
{
|
|
"audio/PCMU", 8000, 1,
|
|
"",
|
|
AudioRTCPFeedback,
|
|
},
|
|
}
|
|
case "pcma":
|
|
codecs = []webrtc.RTPCodecCapability{
|
|
{
|
|
"audio/PCMU", 8000, 1,
|
|
"",
|
|
AudioRTCPFeedback,
|
|
},
|
|
}
|
|
default:
|
|
return nil, errors.New("unknown codec")
|
|
}
|
|
|
|
parms := make([]webrtc.RTPCodecParameters, 0, len(codecs))
|
|
for _, c := range codecs {
|
|
ptype, err := CodecPayloadType(c)
|
|
if err != nil {
|
|
log.Printf("Couldn't determine ptype for codec %v: %v",
|
|
c.MimeType, err)
|
|
continue
|
|
}
|
|
parms = append(parms, webrtc.RTPCodecParameters{
|
|
RTPCodecCapability: c,
|
|
PayloadType: ptype,
|
|
})
|
|
}
|
|
return parms, nil
|
|
}
|
|
|
|
func APIFromCodecs(codecs []webrtc.RTPCodecParameters) (*webrtc.API, error) {
|
|
s := webrtc.SettingEngine{}
|
|
s.SetSRTPReplayProtectionWindow(512)
|
|
s.DisableActiveTCP(true)
|
|
if !UseMDNS {
|
|
s.SetICEMulticastDNSMode(ice.MulticastDNSModeDisabled)
|
|
}
|
|
m := webrtc.MediaEngine{}
|
|
|
|
for _, codec := range codecs {
|
|
tpe := webrtc.RTPCodecTypeVideo
|
|
if strings.HasPrefix(strings.ToLower(codec.MimeType), "audio/") {
|
|
tpe = webrtc.RTPCodecTypeAudio
|
|
}
|
|
err := m.RegisterCodec(codec, tpe)
|
|
if err != nil {
|
|
log.Printf("%v", err)
|
|
continue
|
|
}
|
|
}
|
|
|
|
if UDPMin > 0 && UDPMax > 0 {
|
|
s.SetEphemeralUDPPortRange(UDPMin, UDPMax)
|
|
}
|
|
m.RegisterHeaderExtension(
|
|
webrtc.RTPHeaderExtensionCapability{sdp.SDESMidURI},
|
|
webrtc.RTPCodecTypeVideo)
|
|
m.RegisterHeaderExtension(
|
|
webrtc.RTPHeaderExtensionCapability{sdp.SDESRTPStreamIDURI},
|
|
webrtc.RTPCodecTypeVideo)
|
|
|
|
return webrtc.NewAPI(
|
|
webrtc.WithSettingEngine(s),
|
|
webrtc.WithMediaEngine(&m),
|
|
), nil
|
|
}
|
|
|
|
func APIFromNames(names []string) (*webrtc.API, error) {
|
|
if len(names) == 0 {
|
|
names = []string{"vp8", "opus"}
|
|
}
|
|
var codecs []webrtc.RTPCodecParameters
|
|
for _, n := range names {
|
|
cs, err := codecsFromName(n)
|
|
if err != nil {
|
|
log.Printf("Codec %v: %v", n, err)
|
|
continue
|
|
}
|
|
codecs = append(codecs, cs...)
|
|
}
|
|
|
|
return APIFromCodecs(codecs)
|
|
}
|
|
|
|
func Add(name string, desc *Description) (*Group, error) {
|
|
g, notify, err := add(name, desc)
|
|
for _, c := range notify {
|
|
c.Joined(g.Name(), "change")
|
|
}
|
|
return g, err
|
|
}
|
|
|
|
func validGroupName(name string) bool {
|
|
if filepath.Separator != '/' &&
|
|
strings.ContainsRune(name, filepath.Separator) {
|
|
return false
|
|
}
|
|
|
|
s := path.Clean("/" + name)
|
|
if s == "/" {
|
|
return false
|
|
}
|
|
|
|
return s == "/"+name
|
|
}
|
|
|
|
func add(name string, desc *Description) (*Group, []Client, error) {
|
|
if !validGroupName(name) {
|
|
return nil, 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 = readDescription(name, true)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
|
|
g = &Group{
|
|
name: name,
|
|
description: desc,
|
|
clients: make(map[string]Client),
|
|
timestamp: time.Now(),
|
|
}
|
|
groups.groups[name] = g
|
|
}
|
|
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
|
|
notify := false
|
|
if desc != nil {
|
|
if !descriptionMatch(g.description, desc) {
|
|
g.description = desc
|
|
notify = true
|
|
}
|
|
} else if !descriptionUnchanged(name, g.description) {
|
|
desc, err = readDescription(name, true)
|
|
if err != nil {
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
log.Printf("Reading group %v: %v", name, err)
|
|
}
|
|
deleteUnlocked(g)
|
|
return nil, nil, err
|
|
}
|
|
g.description = desc
|
|
notify = true
|
|
}
|
|
|
|
autoLockKick(g)
|
|
|
|
var clients []Client
|
|
if notify {
|
|
clients = g.getClientsUnlocked(nil)
|
|
}
|
|
return g, clients, 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()
|
|
if groups.groups == nil {
|
|
return nil
|
|
}
|
|
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 member(v string, l []string) bool {
|
|
for _, w := range l {
|
|
if v == w {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func AddClient(group string, c Client, creds ClientCredentials) (*Group, error) {
|
|
g, err := Add(group, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
|
|
clients := g.getClientsUnlocked(nil)
|
|
|
|
if !member("system", c.Permissions()) {
|
|
username, perms, err := g.getPermission(creds)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
c.SetUsername(username)
|
|
c.SetPermissions(perms)
|
|
|
|
if !member("op", perms) {
|
|
if g.locked != nil {
|
|
m := *g.locked
|
|
if m == "" {
|
|
m = "this group is locked"
|
|
}
|
|
return nil, UserError(m)
|
|
}
|
|
if g.description.NotBefore != nil ||
|
|
g.description.Expires != nil {
|
|
now := time.Now()
|
|
if g.description.NotBefore != nil &&
|
|
g.description.NotBefore.After(now) {
|
|
return nil, UserError(
|
|
"this group is not open yet",
|
|
)
|
|
}
|
|
if g.description.Expires != nil &&
|
|
g.description.Expires.Before(now) {
|
|
return nil, UserError(
|
|
"this group is closed",
|
|
)
|
|
}
|
|
}
|
|
if g.description.Autokick {
|
|
ops := false
|
|
for _, c := range clients {
|
|
if member("op", c.Permissions()) {
|
|
ops = true
|
|
break
|
|
}
|
|
}
|
|
if !ops {
|
|
return nil, UserError(
|
|
"there are no operators " +
|
|
"in this group",
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !member("op", perms) && g.description.MaxClients > 0 {
|
|
if len(g.clients) >= g.description.MaxClients {
|
|
return nil, UserError("too many users")
|
|
}
|
|
}
|
|
}
|
|
id := c.Id()
|
|
if id == "" {
|
|
return nil, errors.New("client has empty id")
|
|
}
|
|
if g.clients[id] != nil {
|
|
return nil, ProtocolError("duplicate client id")
|
|
}
|
|
g.clients[id] = c
|
|
g.timestamp = time.Now()
|
|
|
|
c.Joined(g.Name(), "join")
|
|
|
|
u := c.Username()
|
|
p := c.Permissions()
|
|
s := c.Data()
|
|
c.PushClient(g.Name(), "add", c.Id(), u, p, s)
|
|
for _, cc := range clients {
|
|
pp := cc.Permissions()
|
|
uu := cc.Username()
|
|
c.PushClient(g.Name(), "add", cc.Id(), uu, pp, cc.Data())
|
|
cc.PushClient(g.Name(), "add", id, u, p, s)
|
|
}
|
|
|
|
return g, nil
|
|
}
|
|
|
|
// called locked
|
|
func autoLockKick(g *Group) {
|
|
if !(g.description.Autolock && g.locked == nil) &&
|
|
!g.description.Autokick {
|
|
return
|
|
}
|
|
|
|
clients := g.getClientsUnlocked(nil)
|
|
for _, c := range clients {
|
|
if member("op", c.Permissions()) {
|
|
return
|
|
}
|
|
}
|
|
if g.description.Autolock && g.locked == nil {
|
|
m := "this group is locked"
|
|
g.locked = &m
|
|
for _, c := range clients {
|
|
c.Joined(g.Name(), "change")
|
|
}
|
|
}
|
|
|
|
if g.description.Autokick {
|
|
// we cannot call kickall, since it requires the group to
|
|
// be unlocked. And calling it asynchronously might
|
|
// spuriously kick out an operator.
|
|
go func(clients []Client) {
|
|
for _, c := range clients {
|
|
c.Kick(
|
|
"", nil,
|
|
"there are no operators in this group",
|
|
)
|
|
}
|
|
}(g.getClientsUnlocked(nil))
|
|
}
|
|
}
|
|
|
|
func DelClient(c Client) {
|
|
g := c.Group()
|
|
if g == nil {
|
|
return
|
|
}
|
|
g.mu.Lock()
|
|
if g.clients[c.Id()] != c {
|
|
log.Printf("Deleting unknown client")
|
|
g.mu.Unlock()
|
|
return
|
|
}
|
|
delete(g.clients, c.Id())
|
|
g.timestamp = time.Now()
|
|
clients := g.getClientsUnlocked(nil)
|
|
g.mu.Unlock()
|
|
|
|
c.Joined(g.Name(), "leave")
|
|
for _, cc := range clients {
|
|
cc.PushClient(
|
|
g.Name(), "delete", c.Id(), c.Username(), nil, nil,
|
|
)
|
|
}
|
|
autoLockKick(g)
|
|
}
|
|
|
|
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 kickall(g *Group, message string) {
|
|
g.Range(func(c Client) bool {
|
|
c.Kick("", nil, message)
|
|
return true
|
|
})
|
|
}
|
|
|
|
func Shutdown(message string) {
|
|
Range(func(g *Group) bool {
|
|
g.SetLocked(true, message)
|
|
kickall(g, 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
const maxChatHistory = 50
|
|
|
|
// deleteFunc is just like slices.DeleteFunc.
|
|
// Remove this once we require Go 1.21.
|
|
func deleteFunc[S ~[]E, E any](s S, f func(E) bool) S {
|
|
i := 0
|
|
for i = range s {
|
|
if f(s[i]) {
|
|
break
|
|
}
|
|
}
|
|
if i >= len(s) {
|
|
return s
|
|
}
|
|
|
|
for j := i + 1; j < len(s); j++ {
|
|
if v := s[j]; !f(v) {
|
|
s[i] = v
|
|
i++
|
|
}
|
|
}
|
|
var zero E
|
|
for j := i; j < len(s); j++ {
|
|
s[j] = zero
|
|
}
|
|
return s[:i]
|
|
}
|
|
|
|
func (g *Group) ClearChatHistory(id string, userId string) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
if id == "" && userId == "" {
|
|
g.history = nil
|
|
return
|
|
}
|
|
g.history = deleteFunc(g.history, func(e ChatHistoryEntry) bool {
|
|
return e.Source == userId && (id == "" || e.Id == id)
|
|
})
|
|
}
|
|
|
|
func (g *Group) AddToChatHistory(id, source string, user *string, time time.Time, 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, Source: source, 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(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
|
|
}
|
|
|
|
// Configuration represents the contents of the data/config.json file.
|
|
type Configuration struct {
|
|
// The modtime and size of the file. These are used to detect
|
|
// when a file has changed on disk.
|
|
modTime time.Time `json:"-"`
|
|
fileSize int64 `json:"-"`
|
|
|
|
PublicServer bool `json:"publicServer"`
|
|
CanonicalHost string `json:"canonicalHost"`
|
|
ProxyURL string `json:"proxyURL"`
|
|
WritableGroups bool `json:"writableGroups"`
|
|
Users map[string]UserDescription
|
|
|
|
// obsolete fields
|
|
Admin []ClientPattern `json:"admin"`
|
|
}
|
|
|
|
func (conf Configuration) Zero() bool {
|
|
return conf.modTime.Equal(time.Time{}) &&
|
|
conf.fileSize == 0
|
|
}
|
|
|
|
var configuration struct {
|
|
mu sync.Mutex
|
|
configuration *Configuration
|
|
}
|
|
|
|
func GetConfiguration() (*Configuration, error) {
|
|
configuration.mu.Lock()
|
|
defer configuration.mu.Unlock()
|
|
|
|
if configuration.configuration == nil {
|
|
configuration.configuration = &Configuration{}
|
|
}
|
|
|
|
filename := filepath.Join(DataDirectory, "config.json")
|
|
fi, err := os.Stat(filename)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
if !configuration.configuration.Zero() {
|
|
configuration.configuration = &Configuration{}
|
|
}
|
|
return configuration.configuration, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if configuration.configuration.modTime.Equal(fi.ModTime()) &&
|
|
configuration.configuration.fileSize == fi.Size() {
|
|
return configuration.configuration, nil
|
|
}
|
|
|
|
f, err := os.Open(filename)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
d := json.NewDecoder(f)
|
|
d.DisallowUnknownFields()
|
|
var conf Configuration
|
|
err = d.Decode(&conf)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if conf.Admin != nil {
|
|
log.Printf("%v: field \"admin\" is obsolete, ignored", filename)
|
|
conf.Admin = nil
|
|
}
|
|
configuration.configuration = &conf
|
|
return configuration.configuration, nil
|
|
}
|
|
|
|
// called locked
|
|
func (g *Group) getPasswordPermission(creds ClientCredentials) (Permissions, error) {
|
|
desc := g.description
|
|
|
|
if creds.Username == nil {
|
|
return Permissions{}, errors.New("username not provided")
|
|
}
|
|
if desc.Users != nil {
|
|
if c, found := desc.Users[*creds.Username]; found {
|
|
ok, err := c.Password.Match(creds.Password)
|
|
if err != nil {
|
|
return Permissions{}, err
|
|
}
|
|
if ok {
|
|
return c.Permissions, nil
|
|
} else {
|
|
return Permissions{}, &NotAuthorisedError{}
|
|
}
|
|
}
|
|
}
|
|
|
|
if desc.WildcardUser != nil {
|
|
ok, _ := desc.WildcardUser.Password.Match(creds.Password)
|
|
if ok {
|
|
return desc.WildcardUser.Permissions, nil
|
|
}
|
|
}
|
|
return Permissions{}, &NotAuthorisedError{}
|
|
}
|
|
|
|
// Return true if there is a user entry with the given username.
|
|
// Always return false for an empty username.
|
|
func (g *Group) UserExists(username string) bool {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
return g.userExists(username)
|
|
}
|
|
|
|
// called locked
|
|
func (g *Group) userExists(username string) bool {
|
|
desc := g.description
|
|
if desc.Users == nil {
|
|
return false
|
|
}
|
|
_, found := desc.Users[username]
|
|
return found
|
|
}
|
|
|
|
// called locked
|
|
func (g *Group) getPermission(creds ClientCredentials) (string, []string, error) {
|
|
desc := g.description
|
|
var username string
|
|
var perms []string
|
|
if creds.Token != "" {
|
|
tok, err := token.Parse(creds.Token, desc.AuthKeys)
|
|
if err != nil {
|
|
return "", nil, &NotAuthorisedError{err: err}
|
|
}
|
|
|
|
conf, err := GetConfiguration()
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
username, perms, err =
|
|
tok.Check(conf.CanonicalHost, g.name, creds.Username)
|
|
if err != nil {
|
|
return "", nil, &NotAuthorisedError{err: err}
|
|
}
|
|
if username == "" && creds.Username != nil {
|
|
if g.userExists(*creds.Username) {
|
|
return "", nil, ErrDuplicateUsername
|
|
}
|
|
username = *creds.Username
|
|
}
|
|
} else if creds.Username != nil {
|
|
username = *creds.Username
|
|
ps, err := g.getPasswordPermission(creds)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
perms = ps.Permissions(desc)
|
|
} else {
|
|
return "", nil, errors.New("neither username nor token provided")
|
|
}
|
|
|
|
return username, perms, nil
|
|
}
|
|
|
|
func (g *Group) GetPermission(creds ClientCredentials) (string, []string, error) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
return g.getPermission(creds)
|
|
}
|
|
|
|
type Status struct {
|
|
Name string `json:"name"`
|
|
Redirect string `json:"redirect,omitempty"`
|
|
Location string `json:"location,omitempty"`
|
|
Endpoint string `json:"endpoint,omitempty"`
|
|
DisplayName string `json:"displayName,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
AuthServer string `json:"authServer,omitempty"`
|
|
AuthPortal string `json:"authPortal,omitempty"`
|
|
Locked bool `json:"locked,omitempty"`
|
|
ClientCount *int `json:"clientCount,omitempty"`
|
|
CanChangePassword bool `json:"canChangePassword,omitempty"`
|
|
}
|
|
|
|
// Status returns a group's status.
|
|
// Base is the base URL for groups; if omitted, then both the Location and
|
|
// Endpoint members are omitted from the result.
|
|
func (g *Group) Status(authentified bool, base *url.URL) Status {
|
|
desc := g.Description()
|
|
|
|
if desc.Redirect != "" {
|
|
return Status{
|
|
Name: g.name,
|
|
Redirect: desc.Redirect,
|
|
DisplayName: desc.DisplayName,
|
|
Description: desc.Description,
|
|
}
|
|
}
|
|
|
|
var location, endpoint string
|
|
if base != nil {
|
|
wss := "wss"
|
|
if base.Scheme == "http" {
|
|
wss = "ws"
|
|
}
|
|
l := url.URL{
|
|
Scheme: base.Scheme,
|
|
Host: base.Host,
|
|
Path: path.Join(
|
|
path.Join(base.Path, "/group/"),
|
|
g.Name()) + "/",
|
|
}
|
|
location = l.String()
|
|
e := url.URL{
|
|
Scheme: wss,
|
|
Host: base.Host,
|
|
Path: path.Join(base.Path, "/ws"),
|
|
}
|
|
endpoint = e.String()
|
|
}
|
|
|
|
d := Status{
|
|
Name: g.name,
|
|
Location: location,
|
|
Endpoint: endpoint,
|
|
DisplayName: desc.DisplayName,
|
|
AuthServer: desc.AuthServer,
|
|
AuthPortal: desc.AuthPortal,
|
|
Description: desc.Description,
|
|
}
|
|
|
|
if authentified || desc.Public {
|
|
// these are considered private information
|
|
locked, _ := g.Locked()
|
|
count := g.ClientCount()
|
|
d.Locked = locked
|
|
d.ClientCount = &count
|
|
}
|
|
if authentified {
|
|
conf, err := GetConfiguration()
|
|
if err == nil {
|
|
d.CanChangePassword = conf.WritableGroups
|
|
}
|
|
}
|
|
return d
|
|
}
|
|
|
|
func GetPublic(base *url.URL) []Status {
|
|
gs := make([]Status, 0)
|
|
Range(func(g *Group) bool {
|
|
if g.Description().Public {
|
|
gs = append(gs, g.Status(false, base))
|
|
}
|
|
return true
|
|
})
|
|
sort.Slice(gs, func(i, j int) bool {
|
|
return gs[i].Name < gs[j].Name
|
|
})
|
|
return gs
|
|
}
|
|
|
|
// Update checks that all in-memory groups are up-to-date and updates the
|
|
// list of public groups. It also removes from memory any non-public
|
|
// groups that haven't been accessed in maxHistoryAge.
|
|
func Update() {
|
|
_, err := GetConfiguration()
|
|
if err != nil {
|
|
log.Printf("%v: %v",
|
|
filepath.Join(DataDirectory, "config.json"),
|
|
err,
|
|
)
|
|
}
|
|
|
|
names := GetNames()
|
|
for _, name := range names {
|
|
g := Get(name)
|
|
if g == nil {
|
|
continue
|
|
}
|
|
|
|
deleted := false
|
|
if g.mayExpire() {
|
|
// Delete checks if the group is still empty
|
|
deleted = Delete(name)
|
|
}
|
|
|
|
// update group description
|
|
if !deleted {
|
|
Add(name, nil)
|
|
}
|
|
}
|
|
|
|
err = filepath.WalkDir(
|
|
Directory,
|
|
func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
log.Printf("Group file %v: %v", path, err)
|
|
return nil
|
|
}
|
|
if d.IsDir() {
|
|
base := filepath.Base(path)
|
|
if base[0] == '.' {
|
|
log.Printf(
|
|
"Ignoring group directory %v",
|
|
path,
|
|
)
|
|
return fs.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
filename, err := filepath.Rel(Directory, path)
|
|
if err != nil {
|
|
log.Printf("Group file %v: %v", path, err)
|
|
return nil
|
|
}
|
|
if !strings.HasSuffix(filename, ".json") {
|
|
log.Printf(
|
|
"Unexpected extension for group file %v",
|
|
path,
|
|
)
|
|
return nil
|
|
}
|
|
base := filepath.Base(filename)
|
|
if base[0] == '.' {
|
|
log.Printf("Ignoring group file %v", filename)
|
|
return nil
|
|
}
|
|
name := strings.TrimSuffix(filename, ".json")
|
|
desc, err := GetDescription(name)
|
|
if err != nil {
|
|
log.Printf("Group file %v: %v", path, err)
|
|
return nil
|
|
}
|
|
if desc.Public {
|
|
Add(name, desc)
|
|
}
|
|
return nil
|
|
},
|
|
)
|
|
|
|
if err != nil {
|
|
log.Printf("Couldn't read groups: %v", err)
|
|
}
|
|
}
|