github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/web/realtime/realtime.go (about)

     1  package realtime
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"net/http"
     7  	"strings"
     8  	"time"
     9  
    10  	"github.com/cozy/cozy-stack/model/instance"
    11  	"github.com/cozy/cozy-stack/model/permission"
    12  	"github.com/cozy/cozy-stack/model/vfs"
    13  	"github.com/cozy/cozy-stack/pkg/consts"
    14  	"github.com/cozy/cozy-stack/pkg/couchdb"
    15  	"github.com/cozy/cozy-stack/pkg/jsonapi"
    16  	"github.com/cozy/cozy-stack/pkg/logger"
    17  	"github.com/cozy/cozy-stack/pkg/prefixer"
    18  	"github.com/cozy/cozy-stack/pkg/realtime"
    19  	"github.com/cozy/cozy-stack/web/middlewares"
    20  	"github.com/gorilla/websocket"
    21  	"github.com/labstack/echo/v4"
    22  )
    23  
    24  const (
    25  	// Time allowed to write a message to the peer
    26  	writeWait = 10 * time.Second
    27  
    28  	// Time allowed to read the next pong message from the peer
    29  	pongWait = 60 * time.Second
    30  
    31  	// Send pings to peer with this period (must be less than pongWait)
    32  	pingPeriod = (pongWait * 9) / 10
    33  
    34  	// Maximum message size allowed from peer
    35  	maxMessageSize = 1024
    36  )
    37  
    38  var upgrader = websocket.Upgrader{
    39  	// Don't check the origin of the connexion, we check authorization later
    40  	CheckOrigin:     func(r *http.Request) bool { return true },
    41  	Subprotocols:    []string{"io.cozy.websocket"},
    42  	ReadBufferSize:  1024,
    43  	WriteBufferSize: 1024,
    44  }
    45  
    46  type command struct {
    47  	Method  string `json:"method"`
    48  	Payload struct {
    49  		Type string `json:"type"`
    50  		ID   string `json:"id"`
    51  	} `json:"payload"`
    52  }
    53  
    54  type wsResponsePayload struct {
    55  	Type string      `json:"type"`
    56  	ID   string      `json:"id"`
    57  	Doc  interface{} `json:"doc,omitempty"`
    58  }
    59  
    60  type wsResponse struct {
    61  	Event   string            `json:"event"`
    62  	Payload wsResponsePayload `json:"payload"`
    63  }
    64  
    65  type wsErrorPayload struct {
    66  	Status string      `json:"status"`
    67  	Code   string      `json:"code"`
    68  	Title  string      `json:"title"`
    69  	Source interface{} `json:"source"`
    70  }
    71  
    72  type wsError struct {
    73  	Event   string         `json:"event"`
    74  	Payload wsErrorPayload `json:"payload"`
    75  }
    76  
    77  func unauthorized(cmd interface{}) *wsError {
    78  	return &wsError{
    79  		Event: "error",
    80  		Payload: wsErrorPayload{
    81  			Status: "401 Unauthorized",
    82  			Code:   "unauthorized",
    83  			Title:  "The authentication has failed",
    84  			Source: cmd,
    85  		},
    86  	}
    87  }
    88  
    89  func forbidden(cmd *command) *wsError {
    90  	return &wsError{
    91  		Event: "error",
    92  		Payload: wsErrorPayload{
    93  			Status: "403 Forbidden",
    94  			Code:   "forbidden",
    95  			Title:  fmt.Sprintf("The application can't subscribe to %s", cmd.Payload.Type),
    96  			Source: cmd,
    97  		},
    98  	}
    99  }
   100  
   101  func unknownMethod(method string, cmd interface{}) *wsError {
   102  	return &wsError{
   103  		Event: "error",
   104  		Payload: wsErrorPayload{
   105  			Status: "405 Method Not Allowed",
   106  			Code:   "method not allowed",
   107  			Title:  fmt.Sprintf("The %s method is not supported", method),
   108  			Source: cmd,
   109  		},
   110  	}
   111  }
   112  
   113  func missingType(cmd *command) *wsError {
   114  	return &wsError{
   115  		Event: "error",
   116  		Payload: wsErrorPayload{
   117  			Status: "404 Page Not Found",
   118  			Code:   "page not found",
   119  			Title:  "The type parameter is mandatory for SUBSCRIBE",
   120  			Source: cmd,
   121  		},
   122  	}
   123  }
   124  
   125  func sendErr(ctx context.Context, errc chan *wsError, e *wsError) {
   126  	select {
   127  	case errc <- e:
   128  	case <-ctx.Done():
   129  	}
   130  }
   131  
   132  func authorized(i *instance.Instance, perms permission.Set, permType, id string) bool {
   133  	if perms.AllowWholeType(permission.GET, permType) {
   134  		return true
   135  	} else if id == "" {
   136  		return false
   137  	} else if permType == consts.Files {
   138  		fs := i.VFS()
   139  		dir, file, err := fs.DirOrFileByID(id)
   140  		if dir != nil {
   141  			err = vfs.Allows(fs, perms, permission.GET, dir)
   142  		} else if file != nil {
   143  			err = vfs.Allows(fs, perms, permission.GET, file)
   144  		}
   145  		return err == nil
   146  	} else {
   147  		return perms.AllowID(permission.GET, permType, id)
   148  	}
   149  }
   150  
   151  func readPump(ctx context.Context, c echo.Context, i *instance.Instance, ws *websocket.Conn,
   152  	ds *realtime.Subscriber, errc chan *wsError, withAuthentication bool) {
   153  	defer close(errc)
   154  
   155  	var err error
   156  	var pdoc *permission.Permission
   157  
   158  	if withAuthentication {
   159  		var auth map[string]string
   160  		if err = ws.ReadJSON(&auth); err != nil {
   161  			sendErr(ctx, errc, unknownMethod(auth["method"], auth))
   162  			return
   163  		}
   164  		if strings.ToUpper(auth["method"]) != "AUTH" {
   165  			sendErr(ctx, errc, unknownMethod(auth["method"], auth))
   166  			return
   167  		}
   168  		if auth["payload"] == "" {
   169  			sendErr(ctx, errc, unauthorized(auth))
   170  			return
   171  		}
   172  		pdoc, err = middlewares.ParseJWT(c, i, auth["payload"])
   173  		if err != nil {
   174  			sendErr(ctx, errc, unauthorized(auth))
   175  			return
   176  		}
   177  	}
   178  
   179  	for {
   180  		cmd := &command{}
   181  		if err = ws.ReadJSON(cmd); err != nil {
   182  			if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
   183  				logger.
   184  					WithDomain(ds.DomainName()).
   185  					WithNamespace("realtime").
   186  					Debugf("Error: %s", err)
   187  			}
   188  			break
   189  		}
   190  
   191  		method := strings.ToUpper(cmd.Method)
   192  		if method != "SUBSCRIBE" && method != "UNSUBSCRIBE" {
   193  			sendErr(ctx, errc, unknownMethod(cmd.Method, cmd))
   194  			continue
   195  		}
   196  		if cmd.Payload.Type == "" {
   197  			sendErr(ctx, errc, missingType(cmd))
   198  			continue
   199  		}
   200  		permType := cmd.Payload.Type
   201  		permID := cmd.Payload.ID
   202  		// XXX: thumbnails is a synthetic doctype, listening to its events
   203  		// requires a permissions on io.cozy.files. Same for note events.
   204  		if permType == consts.Thumbnails || permType == consts.NotesEvents {
   205  			permType = consts.Files
   206  		}
   207  		// XXX: the passphrase settings document is synthetic, and a
   208  		// permission on the instance settings is enough to watch it.
   209  		if permType == consts.Settings && permID == consts.PassphraseParametersID {
   210  			permID = consts.InstanceSettingsID
   211  		}
   212  		// XXX: no permissions are required for io.cozy.sharings.initial_sync
   213  		// and io.cozy.auth.confirmations
   214  		if withAuthentication &&
   215  			cmd.Payload.Type != consts.SharingsInitialSync &&
   216  			cmd.Payload.Type != consts.AuthConfirmations {
   217  			if !authorized(i, pdoc.Permissions, permType, permID) {
   218  				sendErr(ctx, errc, forbidden(cmd))
   219  				continue
   220  			}
   221  		}
   222  
   223  		if method == "SUBSCRIBE" {
   224  			if cmd.Payload.ID == "" {
   225  				ds.Subscribe(cmd.Payload.Type)
   226  			} else {
   227  				ds.Watch(cmd.Payload.Type, cmd.Payload.ID)
   228  			}
   229  		} else if method == "UNSUBSCRIBE" {
   230  			if cmd.Payload.ID == "" {
   231  				ds.Unsubscribe(cmd.Payload.Type)
   232  			} else {
   233  				ds.Unwatch(cmd.Payload.Type, cmd.Payload.ID)
   234  			}
   235  		}
   236  	}
   237  }
   238  
   239  // Ws is the API handler for realtime via a websocket connection.
   240  func Ws(c echo.Context) error {
   241  	var db prefixer.Prefixer
   242  
   243  	// The realtime webservice can be plugged in a context without instance
   244  	// fetching. For instance in the administration server. In such case, we do
   245  	// not need authentication
   246  	inst, withAuthentication := middlewares.GetInstanceSafe(c)
   247  	if !withAuthentication {
   248  		db = prefixer.GlobalPrefixer
   249  	} else {
   250  		db = inst
   251  	}
   252  
   253  	ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
   254  	if err != nil {
   255  		return err
   256  	}
   257  	defer ws.Close()
   258  
   259  	ws.SetReadLimit(maxMessageSize)
   260  	if err = ws.SetReadDeadline(time.Now().Add(pongWait)); err != nil {
   261  		return nil
   262  	}
   263  	ws.SetPongHandler(func(string) error {
   264  		return ws.SetReadDeadline(time.Now().Add(pongWait))
   265  	})
   266  
   267  	ds := realtime.GetHub().Subscriber(db)
   268  	defer ds.Close()
   269  	ctx, cancel := context.WithCancel(context.Background())
   270  	defer cancel()
   271  	errc := make(chan *wsError)
   272  	go readPump(ctx, c, inst, ws, ds, errc, withAuthentication)
   273  
   274  	ticker := time.NewTicker(pingPeriod)
   275  	defer ticker.Stop()
   276  
   277  	for {
   278  		select {
   279  		case e, ok := <-errc:
   280  			if !ok { // Websocket has been closed by the client
   281  				return nil
   282  			}
   283  			if err := ws.SetWriteDeadline(time.Now().Add(writeWait)); err != nil {
   284  				return nil
   285  			}
   286  			if err := ws.WriteJSON(e); err != nil {
   287  				return nil
   288  			}
   289  		case e := <-ds.Channel:
   290  			if err := ws.SetWriteDeadline(time.Now().Add(writeWait)); err != nil {
   291  				return err
   292  			}
   293  			res := wsResponse{
   294  				Event: e.Verb,
   295  				Payload: wsResponsePayload{
   296  					Type: e.Doc.DocType(),
   297  					ID:   e.Doc.ID(),
   298  					Doc:  e.Doc,
   299  				},
   300  			}
   301  			if err := ws.WriteJSON(res); err != nil {
   302  				return nil
   303  			}
   304  		case <-ticker.C:
   305  			if err := ws.SetWriteDeadline(time.Now().Add(writeWait)); err != nil {
   306  				return err
   307  			}
   308  			if err := ws.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
   309  				return nil
   310  			}
   311  		}
   312  	}
   313  }
   314  
   315  // Notify is the API handler for POST /realtime/:doctype/:id: this route can be
   316  // used to send documents in the real-time without having to persist them in
   317  // CouchDB.
   318  func Notify(c echo.Context) error {
   319  	inst := middlewares.GetInstance(c)
   320  	doctype := c.Param("doctype")
   321  	id := c.Param("id")
   322  
   323  	if err := permission.CheckReadable(doctype); err != nil {
   324  		return jsonapi.BadRequest(err)
   325  	}
   326  
   327  	var payload couchdb.JSONDoc
   328  	if err := c.Bind(&payload); err != nil {
   329  		return jsonapi.BadRequest(err)
   330  	}
   331  	payload.SetID(id)
   332  	payload.Type = doctype
   333  	if err := middlewares.Allow(c, permission.POST, &payload); err != nil {
   334  		return err
   335  	}
   336  
   337  	realtime.GetHub().Publish(inst, realtime.EventNotify, &payload, nil)
   338  	return c.NoContent(http.StatusNoContent)
   339  }
   340  
   341  // Routes set the routing for the realtime service
   342  func Routes(router *echo.Group) {
   343  	router.GET("/", Ws)
   344  	router.POST("/:doctype/:id", Notify)
   345  }