mark/attachment/attachment.go
Manuel Rüger d6b37affd3 fix: use index-based loop in ResolveLocalAttachments to persist checksums
The range loop 'for _, attachment := range attachments' copied each
element by value. Assigning attachment.Checksum inside the loop only
modified the local copy; the original slice element was never updated.
All returned attachments had empty Checksum fields, causing every
attachment to be treated as changed on every run (the checksum
comparison would never match).

Switch to an index-based loop so the checksum is written directly to
the slice element.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 19:18:29 +01:00

314 lines
6.9 KiB
Go

package attachment
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"net/url"
"path"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/kovetskiy/mark/confluence"
"github.com/kovetskiy/mark/vfs"
"github.com/reconquest/karma-go"
"github.com/reconquest/pkg/log"
)
const (
AttachmentChecksumPrefix = `mark:checksum: `
)
type Attachment struct {
ID string
Name string
Filename string
FileBytes []byte
Checksum string
Link string
Width string
Height string
Replace string
}
type Attacher interface {
Attach(Attachment)
}
func ResolveAttachments(
api *confluence.API,
page *confluence.PageInfo,
attachments []Attachment,
) ([]Attachment, error) {
for i := range attachments {
checksum, err := GetChecksum(bytes.NewReader(attachments[i].FileBytes))
if err != nil {
return nil, karma.Format(
err,
"unable to get checksum for attachment: %q", attachments[i].Name,
)
}
attachments[i].Checksum = checksum
}
remotes, err := api.GetAttachments(page.ID)
if err != nil {
return nil, karma.Format(err, "unable to get attachments for page %s", page.ID)
}
existing := []Attachment{}
creating := []Attachment{}
updating := []Attachment{}
for _, attachment := range attachments {
var found bool
var same bool
for _, remote := range remotes {
if remote.Filename == attachment.Filename {
same = attachment.Checksum == strings.TrimPrefix(
remote.Metadata.Comment,
AttachmentChecksumPrefix,
)
attachment.ID = remote.ID
attachment.Link = path.Join(
remote.Links.Context,
remote.Links.Download,
)
found = true
break
}
}
if found {
if same {
existing = append(existing, attachment)
} else {
updating = append(updating, attachment)
}
} else {
creating = append(creating, attachment)
}
}
for i, attachment := range creating {
log.Infof(nil, "creating attachment: %q", attachment.Name)
info, err := api.CreateAttachment(
page.ID,
attachment.Filename,
AttachmentChecksumPrefix+attachment.Checksum,
bytes.NewReader(attachment.FileBytes),
)
if err != nil {
return nil, karma.Format(
err,
"unable to create attachment %q",
attachment.Name,
)
}
attachment.ID = info.ID
attachment.Link = path.Join(
info.Links.Context,
info.Links.Download,
)
creating[i] = attachment
}
for i, attachment := range updating {
log.Infof(nil, "updating attachment: %q", attachment.Name)
info, err := api.UpdateAttachment(
page.ID,
attachment.ID,
attachment.Filename,
AttachmentChecksumPrefix+attachment.Checksum,
bytes.NewReader(attachment.FileBytes),
)
if err != nil {
return nil, karma.Format(
err,
"unable to update attachment %q",
attachment.Name,
)
}
attachment.Link = path.Join(
info.Links.Context,
info.Links.Download,
)
updating[i] = attachment
}
for i := range existing {
log.Infof(nil, "keeping unmodified attachment: %q", existing[i].Name)
}
attachments = []Attachment{}
attachments = append(attachments, existing...)
attachments = append(attachments, creating...)
attachments = append(attachments, updating...)
return attachments, nil
}
func ResolveLocalAttachments(opener vfs.Opener, base string, replacements []string) ([]Attachment, error) {
attachments, err := prepareAttachments(opener, base, replacements)
if err != nil {
return nil, err
}
for i := range attachments {
checksum, err := GetChecksum(bytes.NewReader(attachments[i].FileBytes))
if err != nil {
return nil, karma.Format(
err,
"unable to get checksum for attachment: %q", attachments[i].Name,
)
}
attachments[i].Checksum = checksum
}
return attachments, err
}
// prepareAttachements creates an array of attachement objects based on an array of filepaths
func prepareAttachments(opener vfs.Opener, base string, replacements []string) ([]Attachment, error) {
attachments := []Attachment{}
for _, name := range replacements {
attachment, err := prepareAttachment(opener, base, name)
if err != nil {
return nil, err
}
attachments = append(attachments, attachment)
}
return attachments, nil
}
// prepareAttachement opens the file, reads its content and creates an attachement object
func prepareAttachment(opener vfs.Opener, base, name string) (Attachment, error) {
attachmentPath := filepath.Join(base, name)
file, err := opener.Open(attachmentPath)
if err != nil {
return Attachment{}, karma.Format(err, "unable to open file: %q", attachmentPath)
}
defer func() {
_ = file.Close()
}()
fileBytes, err := io.ReadAll(file)
if err != nil {
return Attachment{}, karma.Format(err, "unable to read file: %q", attachmentPath)
}
attachment := Attachment{
Name: name,
Filename: strings.ReplaceAll(name, "/", "_"),
FileBytes: fileBytes,
Replace: name,
}
// Try to detect image dimensions if it's an image attachment
ext := strings.ToLower(filepath.Ext(name))
switch ext {
case ".jpg", ".jpeg", ".png", ".gif":
if config, _, err := image.DecodeConfig(bytes.NewReader(fileBytes)); err == nil {
attachment.Width = strconv.Itoa(config.Width)
attachment.Height = strconv.Itoa(config.Height)
}
}
return attachment, nil
}
func CompileAttachmentLinks(markdown []byte, attachments []Attachment) []byte {
links := map[string]string{}
replaces := []string{}
for _, attachment := range attachments {
links[attachment.Replace] = parseAttachmentLink(attachment.Link)
replaces = append(replaces, attachment.Replace)
}
// sort by length so first items will have bigger length
// it's helpful for replacing in case of following names
// attachments/a.jpg
// attachments/a.jpg.jpg
// so we replace longer and then shorter
sort.SliceStable(replaces, func(i, j int) bool {
return len(replaces[i]) > len(replaces[j])
})
for _, replace := range replaces {
to := links[replace]
found := false
if bytes.Contains(markdown, []byte("attachment://"+replace)) {
from := "attachment://" + replace
log.Debugf(nil, "replacing legacy link: %q -> %q", from, to)
markdown = bytes.ReplaceAll(
markdown,
[]byte(from),
[]byte(to),
)
found = true
}
if bytes.Contains(markdown, []byte(replace)) {
from := replace
log.Debugf(nil, "replacing link: %q -> %q", from, to)
markdown = bytes.ReplaceAll(
markdown,
[]byte(from),
[]byte(to),
)
found = true
}
if !found {
log.Warningf(nil, "unused attachment: %s", replace)
}
}
return markdown
}
func GetChecksum(reader io.Reader) (string, error) {
hash := sha256.New()
if _, err := io.Copy(hash, reader); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
func parseAttachmentLink(attachLink string) string {
uri, err := url.ParseRequestURI(attachLink)
if err != nil {
return strings.ReplaceAll(attachLink, "&", "&amp;")
} else {
return uri.Path +
"?" + url.QueryEscape(uri.Query().Encode())
}
}