github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/web/notes/notes.go (about) 1 // Package notes is about the documents of cozy-notes. The notes are persisted 2 // as files, but they also have some specific routes for enabling collaborative 3 // edition. 4 package notes 5 6 import ( 7 "encoding/json" 8 "errors" 9 "io" 10 "net/http" 11 "os" 12 "strconv" 13 "strings" 14 "time" 15 16 "github.com/cozy/cozy-stack/model/note" 17 "github.com/cozy/cozy-stack/model/permission" 18 "github.com/cozy/cozy-stack/model/sharing" 19 "github.com/cozy/cozy-stack/model/vfs" 20 "github.com/cozy/cozy-stack/pkg/consts" 21 "github.com/cozy/cozy-stack/pkg/jsonapi" 22 "github.com/cozy/cozy-stack/web/files" 23 "github.com/cozy/cozy-stack/web/middlewares" 24 "github.com/labstack/echo/v4" 25 ) 26 27 // CreateNote is the API handler for POST /notes. It creates a note, aka a file 28 // with a set of metadata to enable collaborative edition. 29 func CreateNote(c echo.Context) error { 30 inst := middlewares.GetInstance(c) 31 doc := ¬e.Document{} 32 if _, err := jsonapi.Bind(c.Request().Body, doc); err != nil { 33 return err 34 } 35 doc.CreatedBy = getCreatedBy(c) 36 37 // We first look if we have a permission on the whole doctype, as it is 38 // cheap. If not, we look on more finer permissions, which is a bit more 39 // complicated and costly, but is needed for creating a note in a shared by 40 // link folder for example. 41 if err := middlewares.AllowWholeType(c, permission.POST, consts.Files); err != nil { 42 dirID, errd := doc.GetDirID(inst) 43 if errd != nil { 44 return err 45 } 46 fileDoc, errf := vfs.NewFileDoc( 47 "tmp.cozy-note", // We don't care, but it can't be empty 48 dirID, 49 0, // We don't care 50 nil, // Let the VFS compute the md5sum 51 consts.NoteMimeType, 52 "text", 53 time.Now(), 54 false, // Not executable 55 false, // Not trashed 56 false, // Not encrypted 57 nil, // No tags 58 ) 59 if errf != nil { 60 return err 61 } 62 if err := middlewares.AllowVFS(c, permission.POST, fileDoc); err != nil { 63 return err 64 } 65 } 66 67 file, err := note.Create(inst, doc) 68 if err != nil { 69 return wrapError(err) 70 } 71 72 return files.FileData(c, http.StatusCreated, file, false, nil) 73 } 74 75 // ListNotes is the API handler for GET /notes. It returns the list of the 76 // notes. 77 func ListNotes(c echo.Context) error { 78 if err := middlewares.AllowWholeType(c, permission.GET, consts.Files); err != nil { 79 return err 80 } 81 82 inst := middlewares.GetInstance(c) 83 bookmark := c.QueryParam("page[cursor]") 84 docs, bookmark, err := note.List(inst, bookmark) 85 if err != nil { 86 return wrapError(err) 87 } 88 89 var links jsonapi.LinksList 90 if bookmark != "" { 91 links.Next = "/notes?page[cursor]=" + bookmark 92 } 93 94 fp := vfs.NewFilePatherWithCache(inst.VFS()) 95 objs := make([]jsonapi.Object, len(docs)) 96 for i, doc := range docs { 97 f := files.NewFile(doc, inst) 98 f.IncludePath(fp) 99 objs[i] = f 100 } 101 return jsonapi.DataList(c, http.StatusOK, objs, &links) 102 } 103 104 // GetNote is the API handler for GET /notes/:id. It fetches the file with the 105 // given id, and also includes the changes in the content that have been 106 // accepted by the stack but not yet persisted on the file. 107 func GetNote(c echo.Context) error { 108 inst := middlewares.GetInstance(c) 109 fileID := c.Param("id") 110 file, err := inst.VFS().FileByID(fileID) 111 if err != nil { 112 return wrapError(err) 113 } 114 115 if err := middlewares.AllowVFS(c, permission.GET, file); err != nil { 116 return err 117 } 118 119 file, err = note.GetFile(inst, file) 120 if err != nil { 121 return wrapError(err) 122 } 123 124 return files.FileData(c, http.StatusOK, file, false, nil) 125 } 126 127 func GetNoteText(c echo.Context) error { 128 inst := middlewares.GetInstance(c) 129 fileID := c.Param("id") 130 file, err := inst.VFS().FileByID(fileID) 131 if err != nil { 132 return wrapError(err) 133 } 134 135 if err := middlewares.AllowVFS(c, permission.GET, file); err != nil { 136 return err 137 } 138 139 content, err := note.GetText(inst, file) 140 if err != nil { 141 return wrapError(err) 142 } 143 return c.String(http.StatusOK, content) 144 } 145 146 func GetTexts(c echo.Context) error { 147 inst := middlewares.GetInstance(c) 148 ids := strings.Split(c.QueryParam("ids"), ",") 149 texts := make(map[string]string) 150 151 for _, id := range ids { 152 file, err := inst.VFS().FileByID(id) 153 if err != nil { 154 return wrapError(err) 155 } 156 if err := middlewares.AllowVFS(c, permission.GET, file); err != nil { 157 return err 158 } 159 content, err := note.GetText(inst, file) 160 if err != nil { 161 return wrapError(err) 162 } 163 texts[id] = content 164 } 165 166 return c.JSON(http.StatusOK, texts) 167 } 168 169 // GetSteps is the API handler for GET /notes/:id/steps?Version=xxx. It returns 170 // the steps since the given version. If the version is too old, and the steps 171 // are no longer available, it returns a 412 response with the whole document 172 // for the note. 173 func GetSteps(c echo.Context) error { 174 inst := middlewares.GetInstance(c) 175 fileID := c.Param("id") 176 file, err := inst.VFS().FileByID(fileID) 177 if err != nil { 178 return wrapError(err) 179 } 180 181 if err := middlewares.AllowVFS(c, permission.GET, file); err != nil { 182 return err 183 } 184 185 rev, err := strconv.ParseInt(c.QueryParam("Version"), 10, 64) 186 if err != nil { 187 return jsonapi.InvalidParameter("Version", err) 188 } 189 steps, err := note.GetSteps(inst, file.DocID, rev) 190 if errors.Is(err, note.ErrTooOld) { 191 file, err = note.GetFile(inst, file) 192 if err != nil { 193 return wrapError(err) 194 } 195 return files.FileData(c, http.StatusPreconditionFailed, file, false, nil) 196 } 197 if err != nil { 198 return wrapError(err) 199 } 200 201 objs := make([]jsonapi.Object, len(steps)) 202 for i, step := range steps { 203 objs[i] = step 204 } 205 206 return jsonapi.DataList(c, http.StatusOK, objs, nil) 207 } 208 209 // PatchNote is the API handler for PATCH /notes/:id. It applies some steps on 210 // the note document. 211 func PatchNote(c echo.Context) error { 212 inst := middlewares.GetInstance(c) 213 fileID := c.Param("id") 214 file, err := inst.VFS().FileByID(fileID) 215 if err != nil { 216 return wrapError(err) 217 } 218 219 if err := middlewares.AllowVFS(c, permission.PATCH, file); err != nil { 220 return err 221 } 222 223 objs, err := jsonapi.BindCompound(c.Request().Body) 224 if err != nil { 225 return err 226 } 227 steps := make([]note.Step, len(objs)) 228 for i, obj := range objs { 229 if obj.Attributes == nil { 230 return jsonapi.BadJSON() 231 } 232 if err = json.Unmarshal(*obj.Attributes, &steps[i]); err != nil { 233 return wrapError(err) 234 } 235 } 236 237 ifMatch := c.Request().Header.Get("If-Match") 238 if file, err = note.ApplySteps(inst, file, ifMatch, steps); err != nil { 239 return wrapError(err) 240 } 241 242 return files.FileData(c, http.StatusOK, file, false, nil) 243 } 244 245 // ChangeTitle is the API handler for PUT /notes/:id/title. It updates the 246 // title and renames the file. 247 func ChangeTitle(c echo.Context) error { 248 inst := middlewares.GetInstance(c) 249 fileID := c.Param("id") 250 file, err := inst.VFS().FileByID(fileID) 251 if err != nil { 252 return wrapError(err) 253 } 254 255 if err := middlewares.AllowVFS(c, permission.PUT, file); err != nil { 256 return err 257 } 258 259 event := note.Event{} 260 if _, err := jsonapi.Bind(c.Request().Body, &event); err != nil { 261 return err 262 } 263 264 title, _ := event["title"].(string) 265 sessID, _ := event["sessionID"].(string) 266 if file, err = note.UpdateTitle(inst, file, title, sessID); err != nil { 267 return wrapError(err) 268 } 269 270 return files.FileData(c, http.StatusOK, file, false, nil) 271 } 272 273 // PutTelepointer is the API handler for PUT /notes/:id/telepointer. It updates 274 // the position of a pointer. 275 func PutTelepointer(c echo.Context) error { 276 inst := middlewares.GetInstance(c) 277 fileID := c.Param("id") 278 file, err := inst.VFS().FileByID(fileID) 279 if err != nil { 280 return wrapError(err) 281 } 282 283 if err := middlewares.AllowVFS(c, permission.PUT, file); err != nil { 284 return err 285 } 286 287 pointer := note.Event{} 288 if _, err := jsonapi.Bind(c.Request().Body, &pointer); err != nil { 289 return err 290 } 291 pointer.SetID(file.ID()) 292 293 if err := note.PutTelepointer(inst, pointer); err != nil { 294 return wrapError(err) 295 } 296 297 return c.NoContent(http.StatusNoContent) 298 } 299 300 // ForceNoteSync is the API handler for POST /notes/:id/sync. It forces writing 301 // the note to the VFS 302 func ForceNoteSync(c echo.Context) error { 303 inst := middlewares.GetInstance(c) 304 fileID := c.Param("id") 305 file, err := inst.VFS().FileByID(fileID) 306 if err != nil { 307 return wrapError(err) 308 } 309 310 if err := middlewares.AllowVFS(c, permission.PUT, file); err != nil { 311 return err 312 } 313 314 if err := note.Update(inst, file.ID()); err != nil { 315 return wrapError(err) 316 } 317 318 return c.NoContent(http.StatusNoContent) 319 } 320 321 // OpenNoteURL is the API handler for GET /notes/:id/open. It returns the 322 // parameters to build the URL where the note can be opened. 323 func OpenNoteURL(c echo.Context) error { 324 inst := middlewares.GetInstance(c) 325 fileID := c.Param("id") 326 open, err := sharing.OpenNote(inst, fileID) 327 if err != nil { 328 return wrapError(err) 329 } 330 331 pdoc, err := middlewares.GetPermission(c) 332 if err != nil { 333 return err 334 } 335 memberIndex, _ := strconv.Atoi(c.QueryParam("MemberIndex")) 336 readOnly := c.QueryParam("ReadOnly") == "true" 337 338 // If a directory is shared by link and contains a note, the note can be 339 // opened with the same sharecode as the directory. The sharecode is also 340 // used to identify the member that previews a sharing. 341 if pdoc.Type == permission.TypeShareByLink || pdoc.Type == permission.TypeSharePreview { 342 code := middlewares.GetRequestToken(c) 343 open.AddShareByLinkCode(code) 344 } 345 346 sharingID := c.QueryParam("SharingID") // Cozy to Cozy sharing 347 if err := open.CheckPermission(pdoc, sharingID); err != nil { 348 return middlewares.ErrForbidden 349 } 350 351 doc, err := open.GetResult(memberIndex, readOnly) 352 if err != nil { 353 return wrapError(err) 354 } 355 356 return jsonapi.Data(c, http.StatusOK, doc, nil) 357 } 358 359 // UpdateNoteSchema is the API handler for PUT /notes/:id:/schema. It updates 360 // the schema of the note and invalidates the previous steps. 361 func UpdateNoteSchema(c echo.Context) error { 362 inst := middlewares.GetInstance(c) 363 doc := ¬e.Document{} 364 if _, err := jsonapi.Bind(c.Request().Body, doc); err != nil { 365 return err 366 } 367 368 fileID := c.Param("id") 369 file, err := inst.VFS().FileByID(fileID) 370 if err != nil { 371 return wrapError(err) 372 } 373 374 if err := middlewares.AllowVFS(c, permission.PUT, file); err != nil { 375 return err 376 } 377 378 file, err = note.UpdateSchema(inst, file, doc.SchemaSpec) 379 if err != nil { 380 return wrapError(err) 381 } 382 383 return files.FileData(c, http.StatusOK, file, false, nil) 384 } 385 386 // UploadImage is the API handler for POST /notes/:id/images. It uploads an 387 // image for the note. 388 func UploadImage(c echo.Context) error { 389 // Check permission 390 inst := middlewares.GetInstance(c) 391 doc, err := inst.VFS().FileByID(c.Param("id")) 392 if err != nil { 393 return wrapError(err) 394 } 395 if err := middlewares.AllowVFS(c, permission.POST, doc); err != nil { 396 return err 397 } 398 399 // Check that the uploaded file is an image 400 contentType := c.Request().Header.Get(echo.HeaderContentType) 401 if !strings.HasPrefix(contentType, "image/") { 402 err := errors.New("Only images are accepted") 403 return jsonapi.InvalidParameter(echo.HeaderContentType, err) 404 } 405 406 // Check the VFS quota 407 quota := inst.DiskQuota() 408 if quota > 0 { 409 size := c.Request().ContentLength 410 if size <= 0 { 411 err := errors.New("The Content-Length header is mandatory") 412 return jsonapi.InvalidParameter(echo.HeaderContentLength, err) 413 } 414 if size > note.MaxImageWeight { 415 return jsonapi.Errorf(http.StatusRequestEntityTooLarge, "%s", vfs.ErrMaxFileSize) 416 } 417 used, err := inst.VFS().FilesUsage() 418 if err != nil { 419 return jsonapi.InternalServerError(errors.New("Cannot check quota")) 420 } 421 if used+size > quota { 422 return jsonapi.Errorf(http.StatusRequestEntityTooLarge, "%s", vfs.ErrFileTooBig) 423 } 424 } 425 426 // Create the image document 427 name := c.QueryParam("Name") 428 upload, err := note.NewImageUpload(inst, doc, name, contentType) 429 if err != nil { 430 inst.Logger().WithNamespace("notes").Infof("Image upload has failed: %s", err) 431 return jsonapi.BadRequest(errors.New("Upload has failed")) 432 } 433 434 // Manage the content upload 435 _, err = io.Copy(upload, c.Request().Body) 436 if cerr := upload.Close(); cerr != nil && (err == nil || errors.Is(err, io.ErrUnexpectedEOF)) { 437 err = cerr 438 } 439 if err != nil { 440 inst.Logger().WithNamespace("notes").Infof("Image upload has failed: %s", err) 441 return jsonapi.BadRequest(errors.New("Upload has failed")) 442 } 443 444 image := files.NewNoteImage(inst, upload.Image) 445 return jsonapi.Data(c, http.StatusCreated, image, nil) 446 } 447 448 // CopyImage is the API handler for POST /notes/:id/:image-id/copy. It copies 449 // an existing image to another note. 450 func CopyImage(c echo.Context) error { 451 // Check permission 452 inst := middlewares.GetInstance(c) 453 srcDoc, err := inst.VFS().FileByID(c.Param("id")) 454 if err != nil { 455 return wrapError(err) 456 } 457 if err := middlewares.AllowVFS(c, permission.POST, srcDoc); err != nil { 458 return err 459 } 460 461 dstDoc, err := inst.VFS().FileByID(c.QueryParam("To")) 462 if err != nil { 463 return wrapError(err) 464 } 465 if err := middlewares.AllowVFS(c, permission.POST, dstDoc); err != nil { 466 return err 467 } 468 469 imageID := c.Param("id") + "/" + c.Param("image-id") 470 image, err := note.CopyImageToAnotherNote(inst, imageID, dstDoc) 471 if err != nil { 472 inst.Logger().WithNamespace("notes").Infof("Image copy has failed: %s", err) 473 return wrapError(err) 474 } 475 476 apiImage := files.NewNoteImage(inst, image) 477 return jsonapi.Data(c, http.StatusCreated, apiImage, nil) 478 } 479 480 // GetImage returns the image for a note, possibly resized. 481 func GetImage(c echo.Context) error { 482 inst := middlewares.GetInstance(c) 483 _, err := inst.VFS().FileByID(c.Param("id")) 484 if err != nil { 485 return wrapError(err) 486 } 487 488 imageID := c.Param("id") + "/" + c.Param("image-id") 489 secret := c.Param("secret") 490 thumbID, err := vfs.GetStore().GetThumb(inst, secret) 491 if err != nil { 492 return wrapError(err) 493 } 494 if imageID != thumbID { 495 return jsonapi.NewError(http.StatusBadRequest, "Wrong download token") 496 } 497 498 return inst.ThumbsFS().ServeNoteThumbContent(c.Response(), c.Request(), imageID) 499 } 500 501 // Routes sets the routing for the collaborative edition of notes. 502 func Routes(router *echo.Group) { 503 router.POST("", CreateNote) 504 router.GET("", ListNotes) 505 router.GET("/:id", GetNote) 506 router.GET("/:id/steps", GetSteps) 507 router.GET("/:id/text", GetNoteText) 508 router.GET("/texts", GetTexts) 509 router.PATCH("/:id", PatchNote) 510 router.PUT("/:id/title", ChangeTitle) 511 router.PUT("/:id/telepointer", PutTelepointer) 512 router.POST("/:id/sync", ForceNoteSync) 513 router.GET("/:id/open", OpenNoteURL) 514 router.PUT("/:id/schema", UpdateNoteSchema) 515 router.POST("/:id/images", UploadImage) 516 router.POST("/:id/:image-id/copy", CopyImage) 517 router.GET("/:id/images/:image-id/:secret", GetImage) 518 } 519 520 func wrapError(err error) *jsonapi.Error { 521 switch err { 522 case note.ErrInvalidSchema: 523 return jsonapi.InvalidAttribute("schema", err) 524 case note.ErrInvalidFile, sharing.ErrCannotOpenFile: 525 return jsonapi.NotFound(err) 526 case note.ErrNoSteps, note.ErrInvalidSteps: 527 return jsonapi.BadRequest(err) 528 case note.ErrCannotApply: 529 return jsonapi.Conflict(err) 530 case os.ErrNotExist, vfs.ErrParentDoesNotExist, vfs.ErrParentInTrash: 531 return jsonapi.NotFound(err) 532 case vfs.ErrFileTooBig, vfs.ErrMaxFileSize: 533 return jsonapi.Errorf(http.StatusRequestEntityTooLarge, "%s", err) 534 case sharing.ErrMemberNotFound: 535 return jsonapi.NotFound(err) 536 } 537 return jsonapi.InternalServerError(err) 538 } 539 540 func getCreatedBy(c echo.Context) string { 541 if claims, ok := c.Get("claims").(permission.Claims); ok { 542 switch claims.AudienceString() { 543 case consts.AppAudience, consts.KonnectorAudience: 544 return claims.Subject 545 } 546 } 547 return "" 548 }