mirror of
https://github.com/jech/galene.git
synced 2024-11-22 16:45:58 +01:00
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:
parent
e14eec86d3
commit
fc6387bb38
4 changed files with 411 additions and 4 deletions
2
README
2
README
|
@ -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.
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue