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 - `users` defines the users allowed to administer the server, and has the
same syntax as user definitions in groups (see below), except that the same syntax as user definitions in groups (see below), except that the
only meaningful permission is `"admin"`; 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 - `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 allowed. This is safe if the server is on the public Internet, but not
necessarily so if it is on a private network. necessarily so if it is on a private network.

View File

@ -3,6 +3,8 @@ package group
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io/fs"
"log" "log"
"os" "os"
"path" "path"
@ -11,6 +13,9 @@ import (
"time" "time"
) )
var ErrTagMismatch = errors.New("tag mismatch")
var ErrDescriptionsNotWritable = &NotAuthorisedError{}
type Permissions struct { type Permissions struct {
// non-empty for a named permissions set // non-empty for a named permissions set
name string name string
@ -123,6 +128,9 @@ type Description struct {
modTime time.Time `json:"-"` modTime time.Time `json:"-"`
fileSize int64 `json:"-"` fileSize int64 `json:"-"`
// Whether this is an automatically generated subgroup
isSubgroup bool `json:"-"`
// The user-friendly group name // The user-friendly group name
DisplayName string `json:"displayName,omitempty"` DisplayName string `json:"displayName,omitempty"`
@ -265,6 +273,133 @@ func GetDescription(name string) (*Description, error) {
return readDescription(name, true) 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 // readDescription reads a group's description from disk
func readDescription(name string, allowSubgroups bool) (*Description, error) { func readDescription(name string, allowSubgroups bool) (*Description, error) {
r, fileName, isSubgroup, err := r, fileName, isSubgroup, err :=
@ -291,6 +426,7 @@ func readDescription(name string, allowSubgroups bool) (*Description, error) {
if !desc.AutoSubgroups { if !desc.AutoSubgroups {
return nil, os.ErrNotExist return nil, os.ErrNotExist
} }
desc.isSubgroup = true
desc.Public = false desc.Public = false
desc.Description = "" desc.Description = ""
} }
@ -374,3 +510,150 @@ func upgradeDescription(desc *Description) error {
return 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 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 ( import (
"encoding/json" "encoding/json"
"errors"
"os"
"reflect" "reflect"
"testing" "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 { type NotAuthorisedError struct {
err error err error
} }
func (err *NotAuthorisedError) Error() string { func (err *NotAuthorisedError) Error() string {
if err.err != nil { if err.err != nil {
return "not authorised: " + err.err.Error() return "not authorised: " + err.err.Error()
@ -857,6 +858,7 @@ type Configuration struct {
PublicServer bool `json:"publicServer"` PublicServer bool `json:"publicServer"`
CanonicalHost string `json:"canonicalHost"` CanonicalHost string `json:"canonicalHost"`
ProxyURL string `json:"proxyURL"` ProxyURL string `json:"proxyURL"`
WritableGroups bool `json:"writableGroups"`
Users map[string]UserDescription Users map[string]UserDescription
// obsolete fields // obsolete fields