1
Fork 0

Implement accessors for description files.

Allow reading and modifying description files, in a manner
that aligns with the needs of the API.
This commit is contained in:
Juliusz Chroboczek 2024-04-09 16:51:29 +02:00
parent e14eec86d3
commit fc6387bb38
4 changed files with 411 additions and 4 deletions

2
README
View File

@ -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.

View File

@ -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)
}

View File

@ -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",
)
}
}

View File

@ -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()
@ -857,6 +858,7 @@ type Configuration struct {
PublicServer bool `json:"publicServer"`
CanonicalHost string `json:"canonicalHost"`
ProxyURL string `json:"proxyURL"`
WritableGroups bool `json:"writableGroups"`
Users map[string]UserDescription
// obsolete fields