1
Fork 0
mirror of https://github.com/jech/galene.git synced 2024-11-22 16:45:58 +01:00
galene/group/description.go
Juliusz Chroboczek 841d95d21c Fix handling of AutoSubgroups in readDescriptionFile.
We used to test AutoSubgroups before upgrading the description,
which would break handling of the (obsolete) AllowSubgroups
field.

Thanks to David Saulpic.
2024-04-17 18:50:35 +02:00

702 lines
16 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 !errors.Is(err, os.ErrNotExist) {
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 errors.Is(err, os.ErrNotExist) {
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
}
dir := filepath.Dir(filename)
err = os.MkdirAll(dir, 0700)
if err != nil {
return err
}
f, err := os.CreateTemp(dir, "*.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
}
err = upgradeDescription(&desc)
if err != nil {
return nil, err
}
desc.FileName = fileName
desc.fileSize = fi.Size()
desc.modTime = fi.ModTime()
if isSubgroup {
if !desc.AutoSubgroups {
return nil, os.ErrNotExist
}
desc.isSubgroup = true
desc.Public = false
desc.Description = ""
}
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)
}