diff --git a/README b/README index 9838fc1..00f46fc 100644 --- a/README +++ b/README @@ -75,6 +75,8 @@ The fields are as follows: - `users` defines the users allowed to administer the server, and has the same syntax as user definitions in groups (see below), except that the only meaningful permission is `"admin"`; +- `writableGroups`: if true, then the API can modify group description + files; by default, group files are treated as read-only; - `publicServer`: if true, then cross-origin access to the server is allowed. This is safe if the server is on the public Internet, but not necessarily so if it is on a private network. diff --git a/group/description.go b/group/description.go index e16eef4..e83a8fc 100644 --- a/group/description.go +++ b/group/description.go @@ -3,6 +3,8 @@ package group import ( "encoding/json" "errors" + "fmt" + "io/fs" "log" "os" "path" @@ -11,6 +13,9 @@ import ( "time" ) +var ErrTagMismatch = errors.New("tag mismatch") +var ErrDescriptionsNotWritable = &NotAuthorisedError{} + type Permissions struct { // non-empty for a named permissions set name string @@ -123,6 +128,9 @@ type Description struct { 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"` @@ -265,6 +273,133 @@ func GetDescription(name string) (*Description, error) { 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 := @@ -291,6 +426,7 @@ func readDescription(name string, allowSubgroups bool) (*Description, error) { if !desc.AutoSubgroups { return nil, os.ErrNotExist } + desc.isSubgroup = true desc.Public = false desc.Description = "" } @@ -374,3 +510,150 @@ func upgradeDescription(desc *Description) error { 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 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 != "" { + 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) +} diff --git a/group/description_test.go b/group/description_test.go index 28fe4a1..6be9473 100644 --- a/group/description_test.go +++ b/group/description_test.go @@ -2,6 +2,8 @@ package group import ( "encoding/json" + "errors" + "os" "reflect" "testing" ) @@ -95,3 +97,121 @@ func TestUpgradeDescription(t *testing.T) { } } } + +func TestNonWritableGroups(t *testing.T) { + Directory = t.TempDir() + configuration.configuration = &Configuration{} + + _, err := GetDescription("test") + if !errors.Is(err, os.ErrNotExist) { + t.Errorf("GetDescription: got %#v, expected ErrNotExist", err) + } + + err = UpdateDescription("test", "", &Description{}) + if !errors.Is(err, ErrDescriptionsNotWritable) { + t.Errorf("UpdateDescription: got %#v, " + + "expected ErrDescriptionsNotWritable", err) + } +} + +func TestWritableGroups(t *testing.T) { + Directory = t.TempDir() + configuration.configuration = &Configuration{ + WritableGroups: true, + } + + _, err := GetDescription("test") + if !errors.Is(err, os.ErrNotExist) { + t.Errorf("GetDescription: got %v, expected ErrNotExist", err) + } + + err = UpdateDescription("test", "\"etag\"", &Description{}) + if !errors.Is(err, ErrTagMismatch) { + t.Errorf("UpdateDescription: got %v, expected ErrTagMismatch", + err) + } + + err = UpdateDescription("test", "", &Description{}) + if err != nil { + t.Errorf("UpdateDescription: got %v", err) + } + + _, err = GetDescription("test") + if err != nil { + t.Errorf("GetDescription: got %v", err) + } + + desc, token, err := GetSanitisedDescription("test") + if err != nil || token == "" { + t.Errorf("GetSanitisedDescription: got %v", err) + } + + desc.DisplayName = "Test" + + err = UpdateDescription("test", "\"badetag\"", desc) + if !errors.Is(err, ErrTagMismatch) { + t.Errorf("UpdateDescription: got %v, expected ErrTagMismatch", + err) + } + + err = UpdateDescription("test", token, desc) + if err != nil { + t.Errorf("UpdateDescription: got %v", err) + } + + desc, err = GetDescription("test") + if err != nil || desc.DisplayName != "Test" { + t.Errorf("GetDescription: expected %v %v, got %v %v", + nil, "Test", err, desc.AllowAnonymous, + ) + } + + _, _, err = GetSanitisedUser("test", "jch") + if !errors.Is(err, os.ErrNotExist) { + t.Errorf("GetSanitisedUser: got %v, expected ErrNotExist", err) + } + + err = UpdateUser("test", "jch", "", &UserDescription{ + Permissions: Permissions{name: "observe"}, + }) + if err != nil { + t.Errorf("UpdateUser: got %v", err) + } + + user, token, err := GetSanitisedUser("test", "jch") + if err != nil || token == "" || user.Permissions.name != "observe" { + t.Errorf("GetDescription: got %v %v, expected %v %v", + err, user.Permissions.name, nil, "observe", + ) + } + + err = UpdateUser("test", "jch", "", &UserDescription{ + Permissions: Permissions{name: "present"}, + }) + if !errors.Is(err, ErrTagMismatch) { + t.Errorf("UpdateDescription: got %v, expected ErrTagMismatch", + err) + } + + err = UpdateUser("test", "jch", token, &UserDescription{ + Permissions: Permissions{name: "present"}, + }) + if err != nil { + t.Errorf("UpdateUser: got %v", err) + } + + err = SetUserPassword("test", "jch", Password{ + Type: "", + Key: "pw", + }) + if err != nil { + t.Errorf("SetUserPassword: got %v", err) + } + + desc, err = GetDescription("test") + if err != nil || desc.Users["jch"].Password.Key != "pw" { + t.Errorf("GetDescription: got %v %v, expected %v %v", + err, desc.Users["jch"].Password.Key, nil, "pw", + ) + } +} diff --git a/group/group.go b/group/group.go index a3c561f..63e488b 100644 --- a/group/group.go +++ b/group/group.go @@ -29,6 +29,7 @@ var UDPMin, UDPMax uint16 type NotAuthorisedError struct { err error } + func (err *NotAuthorisedError) Error() string { if err.err != nil { return "not authorised: " + err.err.Error() @@ -854,10 +855,11 @@ type Configuration struct { modTime time.Time `json:"-"` fileSize int64 `json:"-"` - PublicServer bool `json:"publicServer"` - CanonicalHost string `json:"canonicalHost"` - ProxyURL string `json:"proxyURL"` - Users map[string]UserDescription + 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"`