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{}