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 := &note.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 := &note.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  }