17 _ "golang.org/x/image/webp"
21 imageDirName = "images"
22 imageMaxBytes = 20 << 20
26 errImageSize = errors.New("image exceeds 20 MB")
27 errImageFormat = errors.New("use a JPEG, PNG, or WebP image")
30 // ImageStore holds immutable images shared by the root, children, and
31 // subagents. Names are the SHA-256 of the bytes with the decoded format as
32 // extension. Publication is atomic and does not replace an existing image.
33 type ImageStore struct {
37 // Save validates and stores data, and returns its filename. Only JPEG, PNG, and
38 // WebP images up to 20 MB are accepted.
39 func (s *ImageStore) Save(data []byte) (name string, err error) {
40 format, err := imageFormat(data)
42 return "", fmt.Errorf("check image: %w", err)
45 sum := sha256.Sum256(data)
46 name = hex.EncodeToString(sum[:]) + "." + format
47 err = os.MkdirAll(s.dir, 0o700)
49 return "", fmt.Errorf("create image directory: %w", err)
52 file, err := os.CreateTemp(s.dir, ".image-*")
54 return "", fmt.Errorf("create image file: %w", err)
58 err = errors.Join(err, os.Remove(file.Name()))
61 _, err = file.Write(data)
66 err = errors.Join(err, file.Close())
68 return "", fmt.Errorf("write image: %w", err)
71 err = os.Link(file.Name(), filepath.Join(s.dir, name))
72 if err != nil && !errors.Is(err, os.ErrExist) {
73 return "", fmt.Errorf("publish image: %w", err)
79 // Read returns the bytes of a stored image after checking its name and content.
80 func (s *ImageStore) Read(name string) ([]byte, error) {
82 return nil, errors.New("invalid image name")
85 data, err := imageReadFile(filepath.Join(s.dir, name))
87 return nil, fmt.Errorf("read stored image: %w", err)
90 format, err := imageFormat(data)
92 return nil, fmt.Errorf("check stored image: %w", err)
95 sum := sha256.Sum256(data)
96 if name != hex.EncodeToString(sum[:])+"."+format {
97 return nil, errors.New("image content does not match its name")
103 func imageFormat(data []byte) (string, error) {
104 if len(data) > imageMaxBytes {
105 return "", errImageSize
108 config, format, err := image.DecodeConfig(bytes.NewReader(data))
109 if err != nil || config.Width <= 0 || config.Height <= 0 {
110 return "", errImageFormat
119 return "", errImageFormat
123 func imageName(name string) bool {
124 hash, ext, found := strings.Cut(name, ".")
125 return found && len(hash) == 64 && strings.Trim(hash, "0123456789abcdef") == "" && (ext == "jpg" || ext == "png" || ext == "webp")
128 func imageReadFile(path string) (data []byte, err error) {
129 file, err := os.Open(path)
131 return nil, fmt.Errorf("open image: %w", err)
135 err = errors.Join(err, file.Close())
138 data, err = io.ReadAll(io.LimitReader(file, imageMaxBytes+1))
140 return nil, fmt.Errorf("read image: %w", err)
143 if len(data) > imageMaxBytes {
144 return nil, errImageSize
150 // imageFiles reads only complete marker lines with valid store filenames.
151 func imageFiles(text string) []string {
154 for line := range strings.SplitSeq(text, "\n") {
155 name, found := strings.CutPrefix(line, "[image ")
156 if !found || !strings.HasSuffix(name, "]") {
160 name = strings.TrimSuffix(name, "]")
162 names = append(names, name)
169 func imageText(names []string, caption string) string {
170 lines := make([]string, 0, len(names)+1)
171 for _, name := range names {
172 lines = append(lines, "[image "+name+"]")
176 lines = append(lines, caption)
179 return strings.Join(lines, "\n")