mirror of
https://github.com/jech/galene.git
synced 2024-11-10 02:35:58 +01:00
841d95d21c
We used to test AutoSubgroups before upgrading the description, which would break handling of the (obsolete) AllowSubgroups field. Thanks to David Saulpic.
702 lines
16 KiB
Go
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)
|
|
}
|