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 }