Merge remote-tracking branch 'upstream/master' into a11y

This commit is contained in:
Storm Dragon
2026-05-09 12:48:22 -04:00
11 changed files with 111 additions and 62 deletions
+6
View File
@@ -12,6 +12,12 @@ Galene 1.1 (unreleased):
* Implemented support for RSA tokens. Thanks to Jean Dupouy. * Implemented support for RSA tokens. Thanks to Jean Dupouy.
* Added support for include-inferiors in cryptographic tokens. Thanks * Added support for include-inferiors in cryptographic tokens. Thanks
to izeau. to izeau.
* Fixed an issue with the chat window and the users' list disappearing
on mobile. Thanks to Kirill smelkov.
* Fixed an issue that made it impossible to add a wildcard user to a
group with no users. Thanks to Andrea Zucchelli.
* Added some more protection against denial of service attacks. Thanks
to Vinayak Mishra.
9 August 2025: Galene 1.0 9 August 2025: Galene 1.0
+15 -1
View File
@@ -28,7 +28,19 @@ in this directory.
* [galene.md][2]: usage and administration; * [galene.md][2]: usage and administration;
* [galene-client.md][3]: writing clients; * [galene-client.md][3]: writing clients;
* [galene-protocol.md][4]: the client protocol; * [galene-protocol.md][4]: the client protocol;
* [galene-api.md][4]: Galene's administrative API. * [galene-api.md][5]: Galene's administrative API.
## Contributing
In order to contribute to Galene, you may:
* send patches to [the Galene mailing list][6] by sending mail to
<galene@lists.galene.org>;
* submit pull requests on GitHub.
For general discussion, please use the [Galene mailing list][6] (feel free
to send mail without subscribing). Please do not use Github for general
discussion.
## Further information ## Further information
@@ -43,3 +55,5 @@ Answers to common questions and issues are at <https://galene.org/faq.html>.
[2]: <galene.md> [2]: <galene.md>
[3]: <galene-client.md> [3]: <galene-client.md>
[4]: <galene-protocol.md> [4]: <galene-protocol.md>
[5]: <galene-api.md>
[6]: <https://lists.galene.org/>
+3 -3
View File
@@ -31,8 +31,8 @@ type Password RawPassword
// out of memory when doing too many BCrypt hashes at the same time. // out of memory when doing too many BCrypt hashes at the same time.
var hashSemaphore = make(chan struct{}, runtime.GOMAXPROCS(-1)) var hashSemaphore = make(chan struct{}, runtime.GOMAXPROCS(-1))
// constantTimeCompare compares a and b in time proportional to the length of a. // ConstantTimeCompare compares a and b in time proportional to the length of a.
func constantTimeCompare(a, b string) bool { func ConstantTimeCompare(a, b string) bool {
as := []byte(a) as := []byte(a)
bs := make([]byte, len(as)) bs := make([]byte, len(as))
copy(bs, b) copy(bs, b)
@@ -48,7 +48,7 @@ func (p Password) Match(pw string) (bool, error) {
if p.Key == nil { if p.Key == nil {
return false, errors.New("missing key") return false, errors.New("missing key")
} }
return constantTimeCompare(pw, *p.Key), nil return ConstantTimeCompare(pw, *p.Key), nil
case "wildcard": case "wildcard":
return true, nil return true, nil
case "pbkdf2": case "pbkdf2":
+3 -3
View File
@@ -761,9 +761,6 @@ func SetUserPassword(group, username string, wildcard bool, pw Password) error {
if err != nil { if err != nil {
return err return err
} }
if desc.Users == nil {
return os.ErrNotExist
}
if wildcard { if wildcard {
if desc.WildcardUser == nil { if desc.WildcardUser == nil {
@@ -771,6 +768,9 @@ func SetUserPassword(group, username string, wildcard bool, pw Password) error {
} }
desc.WildcardUser.Password = pw desc.WildcardUser.Password = pw
} else { } else {
if desc.Users == nil {
return os.ErrNotExist
}
user, ok := desc.Users[username] user, ok := desc.Users[username]
if !ok { if !ok {
return os.ErrNotExist return os.ErrNotExist
+1 -1
View File
@@ -25,7 +25,7 @@ func TestConstantTimeCompare(t *testing.T) {
} }
for _, test := range tests { for _, test := range tests {
e := constantTimeCompare(test.a, test.b) e := ConstantTimeCompare(test.a, test.b)
if e != (test.a == test.b) { if e != (test.a == test.b) {
t.Errorf("constantTimeCompare(%v, %v): got %v", t.Errorf("constantTimeCompare(%v, %v): got %v",
test.a, test.b, e, test.a, test.b, e,
+40 -38
View File
@@ -832,11 +832,14 @@ func readMessage(conn *websocket.Conn, m *clientMessage) error {
return conn.ReadJSON(&m) return conn.ReadJSON(&m)
} }
const maxWSMessageSize = 1024 * 1024
const protocolVersion = "2" const protocolVersion = "2"
func StartClient(conn *websocket.Conn, addr net.Addr) (err error) { func StartClient(conn *websocket.Conn, addr net.Addr) (err error) {
var m clientMessage var m clientMessage
conn.SetReadLimit(maxWSMessageSize)
err = readMessage(conn, &m) err = readMessage(conn, &m)
if err != nil { if err != nil {
conn.Close() conn.Close()
@@ -919,6 +922,10 @@ type pushClientAction struct {
data map[string]interface{} data map[string]interface{}
} }
type changePermissionsAction struct {
kind string
}
type permissionsChangedAction struct{} type permissionsChangedAction struct{}
type joinedAction struct { type joinedAction struct {
@@ -1219,6 +1226,29 @@ func handleAction(c *webClient, a any) error {
} }
} }
} }
case changePermissionsAction:
switch a.kind {
case "op":
c.permissions = addnew("op", c.permissions)
g := c.Group()
if g != nil && g.Description().AllowRecording {
c.permissions = addnew("record", c.permissions)
}
case "unop":
c.permissions = remove("op", c.permissions)
c.permissions = remove("record", c.permissions)
case "present":
c.permissions = addnew("present", c.permissions)
case "unpresent":
c.permissions = remove("present", c.permissions)
case "shutup":
c.permissions = remove("message", c.permissions)
case "unshutup":
c.permissions = addnew("message", c.permissions)
default:
return group.UserError("unknown permission")
}
c.action(permissionsChangedAction{})
case permissionsChangedAction: case permissionsChangedAction:
g := c.Group() g := c.Group()
if g == nil { if g == nil {
@@ -1335,41 +1365,6 @@ func closeDownConn(c *webClient, id string, message string) error {
return nil return nil
} }
func setPermissions(g *group.Group, id string, perm string) error {
client := g.GetClient(id)
if client == nil {
return group.UserError("no such user")
}
c, ok := client.(*webClient)
if !ok {
return group.UserError("this is not a real user")
}
switch perm {
case "op":
c.permissions = addnew("op", c.permissions)
if g.Description().AllowRecording {
c.permissions = addnew("record", c.permissions)
}
case "unop":
c.permissions = remove("op", c.permissions)
c.permissions = remove("record", c.permissions)
case "present":
c.permissions = addnew("present", c.permissions)
case "unpresent":
c.permissions = remove("present", c.permissions)
case "shutup":
c.permissions = remove("message", c.permissions)
case "unshutup":
c.permissions = addnew("message", c.permissions)
default:
return group.UserError("unknown permission")
}
c.action(permissionsChangedAction{})
return nil
}
func (c *webClient) Kick(id string, user *string, message string) error { func (c *webClient) Kick(id string, user *string, message string) error {
c.action(kickAction{id, user, message}) c.action(kickAction{id, user, message})
return nil return nil
@@ -1909,10 +1904,17 @@ func handleClientMessage(c *webClient, m clientMessage) error {
if !member("op", c.permissions) { if !member("op", c.permissions) {
return c.error(group.UserError("not authorised")) return c.error(group.UserError("not authorised"))
} }
err := setPermissions(g, m.Dest, m.Kind) t := g.GetClient(m.Dest)
if err != nil { if t == nil {
return c.error(err) return c.error(group.UserError("no suck user"))
} }
target, ok := t.(*webClient)
if !ok {
return c.error(group.UserError(
"this is not a real user",
))
}
target.action(changePermissionsAction{m.Kind})
case "identify": case "identify":
if !member("op", c.permissions) { if !member("op", c.permissions) {
return c.error(group.UserError("not authorised")) return c.error(group.UserError("not authorised"))
+3 -1
View File
@@ -1301,6 +1301,8 @@ header .collapse:hover {
#left-sidebar.active { #left-sidebar.active {
min-width: 200px; min-width: 200px;
max-width: 200px; max-width: 200px;
/* on top of video peers */
z-index: 1002;
} }
#left-sidebar { #left-sidebar {
@@ -1419,7 +1421,7 @@ header .collapse:hover {
#invite-dialog input { #invite-dialog input {
margin-top: 1em; margin-top: 1em;
width: 12em; width: 16em;
padding: .2em padding: .2em
} }
+2 -2
View File
@@ -196,7 +196,7 @@
</form> </form>
<form> <form>
<input id="preprocessingbox" type="checkbox"/ checked> <input id="preprocessingbox" type="checkbox" checked/>
<label for="preprocessingbox">Noise suppression</label> <label for="preprocessingbox">Noise suppression</label>
</form> </form>
@@ -305,7 +305,7 @@
<input id="invite-expires" type="datetime-local"/> <input id="invite-expires" type="datetime-local"/>
<br> <br>
<button id="invite-cancel" value="cancel" type="button">Cancel</button> <button id="invite-cancel" value="cancel" type="button">Cancel</button>
<button value="invite" value="invite">Invite</button> <button value="invite">Invite</button>
</form> </form>
</dialog> </dialog>
+25 -8
View File
@@ -397,7 +397,7 @@ function setChangePassword(username) {
if(!(a instanceof HTMLAnchorElement)) if(!(a instanceof HTMLAnchorElement))
throw new Error('Bad type for chpwspan'); throw new Error('Bad type for chpwspan');
if(username) { if(username) {
a.href = `/change-password.html?group=${encodeURI(group)}&username=${encodeURI(username)}`; a.href = `/change-password.html?group=${encodeURIComponent(group)}&username=${encodeURIComponent(username)}`;
a.target = '_blank'; a.target = '_blank';
s.classList.remove('invisible'); s.classList.remove('invisible');
} else { } else {
@@ -533,7 +533,9 @@ function setViewportHeight() {
document.documentElement.style.setProperty( document.documentElement.style.setProperty(
'--vh', `${window.innerHeight/100}px`, '--vh', `${window.innerHeight/100}px`,
); );
showVideo(); if (!getVisibility('left')) {
showVideo();
}
// Ajust video component size // Ajust video component size
resizePeers(); resizePeers();
} }
@@ -577,6 +579,16 @@ function setVisibility(id, visible) {
elt.classList.add('invisible'); elt.classList.add('invisible');
} }
/**
* getVisibility tells whether specified element is visible.
*
* @param {string} id
*/
function getVisibility(id) {
let elt = document.getElementById(id);
return !elt.classList.contains('invisible');
}
/** /**
* Shows and hides various UI elements depending on the protocol state. * Shows and hides various UI elements depending on the protocol state.
*/ */
@@ -1204,6 +1216,7 @@ Filter.prototype.draw = async function() {
}); });
if(frameRate && frameRate != this.frameRate) { if(frameRate && frameRate != this.frameRate) {
clearInterval(this.timer); clearInterval(this.timer);
this.frameRate = frameRate;
this.timer = setInterval(() => this.draw(), 1000 / this.frameRate); this.timer = setInterval(() => this.draw(), 1000 / this.frameRate);
} }
} }
@@ -2521,7 +2534,7 @@ document.getElementById('invite-dialog').onclose = function(e) {
try { try {
expires = dateFromInput(ex.value); expires = dateFromInput(ex.value);
} catch(e) { } catch(e) {
displayError(`Couldn't parse ${nb.value}: ${e}`); displayError(`Couldn't parse ${ex.value}: ${e}`);
return; return;
} }
} }
@@ -2673,7 +2686,7 @@ function addUser(id, userinfo) {
user.setAttribute('tabindex', '0'); user.setAttribute('tabindex', '0');
setUserStatus(id, user, userinfo); setUserStatus(id, user, userinfo);
user.addEventListener('click', function(e) { user.addEventListener('click', function(e) {
let elt = e.target; let elt = e.currentTarget;
if(!elt || !(elt instanceof HTMLElement)) if(!elt || !(elt instanceof HTMLElement))
throw new Error("Couldn't find user div"); throw new Error("Couldn't find user div");
userMenu(elt); userMenu(elt);
@@ -3185,7 +3198,7 @@ function gotUserMessage(id, dest, username, time, privileged, kind, error, messa
case 'kicked': case 'kicked':
case 'error': case 'error':
case 'warning': case 'warning':
case 'info': case 'info': {
if(!privileged) { if(!privileged) {
console.error(`Got unprivileged message of kind ${kind}`); console.error(`Got unprivileged message of kind ${kind}`);
return; return;
@@ -3193,7 +3206,8 @@ function gotUserMessage(id, dest, username, time, privileged, kind, error, messa
let from = id ? (username || 'Anonymous') : 'The Server'; let from = id ? (username || 'Anonymous') : 'The Server';
displayError(`${from} said: ${message}`, kind); displayError(`${from} said: ${message}`, kind);
break; break;
case 'mute': }
case 'mute': {
if(!privileged) { if(!privileged) {
console.error(`Got unprivileged message of kind ${kind}`); console.error(`Got unprivileged message of kind ${kind}`);
return; return;
@@ -3202,6 +3216,7 @@ function gotUserMessage(id, dest, username, time, privileged, kind, error, messa
let by = username ? ' by ' + username : ''; let by = username ? ' by ' + username : '';
displayWarning(`You have been muted${by}`); displayWarning(`You have been muted${by}`);
break; break;
}
case 'clearchat': { case 'clearchat': {
if(!privileged) { if(!privileged) {
console.error(`Got unprivileged message of kind ${kind}`); console.error(`Got unprivileged message of kind ${kind}`);
@@ -3975,7 +3990,7 @@ function parseCommand(line) {
while(i < line.length && line[i] === ' ') while(i < line.length && line[i] === ' ')
i++; i++;
let start = ' '; let start = ' ';
if(i < line.length && line[i] === '"' || line[i] === "'") { if(i < line.length && (line[i] === '"' || line[i] === "'")) {
start = line[i]; start = line[i];
i++; i++;
} }
@@ -4334,7 +4349,9 @@ function handleInput() {
} else { } else {
let c = commands[cmd]; let c = commands[cmd];
if(!c) { if(!c) {
displayError(`Uknown command /${cmd}, type /help for help`); displayError(
`Unknown command /${cmd}, type /help for help`
);
return; return;
} }
if(c.predicate) { if(c.predicate) {
+12 -4
View File
@@ -84,6 +84,8 @@ func sendJSON(w http.ResponseWriter, r *http.Request, v any) {
e.Encode(v) e.Encode(v)
} }
const maxAPIMessageSize = 1024 * 1024
func getText(w http.ResponseWriter, r *http.Request) ([]byte, bool) { func getText(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
ctype, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) ctype, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || !strings.EqualFold(ctype, "text/plain") { if err != nil || !strings.EqualFold(ctype, "text/plain") {
@@ -93,7 +95,9 @@ func getText(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
return nil, true return nil, true
} }
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 4096)) body, err := io.ReadAll(
http.MaxBytesReader(w, r.Body, maxAPIMessageSize),
)
if err != nil { if err != nil {
httpError(w, err) httpError(w, err)
return nil, true return nil, true
@@ -111,7 +115,9 @@ func getJSON(w http.ResponseWriter, r *http.Request, v any) bool {
return true return true
} }
d := json.NewDecoder(r.Body) d := json.NewDecoder(
http.MaxBytesReader(w, r.Body, maxAPIMessageSize),
)
err = d.Decode(v) err = d.Decode(v)
if err != nil { if err != nil {
httpError(w, err) httpError(w, err)
@@ -495,7 +501,7 @@ type jwkset = struct {
} }
func keysHandler(w http.ResponseWriter, r *http.Request, g string) { func keysHandler(w http.ResponseWriter, r *http.Request, g string) {
if apiCORS(w, r, "HEAD, GET") { if apiCORS(w, r, "PUT, DELETE") {
return return
} }
if !checkAdmin(w, r) { if !checkAdmin(w, r) {
@@ -513,7 +519,9 @@ func keysHandler(w http.ResponseWriter, r *http.Request, g string) {
http.StatusUnsupportedMediaType) http.StatusUnsupportedMediaType)
return return
} }
d := json.NewDecoder(r.Body) d := json.NewDecoder(
http.MaxBytesReader(w, r.Body, maxAPIMessageSize),
)
var keys jwkset var keys jwkset
err = d.Decode(&keys) err = d.Decode(&keys)
if err != nil { if err != nil {
+1 -1
View File
@@ -295,7 +295,7 @@ func whipResourceHandler(w http.ResponseWriter, r *http.Request) {
if t := c.Token(); t != "" { if t := c.Token(); t != "" {
token := parseBearerToken(r.Header.Get("Authorization")) token := parseBearerToken(r.Header.Get("Authorization"))
if token != t { if !group.ConstantTimeCompare(t, token) {
http.Error(w, "Forbidden", http.StatusForbidden) http.Error(w, "Forbidden", http.StatusForbidden)
return return
} }