Started reworking skaldctl. Give room status along with room name.
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
# shellcheck shell=bash disable=SC2034,SC2154
|
# shellcheck shell=bash disable=SC2034,SC2154
|
||||||
|
|
||||||
pkgname=skald-git
|
pkgname=skald-git
|
||||||
pkgver=0.0.0.r1538.g8e265ed
|
pkgver=0.0.0.r1539.g4f35f4b
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc='Audio-only hall-based conferencing server'
|
pkgdesc='Audio-only hall-based conferencing server'
|
||||||
arch=('x86_64' 'aarch64')
|
arch=('x86_64' 'aarch64')
|
||||||
|
|||||||
+2
-2
@@ -65,9 +65,9 @@ Current runtime defaults and config paths:
|
|||||||
- `data/ice-servers.json`
|
- `data/ice-servers.json`
|
||||||
- `data/var/tokens.jsonl`
|
- `data/var/tokens.jsonl`
|
||||||
- `halls/hallname.json`
|
- `halls/hallname.json`
|
||||||
- `skaldctl.json`
|
|
||||||
|
|
||||||
The old `groups/` and `galenectl.json` paths are not current runtime defaults.
|
The old `groups/`, `galenectl.json`, and `skaldctl.json` paths are not current
|
||||||
|
runtime defaults.
|
||||||
Existing Galene installations should be treated as incompatible unless a later
|
Existing Galene installations should be treated as incompatible unless a later
|
||||||
task explicitly adds a one-shot manual migration tool or documented manual
|
task explicitly adds a one-shot manual migration tool or documented manual
|
||||||
conversion procedure.
|
conversion procedure.
|
||||||
|
|||||||
+45
-2
@@ -18,6 +18,13 @@ import (
|
|||||||
var ErrTagMismatch = errors.New("tag mismatch")
|
var ErrTagMismatch = errors.New("tag mismatch")
|
||||||
var ErrDescriptionsNotWritable = &NotAuthorisedError{}
|
var ErrDescriptionsNotWritable = &NotAuthorisedError{}
|
||||||
var ErrUnknownPermission = errors.New("unknown permission")
|
var ErrUnknownPermission = errors.New("unknown permission")
|
||||||
|
var localAdministration bool
|
||||||
|
|
||||||
|
// SetLocalAdministration allows a local administration tool to update hall
|
||||||
|
// files without enabling writes through the server's HTTP API.
|
||||||
|
func SetLocalAdministration(enabled bool) {
|
||||||
|
localAdministration = enabled
|
||||||
|
}
|
||||||
|
|
||||||
type Permissions struct {
|
type Permissions struct {
|
||||||
// non-empty for a named permissions set
|
// non-empty for a named permissions set
|
||||||
@@ -338,6 +345,9 @@ func makeETag(fileSize int64, modTime time.Time) string {
|
|||||||
// DeleteDescription deletes a description (and therefore persistently
|
// DeleteDescription deletes a description (and therefore persistently
|
||||||
// deletes a hall) but only if it matches a given ETag.
|
// deletes a hall) but only if it matches a given ETag.
|
||||||
func DeleteDescription(name, etag string) error {
|
func DeleteDescription(name, etag string) error {
|
||||||
|
if !validHallName(name) {
|
||||||
|
return UserError("illegal hall name")
|
||||||
|
}
|
||||||
halls.mu.Lock()
|
halls.mu.Lock()
|
||||||
defer halls.mu.Unlock()
|
defer halls.mu.Unlock()
|
||||||
|
|
||||||
@@ -357,7 +367,22 @@ func UpdateDescription(name, etag string, desc *Description) error {
|
|||||||
if desc.Users != nil || desc.WildcardUser != nil || desc.AuthKeys != nil {
|
if desc.Users != nil || desc.WildcardUser != nil || desc.AuthKeys != nil {
|
||||||
return errors.New("description is not sanitised")
|
return errors.New("description is not sanitised")
|
||||||
}
|
}
|
||||||
|
return updateDescription(name, etag, desc, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteDescription writes a full hall description from a local administration
|
||||||
|
// tool. It is not available to the server's HTTP API.
|
||||||
|
func WriteDescription(name, etag string, desc *Description) error {
|
||||||
|
if !localAdministration {
|
||||||
|
return ErrDescriptionsNotWritable
|
||||||
|
}
|
||||||
|
return updateDescription(name, etag, desc, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateDescription(name, etag string, desc *Description, preserveSecrets bool) error {
|
||||||
|
if !validHallName(name) {
|
||||||
|
return UserError("illegal hall name")
|
||||||
|
}
|
||||||
halls.mu.Lock()
|
halls.mu.Lock()
|
||||||
defer halls.mu.Unlock()
|
defer halls.mu.Unlock()
|
||||||
|
|
||||||
@@ -381,7 +406,7 @@ func UpdateDescription(name, etag string, desc *Description) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
newdesc := *desc
|
newdesc := *desc
|
||||||
if old != nil {
|
if old != nil && preserveSecrets {
|
||||||
newdesc.Users = old.Users
|
newdesc.Users = old.Users
|
||||||
newdesc.WildcardUser = old.WildcardUser
|
newdesc.WildcardUser = old.WildcardUser
|
||||||
newdesc.AuthKeys = old.AuthKeys
|
newdesc.AuthKeys = old.AuthKeys
|
||||||
@@ -395,7 +420,7 @@ func rewriteDescriptionFile(filename string, desc *Description) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !conf.WritableHalls {
|
if !conf.WritableHalls && !localAdministration {
|
||||||
return ErrDescriptionsNotWritable
|
return ErrDescriptionsNotWritable
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,9 +682,15 @@ func GetUserTag(hall, username string, wildcard bool) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func DeleteUser(hall, username string, wildcard bool, etag string) error {
|
func DeleteUser(hall, username string, wildcard bool, etag string) error {
|
||||||
|
if !validHallName(hall) {
|
||||||
|
return UserError("illegal hall name")
|
||||||
|
}
|
||||||
if wildcard && username != "" {
|
if wildcard && username != "" {
|
||||||
return errors.New("wildcard with username")
|
return errors.New("wildcard with username")
|
||||||
}
|
}
|
||||||
|
if !wildcard && !validUsername(username) {
|
||||||
|
return UserError("illegal username")
|
||||||
|
}
|
||||||
|
|
||||||
halls.mu.Lock()
|
halls.mu.Lock()
|
||||||
defer halls.mu.Unlock()
|
defer halls.mu.Unlock()
|
||||||
@@ -698,9 +729,15 @@ func DeleteUser(hall, username string, wildcard bool, etag string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func UpdateUser(hall, username string, wildcard bool, etag string, user *UserDescription) error {
|
func UpdateUser(hall, username string, wildcard bool, etag string, user *UserDescription) error {
|
||||||
|
if !validHallName(hall) {
|
||||||
|
return UserError("illegal hall name")
|
||||||
|
}
|
||||||
if wildcard && username != "" {
|
if wildcard && username != "" {
|
||||||
return errors.New("wildcard with username")
|
return errors.New("wildcard with username")
|
||||||
}
|
}
|
||||||
|
if !wildcard && !validUsername(username) {
|
||||||
|
return UserError("illegal username")
|
||||||
|
}
|
||||||
if user.Password.Type != "" || user.Password.Key != nil {
|
if user.Password.Type != "" || user.Password.Key != nil {
|
||||||
return errors.New("user description is not sanitised")
|
return errors.New("user description is not sanitised")
|
||||||
}
|
}
|
||||||
@@ -750,9 +787,15 @@ func UpdateUser(hall, username string, wildcard bool, etag string, user *UserDes
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SetUserPassword(hall, username string, wildcard bool, pw Password) error {
|
func SetUserPassword(hall, username string, wildcard bool, pw Password) error {
|
||||||
|
if !validHallName(hall) {
|
||||||
|
return UserError("illegal hall name")
|
||||||
|
}
|
||||||
if wildcard && username != "" {
|
if wildcard && username != "" {
|
||||||
return errors.New("wildcard with username")
|
return errors.New("wildcard with username")
|
||||||
}
|
}
|
||||||
|
if !wildcard && !validUsername(username) {
|
||||||
|
return UserError("illegal username")
|
||||||
|
}
|
||||||
|
|
||||||
halls.mu.Lock()
|
halls.mu.Lock()
|
||||||
defer halls.mu.Unlock()
|
defer halls.mu.Unlock()
|
||||||
|
|||||||
@@ -263,6 +263,44 @@ func TestWritableHalls(t *testing.T) {
|
|||||||
testUser(t, "", true)
|
testUser(t, "", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLocalAdministrationWritesFullDescription(t *testing.T) {
|
||||||
|
err := setupTest(t.TempDir(), t.TempDir(), false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setupTest: %v", err)
|
||||||
|
}
|
||||||
|
SetLocalAdministration(true)
|
||||||
|
defer SetLocalAdministration(false)
|
||||||
|
|
||||||
|
permissions, err := NewPermissions("op")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPermissions: %v", err)
|
||||||
|
}
|
||||||
|
pw := "secret"
|
||||||
|
err = WriteDescription("test", "", &Description{
|
||||||
|
Autolock: true,
|
||||||
|
Users: map[string]UserDescription{
|
||||||
|
"Username": {
|
||||||
|
Password: Password{
|
||||||
|
Type: "plain",
|
||||||
|
Key: &pw,
|
||||||
|
},
|
||||||
|
Permissions: permissions,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WriteDescription: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
desc, err := GetDescription("test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDescription: %v", err)
|
||||||
|
}
|
||||||
|
if !desc.Autolock || desc.Users["Username"].Permissions.String() != "op" {
|
||||||
|
t.Fatalf("description = %#v", desc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testUser(t *testing.T, username string, wildcard bool) {
|
func testUser(t *testing.T, username string, wildcard bool) {
|
||||||
_, _, err := GetSanitisedUser("test", username, wildcard)
|
_, _, err := GetSanitisedUser("test", username, wildcard)
|
||||||
if !errors.Is(err, os.ErrNotExist) {
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
|||||||
@@ -405,6 +405,11 @@ func validHallName(name string) bool {
|
|||||||
return s == "/"+name
|
return s == "/"+name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidHallName reports whether name is safe to use as a hall name.
|
||||||
|
func ValidHallName(name string) bool {
|
||||||
|
return validHallName(name)
|
||||||
|
}
|
||||||
|
|
||||||
func add(name string, desc *Description) (*Hall, []Client, error) {
|
func add(name string, desc *Description) (*Hall, []Client, error) {
|
||||||
if !validHallName(name) {
|
if !validHallName(name) {
|
||||||
return nil, nil, UserError("illegal hall name")
|
return nil, nil, UserError("illegal hall name")
|
||||||
@@ -1034,6 +1039,11 @@ func validUsername(username string) bool {
|
|||||||
return username != "" && validHallName(username)
|
return username != "" && validHallName(username)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidUsername reports whether username is safe to use as a login name.
|
||||||
|
func ValidUsername(username string) bool {
|
||||||
|
return validUsername(username)
|
||||||
|
}
|
||||||
|
|
||||||
// called locked
|
// called locked
|
||||||
func (g *Hall) getPermission(creds ClientCredentials) (string, []string, error) {
|
func (g *Hall) getPermission(creds ClientCredentials) (string, []string, error) {
|
||||||
desc := g.description
|
desc := g.description
|
||||||
@@ -1097,6 +1107,7 @@ type Status struct {
|
|||||||
AuthServer string `json:"authServer,omitempty"`
|
AuthServer string `json:"authServer,omitempty"`
|
||||||
AuthPortal string `json:"authPortal,omitempty"`
|
AuthPortal string `json:"authPortal,omitempty"`
|
||||||
Locked bool `json:"locked,omitempty"`
|
Locked bool `json:"locked,omitempty"`
|
||||||
|
Recording bool `json:"recording,omitempty"`
|
||||||
ClientCount *int `json:"clientCount,omitempty"`
|
ClientCount *int `json:"clientCount,omitempty"`
|
||||||
CanChangePassword bool `json:"canChangePassword,omitempty"`
|
CanChangePassword bool `json:"canChangePassword,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,15 @@ func broadcastHallInfo(g *hall.Hall, message string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hallRecording(g *hall.Hall) bool {
|
||||||
|
for _, c := range g.GetClients(nil) {
|
||||||
|
if _, ok := c.(*diskwriter.Client); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func canEditChalkboard(perms []string) bool {
|
func canEditChalkboard(perms []string) bool {
|
||||||
return member("op", perms) ||
|
return member("op", perms) ||
|
||||||
member("admin", perms) ||
|
member("admin", perms) ||
|
||||||
@@ -1193,6 +1202,7 @@ func handleAction(c *webClient, a any) error {
|
|||||||
g = hall.Get(a.hall)
|
g = hall.Get(a.hall)
|
||||||
if g != nil {
|
if g != nil {
|
||||||
s := g.Status(true, nil)
|
s := g.Status(true, nil)
|
||||||
|
s.Recording = hallRecording(g)
|
||||||
status = &s
|
status = &s
|
||||||
data = g.Data()
|
data = g.Data()
|
||||||
}
|
}
|
||||||
@@ -1272,6 +1282,7 @@ func handleAction(c *webClient, a any) error {
|
|||||||
}
|
}
|
||||||
perms := append([]string(nil), c.permissions...)
|
perms := append([]string(nil), c.permissions...)
|
||||||
status := g.Status(true, nil)
|
status := g.Status(true, nil)
|
||||||
|
status.Recording = hallRecording(g)
|
||||||
username := c.username
|
username := c.username
|
||||||
c.write(clientMessage{
|
c.write(clientMessage{
|
||||||
Type: "joined",
|
Type: "joined",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.stormux.org/storm/skald/diskwriter"
|
||||||
"git.stormux.org/storm/skald/hall"
|
"git.stormux.org/storm/skald/hall"
|
||||||
"git.stormux.org/storm/skald/token"
|
"git.stormux.org/storm/skald/token"
|
||||||
"git.stormux.org/storm/skald/unbounded"
|
"git.stormux.org/storm/skald/unbounded"
|
||||||
@@ -56,6 +57,40 @@ func TestParseStatefulToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHallRecording(t *testing.T) {
|
||||||
|
oldDirectory := hall.Directory
|
||||||
|
hall.Directory = t.TempDir()
|
||||||
|
defer func() {
|
||||||
|
hall.Directory = oldDirectory
|
||||||
|
}()
|
||||||
|
if err := os.WriteFile(
|
||||||
|
filepath.Join(hall.Directory, "recording-status-test.json"),
|
||||||
|
[]byte("{}"), 0o600,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("Write hall description: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
g, err := hall.Add("recording-status-test", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Add hall: %v", err)
|
||||||
|
}
|
||||||
|
defer hall.Delete(g.Name())
|
||||||
|
|
||||||
|
if hallRecording(g) {
|
||||||
|
t.Fatal("empty hall reported active recording")
|
||||||
|
}
|
||||||
|
|
||||||
|
disk := diskwriter.New(g)
|
||||||
|
if _, err := hall.AddClient(g.Name(), disk, hall.ClientCredentials{}); err != nil {
|
||||||
|
t.Fatalf("Add recording client: %v", err)
|
||||||
|
}
|
||||||
|
defer hall.DelClient(disk)
|
||||||
|
|
||||||
|
if !hallRecording(g) {
|
||||||
|
t.Fatal("hall did not report active recording")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testWebClient(id string) *webClient {
|
func testWebClient(id string) *webClient {
|
||||||
return &webClient{
|
return &webClient{
|
||||||
id: id,
|
id: id,
|
||||||
|
|||||||
+18
-18
@@ -103,10 +103,9 @@ WantedBy=multi-user.target
|
|||||||
|
|
||||||
### Set up skaldctl
|
### Set up skaldctl
|
||||||
|
|
||||||
There are two ways to administer a Skald instance: by manually editing
|
The `skaldctl` utility administers a Skald instance by editing its JSON files
|
||||||
JSON files on the server, or by using the `skaldctl` utility.
|
locally. Run it on the server as a user that may write the Skald `data/` and
|
||||||
The `skaldctl` utility is recommended, since it avoids issues with
|
`halls/` directories. It does not connect to Skald over HTTPS.
|
||||||
concurrent modifications and is less error-prone than the alternative.
|
|
||||||
|
|
||||||
Build the `skaldctl` utility, and copy it somewhere on your path:
|
Build the `skaldctl` utility, and copy it somewhere on your path:
|
||||||
|
|
||||||
@@ -116,35 +115,36 @@ go build -ldflags='-s -w'
|
|||||||
sudo cp skaldctl /usr/local/bin/
|
sudo cp skaldctl /usr/local/bin/
|
||||||
```
|
```
|
||||||
|
|
||||||
Now create an administrator password, and set up skaldctl:
|
Create the initial server configuration file:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
skaldctl -admin-username admin initial-setup
|
skaldctl initial-setup
|
||||||
```
|
```
|
||||||
|
|
||||||
This command creates two files: `skaldctl.json` and `config.json`. The
|
If Skald uses directories other than `./data` and `./halls`, pass them before
|
||||||
former is already at the right place, the latter must be copied to the
|
the command name. For example, packages that use `/var/lib/skald` can be
|
||||||
server's `data/` directory:
|
administered with:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
rsync config.json skald@skald.example.org:data/
|
sudo -u skald skaldctl -data /var/lib/skald/data -halls /var/lib/skald/halls initial-setup
|
||||||
```
|
```
|
||||||
|
|
||||||
### Hall setup
|
### Hall setup
|
||||||
|
|
||||||
Create a hall:
|
For a guided setup, create a hall interactively:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
skaldctl create-hall -interactive
|
||||||
|
```
|
||||||
|
|
||||||
|
This asks for the hall name, common hall settings, operator accounts, and an
|
||||||
|
optional shared participant password. To create a minimal hall without
|
||||||
|
prompts, use:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
skaldctl create-hall -hall city-watch
|
skaldctl create-hall -hall city-watch
|
||||||
```
|
```
|
||||||
|
|
||||||
If you didn't install a TLS certificate above, you will need to run
|
|
||||||
`skaldctl` with the flag `-insecure`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
skaldctl -insecure create-hall -hall city-watch
|
|
||||||
```
|
|
||||||
|
|
||||||
Create an "op", a user with hall moderation privileges:
|
Create an "op", a user with hall moderation privileges:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -162,9 +162,9 @@ fields are as follows:
|
|||||||
same syntax as user definitions in halls (see below), except that the
|
same syntax as user definitions in halls (see below), except that the
|
||||||
only meaningful permission is `"admin"`;
|
only meaningful permission is `"admin"`;
|
||||||
|
|
||||||
- `writableHalls`: if true, then the API used by `skaldctl` can be used
|
- `writableHalls`: if true, then the HTTP administrative API can be used
|
||||||
to modify hall definitions; if unset or false, then only read-only
|
to modify hall definitions; if unset or false, then the HTTP API is
|
||||||
access is allowed;
|
read-only. This does not affect local `skaldctl` commands;
|
||||||
|
|
||||||
- `recordingRetention`: if set, completed recording files older than this
|
- `recordingRetention`: if set, completed recording files older than this
|
||||||
duration are automatically deleted from the recordings directory. The
|
duration are automatically deleted from the recordings directory. The
|
||||||
@@ -175,8 +175,8 @@ fields are as follows:
|
|||||||
- `allowOrigin` is an array containing the list of HTTP origins that
|
- `allowOrigin` is an array containing the list of HTTP origins that
|
||||||
are allowed to access the server;
|
are allowed to access the server;
|
||||||
|
|
||||||
- `allowAdminOrigin` is like `allowOrigin`, but applies to the
|
- `allowAdminOrigin` is like `allowOrigin`, but applies to the HTTP
|
||||||
administrative API (the one used by `skaldctl`);
|
administrative API;
|
||||||
|
|
||||||
- `proxyURL`: if running behind a reverse proxy, this specifies the root
|
- `proxyURL`: if running behind a reverse proxy, this specifies the root
|
||||||
URL that will be visible outside the proxy;
|
URL that will be visible outside the proxy;
|
||||||
@@ -191,6 +191,9 @@ fields are as follows:
|
|||||||
Halls are described by JSON files in the `halls/` directory. These
|
Halls are described by JSON files in the `halls/` directory. These
|
||||||
files are normally administered using the `skaldctl` utility, but may
|
files are normally administered using the `skaldctl` utility, but may
|
||||||
also be edited manually (there is no need to restart the server).
|
also be edited manually (there is no need to restart the server).
|
||||||
|
`skaldctl` edits these files directly and must be run on the server as a
|
||||||
|
user that may write the hall directory. By default it uses `./data` and
|
||||||
|
`./halls`; use the global `-data` and `-halls` options for other paths.
|
||||||
|
|
||||||
### Managing halls using `skaldctl`
|
### Managing halls using `skaldctl`
|
||||||
|
|
||||||
@@ -202,6 +205,13 @@ A hall is created using `skaldctl create-hall`:
|
|||||||
skaldctl create-hall -hall city-watch
|
skaldctl create-hall -hall city-watch
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For a guided setup that asks for the hall name, common settings, operator
|
||||||
|
accounts, and an optional shared participant password, use:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
skaldctl create-hall -interactive
|
||||||
|
```
|
||||||
|
|
||||||
There are a number of options to customise the behaviour of the hall, see
|
There are a number of options to customise the behaviour of the hall, see
|
||||||
`skaldctl create-hall -help` for a full list. For example, in order to
|
`skaldctl create-hall -help` for a full list. For example, in order to
|
||||||
create a hall that allows unrestricted creation of tokens, say:
|
create a hall that allows unrestricted creation of tokens, say:
|
||||||
@@ -291,8 +301,8 @@ It is sometimes necessary to create a large number of identical halls.
|
|||||||
For example, the author has been using Skald to supervise computer
|
For example, the author has been using Skald to supervise computer
|
||||||
science practicals, where up to 40 students are working in pairs.
|
science practicals, where up to 40 students are working in pairs.
|
||||||
|
|
||||||
While it is possible to automate the creation of halls by accessing
|
While it is possible to automate the creation of halls by scripting calls to
|
||||||
Skald's API, by scripting calls to skaldctl, or by generating files
|
`skaldctl`, by accessing Skald's HTTP administrative API, or by generating files
|
||||||
directly under `halls/`, Skald provides a facility called *automatic
|
directly under `halls/`, Skald provides a facility called *automatic
|
||||||
subhalls* that can be used to generate halls on demand.
|
subhalls* that can be used to generate halls on demand.
|
||||||
|
|
||||||
@@ -300,7 +310,7 @@ Automatic subhalls are enabled by setting the `"auto-subhalls"`
|
|||||||
field in the hall description:
|
field in the hall description:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
skaldctl create-hall unseen-university -auto-subhalls
|
skaldctl create-hall -hall unseen-university -auto-subhalls
|
||||||
```
|
```
|
||||||
|
|
||||||
Whenever a user attempts to access a subhall of `unseen-university`, for
|
Whenever a user attempts to access a subhall of `unseen-university`, for
|
||||||
|
|||||||
+346
-517
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.stormux.org/storm/skald/hall"
|
"git.stormux.org/storm/skald/hall"
|
||||||
@@ -40,6 +43,58 @@ func TestMakePassword(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInteractiveHall(t *testing.T) {
|
||||||
|
input := strings.NewReader(strings.Join([]string{
|
||||||
|
"city-watch",
|
||||||
|
"City Watch",
|
||||||
|
"An audio hall",
|
||||||
|
"yes",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"no",
|
||||||
|
"no",
|
||||||
|
"vetinari",
|
||||||
|
"",
|
||||||
|
"yes",
|
||||||
|
"",
|
||||||
|
}, "\n"))
|
||||||
|
passwords := []string{"secret", "secret", "guest", "guest"}
|
||||||
|
readPassword := func(string) (string, error) {
|
||||||
|
password := passwords[0]
|
||||||
|
passwords = passwords[1:]
|
||||||
|
return password, nil
|
||||||
|
}
|
||||||
|
var output bytes.Buffer
|
||||||
|
name, desc, err := interactiveHall(
|
||||||
|
bufio.NewReader(input), &output, readPassword, "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("interactiveHall: %v", err)
|
||||||
|
}
|
||||||
|
if name != "city-watch" {
|
||||||
|
t.Fatalf("name = %q", name)
|
||||||
|
}
|
||||||
|
if desc.DisplayName != "City Watch" || desc.Description != "An audio hall" {
|
||||||
|
t.Fatalf("description = %#v", desc)
|
||||||
|
}
|
||||||
|
if !desc.Public || !desc.AllowRecording || !desc.Autolock {
|
||||||
|
t.Fatalf("common settings = %#v", desc)
|
||||||
|
}
|
||||||
|
user := desc.Users["vetinari"]
|
||||||
|
if ok, err := user.Password.Match("secret"); !ok || err != nil {
|
||||||
|
t.Fatalf("operator password match = %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
if user.Permissions.String() != "op" {
|
||||||
|
t.Fatalf("operator permissions = %v", user.Permissions)
|
||||||
|
}
|
||||||
|
if desc.WildcardUser == nil {
|
||||||
|
t.Fatal("wildcard user is nil")
|
||||||
|
}
|
||||||
|
if ok, err := desc.WildcardUser.Password.Match("guest"); !ok || err != nil {
|
||||||
|
t.Fatalf("wildcard password match = %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFormatPermissions(t *testing.T) {
|
func TestFormatPermissions(t *testing.T) {
|
||||||
tests := []struct{ j, v, p string }{
|
tests := []struct{ j, v, p string }{
|
||||||
{`"op"`, "op", "[cmopt]"},
|
{`"op"`, "op", "[cmopt]"},
|
||||||
|
|||||||
+25
-2
@@ -43,6 +43,13 @@ let serverConnection;
|
|||||||
*/
|
*/
|
||||||
let hallStatus = {};
|
let hallStatus = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this hall is currently being recorded.
|
||||||
|
*
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
let hallRecording = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True if we need to request a password.
|
* True if we need to request a password.
|
||||||
*
|
*
|
||||||
@@ -2023,7 +2030,13 @@ function capitalise(s) {
|
|||||||
* @param {string} title
|
* @param {string} title
|
||||||
*/
|
*/
|
||||||
function setTitle(title) {
|
function setTitle(title) {
|
||||||
let displayTitle = title ? `Hall: ${title}` : 'Skald';
|
let displayTitle = 'Skald';
|
||||||
|
if(title) {
|
||||||
|
let status = hallStatus.locked ? 'locked' : 'unlocked';
|
||||||
|
if(hallRecording)
|
||||||
|
status += ', recording';
|
||||||
|
displayTitle = `Hall: ${title} (${status})`;
|
||||||
|
}
|
||||||
document.title = displayTitle;
|
document.title = displayTitle;
|
||||||
document.getElementById('title').textContent = displayTitle;
|
document.getElementById('title').textContent = displayTitle;
|
||||||
}
|
}
|
||||||
@@ -2091,6 +2104,7 @@ async function gotJoined(kind, hall, perms, status, data, error, message) {
|
|||||||
document.location.href = message;
|
document.location.href = message;
|
||||||
return;
|
return;
|
||||||
case 'leave':
|
case 'leave':
|
||||||
|
hallRecording = false;
|
||||||
closeSafariStream();
|
closeSafariStream();
|
||||||
this.close();
|
this.close();
|
||||||
setButtonsVisibility();
|
setButtonsVisibility();
|
||||||
@@ -2110,9 +2124,12 @@ async function gotJoined(kind, hall, perms, status, data, error, message) {
|
|||||||
token = null;
|
token = null;
|
||||||
}
|
}
|
||||||
// don't discard endPoint and friends
|
// don't discard endPoint and friends
|
||||||
|
hallStatus.locked = false;
|
||||||
|
hallStatus.recording = false;
|
||||||
for(let key in status)
|
for(let key in status)
|
||||||
hallStatus[key] = status[key];
|
hallStatus[key] = status[key];
|
||||||
setTitle((status && status.displayName) || capitalise(hall));
|
hallRecording = !!hallStatus.recording;
|
||||||
|
setTitle(hallStatus.displayName || capitalise(hall));
|
||||||
setConnected(true);
|
setConnected(true);
|
||||||
displayUsername();
|
displayUsername();
|
||||||
updateChalkboardAccess();
|
updateChalkboardAccess();
|
||||||
@@ -2392,9 +2409,15 @@ function gotUserMessage(id, dest, username, time, privileged, kind, error, messa
|
|||||||
}
|
}
|
||||||
let text = '' + message;
|
let text = '' + message;
|
||||||
if(text === 'Recording started') {
|
if(text === 'Recording started') {
|
||||||
|
hallRecording = true;
|
||||||
|
setTitle(hallStatus.displayName || capitalise(hall));
|
||||||
announceUrgent(text);
|
announceUrgent(text);
|
||||||
playNotificationSound('recording-started');
|
playNotificationSound('recording-started');
|
||||||
} else {
|
} else {
|
||||||
|
if(text === 'Recording stopped') {
|
||||||
|
hallRecording = false;
|
||||||
|
setTitle(hallStatus.displayName || capitalise(hall));
|
||||||
|
}
|
||||||
announceChat(text);
|
announceChat(text);
|
||||||
if(text === 'Recording stopped')
|
if(text === 'Recording stopped')
|
||||||
playNotificationSound('recording-stopped');
|
playNotificationSound('recording-stopped');
|
||||||
|
|||||||
@@ -154,3 +154,19 @@ X Verify completed files with ffprobe or equivalent in tests where available.
|
|||||||
- Verify sounds play after login in Firefox.
|
- Verify sounds play after login in Firefox.
|
||||||
- Verify sounds never replace screen-reader live-region announcements.
|
- Verify sounds never replace screen-reader live-region announcements.
|
||||||
- Tune sound levels and shapes with live users.
|
- Tune sound levels and shapes with live users.
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# PHASE 8: LOCAL SKALDCTL ADMINISTRATION
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
X Remove skaldctl's remote HTTPS administration path and operate directly on
|
||||||
|
the server's local data, hall, and token files.
|
||||||
|
X Add `create-hall -interactive` for guided hall settings, operator accounts,
|
||||||
|
and an optional shared participant password.
|
||||||
|
X Document that skaldctl runs on the Skald server with filesystem access.
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# PHASE 9: HALL HEADING STATUS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
X Show lock and recording state in the browser's hall heading.
|
||||||
|
|||||||
+1
-1
@@ -180,7 +180,7 @@ func apiHallHandler(w http.ResponseWriter, r *http.Request, pth string) {
|
|||||||
first, kind, rest := splitPath(pth)
|
first, kind, rest := splitPath(pth)
|
||||||
g := ""
|
g := ""
|
||||||
if first != "" {
|
if first != "" {
|
||||||
g = first[1:]
|
g = strings.TrimSuffix(first[1:], "/")
|
||||||
}
|
}
|
||||||
if g == "" && kind == "" {
|
if g == "" && kind == "" {
|
||||||
if apiCORS(w, r, "HEAD, GET") {
|
if apiCORS(w, r, "HEAD, GET") {
|
||||||
|
|||||||
@@ -60,3 +60,15 @@ func TestStaticLiveRegionAnnouncementsStayWired(t *testing.T) {
|
|||||||
requireFunctionContains(t, js, "setUserStatus", "announceUrgent(`${username}: hand raised`)")
|
requireFunctionContains(t, js, "setUserStatus", "announceUrgent(`${username}: hand raised`)")
|
||||||
requireFunctionContains(t, js, "setUserStatus", "announceUrgent(`${username}: hand lowered`)")
|
requireFunctionContains(t, js, "setUserStatus", "announceUrgent(`${username}: hand lowered`)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHallHeadingIncludesCurrentStatus(t *testing.T) {
|
||||||
|
js := readStaticFile(t, "skald.js")
|
||||||
|
|
||||||
|
requireFunctionContains(t, js, "setTitle", "hallStatus.locked ? 'locked' : 'unlocked'")
|
||||||
|
requireFunctionContains(t, js, "setTitle", "status += ', recording'")
|
||||||
|
requireFunctionContains(t, js, "gotJoined", "hallStatus.locked = false")
|
||||||
|
requireFunctionContains(t, js, "gotJoined", "hallStatus.recording = false")
|
||||||
|
requireFunctionContains(t, js, "gotJoined", "hallRecording = !!hallStatus.recording")
|
||||||
|
requireFunctionContains(t, js, "gotUserMessage", "hallRecording = true")
|
||||||
|
requireFunctionContains(t, js, "gotUserMessage", "hallRecording = false")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user