github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/model/note/image.go (about)

     1  package note
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"io"
     7  	"path"
     8  	"strings"
     9  
    10  	"github.com/cozy/cozy-stack/model/instance"
    11  	"github.com/cozy/cozy-stack/model/job"
    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/metadata"
    16  	"github.com/cozy/cozy-stack/pkg/prefixer"
    17  	"github.com/cozy/cozy-stack/pkg/realtime"
    18  	"github.com/gofrs/uuid/v5"
    19  )
    20  
    21  // MaxWidth is the maximal width of an image for a note. If larger, the image
    22  // will be resized.
    23  const MaxWidth = 768
    24  
    25  // MaxImageWeight is the maximal weight (in bytes) for an image.
    26  const MaxImageWeight = 100 * 1024 * 1024
    27  
    28  // Image is a file that will be persisted inside the note archive.
    29  type Image struct {
    30  	DocID    string                `json:"_id,omitempty"`
    31  	DocRev   string                `json:"_rev,omitempty"`
    32  	Name     string                `json:"name"`
    33  	Mime     string                `json:"mime"`
    34  	Width    int                   `json:"width,omitempty"`
    35  	Height   int                   `json:"height,omitempty"`
    36  	ToResize bool                  `json:"willBeResized,omitempty"`
    37  	ToRemove bool                  `json:"willBeRemoved,omitempty"`
    38  	Metadata metadata.CozyMetadata `json:"cozyMetadata,omitempty"`
    39  
    40  	seen         bool
    41  	originalName string
    42  }
    43  
    44  // ID returns the image qualified identifier
    45  func (img *Image) ID() string { return img.DocID }
    46  
    47  // Rev returns the image revision
    48  func (img *Image) Rev() string { return img.DocRev }
    49  
    50  // DocType returns the image type
    51  func (img *Image) DocType() string { return consts.NotesImages }
    52  
    53  // Clone implements couchdb.Doc
    54  func (img *Image) Clone() couchdb.Doc {
    55  	cloned := *img
    56  	return &cloned
    57  }
    58  
    59  // SetID changes the image qualified identifier
    60  func (img *Image) SetID(id string) { img.DocID = id }
    61  
    62  // SetRev changes the image revision
    63  func (img *Image) SetRev(rev string) { img.DocRev = rev }
    64  
    65  // ImageUpload is used while an image is uploaded to the stack.
    66  type ImageUpload struct {
    67  	Image *Image
    68  	note  *vfs.FileDoc
    69  	inst  *instance.Instance
    70  	meta  *vfs.MetaExtractor // extracts metadata from the content
    71  	thumb vfs.ThumbFiler     // the VFs where the file will be uploaded
    72  }
    73  
    74  // NewImageUpload can be used to manage uploading a new image for a note.
    75  func NewImageUpload(inst *instance.Instance, note *vfs.FileDoc, name, mime string) (*ImageUpload, error) {
    76  	uuidv7, _ := uuid.NewV7()
    77  	id := note.ID() + "/" + uuidv7.String()
    78  	md := metadata.New()
    79  	md.CreatedByApp = consts.NotesSlug
    80  	img := &Image{DocID: id, Name: name, Mime: mime, Metadata: *md, originalName: name}
    81  
    82  	thumb, err := inst.ThumbsFS().CreateNoteThumb(id, mime, consts.NoteImageOriginalFormat)
    83  	if err != nil {
    84  		return nil, err
    85  	}
    86  
    87  	var meta vfs.MetaExtractor
    88  	switch mime {
    89  	case "image/heic", "image/heif":
    90  		meta = vfs.NewExifExtractor(img.Metadata.CreatedAt, false)
    91  	default:
    92  		meta = vfs.NewImageExtractor(img.Metadata.CreatedAt)
    93  	}
    94  
    95  	upload := ImageUpload{inst: inst, note: note, meta: &meta, thumb: thumb, Image: img}
    96  	return &upload, nil
    97  }
    98  
    99  // Write implements the io.Writer interface (used by io.Copy).
   100  func (u *ImageUpload) Write(p []byte) (int, error) {
   101  	if u.meta != nil {
   102  		if _, err := (*u.meta).Write(p); err != nil && !errors.Is(err, io.ErrClosedPipe) {
   103  			(*u.meta).Abort(err)
   104  			u.meta = nil
   105  		}
   106  	}
   107  	return u.thumb.Write(p)
   108  }
   109  
   110  // Close is called to finalize an upload.
   111  func (u *ImageUpload) Close() error {
   112  	lock := u.inst.NotesLock()
   113  	if err := lock.Lock(); err != nil {
   114  		return err
   115  	}
   116  	defer lock.Unlock()
   117  
   118  	if err := u.thumb.Commit(); err != nil {
   119  		if u.meta != nil {
   120  			(*u.meta).Abort(err)
   121  		}
   122  		return err
   123  	}
   124  
   125  	if u.meta != nil {
   126  		if errc := (*u.meta).Close(); errc == nil {
   127  			result := (*u.meta).Result()
   128  			if w, ok := result["width"].(int); ok {
   129  				u.Image.Width = w
   130  				if w > MaxWidth {
   131  					u.Image.ToResize = true
   132  				}
   133  			}
   134  			if h, ok := result["height"].(int); ok {
   135  				u.Image.Height = h
   136  			}
   137  		}
   138  	}
   139  
   140  	// Check the unicity of the filename
   141  	if images, err := getImages(u.inst, u.note.ID()); err == nil {
   142  		names := make([]string, len(images))
   143  		for i := range images {
   144  			names[i] = images[i].Name
   145  		}
   146  		ext := path.Ext(u.Image.Name)
   147  		basename := strings.TrimSuffix(path.Base(u.Image.Name), ext)
   148  		for i := 2; i < 1000; i++ {
   149  			if !contains(names, u.Image.Name) {
   150  				break
   151  			}
   152  			u.Image.Name = fmt.Sprintf("%s (%d)%s", basename, i, ext)
   153  		}
   154  	}
   155  
   156  	// Save in CouchDB
   157  	if err := couchdb.CreateNamedDocWithDB(u.inst, u.Image); err != nil {
   158  		formats := []string{consts.NoteImageOriginalFormat}
   159  		_ = u.inst.ThumbsFS().RemoveNoteThumb(u.Image.ID(), formats)
   160  		return err
   161  	}
   162  
   163  	// Push a job for the thumbnail worker if the image needs to be resized
   164  	if u.Image.ToResize {
   165  		evt, _ := job.NewEvent(&realtime.Event{Verb: "CREATED"})
   166  		msg, _ := job.NewMessage(struct {
   167  			NoteImage *Image `json:"noteImage"`
   168  		}{
   169  			NoteImage: u.Image,
   170  		})
   171  		_, _ = job.System().PushJob(u.inst, &job.JobRequest{
   172  			WorkerType: "thumbnail",
   173  			Event:      evt,
   174  			Message:    msg,
   175  		})
   176  	}
   177  
   178  	return nil
   179  }
   180  
   181  // CopyImageToAnotherNote makes a copy of an image from one note to be used in
   182  // another note.
   183  func CopyImageToAnotherNote(inst *instance.Instance, imageID string, dstDoc *vfs.FileDoc) (*Image, error) {
   184  	// Open the existing image
   185  	var image Image
   186  	if err := couchdb.GetDoc(inst, consts.NotesImages, imageID, &image); err != nil {
   187  		return nil, err
   188  	}
   189  	thumb, err := inst.ThumbsFS().OpenNoteThumb(imageID, consts.NoteImageOriginalFormat)
   190  	if err != nil {
   191  		return nil, err
   192  	}
   193  	defer thumb.Close()
   194  
   195  	// Prepare the new image document
   196  	upload, err := NewImageUpload(inst, dstDoc, image.Name, image.Mime)
   197  	if err != nil {
   198  		return nil, err
   199  	}
   200  
   201  	// Copy the content
   202  	_, err = io.Copy(upload, thumb)
   203  	if cerr := upload.Close(); cerr != nil && (err == nil || errors.Is(err, io.ErrUnexpectedEOF)) {
   204  		err = cerr
   205  	}
   206  	if err != nil {
   207  		return nil, err
   208  	}
   209  	return upload.Image, nil
   210  }
   211  
   212  func contains(haystack []string, needle string) bool {
   213  	for _, v := range haystack {
   214  		if needle == v {
   215  			return true
   216  		}
   217  	}
   218  	return false
   219  }
   220  
   221  // GetImages returns the images for the given note.
   222  func GetImages(inst *instance.Instance, fileID string) ([]*Image, error) {
   223  	lock := inst.NotesLock()
   224  	if err := lock.Lock(); err != nil {
   225  		return nil, err
   226  	}
   227  	defer lock.Unlock()
   228  
   229  	return getImages(inst, fileID)
   230  }
   231  
   232  // getImages is the same as GetSteps, but with the notes lock already acquired
   233  func getImages(db prefixer.Prefixer, fileID string) ([]*Image, error) {
   234  	var images []*Image
   235  	req := couchdb.AllDocsRequest{
   236  		Limit:    1000,
   237  		StartKey: startkey(fileID),
   238  		EndKey:   endkey(fileID),
   239  	}
   240  	if err := couchdb.GetAllDocs(db, consts.NotesImages, &req, &images); err != nil {
   241  		return nil, err
   242  	}
   243  	return images, nil
   244  }
   245  
   246  // cleanImages will remove images that are no longer used. They are not deleted
   247  // on the first pass where they have not been seen in markdown to allow
   248  // features like cut/paste or undo to have a short grace time when the image
   249  // can be removed from the markdown and reinserted a few seconds later.
   250  func cleanImages(inst *instance.Instance, images []*Image) {
   251  	formats := []string{
   252  		consts.NoteImageOriginalFormat,
   253  		consts.NoteImageThumbFormat,
   254  	}
   255  	for _, img := range images {
   256  		if img.ToRemove {
   257  			if img.seen {
   258  				img.ToRemove = false
   259  				_ = couchdb.UpdateDoc(inst, img)
   260  			} else {
   261  				_ = inst.ThumbsFS().RemoveNoteThumb(img.ID(), formats)
   262  				_ = couchdb.DeleteDoc(inst, img)
   263  			}
   264  		} else if !img.seen {
   265  			img.ToRemove = true
   266  			_ = couchdb.UpdateDoc(inst, img)
   267  		}
   268  	}
   269  }
   270  
   271  func hasImages(images []*Image) bool {
   272  	for _, img := range images {
   273  		if img.seen {
   274  			return true
   275  		}
   276  	}
   277  	return false
   278  }
   279  
   280  var _ couchdb.Doc = &Image{}