Kaynağa Gözat

IMGPROXY_KEEP_COPYRIGHT config & keep_copyright option

DarthSim 3 yıl önce
ebeveyn
işleme
4d4a1d724f

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@
 - Add support of RLE-encoded BMP.
 - Add `IMGPROXY_ENFORCE_THUMBNAIL` config and [enforce_thumbnail](https://docs.imgproxy.net/generating_the_url?id=enforce-thumbnail) processing option.
 - Add `X-Result-Width` and `X-Result-Height` to debug headers.
+- Add `IMGPROXY_KEEP_COPYRIGHT` config and [keep_copyright](https://docs.imgproxy.net/generating_the_url?id=keep-copyright) processing option.
 
 ### Change
 - Use thumbnail embedded to HEIC/AVIF if its size is larger than or equal to the requested.

+ 3 - 0
config/config.go

@@ -47,6 +47,7 @@ var (
 	Quality               int
 	FormatQuality         map[imagetype.Type]int
 	StripMetadata         bool
+	KeepCopyright         bool
 	StripColorProfile     bool
 	AutoRotate            bool
 	EnforceThumbnail      bool
@@ -203,6 +204,7 @@ func Reset() {
 	Quality = 80
 	FormatQuality = map[imagetype.Type]int{imagetype.AVIF: 50}
 	StripMetadata = true
+	KeepCopyright = true
 	StripColorProfile = true
 	AutoRotate = true
 	EnforceThumbnail = false
@@ -350,6 +352,7 @@ func Configure() error {
 		return err
 	}
 	configurators.Bool(&StripMetadata, "IMGPROXY_STRIP_METADATA")
+	configurators.Bool(&KeepCopyright, "IMGPROXY_KEEP_COPYRIGHT")
 	configurators.Bool(&StripColorProfile, "IMGPROXY_STRIP_COLOR_PROFILE")
 	configurators.Bool(&AutoRotate, "IMGPROXY_AUTO_ROTATE")
 	configurators.Bool(&EnforceThumbnail, "IMGPROXY_ENFORCE_THUMBNAIL")

+ 1 - 0
docs/configuration.md

@@ -417,6 +417,7 @@ imgproxy can send logs to syslog, but this feature is disabled by default. To en
 * `IMGPROXY_USE_LINEAR_COLORSPACE`: when `true`, imgproxy will process images in linear colorspace. This will slow down processing. Note that images won't be fully processed in linear colorspace while shrink-on-load is enabled (see below).
 * `IMGPROXY_DISABLE_SHRINK_ON_LOAD`: when `true`, disables shrink-on-load for JPEGs and WebP files. Allows processing the entire image in linear colorspace but dramatically slows down resizing and increases memory usage when working with large images.
 * `IMGPROXY_STRIP_METADATA`: when `true`, imgproxy will strip all metadata (EXIF, IPTC, etc.) from JPEG and WebP output images. Default: `true`
+* `IMGPROXY_KEEP_COPYRIGHT`: when `true`, imgproxy will not remove copyright info while stripping metadata. Default: `true`
 * `IMGPROXY_STRIP_COLOR_PROFILE`: when `true`, imgproxy will transform the embedded color profile (ICC) to sRGB and remove it from the image. Otherwise, imgproxy will try to keep it as is. Default: `true`
 * `IMGPROXY_AUTO_ROTATE`: when `true`, imgproxy will automatically rotate images based on the EXIF Orientation parameter (if available in the image meta data). The orientation tag will be removed from the image in all cases. Default: `true`
 * `IMGPROXY_ENFORCE_THUMBNAIL`: when `true` and the source image has an embedded thumbnail, imgproxy will always use the embedded thumbnail instead of the main image. Currently, only thumbnails embedded in `heic` and `avif` are supported. Default: `false`

+ 9 - 0
docs/generating_the_url.md

@@ -502,6 +502,15 @@ sm:%strip_metadata
 
 When set to `1`, `t` or `true`, imgproxy will strip the metadata (EXIF, IPTC, etc.) on JPEG and WebP output images. This is normally controlled by the [IMGPROXY_STRIP_METADATA](configuration.md#miscellaneous) configuration but this procesing option allows the configuration to be set for each request.
 
+### Keep Copyright
+
+```
+keep_copyright:%keep_copyright
+kcr:%keep_copyright
+```
+
+When set to `1`, `t` or `true`, imgproxy will not remove copyright info while stripping metadata. This is normally controlled by the [IMGPROXY_KEEP_COPYRIGHT](configuration.md#miscellaneous) configuration but this procesing option allows the configuration to be set for each request.
+
 ### Strip Color Profile
 
 ```

+ 1 - 0
go.mod

@@ -23,6 +23,7 @@ require (
 	github.com/prometheus/client_golang v1.12.1
 	github.com/sirupsen/logrus v1.8.1
 	github.com/stretchr/testify v1.7.1
+	github.com/trimmer-io/go-xmp v1.0.0 // indirect
 	go.uber.org/automaxprocs v1.5.0
 	golang.org/x/image v0.0.0-20220321031419-a8550c1d254a
 	golang.org/x/net v0.0.0-20220403103023-749bd193bc2b

+ 4 - 0
go.sum

@@ -783,6 +783,8 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
 github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
 github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
 github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
+github.com/montanaflynn/stats v0.6.6 h1:Duep6KMIDpY4Yo11iFsvyqJDyfzLF9+sndUKT+v64GQ=
+github.com/montanaflynn/stats v0.6.6/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
 github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
 github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
 github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
@@ -1006,6 +1008,8 @@ github.com/tj/go-elastic v0.0.0-20171221160941-36157cbbebc2/go.mod h1:WjeM0Oo1eN
 github.com/tj/go-kinesis v0.0.0-20171128231115-08b17f58cb1b/go.mod h1:/yhzCV0xPfx6jb1bBgRFjl5lytqVqZXEaeqWP8lTEao=
 github.com/tj/go-spin v1.1.0/go.mod h1:Mg1mzmePZm4dva8Qz60H2lHwmJ2loum4VIrLgVnKwh4=
 github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
+github.com/trimmer-io/go-xmp v1.0.0 h1:zY8bolSga5kOjBAaHS6hrdxLgEoYuT875xTy0QDwZWs=
+github.com/trimmer-io/go-xmp v1.0.0/go.mod h1:Aaptr9sp1lLv7UnCAdQ+gSHZyY2miYaKmcNVj7HRBwA=
 github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
 github.com/twitchtv/twirp v8.1.1+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A=
 github.com/ugorji/go v0.0.0-20171122102828-84cb69a8af83/go.mod h1:hnLbHMwcvSihnDhEfx2/BzKp2xb0Y+ErdfYcrs9tkJQ=

+ 205 - 0
imagemeta/iptc/iptc.go

@@ -0,0 +1,205 @@
+package iptc
+
+import (
+	"bytes"
+	"encoding/binary"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"math"
+)
+
+var (
+	ps3Header         = []byte("Photoshop 3.0\x00")
+	ps3BlockHeader    = []byte("8BIM")
+	ps3IptcRecourceID = []byte("\x04\x04")
+	iptcTagHeader     = byte(0x1c)
+
+	errInvalidPS3Header = errors.New("invalid Photoshop 3.0 header")
+	errInvalidDataSize  = errors.New("invalid IPTC data size")
+)
+
+type IptcMap map[TagKey][]TagValue
+
+func (m IptcMap) AddTag(key TagKey, data []byte) error {
+	info, infoFound := tagInfoMap[key]
+	if !infoFound {
+		return fmt.Errorf("unknown tag %d:%d", key.RecordID, key.TagID)
+	}
+
+	dataSize := len(data)
+	if dataSize < info.MinSize || dataSize > info.MaxSize {
+		return fmt.Errorf("invalid tag data size. Min: %d, Max: %d, Has: %d", info.MinSize, info.MaxSize, dataSize)
+	}
+
+	value := TagValue{info.Format, data}
+
+	if info.Repeatable {
+		m[key] = append(m[key], value)
+	} else {
+		m[key] = []TagValue{value}
+	}
+
+	return nil
+}
+
+func (m IptcMap) MarshalJSON() ([]byte, error) {
+	mm := make(map[string]interface{}, len(m))
+	for key, values := range m {
+		info, infoFound := tagInfoMap[key]
+		if !infoFound {
+			continue
+		}
+
+		if info.Repeatable {
+			mm[info.Title] = values
+		} else {
+			mm[info.Title] = values[0]
+		}
+
+		// Add some additional fields for backward compatibility
+		if key.RecordID == 2 {
+			if key.TagID == 5 {
+				mm["Name"] = values[0]
+			} else if key.TagID == 120 {
+				mm["Caption"] = values[0]
+			}
+		}
+	}
+	return json.Marshal(mm)
+}
+
+func ParseTags(data []byte, m IptcMap) error {
+	buf := bytes.NewBuffer(data)
+
+	// Min tag size is 5 (2 tagHeader)
+	for buf.Len() >= 5 {
+		if buf.Next(1)[0] != iptcTagHeader {
+			continue
+		}
+
+		recordID, _ := buf.ReadByte()
+		tagID, _ := buf.ReadByte()
+		dataSize16 := binary.BigEndian.Uint16(buf.Next(2))
+
+		var dataSize int
+
+		if dataSize16 < 32768 {
+			dataSize = int(dataSize16)
+		} else {
+			dataSizeSize := dataSize16 & 32767
+
+			switch dataSizeSize {
+			case 4:
+				dataSize32 := uint32(0)
+				if err := binary.Read(buf, binary.BigEndian, &dataSize32); err != nil {
+					return fmt.Errorf("%s: %s", errInvalidDataSize, err)
+				}
+				dataSize = int(dataSize32)
+			case 8:
+				dataSize64 := uint64(0)
+				if err := binary.Read(buf, binary.BigEndian, &dataSize64); err != nil {
+					return fmt.Errorf("%s: %s", errInvalidDataSize, err)
+				}
+				dataSize = int(dataSize64)
+			default:
+				return errInvalidDataSize
+			}
+		}
+
+		// Ignore errors here. If tag is invalid, just don't add it
+		m.AddTag(TagKey{recordID, tagID}, buf.Next(dataSize))
+	}
+
+	return nil
+}
+
+func ParsePS3(data []byte, m IptcMap) error {
+	buf := bytes.NewBuffer(data)
+
+	if !bytes.Equal(buf.Next(14), ps3Header) {
+		return errInvalidPS3Header
+	}
+
+	// Read blocks
+	// Minimal block size is 12 (4 blockHeader + 2 resoureceID + 2 name + 4 blockSize)
+	for buf.Len() >= 12 {
+		if !bytes.Equal(buf.Bytes()[:4], ps3BlockHeader) {
+			buf.Next(1)
+			continue
+		}
+
+		// Skip block header
+		buf.Next(4)
+
+		resoureceID := buf.Next(2)
+
+		// Skip name
+		// Name is zero terminated string padded to even
+		for buf.Len() > 0 && buf.Next(2)[1] != 0 {
+		}
+
+		if buf.Len() < 4 {
+			break
+		}
+
+		blockSize := int(binary.BigEndian.Uint32(buf.Next(4)))
+
+		if buf.Len() < blockSize {
+			break
+		}
+		blockData := buf.Next(blockSize)
+
+		// 1028 is IPTC tags block
+		if bytes.Equal(resoureceID, ps3IptcRecourceID) {
+			return ParseTags(blockData, m)
+		}
+	}
+
+	return nil
+}
+
+func (m IptcMap) DumpTags() []byte {
+	buf := new(bytes.Buffer)
+
+	for key, values := range m {
+		for _, value := range values {
+			dataSize := len(value.Raw)
+			// Skip tags with too big data size
+			if dataSize > math.MaxUint32 {
+				continue
+			}
+
+			buf.WriteByte(iptcTagHeader)
+			buf.WriteByte(key.RecordID)
+			buf.WriteByte(key.TagID)
+
+			if dataSize < (1 << 15) {
+				binary.Write(buf, binary.BigEndian, uint16(dataSize))
+			} else {
+				binary.Write(buf, binary.BigEndian, uint16(4+(1<<15)))
+				binary.Write(buf, binary.BigEndian, uint32(dataSize))
+			}
+
+			buf.Write(value.Raw)
+		}
+	}
+
+	return buf.Bytes()
+}
+
+func (m IptcMap) Dump() []byte {
+	tagsDump := m.DumpTags()
+
+	buf := new(bytes.Buffer)
+	buf.Grow(26)
+
+	buf.Write(ps3Header)
+	buf.Write(ps3BlockHeader)
+	buf.Write(ps3IptcRecourceID)
+	buf.Write([]byte{0, 0})
+	binary.Write(buf, binary.BigEndian, uint32(len(tagsDump)))
+	buf.Write(tagsDump)
+
+	return buf.Bytes()
+}

+ 467 - 0
imagemeta/iptc/tags.go

@@ -0,0 +1,467 @@
+package iptc
+
+import (
+	"encoding/binary"
+	"encoding/json"
+	"fmt"
+	"math"
+)
+
+type TagFormat int
+
+const (
+	TagFormatByte TagFormat = iota
+	TagFormatShort
+	TagFormatLong
+	TagFormatString
+	TagFormatBinary
+	TagFormatDate
+	TagFormatTime
+)
+
+type TagKey struct {
+	RecordID byte
+	TagID    byte
+}
+
+type TagInfo struct {
+	Name       string
+	Title      string
+	Format     TagFormat
+	Required   bool
+	Repeatable bool
+	MinSize    int
+	MaxSize    int
+}
+
+var tagInfoMap = map[TagKey]TagInfo{
+	{1, 0}: {
+		"ModelVersion",
+		"Model Version",
+		TagFormatShort, true, false, 2, 2,
+	},
+	{1, 5}: {
+		"Destination",
+		"Destination",
+		TagFormatString, false, true, 0, 1024,
+	},
+	{1, 20}: {
+		"FileFormat",
+		"File Format",
+		TagFormatShort, true, false, 2, 2,
+	},
+	{1, 22}: {
+		"FileVersion",
+		"File Version",
+		TagFormatShort, true, false, 2, 2,
+	},
+	{1, 30}: {
+		"ServiceID",
+		"Service Identifier",
+		TagFormatString, true, false, 0, 10,
+	},
+	{1, 40}: {
+		"EnvelopeNum",
+		"Envelope Number",
+		TagFormatString, true, false, 8, 8,
+	},
+	{1, 50}: {
+		"ProductID",
+		"Product I.D.",
+		TagFormatString, false, true, 0, 32,
+	},
+	{1, 60}: {
+		"EnvelopePriority",
+		"Envelope Priority",
+		TagFormatString, false, false, 1, 1,
+	},
+	{1, 70}: {
+		"DateSent",
+		"Date Sent",
+		TagFormatDate, true, false, 8, 8,
+	},
+	{1, 80}: {
+		"TimeSent",
+		"Time Sent",
+		TagFormatTime, false, false, 11, 11,
+	},
+	{1, 90}: {
+		"CharacterSet",
+		"Coded Character Set",
+		TagFormatBinary, false, false, 0, 32,
+	},
+	{1, 100}: {
+		"UNO",
+		"Unique Name of Object",
+		TagFormatString, false, false, 14, 80,
+	},
+	{1, 120}: {
+		"ARMID",
+		"ARM Identifier",
+		TagFormatShort, false, false, 2, 2,
+	},
+	{1, 122}: {
+		"ARMVersion",
+		"ARM Version",
+		TagFormatShort, false, false, 2, 2,
+	},
+	{2, 0}: {
+		"RecordVersion",
+		"Record Version",
+		TagFormatShort, true, false, 2, 2,
+	},
+	{2, 3}: {
+		"ObjectType",
+		"Object Type Reference",
+		TagFormatString, false, false, 3, 67,
+	},
+	{2, 4}: {
+		"ObjectAttribute",
+		"Object Attribute Reference",
+		TagFormatString, false, true, 4, 68,
+	},
+	{2, 5}: {
+		"ObjectName",
+		"Object Name",
+		TagFormatString, false, false, 0, 64,
+	},
+	{2, 7}: {
+		"EditStatus",
+		"Edit Status",
+		TagFormatString, false, false, 0, 64,
+	},
+	{2, 8}: {
+		"EditorialUpdate",
+		"Editorial Update",
+		TagFormatString, false, false, 2, 2,
+	},
+	{2, 10}: {
+		"Urgency",
+		"Urgency",
+		TagFormatString, false, false, 1, 1,
+	},
+	{2, 12}: {
+		"SubjectRef",
+		"Subject Reference",
+		TagFormatString, false, true, 13, 236,
+	},
+	{2, 15}: {
+		"Category",
+		"Category",
+		TagFormatString, false, false, 0, 3,
+	},
+	{2, 20}: {
+		"SupplCategory",
+		"Supplemental Category",
+		TagFormatString, false, true, 0, 32,
+	},
+	{2, 22}: {
+		"FixtureID",
+		"Fixture Identifier",
+		TagFormatString, false, false, 0, 32,
+	},
+	{2, 25}: {
+		"Keywords",
+		"Keywords",
+		TagFormatString, false, true, 0, 64,
+	},
+	{2, 26}: {
+		"ContentLocCode",
+		"Content Location Code",
+		TagFormatString, false, true, 3, 3,
+	},
+	{2, 27}: {
+		"ContentLocName",
+		"Content Location Name",
+		TagFormatString, false, true, 0, 64,
+	},
+	{2, 30}: {
+		"ReleaseDate",
+		"Release Date",
+		TagFormatDate, false, false, 8, 8,
+	},
+	{2, 35}: {
+		"ReleaseTime",
+		"Release Time",
+		TagFormatTime, false, false, 11, 11,
+	},
+	{2, 37}: {
+		"ExpirationDate",
+		"Expiration Date",
+		TagFormatDate, false, false, 8, 8,
+	},
+	{2, 38}: {
+		"ExpirationTime",
+		"Expiration Time",
+		TagFormatTime, false, false, 11, 11,
+	},
+	{2, 40}: {
+		"SpecialInstructions",
+		"Special Instructions",
+		TagFormatString, false, false, 0, 256,
+	},
+	{2, 42}: {
+		"ActionAdvised",
+		"Action Advised",
+		TagFormatString, false, false, 2, 2,
+	},
+	{2, 45}: {
+		"RefService",
+		"Reference Service",
+		TagFormatString, false, true, 0, 10,
+	},
+	{2, 47}: {
+		"RefDate",
+		"Reference Date",
+		TagFormatDate, false, true, 8, 8,
+	},
+	{2, 50}: {
+		"RefNumber",
+		"Reference Number",
+		TagFormatString, false, true, 8, 8,
+	},
+	{2, 55}: {
+		"DateCreated",
+		"Date Created",
+		TagFormatDate, false, false, 8, 19,
+	},
+	{2, 60}: {
+		"TimeCreated",
+		"Time Created",
+		TagFormatTime, false, false, 6, 11,
+	},
+	{2, 62}: {
+		"DigitalCreationDate",
+		"Digital Creation Date",
+		TagFormatDate, false, false, 8, 8,
+	},
+	{2, 63}: {
+		"DigitalCreationTime",
+		"Digital Creation Time",
+		TagFormatTime, false, false, 11, 11,
+	},
+	{2, 65}: {
+		"OriginatingProgram",
+		"Originating Program",
+		TagFormatString, false, false, 0, 32,
+	},
+	{2, 70}: {
+		"ProgramVersion",
+		"Program Version",
+		TagFormatString, false, false, 0, 10,
+	},
+	{2, 75}: {
+		"ObjectCycle",
+		"Object Cycle",
+		TagFormatString, false, false, 1, 1,
+	},
+	{2, 80}: {
+		"Byline",
+		"By-line",
+		TagFormatString, false, true, 0, 32,
+	},
+	{2, 85}: {
+		"BylineTitle",
+		"By-line Title",
+		TagFormatString, false, true, 0, 32,
+	},
+	{2, 90}: {
+		"City",
+		"City",
+		TagFormatString, false, false, 0, 32,
+	},
+	{2, 92}: {
+		"Sublocation",
+		"Sub-location",
+		TagFormatString, false, false, 0, 32,
+	},
+	{2, 95}: {
+		"State",
+		"Province/State",
+		TagFormatString, false, false, 0, 32,
+	},
+	{2, 100}: {
+		"CountryCode",
+		"Country Code",
+		TagFormatString, false, false, 3, 3,
+	},
+	{2, 101}: {
+		"CountryName",
+		"Country Name",
+		TagFormatString, false, false, 0, 64,
+	},
+	{2, 103}: {
+		"OrigTransRef",
+		"Original Transmission Reference",
+		TagFormatString, false, false, 0, 32,
+	},
+	{2, 105}: {
+		"Headline",
+		"Headline",
+		TagFormatString, false, false, 0, 256,
+	},
+	{2, 110}: {
+		"Credit",
+		"Credit",
+		TagFormatString, false, false, 0, 32,
+	},
+	{2, 115}: {
+		"Source",
+		"Source",
+		TagFormatString, false, false, 0, 32,
+	},
+	{2, 116}: {
+		"CopyrightNotice",
+		"Copyright Notice",
+		TagFormatString, false, false, 0, 128,
+	},
+	{2, 118}: {
+		"Contact",
+		"Contact",
+		TagFormatString, false, true, 0, 128,
+	},
+	{2, 120}: {
+		"Caption",
+		"Caption/Abstract",
+		TagFormatString, false, false, 0, 2000,
+	},
+	{2, 122}: {
+		"WriterEditor",
+		"Writer/Editor",
+		TagFormatString, false, true, 0, 32,
+	},
+	{2, 125}: {
+		"RasterizedCaption",
+		"Rasterized Caption",
+		TagFormatBinary, false, false, 7360, 7360,
+	},
+	{2, 130}: {
+		"ImageType",
+		"Image Type",
+		TagFormatString, false, false, 2, 2,
+	},
+	{2, 131}: {
+		"ImageOrientation",
+		"Image Orientation",
+		TagFormatString, false, false, 1, 1,
+	},
+	{2, 135}: {
+		"LanguageID",
+		"Language Identifier",
+		TagFormatString, false, false, 2, 3,
+	},
+	{2, 150}: {
+		"AudioType",
+		"Audio Type",
+		TagFormatString, false, false, 2, 2,
+	},
+	{2, 151}: {
+		"AudioSamplingRate",
+		"Audio Sampling Rate",
+		TagFormatString, false, false, 6, 6,
+	},
+	{2, 152}: {
+		"AudioSamplingRes",
+		"Audio Sampling Resolution",
+		TagFormatString, false, false, 2, 2,
+	},
+	{2, 153}: {
+		"AudioDuration",
+		"Audio Duration",
+		TagFormatString, false, false, 6, 6,
+	},
+	{2, 154}: {
+		"AudioOutcue",
+		"Audio Outcue",
+		TagFormatString, false, false, 0, 64,
+	},
+	{2, 200}: {
+		"PreviewFileFormat",
+		"Preview File Format",
+		TagFormatShort, false, false, 2, 2,
+	},
+	{2, 201}: {
+		"PreviewFileFormatVer",
+		"Preview File Format Version",
+		TagFormatShort, false, false, 2, 2,
+	},
+	{2, 202}: {
+		"PreviewData",
+		"Preview Data",
+		TagFormatBinary, false, false, 0, 256000,
+	},
+	{7, 10}: {
+		"SizeMode",
+		"Size Mode",
+		TagFormatByte, true, false, 1, 1,
+	},
+	{7, 20}: {
+		"MaxSubfileSize",
+		"Max Subfile Size",
+		TagFormatLong, true, false, 3, 4,
+	},
+	{7, 90}: {
+		"ObjectSizeAnnounced",
+		"Object Size Announced",
+		TagFormatLong, false, false, 3, 4,
+	},
+	{7, 95}: {
+		"MaxObjectSize",
+		"Maximum Object Size",
+		TagFormatLong, false, false, 3, 4,
+	},
+	{8, 10}: {
+		"Subfile",
+		"Subfile",
+		TagFormatBinary, true, true, 0, math.MaxUint32,
+	},
+	{9, 10}: {
+		"ConfirmedDataSize",
+		"Confirmed Data Size",
+		TagFormatLong, true, false, 3, 4,
+	},
+}
+
+func GetTagInfo(key TagKey) (TagInfo, error) {
+	info, infoFound := tagInfoMap[key]
+	if !infoFound {
+		return TagInfo{}, fmt.Errorf("unknown tag %d:%d", key.RecordID, key.TagID)
+	}
+	return info, nil
+}
+
+type TagValue struct {
+	Format TagFormat
+	Raw    []byte
+}
+
+func (v TagValue) Typecast() interface{} {
+	switch v.Format {
+	case TagFormatByte, TagFormatShort, TagFormatLong:
+		return v.Int()
+	case TagFormatBinary:
+		return v.Raw
+	default:
+		return string(v.Raw)
+	}
+}
+
+func (v TagValue) Int() int {
+	switch len(v.Raw) {
+	// Check zero data just in case
+	case 0:
+		return 0
+	case 1:
+		return int(v.Raw[0])
+	case 2:
+		return int(binary.BigEndian.Uint16(v.Raw))
+	case 3:
+		return int(binary.BigEndian.Uint16(v.Raw[:2]))<<8 + int(v.Raw[2])
+	default:
+		return int(binary.BigEndian.Uint32(v.Raw[:4]))
+	}
+}
+
+func (v TagValue) MarshalJSON() ([]byte, error) {
+	return json.Marshal(v.Typecast())
+}

+ 14 - 0
options/processing_options.go

@@ -85,6 +85,7 @@ type ProcessingOptions struct {
 	Sharpen           float32
 	Pixelate          int
 	StripMetadata     bool
+	KeepCopyright     bool
 	StripColorProfile bool
 	AutoRotate        bool
 	EnforceThumbnail  bool
@@ -135,6 +136,7 @@ func NewProcessingOptions() *ProcessingOptions {
 			Dpr:               1,
 			Watermark:         WatermarkOptions{Opacity: 1, Replicate: false, Gravity: GravityOptions{Type: GravityCenter}},
 			StripMetadata:     config.StripMetadata,
+			KeepCopyright:     config.KeepCopyright,
 			StripColorProfile: config.StripColorProfile,
 			AutoRotate:        config.AutoRotate,
 			EnforceThumbnail:  config.EnforceThumbnail,
@@ -817,6 +819,16 @@ func applyStripMetadataOption(po *ProcessingOptions, args []string) error {
 	return nil
 }
 
+func applyKeepCopyrightOption(po *ProcessingOptions, args []string) error {
+	if len(args) > 1 {
+		return fmt.Errorf("Invalid keep copyright arguments: %v", args)
+	}
+
+	po.KeepCopyright = parseBoolOption(args[0])
+
+	return nil
+}
+
 func applyStripColorProfileOption(po *ProcessingOptions, args []string) error {
 	if len(args) > 1 {
 		return fmt.Errorf("Invalid strip color profile arguments: %v", args)
@@ -895,6 +907,8 @@ func applyURLOption(po *ProcessingOptions, name string, args []string) error {
 		return applyWatermarkOption(po, args)
 	case "strip_metadata", "sm":
 		return applyStripMetadataOption(po, args)
+	case "keep_copyright", "kcr":
+		return applyKeepCopyrightOption(po, args)
 	case "strip_color_profile", "scp":
 		return applyStripColorProfileOption(po, args)
 	case "enforce_thumbnail", "eth":

+ 94 - 1
processing/finalize.go

@@ -1,16 +1,109 @@
 package processing
 
 import (
+	"bytes"
+
+	"github.com/trimmer-io/go-xmp/xmp"
+
 	"github.com/imgproxy/imgproxy/v3/imagedata"
+	"github.com/imgproxy/imgproxy/v3/imagemeta/iptc"
 	"github.com/imgproxy/imgproxy/v3/options"
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
 
+func stripIPTC(img *vips.Image) []byte {
+	iptcData, err := img.GetBlob("iptc-data")
+	if err != nil || len(iptcData) == 0 {
+		return nil
+	}
+
+	iptcMap := make(iptc.IptcMap)
+	err = iptc.ParsePS3(iptcData, iptcMap)
+	if err != nil {
+		return nil
+	}
+
+	for key := range iptcMap {
+		if key.RecordID == 2 && key.TagID != 80 && key.TagID != 110 && key.TagID != 116 {
+			delete(iptcMap, key)
+		}
+	}
+
+	if len(iptcMap) == 0 {
+		return nil
+	}
+
+	return iptcMap.Dump()
+}
+
+func stripXMP(img *vips.Image) []byte {
+	xmpData, err := img.GetBlob("xmp-data")
+	if err != nil || len(xmpData) == 0 {
+		return nil
+	}
+
+	xmpDoc, err := xmp.Read(bytes.NewReader(xmpData))
+	if err != nil {
+		return nil
+	}
+
+	namespaces := xmpDoc.Namespaces()
+	filteredNs := namespaces[:0]
+
+	for _, ns := range namespaces {
+		if ns.Name == "dc" || ns.Name == "xmpRights" || ns.Name == "cc" {
+			filteredNs = append(filteredNs, ns)
+		}
+	}
+	xmpDoc.FilterNamespaces(filteredNs)
+
+	nodes := xmpDoc.Nodes()
+	for _, n := range nodes {
+		if n.Name() == "dc" {
+			filteredNodes := n.Nodes[:0]
+			for _, nn := range n.Nodes {
+				if nn.Name() == "rights" || nn.Name() == "contributor" || nn.Name() == "creator" || nn.Name() == "publisher" {
+					filteredNodes = append(filteredNodes, nn)
+				}
+			}
+			n.Nodes = filteredNodes
+		}
+	}
+
+	if len(xmpDoc.Nodes()) == 0 {
+		return nil
+	}
+
+	xmpData, err = xmp.Marshal(xmpDoc)
+	if err != nil {
+		return nil
+	}
+
+	return xmpData
+}
+
 func finalize(pctx *pipelineContext, img *vips.Image, po *options.ProcessingOptions, imgdata *imagedata.ImageData) error {
 	if po.StripMetadata {
-		if err := img.Strip(); err != nil {
+		var iptcData, xmpData []byte
+
+		if po.KeepCopyright {
+			iptcData = stripIPTC(img)
+			xmpData = stripXMP(img)
+		}
+
+		if err := img.Strip(po.KeepCopyright); err != nil {
 			return err
 		}
+
+		if po.KeepCopyright {
+			if len(iptcData) > 0 {
+				img.SetBlob("iptc-data", iptcData)
+			}
+
+			if len(xmpData) > 0 {
+				img.SetBlob("xmp-data", xmpData)
+			}
+		}
 	}
 
 	return copyMemoryAndCheckTimeout(pctx.ctx, img)

+ 7 - 1
vips/vips.c

@@ -522,7 +522,7 @@ vips_arrayjoin_go(VipsImage **in, VipsImage **out, int n) {
 }
 
 int
-vips_strip(VipsImage *in, VipsImage **out) {
+vips_strip(VipsImage *in, VipsImage **out, int keep_exif_copyright) {
   static double default_resolution = 72.0 / 25.4;
 
   if (vips_copy(
@@ -540,6 +540,12 @@ vips_strip(VipsImage *in, VipsImage **out) {
     if (strcmp(name, VIPS_META_ICC_NAME) == 0) continue;
     if (strcmp(name, "palette-bit-depth") == 0) continue;
 
+    if (keep_exif_copyright) {
+      if (strcmp(name, VIPS_META_EXIF_NAME) == 0) continue;
+      if (strcmp(name, "exif-ifd0-Copyright") == 0) continue;
+      if (strcmp(name, "exif-ifd0-Artist") == 0) continue;
+    }
+
     vips_image_remove(*out, name);
   }
 

+ 19 - 2
vips/vips.go

@@ -414,6 +414,18 @@ func (img *Image) GetIntSliceDefault(name string, def []int) ([]int, error) {
 	return img.GetIntSlice(name)
 }
 
+func (img *Image) GetBlob(name string) ([]byte, error) {
+	var (
+		tmp  unsafe.Pointer
+		size C.size_t
+	)
+
+	if C.vips_image_get_blob(img.VipsImage, cachedCString(name), &tmp, &size) != 0 {
+		return nil, Error()
+	}
+	return C.GoBytes(tmp, C.int(size)), nil
+}
+
 func (img *Image) SetInt(name string, value int) {
 	C.vips_image_set_int(img.VipsImage, cachedCString(name), C.int(value))
 }
@@ -426,6 +438,11 @@ func (img *Image) SetIntSlice(name string, value []int) {
 	C.vips_image_set_array_int_go(img.VipsImage, cachedCString(name), &in[0], C.int(len(value)))
 }
 
+func (img *Image) SetBlob(name string, value []byte) {
+	defer runtime.KeepAlive(value)
+	C.vips_image_set_blob_copy(img.VipsImage, cachedCString(name), unsafe.Pointer(&value[0]), C.size_t(len(value)))
+}
+
 func (img *Image) CastUchar() error {
 	var tmp *C.VipsImage
 
@@ -750,10 +767,10 @@ func (img *Image) ApplyWatermark(wm *Image, opacity float64) error {
 	return nil
 }
 
-func (img *Image) Strip() error {
+func (img *Image) Strip(keepExifCopyright bool) error {
 	var tmp *C.VipsImage
 
-	if C.vips_strip(img.VipsImage, &tmp) != 0 {
+	if C.vips_strip(img.VipsImage, &tmp, gbool(keepExifCopyright)) != 0 {
 		return Error()
 	}
 	C.swap_and_clear(&img.VipsImage, tmp)

+ 1 - 1
vips/vips.h

@@ -79,7 +79,7 @@ int vips_apply_watermark(VipsImage *in, VipsImage *watermark, VipsImage **out, d
 
 int vips_arrayjoin_go(VipsImage **in, VipsImage **out, int n);
 
-int vips_strip(VipsImage *in, VipsImage **out);
+int vips_strip(VipsImage *in, VipsImage **out, int keep_exif_copyright);
 
 int vips_jpegsave_go(VipsImage *in, void **buf, size_t *len, int quality, int interlace);
 int vips_pngsave_go(VipsImage *in, void **buf, size_t *len, int interlace, int quantize, int colors);