strip.go 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. package metadata
  2. import (
  3. "bytes"
  4. "encoding/binary"
  5. "fmt"
  6. "io"
  7. "os"
  8. "path/filepath"
  9. )
  10. // Strip strips metadata (EXIF, text chunks) from image files using a
  11. // temp-file + rename strategy so the original is never overwritten in place.
  12. // Supported formats: JPEG, PNG. Returns nil for non-image files.
  13. func Strip(path string) error {
  14. tmp, err := stripToTemp(path)
  15. if err != nil {
  16. return err
  17. }
  18. if tmp == "" {
  19. return nil
  20. }
  21. defer os.Remove(tmp)
  22. return os.Rename(tmp, path)
  23. }
  24. // StripTo writes a stripped copy of the source image to destPath.
  25. // Both paths may be the same (caller should use Strip for that).
  26. // Returns nil for non-image files without error.
  27. func StripTo(srcPath, destPath string) error {
  28. srcIsImage, err := IsImageFile(srcPath)
  29. if err != nil || !srcIsImage {
  30. return err
  31. }
  32. f, err := os.Open(srcPath)
  33. if err != nil {
  34. return fmt.Errorf("strip: open: %w", err)
  35. }
  36. defer f.Close()
  37. sig := make([]byte, 8)
  38. if _, err := io.ReadFull(f, sig); err != nil {
  39. return fmt.Errorf("strip: read sig: %w", err)
  40. }
  41. f.Close()
  42. switch {
  43. case isJPEG(sig):
  44. return stripJPEGTo(srcPath, destPath)
  45. case isPNG(sig):
  46. return stripPNGTo(srcPath, destPath)
  47. }
  48. return nil
  49. }
  50. // stripToTemp strips metadata to a temp file in the same directory.
  51. // Returns empty string if the file is not a supported image type.
  52. func stripToTemp(path string) (string, error) {
  53. srcIsImage, err := IsImageFile(path)
  54. if err != nil || !srcIsImage {
  55. return "", err
  56. }
  57. dir := filepath.Dir(path)
  58. tmp, err := os.CreateTemp(dir, ".localgo-strip-*")
  59. if err != nil {
  60. return "", fmt.Errorf("strip: temp: %w", err)
  61. }
  62. tmpPath := tmp.Name()
  63. tmp.Close()
  64. os.Remove(tmpPath)
  65. if err := StripTo(path, tmpPath); err != nil {
  66. os.Remove(tmpPath)
  67. return "", err
  68. }
  69. return tmpPath, nil
  70. }
  71. // IsImageFile returns true if the file at path has a JPEG or PNG magic signature.
  72. func IsImageFile(path string) (bool, error) {
  73. f, err := os.Open(path)
  74. if err != nil {
  75. return false, fmt.Errorf("strip: open: %w", err)
  76. }
  77. defer f.Close()
  78. sig := make([]byte, 8)
  79. if _, err := io.ReadFull(f, sig); err != nil {
  80. return false, nil
  81. }
  82. return isJPEG(sig) || isPNG(sig), nil
  83. }
  84. func isJPEG(sig []byte) bool {
  85. return len(sig) >= 2 && sig[0] == 0xFF && sig[1] == 0xD8
  86. }
  87. func isPNG(sig []byte) bool {
  88. pngSig := []byte{137, 80, 78, 71, 13, 10, 26, 10}
  89. return bytes.Equal(sig, pngSig)
  90. }
  91. // stripJPEGTo removes APP1 (EXIF) and APP13 (Photoshop/IPTC) markers.
  92. func stripJPEGTo(srcPath, destPath string) error {
  93. f, err := os.Open(srcPath)
  94. if err != nil {
  95. return fmt.Errorf("strip: open: %w", err)
  96. }
  97. defer f.Close()
  98. var buf bytes.Buffer
  99. if _, err := io.Copy(&buf, f); err != nil {
  100. return fmt.Errorf("strip: read: %w", err)
  101. }
  102. f.Close()
  103. data := buf.Bytes()
  104. if !isJPEG(data) {
  105. return nil
  106. }
  107. var out bytes.Buffer
  108. out.Write(data[:2]) // SOI
  109. pos := 2
  110. sosSeen := false
  111. for pos+1 < len(data) {
  112. if data[pos] != 0xFF {
  113. break
  114. }
  115. marker := data[pos+1]
  116. if marker == 0xDA {
  117. out.Write(data[pos:])
  118. sosSeen = true
  119. break
  120. }
  121. if marker == 0xD9 || marker == 0x00 || marker == 0x01 {
  122. if pos+2 > len(data) {
  123. break
  124. }
  125. out.Write(data[pos : pos+2])
  126. pos += 2
  127. if marker == 0xD9 {
  128. break
  129. }
  130. continue
  131. }
  132. if pos+3 >= len(data) {
  133. break
  134. }
  135. segLen := int(binary.BigEndian.Uint16(data[pos+2:pos+4])) + 2
  136. if pos+segLen > len(data) {
  137. break
  138. }
  139. if marker != 0xE1 && marker != 0xED {
  140. out.Write(data[pos : pos+segLen])
  141. }
  142. pos += segLen
  143. }
  144. if !sosSeen {
  145. return fmt.Errorf("strip: no SOS marker found in JPEG")
  146. }
  147. return writeAtomic(destPath, out.Bytes())
  148. }
  149. // stripPNGTo removes tEXt, zTXt, iTXt, and eXIf metadata chunks.
  150. func stripPNGTo(srcPath, destPath string) error {
  151. f, err := os.Open(srcPath)
  152. if err != nil {
  153. return fmt.Errorf("strip: open: %w", err)
  154. }
  155. defer f.Close()
  156. var buf bytes.Buffer
  157. if _, err := io.Copy(&buf, f); err != nil {
  158. return fmt.Errorf("strip: read: %w", err)
  159. }
  160. f.Close()
  161. data := buf.Bytes()
  162. if len(data) < 8 || !isPNG(data[:8]) {
  163. return nil
  164. }
  165. var out bytes.Buffer
  166. out.Write(data[:8]) // signature
  167. pos := 8
  168. iendSeen := false
  169. for pos+4 <= len(data) {
  170. chunkLen := int(binary.BigEndian.Uint32(data[pos : pos+4]))
  171. if pos+12+chunkLen > len(data) {
  172. break
  173. }
  174. chunkType := string(data[pos+4 : pos+8])
  175. if chunkType == "tEXt" || chunkType == "zTXt" || chunkType == "iTXt" || chunkType == "eXIf" {
  176. pos += 12 + chunkLen
  177. continue
  178. }
  179. if chunkType == "IEND" {
  180. out.Write(data[pos : pos+12+chunkLen])
  181. iendSeen = true
  182. break
  183. }
  184. out.Write(data[pos : pos+12+chunkLen])
  185. pos += 12 + chunkLen
  186. }
  187. if !iendSeen {
  188. return fmt.Errorf("strip: no IEND chunk found in PNG")
  189. }
  190. return writeAtomic(destPath, out.Bytes())
  191. }
  192. // writeAtomic writes data to path via a temp file and rename.
  193. func writeAtomic(path string, data []byte) error {
  194. dir := filepath.Dir(path)
  195. tmp, err := os.CreateTemp(dir, ".localgo-write-*")
  196. if err != nil {
  197. return fmt.Errorf("strip: temp: %w", err)
  198. }
  199. tmpPath := tmp.Name()
  200. if _, err := tmp.Write(data); err != nil {
  201. tmp.Close()
  202. os.Remove(tmpPath)
  203. return fmt.Errorf("strip: write: %w", err)
  204. }
  205. if err := tmp.Sync(); err != nil {
  206. tmp.Close()
  207. os.Remove(tmpPath)
  208. return fmt.Errorf("strip: sync: %w", err)
  209. }
  210. tmp.Close()
  211. if err := os.Rename(tmpPath, path); err != nil {
  212. os.Remove(tmpPath)
  213. return fmt.Errorf("strip: rename: %w", err)
  214. }
  215. return nil
  216. }