diff --git a/lib/api/pad/init.go b/lib/api/pad/init.go index 2ffb045f..0c6200b7 100644 --- a/lib/api/pad/init.go +++ b/lib/api/pad/init.go @@ -236,6 +236,7 @@ func Init(initStore *lib.InitStore) { initStore.PrivateAPI.Post("/pads/:padId/copyWithoutHistory", CopyPadWithoutHistory(initStore)) initStore.PrivateAPI.Post("/pads/:padId/move", MovePad(initStore)) initStore.PrivateAPI.Get("/pads/:padId/publicStatus", GetPublicStatus(initStore)) + initStore.PrivateAPI.Post("/pads/:padId/sendClientsMessage", SendClientsMessage(initStore)) initStore.PrivateAPI.Post("/pads/:padId/publicStatus", SetPublicStatus(initStore)) // CRUD operations on pad itself (last to avoid conflicts) diff --git a/lib/api/pad/messaging.go b/lib/api/pad/messaging.go new file mode 100644 index 00000000..3f5a0d33 --- /dev/null +++ b/lib/api/pad/messaging.go @@ -0,0 +1,46 @@ +package pad + +import ( + "github.com/ether/etherpad-go/lib" + errors2 "github.com/ether/etherpad-go/lib/api/errors" + utils2 "github.com/ether/etherpad-go/lib/api/utils" + "github.com/gofiber/fiber/v3" +) + +// SendClientsMessageRequest carries the custom message type to broadcast. +type SendClientsMessageRequest struct { + Msg string `json:"msg"` +} + +// SendClientsMessage godoc +// @Summary Send a custom message to all clients of a pad +// @Description Broadcasts a custom COLLABROOM message type to every client connected to the pad (original API sendClientsMessage) +// @Tags Pads +// @Accept json +// @Produce json +// @Param padId path string true "Pad ID" +// @Param request body SendClientsMessageRequest true "Message type" +// @Success 200 {string} string "OK" +// @Failure 400 {object} errors.Error +// @Failure 404 {object} errors.Error +// @Security BearerAuth +// @Router /admin/api/pads/{padId}/sendClientsMessage [post] +func SendClientsMessage(initStore *lib.InitStore) fiber.Handler { + return func(c fiber.Ctx) error { + padId := c.Params("padId") + var request SendClientsMessageRequest + if err := c.Bind().Body(&request); err != nil { + return c.Status(400).JSON(errors2.InvalidRequestError) + } + if request.Msg == "" { + return c.Status(400).JSON(errors2.NewMissingParamError("msg")) + } + + if _, err := utils2.GetPadSafe(padId, true, nil, nil, initStore.PadManager); err != nil { + return c.Status(404).JSON(errors2.PadNotFoundError) + } + + initStore.Handler.SendCustomMessageToPad(padId, request.Msg) + return c.SendStatus(200) + } +} diff --git a/lib/api/session/init.go b/lib/api/session/init.go index 6b8cddcb..96310297 100644 --- a/lib/api/session/init.go +++ b/lib/api/session/init.go @@ -1,30 +1,20 @@ // Package session implements the Etherpad API session endpoints // (createSession, getSessionInfo, deleteSession, listSessionsOfGroup, -// listSessionsOfAuthor from the original HTTP API). -// -// API sessions bind an author to a group for access to that group's pads. -// Like the original (which stores session:*, group2sessions:* and -// author2sessions:* records in its key-value database), sessions are kept in -// the generic key-value storage of the DataStore under "apisession:" keys — -// no dedicated table is needed. +// listSessionsOfAuthor from the original HTTP API). Storage and lookup live +// in pad.SessionManager, which SecurityManager.CheckAccess also consults for +// access to private group pads. package session import ( - "encoding/json" + "sort" "time" "github.com/ether/etherpad-go/lib" "github.com/ether/etherpad-go/lib/api/errors" - "github.com/ether/etherpad-go/lib/utils" + "github.com/ether/etherpad-go/lib/pad" "github.com/gofiber/fiber/v3" ) -const ( - sessionKeyPrefix = "apisession:" - groupKeyPrefix = "apisessions:group:" - authorKeyPrefix = "apisessions:author:" -) - // CreateSessionRequest represents the request to create an API session type CreateSessionRequest struct { GroupID string `json:"groupID"` @@ -55,63 +45,21 @@ type SessionListResponse struct { Sessions []SessionWithID `json:"sessions"` } -func loadSession(store *lib.InitStore, sessionId string) (*SessionInfoResponse, error) { - payload, err := store.Store.GetOIDCStorageValue(sessionKeyPrefix + sessionId) - if err != nil || payload == nil { - return nil, err - } - var info SessionInfoResponse - if err := json.Unmarshal([]byte(*payload), &info); err != nil { - return nil, err +func toInfoResponse(info pad.ApiSessionInfo) SessionInfoResponse { + return SessionInfoResponse{ + GroupID: info.GroupID, + AuthorID: info.AuthorID, + ValidUntil: info.ValidUntil, } - return &info, nil } -func loadIDList(store *lib.InitStore, key string) ([]string, error) { - payload, err := store.Store.GetOIDCStorageValue(key) - if err != nil || payload == nil { - return []string{}, err +func toListResponse(sessions map[string]pad.ApiSessionInfo) SessionListResponse { + list := make([]SessionWithID, 0, len(sessions)) + for id, info := range sessions { + list = append(list, SessionWithID{SessionID: id, SessionInfoResponse: toInfoResponse(info)}) } - var ids []string - if err := json.Unmarshal([]byte(*payload), &ids); err != nil { - return nil, err - } - return ids, nil -} - -func saveIDList(store *lib.InitStore, key string, ids []string) error { - encoded, err := json.Marshal(ids) - if err != nil { - return err - } - return store.Store.SetOIDCStorageValue(key, string(encoded)) -} - -func addToIDList(store *lib.InitStore, key string, id string) error { - ids, err := loadIDList(store, key) - if err != nil { - return err - } - for _, existing := range ids { - if existing == id { - return nil - } - } - return saveIDList(store, key, append(ids, id)) -} - -func removeFromIDList(store *lib.InitStore, key string, id string) error { - ids, err := loadIDList(store, key) - if err != nil { - return err - } - filtered := make([]string, 0, len(ids)) - for _, existing := range ids { - if existing != id { - filtered = append(filtered, existing) - } - } - return saveIDList(store, key, filtered) + sort.Slice(list, func(i, j int) bool { return list[i].SessionID < list[j].SessionID }) + return SessionListResponse{Sessions: list} } // CreateSession godoc @@ -127,7 +75,7 @@ func removeFromIDList(store *lib.InitStore, key string, id string) error { // @Failure 500 {object} errors.Error // @Security BearerAuth // @Router /admin/api/sessions [post] -func CreateSession(store *lib.InitStore) fiber.Handler { +func CreateSession(store *lib.InitStore, sessions *pad.SessionManager) fiber.Handler { return func(c fiber.Ctx) error { var request CreateSessionRequest if err := c.Bind().Body(&request); err != nil { @@ -150,27 +98,11 @@ func CreateSession(store *lib.InitStore) fiber.Handler { return c.Status(404).JSON(errors.NewInvalidParamError("author does not exist")) } - sessionId := "s." + utils.RandomString(16) - info := SessionInfoResponse{ - GroupID: request.GroupID, - AuthorID: request.AuthorID, - ValidUntil: request.ValidUntil, - } - encoded, err := json.Marshal(info) + sessionId, err := sessions.CreateSession(request.GroupID, request.AuthorID, request.ValidUntil) if err != nil { return c.Status(500).JSON(errors.InternalServerError) } - if err := store.Store.SetOIDCStorageValue(sessionKeyPrefix+sessionId, string(encoded)); err != nil { - return c.Status(500).JSON(errors.InternalServerError) - } - if err := addToIDList(store, groupKeyPrefix+request.GroupID, sessionId); err != nil { - return c.Status(500).JSON(errors.InternalServerError) - } - if err := addToIDList(store, authorKeyPrefix+request.AuthorID, sessionId); err != nil { - return c.Status(500).JSON(errors.InternalServerError) - } - return c.JSON(SessionResponse{SessionID: sessionId}) } } @@ -186,17 +118,16 @@ func CreateSession(store *lib.InitStore) fiber.Handler { // @Failure 500 {object} errors.Error // @Security BearerAuth // @Router /admin/api/sessions/{sessionId} [get] -func GetSessionInfo(store *lib.InitStore) fiber.Handler { +func GetSessionInfo(sessions *pad.SessionManager) fiber.Handler { return func(c fiber.Ctx) error { - sessionId := c.Params("sessionId") - info, err := loadSession(store, sessionId) + info, err := sessions.GetSessionInfo(c.Params("sessionId")) if err != nil { return c.Status(500).JSON(errors.InternalServerError) } if info == nil { return c.Status(404).JSON(errors.NewInvalidParamError("session does not exist")) } - return c.JSON(info) + return c.JSON(toInfoResponse(*info)) } } @@ -211,50 +142,19 @@ func GetSessionInfo(store *lib.InitStore) fiber.Handler { // @Failure 500 {object} errors.Error // @Security BearerAuth // @Router /admin/api/sessions/{sessionId} [delete] -func DeleteSession(store *lib.InitStore) fiber.Handler { +func DeleteSession(sessions *pad.SessionManager) fiber.Handler { return func(c fiber.Ctx) error { - sessionId := c.Params("sessionId") - info, err := loadSession(store, sessionId) + deleted, err := sessions.DeleteSession(c.Params("sessionId")) if err != nil { return c.Status(500).JSON(errors.InternalServerError) } - if info == nil { + if !deleted { return c.Status(404).JSON(errors.NewInvalidParamError("session does not exist")) } - - if err := store.Store.DeleteOIDCStorageValue(sessionKeyPrefix + sessionId); err != nil { - return c.Status(500).JSON(errors.InternalServerError) - } - if err := removeFromIDList(store, groupKeyPrefix+info.GroupID, sessionId); err != nil { - return c.Status(500).JSON(errors.InternalServerError) - } - if err := removeFromIDList(store, authorKeyPrefix+info.AuthorID, sessionId); err != nil { - return c.Status(500).JSON(errors.InternalServerError) - } - return c.SendStatus(200) } } -func listSessions(store *lib.InitStore, key string) ([]SessionWithID, error) { - ids, err := loadIDList(store, key) - if err != nil { - return nil, err - } - sessions := make([]SessionWithID, 0, len(ids)) - for _, id := range ids { - info, err := loadSession(store, id) - if err != nil { - return nil, err - } - if info == nil { - continue - } - sessions = append(sessions, SessionWithID{SessionID: id, SessionInfoResponse: *info}) - } - return sessions, nil -} - // ListSessionsOfGroup godoc // @Summary List sessions of a group // @Description Returns all sessions of a group @@ -266,17 +166,17 @@ func listSessions(store *lib.InitStore, key string) ([]SessionWithID, error) { // @Failure 500 {object} errors.Error // @Security BearerAuth // @Router /admin/api/groups/{groupId}/sessions [get] -func ListSessionsOfGroup(store *lib.InitStore) fiber.Handler { +func ListSessionsOfGroup(store *lib.InitStore, sessions *pad.SessionManager) fiber.Handler { return func(c fiber.Ctx) error { groupId := c.Params("groupId") if _, err := store.Store.GetGroup(groupId); err != nil { return c.Status(404).JSON(errors.NewInvalidParamError("group does not exist")) } - sessions, err := listSessions(store, groupKeyPrefix+groupId) + found, err := sessions.ListSessionsOfGroup(groupId) if err != nil { return c.Status(500).JSON(errors.InternalServerError) } - return c.JSON(SessionListResponse{Sessions: sessions}) + return c.JSON(toListResponse(found)) } } @@ -291,24 +191,25 @@ func ListSessionsOfGroup(store *lib.InitStore) fiber.Handler { // @Failure 500 {object} errors.Error // @Security BearerAuth // @Router /admin/api/authors/{authorId}/sessions [get] -func ListSessionsOfAuthor(store *lib.InitStore) fiber.Handler { +func ListSessionsOfAuthor(store *lib.InitStore, sessions *pad.SessionManager) fiber.Handler { return func(c fiber.Ctx) error { authorId := c.Params("authorId") if _, err := store.Store.GetAuthor(authorId); err != nil { return c.Status(404).JSON(errors.NewInvalidParamError("author does not exist")) } - sessions, err := listSessions(store, authorKeyPrefix+authorId) + found, err := sessions.ListSessionsOfAuthor(authorId) if err != nil { return c.Status(500).JSON(errors.InternalServerError) } - return c.JSON(SessionListResponse{Sessions: sessions}) + return c.JSON(toListResponse(found)) } } func Init(store *lib.InitStore) { - store.PrivateAPI.Post("/sessions", CreateSession(store)) - store.PrivateAPI.Get("/sessions/:sessionId", GetSessionInfo(store)) - store.PrivateAPI.Delete("/sessions/:sessionId", DeleteSession(store)) - store.PrivateAPI.Get("/groups/:groupId/sessions", ListSessionsOfGroup(store)) - store.PrivateAPI.Get("/authors/:authorId/sessions", ListSessionsOfAuthor(store)) + sessions := pad.NewSessionManager(store.Store) + store.PrivateAPI.Post("/sessions", CreateSession(store, sessions)) + store.PrivateAPI.Get("/sessions/:sessionId", GetSessionInfo(sessions)) + store.PrivateAPI.Delete("/sessions/:sessionId", DeleteSession(sessions)) + store.PrivateAPI.Get("/groups/:groupId/sessions", ListSessionsOfGroup(store, sessions)) + store.PrivateAPI.Get("/authors/:authorId/sessions", ListSessionsOfAuthor(store, sessions)) } diff --git a/lib/api/stats/init.go b/lib/api/stats/init.go index 5a0363eb..7de720e0 100644 --- a/lib/api/stats/init.go +++ b/lib/api/stats/init.go @@ -50,6 +50,8 @@ func Init(store *lib.InitStore) { checks, )) + store.PrivateAPI.Get("/stats", GetStats(store)) + if store.RetrievedSettings.EnableMetrics { go func() { ticker := time.NewTicker(10 * time.Second) diff --git a/lib/api/stats/stats.go b/lib/api/stats/stats.go new file mode 100644 index 00000000..83957237 --- /dev/null +++ b/lib/api/stats/stats.go @@ -0,0 +1,45 @@ +package stats + +import ( + "github.com/ether/etherpad-go/lib" + "github.com/ether/etherpad-go/lib/api/errors" + "github.com/gofiber/fiber/v3" +) + +// StatsResponse mirrors the original getStats API payload. +type StatsResponse struct { + TotalPads int `json:"totalPads"` + TotalSessions int `json:"totalSessions"` + TotalActivePads int `json:"totalActivePads"` +} + +// GetStats godoc +// @Summary Instance statistics +// @Description Returns the total number of pads, connected sessions and pads with connected users +// @Tags Stats +// @Produce json +// @Success 200 {object} StatsResponse +// @Failure 500 {object} errors.Error +// @Security BearerAuth +// @Router /admin/api/stats [get] +func GetStats(store *lib.InitStore) fiber.Handler { + return func(c fiber.Ctx) error { + padIds, err := store.Store.GetPadIds() + if err != nil { + return c.Status(500).JSON(errors.InternalServerError) + } + sessionStats, err := store.Handler.SessionStore.GetStats() + if err != nil { + return c.Status(500).JSON(errors.InternalServerError) + } + totalPads := 0 + if padIds != nil { + totalPads = len(*padIds) + } + return c.JSON(StatsResponse{ + TotalPads: totalPads, + TotalSessions: sessionStats.ActiveUsers, + TotalActivePads: sessionStats.ActivePads, + }) + } +} diff --git a/lib/pad/SessionManager.go b/lib/pad/SessionManager.go index 1509914d..0cbb33fa 100644 --- a/lib/pad/SessionManager.go +++ b/lib/pad/SessionManager.go @@ -1,12 +1,37 @@ package pad import ( + "encoding/json" "regexp" + "slices" "strings" + "time" "github.com/ether/etherpad-go/lib/db" + "github.com/ether/etherpad-go/lib/utils" ) +// API sessions bind an author to a group for access to that group's private +// pads (createSession in the original HTTP API). Like the original — which +// stores session:*, group2sessions:* and author2sessions:* records in its +// key-value database — they live in the generic key-value storage of the +// DataStore. The lib/api/session endpoints delegate to this manager and +// SecurityManager.CheckAccess consults it via FindAuthorID. +const ( + apiSessionKeyPrefix = "apisession:" + groupSessionsPrefix = "apisessions:group:" + authorSessionsKey = "apisessions:author:" +) + +// ApiSessionInfo is the stored payload of an API session. +type ApiSessionInfo struct { + GroupID string `json:"groupID"` + AuthorID string `json:"authorID"` + ValidUntil int64 `json:"validUntil"` +} + +var cookieQuoteTrimmer = regexp.MustCompile(`^"|"$`) + type SessionManager struct { db db.DataStore } @@ -17,24 +42,165 @@ func NewSessionManager(db db.DataStore) *SessionManager { } } -func (sm *SessionManager) doesSessionExist(sessionID string) bool { - //var session = sm.db.GetSession(sessionID) - return false +// CreateSession stores a new API session and registers it in the group and +// author listings. Validation of group/author existence and expiry is the +// caller's responsibility (the API layer mirrors the original's checks). +func (sm *SessionManager) CreateSession(groupID string, authorID string, validUntil int64) (string, error) { + sessionID := "s." + utils.RandomString(16) + encoded, err := json.Marshal(ApiSessionInfo{ + GroupID: groupID, + AuthorID: authorID, + ValidUntil: validUntil, + }) + if err != nil { + return "", err + } + if err := sm.db.SetOIDCStorageValue(apiSessionKeyPrefix+sessionID, string(encoded)); err != nil { + return "", err + } + if err := sm.addToIDList(groupSessionsPrefix+groupID, sessionID); err != nil { + return "", err + } + if err := sm.addToIDList(authorSessionsKey+authorID, sessionID); err != nil { + return "", err + } + return sessionID, nil } -func (sm *SessionManager) findAuthorID(groupId string, sessionCookie *string) *string { - if sessionCookie == nil { - return nil +// GetSessionInfo returns the session payload, or (nil, nil) if the session +// does not exist. +func (sm *SessionManager) GetSessionInfo(sessionID string) (*ApiSessionInfo, error) { + payload, err := sm.db.GetOIDCStorageValue(apiSessionKeyPrefix + sessionID) + if err != nil || payload == nil { + return nil, err + } + var info ApiSessionInfo + if err := json.Unmarshal([]byte(*payload), &info); err != nil { + return nil, err } + return &info, nil +} - var cookie = *sessionCookie +// DeleteSession removes a session and its listing entries. The boolean +// reports whether the session existed. +func (sm *SessionManager) DeleteSession(sessionID string) (bool, error) { + info, err := sm.GetSessionInfo(sessionID) + if err != nil { + return false, err + } + if info == nil { + return false, nil + } + if err := sm.db.DeleteOIDCStorageValue(apiSessionKeyPrefix + sessionID); err != nil { + return false, err + } + if err := sm.removeFromIDList(groupSessionsPrefix+info.GroupID, sessionID); err != nil { + return false, err + } + if err := sm.removeFromIDList(authorSessionsKey+info.AuthorID, sessionID); err != nil { + return false, err + } + return true, nil +} - var replacerSession = regexp.MustCompile("^\"|\"$") +// ListSessionsOfGroup returns all sessions registered for a group. +func (sm *SessionManager) ListSessionsOfGroup(groupID string) (map[string]ApiSessionInfo, error) { + return sm.listSessions(groupSessionsPrefix + groupID) +} + +// ListSessionsOfAuthor returns all sessions registered for an author. +func (sm *SessionManager) ListSessionsOfAuthor(authorID string) (map[string]ApiSessionInfo, error) { + return sm.listSessions(authorSessionsKey + authorID) +} - var _ = strings.Split(replacerSession.ReplaceAllString(cookie, ""), ",") +func (sm *SessionManager) listSessions(key string) (map[string]ApiSessionInfo, error) { + ids, err := sm.loadIDList(key) + if err != nil { + return nil, err + } + sessions := make(map[string]ApiSessionInfo, len(ids)) + for _, id := range ids { + info, err := sm.GetSessionInfo(id) + if err != nil { + return nil, err + } + if info != nil { + sessions[id] = *info + } + } + return sessions, nil +} + +// DoesSessionExist reports whether a session record exists. +func (sm *SessionManager) DoesSessionExist(sessionID string) (bool, error) { + info, err := sm.GetSessionInfo(sessionID) + return info != nil, err +} + +// FindAuthorID mirrors the original SessionManager.findAuthorID: the cookie +// may be enclosed in double quotes (upstream #3819) and may carry a +// comma-separated list of session ids. The author of the first session that +// belongs to the given group and is not expired is returned. +func (sm *SessionManager) FindAuthorID(groupID string, sessionCookie *string) *string { + if sessionCookie == nil || *sessionCookie == "" { + return nil + } + + sessionIDs := strings.Split(cookieQuoteTrimmer.ReplaceAllString(*sessionCookie, ""), ",") + now := time.Now().Unix() + for _, sessionID := range sessionIDs { + info, err := sm.GetSessionInfo(strings.TrimSpace(sessionID)) + if err != nil || info == nil { + continue + } + if info.GroupID == groupID && now < info.ValidUntil { + return &info.AuthorID + } + } return nil } -func (sm *SessionManager) getSessionInfo() { +// findAuthorID keeps the historical unexported name used by CheckAccess. +func (sm *SessionManager) findAuthorID(groupID string, sessionCookie *string) *string { + return sm.FindAuthorID(groupID, sessionCookie) +} + +func (sm *SessionManager) loadIDList(key string) ([]string, error) { + payload, err := sm.db.GetOIDCStorageValue(key) + if err != nil || payload == nil { + return []string{}, err + } + var ids []string + if err := json.Unmarshal([]byte(*payload), &ids); err != nil { + return nil, err + } + return ids, nil +} + +func (sm *SessionManager) saveIDList(key string, ids []string) error { + encoded, err := json.Marshal(ids) + if err != nil { + return err + } + return sm.db.SetOIDCStorageValue(key, string(encoded)) +} +func (sm *SessionManager) addToIDList(key string, id string) error { + ids, err := sm.loadIDList(key) + if err != nil { + return err + } + if slices.Contains(ids, id) { + return nil + } + return sm.saveIDList(key, append(ids, id)) +} + +func (sm *SessionManager) removeFromIDList(key string, id string) error { + ids, err := sm.loadIDList(key) + if err != nil { + return err + } + filtered := slices.DeleteFunc(ids, func(existing string) bool { return existing == id }) + return sm.saveIDList(key, filtered) } diff --git a/lib/pad/webaccess.go b/lib/pad/webaccess.go index 9e48e4ec..1533cbc3 100644 --- a/lib/pad/webaccess.go +++ b/lib/pad/webaccess.go @@ -30,8 +30,11 @@ func UserCanModify(padId *string, req *webaccess.SocketClientRequest, readOnlyMa return false } + // The authentication layer normally populates PadAuthorizations even when + // requireAuthorization is off (mirrors the original's assert) — but a + // missing map must deny, not crash the request. if req.PadAuthorizations == nil { - panic("This should not happen") + return false } var padMap = *req.PadAuthorizations @@ -43,11 +46,10 @@ func UserCanModify(padId *string, req *webaccess.SocketClientRequest, readOnlyMa func CheckAccess(ctx fiber.Ctx, logger *zap.SugaredLogger, retrievedSettings *settings.Settings, readOnlyManager *ReadOnlyManager) error { var requireAdmin = strings.HasPrefix(strings.ToLower(ctx.Path()), "/admin-auth") - //FIXME this needs to be set // /////////////////////////////////////////////////////////////////////////////////////////////// - // Step 1: Check the preAuthorize hook for early permit/deny (permit is only allowed for non-admin - // pages). If any plugin explicitly grants or denies access, skip the remaining steps. Plugins can - // use the preAuthzFailure hook to override the default 403 error. + // Step 1 of the original — the preAuthorize hook for early permit/deny by plugins — is not + // implemented: the Go plugin system has no preAuthorize/preAuthzFailure hooks yet. Until it + // does, every request goes through the regular authorize/authenticate steps below. // /////////////////////////////////////////////////////////////////////////////////////////////// // This helper is used in steps 2 and 4 below, so it may be called twice per access: once before @@ -246,25 +248,20 @@ func CheckAccess(ctx fiber.Ctx, logger *zap.SugaredLogger, retrievedSettings *se return ctx.Status(403).SendString("Forbidden") } +// NormalizeAuthzLevel mirrors the original webaccess.normalizeAuthzLevel: +// `true` normalizes to "create", the three known levels pass through, and +// everything else (false, empty, unknown strings) is denied. func NormalizeAuthzLevel(level interface{}) (*string, error) { switch castedExpr := level.(type) { case string: - { - switch castedExpr { - case "readOnly": - case "modify": - case "create": - return &castedExpr, nil - default: - println("Invalid level") - return nil, errors.New("unknown authorization level " + castedExpr) - } + switch castedExpr { + case "readOnly", "modify", "create": + return &castedExpr, nil } case bool: - { - if castedExpr { - return nil, nil - } + if castedExpr { + create := "create" + return &create, nil } } return nil, errors.New("access denied") diff --git a/lib/test/api/pad/pad_api_test.go b/lib/test/api/pad/pad_api_test.go index dbce49a6..2816a082 100644 --- a/lib/test/api/pad/pad_api_test.go +++ b/lib/test/api/pad/pad_api_test.go @@ -143,6 +143,10 @@ func TestPadAPI(t *testing.T) { Name: "CheckToken returns 200", Test: testCheckToken, }, + testutils.TestRunConfig{ + Name: "SendClientsMessage broadcasts custom message", + Test: testSendClientsMessage, + }, // Copy pad testutils.TestRunConfig{ Name: "CopyPad copies pad with history", @@ -1023,3 +1027,30 @@ func testCheckToken(t *testing.T, tsStore testutils.TestDataStore) { assert.NoError(t, err) assert.Equal(t, 200, resp.StatusCode) } + +func testSendClientsMessage(t *testing.T, tsStore testutils.TestDataStore) { + initStore := tsStore.ToInitStore() + pad.Init(initStore) + + createTestPad(t, tsStore, "msgpad", "hello\n") + + body, _ := json.Marshal(pad.SendClientsMessageRequest{Msg: "customType"}) + req := httptest.NewRequest("POST", "/admin/api/pads/msgpad/sendClientsMessage", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := initStore.C.Test(req) + assert.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + + // Missing msg is a 400 + req = httptest.NewRequest("POST", "/admin/api/pads/msgpad/sendClientsMessage", bytes.NewBuffer([]byte(`{}`))) + req.Header.Set("Content-Type", "application/json") + resp, _ = initStore.C.Test(req) + assert.Equal(t, 400, resp.StatusCode) + + // Unknown pad is a 404 + body, _ = json.Marshal(pad.SendClientsMessageRequest{Msg: "customType"}) + req = httptest.NewRequest("POST", "/admin/api/pads/nosuchmsgpad/sendClientsMessage", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + resp, _ = initStore.C.Test(req) + assert.Equal(t, 404, resp.StatusCode) +} diff --git a/lib/test/api/stats/metrics_test.go b/lib/test/api/stats/metrics_test.go index bd2b77b3..db88b609 100644 --- a/lib/test/api/stats/metrics_test.go +++ b/lib/test/api/stats/metrics_test.go @@ -1,6 +1,8 @@ package stats import ( + "encoding/json" + "io" "net/http/httptest" "testing" @@ -23,6 +25,10 @@ func TestAdminMessageHandlerAllMethods(t *testing.T) { Name: "Health Endpoint Exists", Test: testHealthendpointExists, }, + testutils.TestRunConfig{ + Name: "GetStats endpoint returns instance stats", + Test: testGetStatsEndpoint, + }, ) } @@ -54,3 +60,24 @@ func testHealthendpointExists(t *testing.T, testDb testutils.TestDataStore) { require.NoError(t, err) require.Equal(t, 200, resp.StatusCode) } + +func testGetStatsEndpoint(t *testing.T, testDb testutils.TestDataStore) { + initStore := testDb.ToInitStore() + stats.Init(initStore) + + // Create a pad so totalPads is at least 1 + _, err := testDb.PadManager.GetPad("statspad", nil, nil) + require.NoError(t, err) + + req := httptest.NewRequest("GET", "/admin/api/stats", nil) + resp, err := initStore.C.Test(req) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) + + var response stats.StatsResponse + body, _ := io.ReadAll(resp.Body) + require.NoError(t, json.Unmarshal(body, &response)) + require.GreaterOrEqual(t, response.TotalPads, 1) + require.GreaterOrEqual(t, response.TotalSessions, 0) + require.GreaterOrEqual(t, response.TotalActivePads, 0) +} diff --git a/lib/test/pad/session_manager_test.go b/lib/test/pad/session_manager_test.go new file mode 100644 index 00000000..fae4ad09 --- /dev/null +++ b/lib/test/pad/session_manager_test.go @@ -0,0 +1,169 @@ +package pad + +import ( + "testing" + "time" + + "github.com/ether/etherpad-go/lib/db" + "github.com/ether/etherpad-go/lib/models/webaccess" + "github.com/ether/etherpad-go/lib/pad" + "github.com/ether/etherpad-go/lib/settings" + "github.com/ether/etherpad-go/lib/test/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSessionManager(t *testing.T) { + testHandler := testutils.NewTestDBHandler(t) + testHandler.AddTests( + testutils.TestRunConfig{ + Name: "FindAuthorID returns author of a valid session", + Test: testFindAuthorIDValidSession, + }, + testutils.TestRunConfig{ + Name: "FindAuthorID copes with quoted multi-id cookies", + Test: testFindAuthorIDQuotedMultiCookie, + }, + testutils.TestRunConfig{ + Name: "FindAuthorID rejects expired sessions and wrong groups", + Test: testFindAuthorIDExpiredAndWrongGroup, + }, + testutils.TestRunConfig{ + Name: "CheckAccess gates private group pads on a session", + Test: testCheckAccessPrivateGroupPad, + }, + ) + + defer testHandler.StartTestDBHandler() +} + +func setupSessionFixture(t *testing.T, ds testutils.TestDataStore, groupId string) (*pad.SessionManager, string) { + t.Helper() + require.NoError(t, ds.DS.SaveGroup(groupId)) + createdAuthor, err := ds.AuthorManager.CreateAuthor(nil) + require.NoError(t, err) + return pad.NewSessionManager(ds.DS), createdAuthor.Id +} + +func testFindAuthorIDValidSession(t *testing.T, ds testutils.TestDataStore) { + groupId := "g.findauthor1234567"[:18] + sm, authorId := setupSessionFixture(t, ds, groupId) + + sessionId, err := sm.CreateSession(groupId, authorId, time.Now().Unix()+3600) + require.NoError(t, err) + require.Equal(t, "s.", sessionId[:2]) + + found := sm.FindAuthorID(groupId, &sessionId) + require.NotNil(t, found) + assert.Equal(t, authorId, *found) + + // No cookie -> no author + assert.Nil(t, sm.FindAuthorID(groupId, nil)) + + exists, err := sm.DoesSessionExist(sessionId) + require.NoError(t, err) + assert.True(t, exists) + exists, err = sm.DoesSessionExist("s.doesnotexist12345") + require.NoError(t, err) + assert.False(t, exists) +} + +func testFindAuthorIDQuotedMultiCookie(t *testing.T, ds testutils.TestDataStore) { + groupId := "g.findquoted123456"[:18] + sm, authorId := setupSessionFixture(t, ds, groupId) + + sessionId, err := sm.CreateSession(groupId, authorId, time.Now().Unix()+3600) + require.NoError(t, err) + + // RFC 6265 servers may quote the cookie value; it may also carry a + // comma-separated list of session ids (upstream #3819). + cookie := `"s.unknown1234567890,` + sessionId + `"` + found := sm.FindAuthorID(groupId, &cookie) + require.NotNil(t, found) + assert.Equal(t, authorId, *found) +} + +func testFindAuthorIDExpiredAndWrongGroup(t *testing.T, ds testutils.TestDataStore) { + groupId := "g.findexpired12345"[:18] + otherGroup := "g.othergroup123456"[:18] + sm, authorId := setupSessionFixture(t, ds, groupId) + require.NoError(t, ds.DS.SaveGroup(otherGroup)) + + expiredId, err := sm.CreateSession(groupId, authorId, time.Now().Unix()-10) + require.NoError(t, err) + assert.Nil(t, sm.FindAuthorID(groupId, &expiredId), "expired session must not grant access") + + validId, err := sm.CreateSession(groupId, authorId, time.Now().Unix()+3600) + require.NoError(t, err) + assert.Nil(t, sm.FindAuthorID(otherGroup, &validId), "session of another group must not match") +} + +func testCheckAccessPrivateGroupPad(t *testing.T, ds testutils.TestDataStore) { + prevLoadTest := settings.Displayed.LoadTest + settings.Displayed.LoadTest = false + defer func() { settings.Displayed.LoadTest = prevLoadTest }() + + groupId := "g.checkaccess12345"[:18] + sm, authorId := setupSessionFixture(t, ds, groupId) + + // Create a private (default) group pad. + padId := groupId + "$secret" + _, err := ds.PadManager.GetPad(padId, nil, &authorId) + require.NoError(t, err) + + token := "t.checkaccesstoken123456" + + // Without a session cookie access to the private group pad is denied. + _, err = ds.SecurityManager.CheckAccess(&padId, nil, &token, nil) + assert.Error(t, err) + + // With a valid session for the pad's group access is granted. + sessionId, err := sm.CreateSession(groupId, authorId, time.Now().Unix()+3600) + require.NoError(t, err) + granted, err := ds.SecurityManager.CheckAccess(&padId, &sessionId, &token, nil) + require.NoError(t, err) + require.NotNil(t, granted) + assert.Equal(t, "grant", granted.AccessStatus) +} + +func TestNormalizeAuthzLevel(t *testing.T) { + // readOnly and modify used to fall through the switch and be rejected + // (missing fallthrough semantics in the Go port); only create worked. + for _, level := range []string{"readOnly", "modify", "create"} { + normalized, err := pad.NormalizeAuthzLevel(level) + require.NoError(t, err, level) + require.NotNil(t, normalized, level) + assert.Equal(t, level, *normalized) + } + + // Original: true normalizes to "create". + normalized, err := pad.NormalizeAuthzLevel(true) + require.NoError(t, err) + require.NotNil(t, normalized) + assert.Equal(t, "create", *normalized) + + // false / unknown levels are denied. + _, err = pad.NormalizeAuthzLevel(false) + assert.Error(t, err) + _, err = pad.NormalizeAuthzLevel("bogus") + assert.Error(t, err) +} + +func TestUserCanModifyNilAuthorizations(t *testing.T) { + // Used to panic("This should not happen") on a nil PadAuthorizations map; + // must deny instead of crashing the request. + prevRequireAuth := settings.Displayed.RequireAuthentication + settings.Displayed.RequireAuthentication = true + defer func() { settings.Displayed.RequireAuthentication = prevRequireAuth }() + + padId := "normalpad" + readOnly := false + req := &webaccess.SocketClientRequest{ReadOnly: &readOnly, PadAuthorizations: nil} + + memDS := db.NewMemoryDataStore() + rom := pad.NewReadOnlyManager(memDS) + + assert.NotPanics(t, func() { + assert.False(t, pad.UserCanModify(&padId, req, *rom)) + }) +} diff --git a/lib/test/ws/pad_message_handler_test.go b/lib/test/ws/pad_message_handler_test.go index 6bd3aabd..a9d8eec9 100644 --- a/lib/test/ws/pad_message_handler_test.go +++ b/lib/test/ws/pad_message_handler_test.go @@ -168,6 +168,10 @@ func TestPadMessageHandler_AllMethods(t *testing.T) { Name: "CLIENT_MESSAGE suggestUserName is relayed to target author", Test: testClientMessageSuggestUserName, }, + testutils.TestRunConfig{ + Name: "SendCustomMessageToPad broadcasts to all pad clients", + Test: testSendCustomMessageToPad, + }, testutils.TestRunConfig{ Name: "HandleMessage with unknown type", Test: testHandleMessageUnknownType, @@ -1931,3 +1935,41 @@ func testClientMessageSuggestUserName(t *testing.T, ds testutils.TestDataStore) assert.NotContains(t, msg, "suggestUserName", "sender must not receive the suggestUserName relay") } } + +// testSendCustomMessageToPad tests the server-side part of the original +// handleCustomMessage / sendClientsMessage API: a custom COLLABROOM message +// type is broadcast to every client of the pad. +func testSendCustomMessageToPad(t *testing.T, ds testutils.TestDataStore) { + padId := "test-pad-custom-message" + authorId, err := setupPadAndAuthor(t, ds, padId, "CustomMsgUser") + require.NoError(t, err) + + conn1 := libws.NewActualMockWebSocketconn() + conn2 := libws.NewActualMockWebSocketconn() + client1 := createTestClient(ds.Hub, "custom-msg-session-1", padId, conn1) + client2 := createTestClient(ds.Hub, "custom-msg-session-2", padId, conn2) + defer func() { + delete(ds.Hub.Clients, client1) + delete(ds.Hub.Clients, client2) + }() + + for _, sessionId := range []string{"custom-msg-session-1", "custom-msg-session-2"} { + ds.PadMessageHandler.SessionStore.InitSessionForTest(sessionId) + ds.PadMessageHandler.SessionStore.AddHandleClientInformationForTest(sessionId, padId, "test-token") + ds.PadMessageHandler.SessionStore.SetAuthorForTest(sessionId, authorId) + ds.PadMessageHandler.SessionStore.SetPadIdForTest(sessionId, padId) + } + + ds.PadMessageHandler.SendCustomMessageToPad(padId, "myCustomType") + + for i, client := range []*libws.Client{client1, client2} { + messages := drainClientMessages(client) + found := false + for _, msg := range messages { + if strings.Contains(msg, "myCustomType") && strings.Contains(msg, "COLLABROOM") { + found = true + } + } + assert.True(t, found, "client %d must receive the custom message, got: %v", i+1, messages) + } +} diff --git a/lib/ws/PadMessageHandler.go b/lib/ws/PadMessageHandler.go index ed74487b..1105fb1c 100644 --- a/lib/ws/PadMessageHandler.go +++ b/lib/ws/PadMessageHandler.go @@ -1574,6 +1574,27 @@ func (p *PadMessageHandler) UpdatePadClients(pad *pad2.Pad) { } } +// SendCustomMessageToPad broadcasts a custom COLLABROOM message type to all +// clients of a pad, mirroring the original handleCustomMessage (backing the +// sendClientsMessage HTTP API). +func (p *PadMessageHandler) SendCustomMessageToPad(padId string, msgType string) { + payload := map[string]any{ + "type": "COLLABROOM", + "data": map[string]any{ + "type": msgType, + "time": time.Now().UnixMilli(), + }, + } + encoded, err := json.Marshal([]any{"message", payload}) + if err != nil { + p.Logger.Errorf("Error marshalling custom message %q: %v", msgType, err) + return + } + for _, socket := range p.GetRoomSockets(padId) { + socket.SafeSend(encoded) + } +} + // HandleClientMessage handles the COLLABROOM CLIENT_MESSAGE family, // mirroring the original's handleSuggestUserName / handlePadOptionsMessage. func (p *PadMessageHandler) HandleClientMessage(message ws.ClientMessage, client *Client, session *ws.Session) {