1
Fork 0
galene/group/description.go

696 lines
15 KiB
Go

package group
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"log"
"os"
"path"
"path/filepath"
"strings"
"time"
)
var ErrTagMismatch = errors.New("tag mismatch")
var ErrDescriptionsNotWritable = &NotAuthorisedError{}
type Permissions struct {
// non-empty for a named permissions set
name string
// only used when unnamed
permissions []string
}
var permissionsMap = map[string][]string{
"op": []string{"op", "present", "token"},
"present": []string{"present"},
"observe": []string{},
"admin": []string{"admin"},
}
func NewPermissions(name string) (Permissions, error) {
_, ok := permissionsMap[name]
if !ok {
return Permissions{}, errors.New("unknown permission")
}
return Permissions{
name: name,
}, nil
}
func (p Permissions) Permissions(desc *Description) []string {
if p.name == "" {
return p.permissions
}
perms := permissionsMap[p.name]
op := false
present := false
token := false
record := false
for _, p := range perms {
switch p {
case "op":
op = true
case "present":
present = true
case "token":
token = true
case "record":
record = true
}
}
if desc != nil && desc.AllowRecording {
if op && !record {
// copy the slice
perms = append([]string{"record"}, perms...)
}
}
if desc != nil && desc.UnrestrictedTokens {
if present && !token {
perms = append([]string{"token"}, perms...)
}
}
return perms
}
func (p *Permissions) UnmarshalJSON(b []byte) error {
var a []string
err := json.Unmarshal(b, &a)
if err == nil {
*p = Permissions{
permissions: a,
}
return nil
}
var s string
err = json.Unmarshal(b, &s)
if err == nil {
_, ok := permissionsMap[s]
if !ok {
return errors.New("Unknown permission " + s)
}
*p = Permissions{
name: s,
}
return nil
}
return err
}
func (p Permissions) MarshalJSON() ([]byte, error) {
if p.name != "" {
return json.Marshal(p.name)
}
return json.Marshal(p.permissions)
}
type UserDescription struct {
Password Password `json:"password"`
Permissions Permissions `json:"permissions"`
}
// Custom MarshalJSON in order to omit ompty fields
func (u UserDescription) MarshalJSON() ([]byte, error) {
uu := make(map[string]any, 2)
if u.Password.Type != "" {
uu["password"] = &u.Password
}
if u.Permissions.name != "" || u.Permissions.permissions != nil {
uu["permissions"] = &u.Permissions
}
return json.Marshal(uu)
}
// Description represents a group description together with some metadata
// about the JSON file it was deserialised from.
type Description struct {
// The file this was deserialised from. This is not necessarily
// the name of the group, for example in case of a subgroup.
FileName string `json:"-"`
// 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:"-"`
// Whether this is an automatically generated subgroup
isSubgroup bool `json:"-"`
// The user-friendly group name
DisplayName string `json:"displayName,omitempty"`
// A user-readable description of the group.
Description string `json:"description,omitempty"`
// A user-readable contact, typically an e-mail address.
Contact string `json:"contact,omitempty"`
// A user-readable comment. Ignored by the server.
Comment string `json:"comment,omitempty"`
// Whether to display the group on the landing page.
Public bool `json:"public,omitempty"`
// A URL to redirect the group to. If this is not empty, most
// other fields are ignored.
Redirect string `json:"redirect,omitempty"`
// The maximum number of simultaneous clients. Unlimited if 0.
MaxClients int `json:"max-clients,omitempty"`
// The time for which history entries are kept.
MaxHistoryAge int `json:"max-history-age,omitempty"`
// Time after which joining is no longer allowed
Expires *time.Time `json:"expires,omitempty"`
// Time before which joining is not allowed
NotBefore *time.Time `json:"not-before,omitempty"`
// Whether recording is allowed.
AllowRecording bool `json:"allow-recording,omitempty"`
// Whether creating tokens is allowed
UnrestrictedTokens bool `json:"unrestricted-tokens,omitempty"`
// Whether subgroups are created on the fly.
AutoSubgroups bool `json:"auto-subgroups,omitempty"`
// Whether to lock the group when the last op logs out.
Autolock bool `json:"autolock,omitempty"`
// Whether to kick all users when the last op logs out.
Autokick bool `json:"autokick,omitempty"`
// Users allowed to login
Users map[string]UserDescription `json:"users,omitempty"`
// Credentials for users with arbitrary username
FallbackUsers []UserDescription `json:"fallback-users,omitempty"`
// The (public) keys used for token authentication.
AuthKeys []map[string]interface{} `json:"authKeys,omitempty"`
// The URL of the authentication server, if any.
AuthServer string `json:"authServer,omitempty"`
// The URL of the authentication portal, if any.
AuthPortal string `json:"authPortal,omitempty"`
// Codec preferences. If empty, a suitable default is chosen in
// the APIFromNames function.
Codecs []string `json:"codecs,omitempty"`
// Obsolete fields
Op []ClientPattern `json:"op,omitempty"`
Presenter []ClientPattern `json:"presenter,omitempty"`
Other []ClientPattern `json:"other,omitempty"`
AllowSubgroups bool `json:"allow-subgroups,omitempty"`
AllowAnonymous bool `json:"allow-anonymous,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 getDescriptionFile[T any](name string, allowSubgroups bool, get func(string) (T, error)) (T, string, bool, error) {
isSubgroup := false
for name != "" {
fileName := filepath.Join(
Directory, path.Clean("/"+name)+".json",
)
r, err := get(fileName)
if !os.IsNotExist(err) {
return r, fileName, isSubgroup, err
}
if !allowSubgroups {
break
}
isSubgroup = true
name, _ = path.Split(name)
name = strings.TrimRight(name, "/")
}
var zero T
return zero, "", false, os.ErrNotExist
}
// descriptionMatch returns true if the description hasn't changed between
// d1 and d2
func descriptionMatch(d1, d2 *Description) bool {
if d1.FileName != d2.FileName {
return false
}
if d1.fileSize != d2.fileSize || !d1.modTime.Equal(d2.modTime) {
return false
}
return true
}
// descriptionUnchanged returns true if a group's description hasn't
// changed since it was last read.
func descriptionUnchanged(name string, desc *Description) bool {
fi, fileName, _, err := getDescriptionFile(name, true, os.Stat)
if err != nil || fileName != desc.FileName {
return false
}
if fi.Size() != desc.fileSize || !fi.ModTime().Equal(desc.modTime) {
return false
}
return true
}
// GetDescription gets a group description, either from cache or from disk
func GetDescription(name string) (*Description, error) {
g := Get(name)
if g != nil {
if descriptionUnchanged(name, g.description) {
return g.description, nil
}
}
return readDescription(name, true)
}
// GetSanitisedDescription returns the subset of the description that is
// published on the web interface together with a suitable ETag.
func GetSanitisedDescription(name string) (*Description, string, error) {
d, err := GetDescription(name)
if err != nil {
return nil, "", err
}
if d.isSubgroup {
return nil, "", os.ErrNotExist
}
desc := *d
desc.Users = nil
desc.FallbackUsers = nil
desc.AuthKeys = nil
return &desc, makeETag(desc.fileSize, desc.modTime), nil
}
// GetDescriptionTag returns an ETag for a description.
func GetDescriptionTag(name string) (string, error) {
fi, _, _, err := getDescriptionFile(name, false, os.Stat)
if err != nil {
return "", err
}
return makeETag(fi.Size(), fi.ModTime()), nil
}
func makeETag(fileSize int64, modTime time.Time) string {
return fmt.Sprintf("\"%v-%v\"", fileSize, modTime.UnixNano())
}
// DeleteDescription deletes a description (and therefore persistently
// deletes a group) but only if it matches a given ETag.
func DeleteDescription(name, etag string) error {
groups.mu.Lock()
defer groups.mu.Unlock()
fi, fileName, _, err := getDescriptionFile(name, false, os.Stat)
if err != nil {
return err
}
if etag != makeETag(fi.Size(), fi.ModTime()) {
return ErrTagMismatch
}
return os.Remove(fileName)
}
// UpdateDescription overwrites a description if it matches a given ETag.
// In order to create a new group, pass an empty ETag.
func UpdateDescription(name, etag string, desc *Description) error {
if desc.Users != nil || desc.FallbackUsers != nil || desc.AuthKeys != nil {
return errors.New("description is not sanitised")
}
groups.mu.Lock()
defer groups.mu.Unlock()
oldetag := ""
var filename string
old, err := readDescription(name, false)
if err == nil {
oldetag = makeETag(old.fileSize, old.modTime)
filename = old.FileName
} else if os.IsNotExist(err) {
old = nil
filename = filepath.Join(
Directory, path.Clean("/"+name)+".json",
)
} else {
return err
}
if oldetag != etag {
return ErrTagMismatch
}
newdesc := *desc
if old != nil {
newdesc.Users = old.Users
newdesc.FallbackUsers = old.FallbackUsers
newdesc.AuthKeys = old.AuthKeys
}
return rewriteDescriptionFile(filename, &newdesc)
}
func rewriteDescriptionFile(filename string, desc *Description) error {
conf, err := GetConfiguration()
if err != nil {
return err
}
if !conf.WritableGroups {
return ErrDescriptionsNotWritable
}
f, err := os.CreateTemp(path.Dir(filename), "*.temp")
if err != nil {
return err
}
temp := f.Name()
encoder := json.NewEncoder(f)
err = encoder.Encode(desc)
if err == nil {
err = f.Sync()
}
if err != nil {
f.Close()
os.Remove(temp)
return err
}
err = f.Close()
if err != nil {
os.Remove(temp)
return err
}
err = os.Rename(temp, filename)
if err != nil {
os.Remove(temp)
return err
}
return nil
}
// readDescription reads a group's description from disk
func readDescription(name string, allowSubgroups bool) (*Description, error) {
r, fileName, isSubgroup, err :=
getDescriptionFile(name, allowSubgroups, os.Open)
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)
d.DisallowUnknownFields()
err = d.Decode(&desc)
if err != nil {
return nil, err
}
if isSubgroup {
if !desc.AutoSubgroups {
return nil, os.ErrNotExist
}
desc.isSubgroup = true
desc.Public = false
desc.Description = ""
}
desc.FileName = fileName
desc.fileSize = fi.Size()
desc.modTime = fi.ModTime()
err = upgradeDescription(&desc)
if err != nil {
return nil, err
}
return &desc, nil
}
func upgradeDescription(desc *Description) error {
if desc.AllowAnonymous {
log.Printf(
"%v: field allow-anonymous is obsolete, ignored",
desc.FileName,
)
desc.AllowAnonymous = false
}
if desc.AllowSubgroups {
desc.AutoSubgroups = true
desc.AllowSubgroups = false
}
upgradePassword := func(pw *Password) Password {
if pw == nil {
return Password{
Type: "wildcard",
}
}
return *pw
}
upgradeUser := func(u ClientPattern, p string) UserDescription {
return UserDescription{
Password: upgradePassword(u.Password),
Permissions: Permissions{
name: p,
},
}
}
upgradeUsers := func(ps []ClientPattern, p string) {
if desc.Users == nil {
desc.Users = make(map[string]UserDescription)
}
for _, u := range ps {
if u.Username == "" {
desc.FallbackUsers = append(desc.FallbackUsers,
upgradeUser(u, p))
continue
}
_, found := desc.Users[u.Username]
if found {
log.Printf("%v: duplicate user %v, ignored",
desc.FileName, u.Username)
continue
}
desc.Users[u.Username] = upgradeUser(u, p)
}
}
if desc.Op != nil {
upgradeUsers(desc.Op, "op")
desc.Op = nil
}
if desc.Presenter != nil {
upgradeUsers(desc.Presenter, "present")
desc.Presenter = nil
}
if desc.Other != nil {
upgradeUsers(desc.Other, "observe")
desc.Other = nil
}
return nil
}
func GetDescriptionNames() ([]string, error) {
var names []string
err := filepath.WalkDir(
Directory,
func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
base := filepath.Base(path)
if d.IsDir() {
if base[0] == '.' {
return fs.SkipDir
}
return nil
}
if base[0] == '.' {
return nil
}
if strings.HasSuffix(base, ".json") {
names = append(names, strings.TrimSuffix(
base, ".json",
))
}
return nil
},
)
return names, err
}
func SetFallbackUsers(group string, users []UserDescription) error {
groups.mu.Lock()
defer groups.mu.Unlock()
desc, err := readDescription(group, false)
if err != nil {
return err
}
desc.FallbackUsers = users
return rewriteDescriptionFile(desc.FileName, desc)
}
func SetKeys(group string, keys []map[string]any) error {
groups.mu.Lock()
defer groups.mu.Unlock()
desc, err := readDescription(group, false)
if err != nil {
return err
}
desc.AuthKeys = keys
return rewriteDescriptionFile(desc.FileName, desc)
}
func GetUsers(group string) ([]string, string, error) {
desc, err := GetDescription(group)
if err != nil {
return nil, "", err
}
users := make([]string, 0, len(desc.Users))
for u := range desc.Users {
users = append(users, u)
}
return users, makeETag(desc.fileSize, desc.modTime), nil
}
func GetSanitisedUser(group, username string) (UserDescription, string, error) {
desc, err := GetDescription(group)
if err != nil {
return UserDescription{}, "", err
}
if desc.Users == nil {
return UserDescription{}, "", os.ErrNotExist
}
u, ok := desc.Users[username]
if !ok {
return UserDescription{}, "", os.ErrNotExist
}
u.Password = Password{}
return u, makeETag(desc.fileSize, desc.modTime), nil
}
func GetUserTag(group, username string) (string, error) {
_, etag, err := GetSanitisedUser(group, username)
return etag, err
}
func DeleteUser(group, username, etag string) error {
groups.mu.Lock()
defer groups.mu.Unlock()
desc, err := readDescription(group, false)
if err != nil {
return err
}
if desc.Users == nil {
return os.ErrNotExist
}
_, ok := desc.Users[username]
if !ok {
return os.ErrNotExist
}
oldetag := makeETag(desc.fileSize, desc.modTime)
if oldetag != etag {
return ErrTagMismatch
}
delete(desc.Users, username)
return rewriteDescriptionFile(desc.FileName, desc)
}
func UpdateUser(group, username, etag string, user *UserDescription) error {
if user.Password.Type != "" || user.Password.Key != nil {
return errors.New("user description is not sanitised")
}
groups.mu.Lock()
defer groups.mu.Unlock()
desc, err := readDescription(group, false)
if err != nil {
return err
}
if desc.Users == nil {
desc.Users = make(map[string]UserDescription)
}
old, ok := desc.Users[username]
var oldetag string
if ok {
oldetag = makeETag(desc.fileSize, desc.modTime)
} else {
oldetag = ""
}
if oldetag != etag {
return ErrTagMismatch
}
user.Password = old.Password
desc.Users[username] = *user
return rewriteDescriptionFile(desc.FileName, desc)
}
func SetUserPassword(group, username string, pw Password) error {
groups.mu.Lock()
defer groups.mu.Unlock()
desc, err := readDescription(group, false)
if err != nil {
return err
}
if desc.Users == nil {
return os.ErrNotExist
}
user, ok := desc.Users[username]
if !ok {
return os.ErrNotExist
}
user.Password = pw
desc.Users[username] = user
return rewriteDescriptionFile(desc.FileName, desc)
}