photo

package module
v0.1.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 2, 2026 License: MIT Imports: 18 Imported by: 0

README

go-photo

Image ingest for Go web apps — sniff, decode, downscale, re-encode, thumbnail, store.

Designed to be dropped into the same apps as go-ai, go-api, go-draw, go-wiki and go-login: one upload path, one naming rule, one place that decides what is safe to keep.

It solves the problem every app with an upload button hits on the same afternoon — a phone posts 12 MB of JPEG carrying the owner's GPS coordinates in its EXIF, and the app stores it verbatim.

Features

  • Re-encoding, not pass-through — strips EXIF (which carries location), guarantees the stored bytes really are the image that was decoded rather than a polyglot with a payload after the marker, and leaves one predictable format to serve.
  • Sniffed, not declared — the Content-Type on a multipart part is client-controlled, so it is not evidence. A .jpg that is really a PHP script is refused.
  • Downscaled sharply — CatmullRom at the factors phone photos actually hit, which is what keeps small print on a photographed receipt or lab printout legible.
  • Thumbnails — optional, written beside the image under a name derived from it, so an app remembers one path rather than two.
  • Two naming rules, or your own — content-addressed (identical uploads collapse onto one file) or dated ({owner}/{yyyy}/{mm}/…), and a Namer is just a function.
  • Path-safe by construction — a stored name comes back out of a database, so a name that tries to climb out of the root is rejected rather than quietly rewritten into something inside it.
  • Keeps what it cannot parse, if you ask — KeepUnsupported stores a PDF byte-for-byte alongside the photos, off by default.

One dependency: golang.org/x/image, for the WebP decoder and the scaler.

Install

go get github.com/anchoo2kewl/go-photo

Quick start

store, err := photo.NewStore("/data/photos", photo.Options{
	MaxEdge:   1600,
	ThumbEdge: 320,
	Quality:   82,
	MaxBytes:  25 << 20,
})

file, header, err := r.FormFile("file")
saved, err := store.Save(file, header.Filename, photo.Dated(userID, "progress"))
if err != nil {
	// errors.Is(err, photo.ErrUnsupportedType) — not an image we accept
	// errors.Is(err, photo.ErrTooLarge)        — over MaxBytes
}

// Persist these; they are what the app needs to serve and delete the image.
saved.RelPath      // "42/2026/09/progress-1757...-9f3c1a20.jpg"
saved.ThumbPath    // "42/2026/09/progress-1757...-9f3c1a20-thumb.jpg"
saved.ContentType  // "image/jpeg"
saved.Width, saved.Height
saved.Bytes()      // stored size
saved.OriginalBytes
saved.Saved()      // 0.86 — "86% smaller", for the upload confirmation

Serving and deleting go through the store, so the containment check is not something a handler can forget:

f, err := store.Open(rec.StoredName)
...
err = store.RemoveWithThumb(rec.StoredName)

Processing without storing

Process is the whole pipeline with no filesystem. Use it when the bytes have somewhere else to go — an object store, or a vision model that decides whether the upload is worth keeping at all:

img, err := photo.Process(raw, header.Filename, photo.Options{MaxEdge: 1600})
if err != nil {
	return err
}

// A 1600px JPEG is what a vision model wants anyway: readable, and a fraction
// of the tokens of the original.
resp, err := chain.Complete(ctx, ai.Request{
	Messages: []ai.Message{ai.UserImage("Read this water test.", img.ContentType, img.Data)},
})

saved, err := store.SaveImage(img, photo.ContentAddressed("attachments"))

Options

Field Default Meaning
MaxEdge 1600 Longest side of the stored image. Never upscales. Negative disables downscaling
ThumbEdge 0 Longest side of the thumbnail. Zero produces none
Quality 82 JPEG quality of the stored image
ThumbQuality 75 JPEG quality of the thumbnail
MaxBytes 0 Reject an input larger than this. Zero means no limit
ForceRecompress false Keep the re-encode even when it is larger, so every stored image is one this package produced
KeepUnsupported false Store an undecodable file (a PDF) byte-for-byte rather than rejecting it

The zero value is usable.

Naming

A Namer turns a processed image into a path relative to the store root. It is called after processing, so the digest and the content type are available:

photo.ContentAddressed("attachments")
// attachments/9f3c…8bd.jpg — identical uploads collapse onto one file

photo.Dated("42", "receipt")
// 42/2026/09/receipt-1757943210000000000-9f3c1a20.jpg

func(img *photo.Image) string {                // or your own
	return path.Join("pools", poolID, img.SHA256+img.Extension())
}

Content-addressing is usually what an app wants, and is why an app using it must count the rows referencing a name before deleting the file behind it — the same bytes may back several records.

When the re-encode comes out larger

An already-optimised image, or noise, can encode larger than it arrived. If the image did not need downscaling, the original bytes are kept and Recompressed is false — storing the bigger of the two to satisfy a rule about re-encoding would be the wrong trade, and the reported dimensions describe what is actually stored.

An image that was downscaled keeps its re-encode regardless: the point of the resize is to stop serving a 4000px original, and a format that happened to compress the big version better does not change that.

Set ForceRecompress to drop the fallback entirely. Do it where the guarantee matters more than the bytes — a re-encode is what strips EXIF, so an app that promises uploaded photos carry no location data cannot afford to keep a phone's original JPEG because it happened to be smaller.

Not in scope

  • EXIF orientation. Rotation metadata is stripped along with everything else, so an image relying on it is stored as its pixels were laid out. Almost every camera and browser upload path now writes upright pixels; if yours does not, rotate before calling Process.
  • Animation. An animated GIF or WebP is stored as its first frame.
  • HEIC. Go has no decoder. With KeepUnsupported it is stored untouched; without it, refused.
  • Serving. Open hands back an *os.File; caching, ranges and Content-Disposition are the app's policy.

License

MIT

Documentation

Overview

Package photo is image ingest for Go web apps: sniff the real type, decode, downscale, re-encode, thumbnail, and write the result somewhere safe.

Designed to be dropped into the same apps as go-ai, go-api, go-draw, go-wiki and go-login. The problem it solves is the one every app with an upload button hits on the same afternoon: a phone camera posts 12 MB of JPEG with the owner's GPS coordinates in the EXIF, and the app stores it verbatim.

Re-encoding rather than passing bytes through is the point. It strips EXIF (which carries location), it guarantees the stored bytes really are the image that was decoded rather than a polyglot file with a payload after the marker, and it leaves one predictable format to serve.

Index

Constants

View Source
const (
	// DefaultMaxEdge keeps a photographed document readable — small print on
	// a receipt or a lab printout survives the downscale — while cutting a
	// modern phone's 4000px original to a fraction of its size.
	DefaultMaxEdge = 1600
	// DefaultQuality trades a little fidelity for a large size reduction.
	DefaultQuality = 82
	// DefaultThumbQuality is lower because a thumbnail is never inspected.
	DefaultThumbQuality = 75
)

Defaults applied when an Options field is left at zero.

Variables

View Source
var ErrOutsideRoot = errors.New("photo: path outside the store root")

ErrOutsideRoot is returned when a stored path would escape the store's root directory. It is the error a crafted filename from the database produces, so it is worth logging rather than swallowing.

View Source
var ErrTooLarge = errors.New("photo: image is larger than the limit")

ErrTooLarge is returned when the input exceeds Options.MaxBytes.

View Source
var ErrUnsupportedType = errors.New("photo: unsupported image type")

ErrUnsupportedType is returned when the bytes are not an image this package can decode, and Options.KeepUnsupported is not set.

Functions

func DetectContentType

func DetectContentType(raw []byte, filename string) string

DetectContentType sniffs the type of a file that was not decoded as an image, falling back to the filename extension for the formats net/http does not recognise. filename may be empty.

func ExtensionFor

func ExtensionFor(contentType string) string

ExtensionFor maps a content type onto a file extension, leading dot included. An unrecognised type gets ".bin" rather than a guess.

func Fit

func Fit(img image.Image, edge int) image.Image

Fit scales img down so its longest side is at most edge, preserving the aspect ratio. An image already within the limit is returned untouched — upscaling a small photo only wastes bytes. An edge of zero or less returns the image unchanged.

func LooksLikeImage

func LooksLikeImage(b []byte) bool

LooksLikeImage reports whether the magic bytes are one of the formats this package decodes.

Types

type Image

type Image struct {
	// Data is what should be stored.
	Data []byte
	// ContentType of Data.
	ContentType string
	// Width and Height of Data. Zero for a file that was kept undecoded.
	Width, Height int
	// OriginalBytes is the size of the input, for reporting how much the
	// upload shrank.
	OriginalBytes int64
	// SHA256 is the hex digest of Data — the stored bytes, not the input, so
	// it can be used as a content-addressed filename.
	SHA256 string
	// Thumb is the thumbnail, or nil when none was asked for or the file was
	// kept undecoded.
	Thumb []byte
	// ThumbWidth and ThumbHeight describe Thumb.
	ThumbWidth, ThumbHeight int
	// Recompressed reports whether Data is a re-encode. It is false when the
	// original bytes were kept — either because the file was not a decodable
	// image, or because re-encoding it came out larger.
	Recompressed bool
}

Image is the result of processing an upload.

func Process

func Process(raw []byte, filename string, opt Options) (*Image, error)

Process decodes raw, downscales it to fit Options.MaxEdge, re-encodes it as JPEG, and optionally produces a thumbnail.

filename is used only as a hint when sniffing an undecodable file's type, and may be empty.

The original bytes are kept, rather than the re-encode, when the image did not need downscaling and the re-encode came out larger — which happens with an already-optimised image, or with noise that JPEG cannot compress. Storing the bigger of the two to satisfy a rule about re-encoding would be the wrong trade, and the dimensions reported in that case are the original's, since that is what is stored.

An image that *was* downscaled keeps its re-encode even so: the point of the resize is to stop serving a 4000px original, and a format that happened to compress the big version better does not change that. Options.ForceRecompress drops the fallback entirely, for an app that needs every stored image to be one this package produced.

func (*Image) Bytes

func (i *Image) Bytes() int64

Bytes is the size of the stored data.

func (*Image) Extension

func (i *Image) Extension() string

Extension is the file extension for the stored bytes, leading dot included.

func (*Image) Saved

func (i *Image) Saved() float64

Saved reports the fraction of the original size that was saved, from 0 to 1. It is zero when nothing was saved.

type Namer

type Namer func(*Image) string

Namer decides where a processed image is stored, as a slash-separated path relative to the store root, extension included. It is called after processing, so Image.SHA256 and Image.ContentType are available to it.

func ContentAddressed

func ContentAddressed(dir string) Namer

ContentAddressed names a file by the digest of its stored bytes, under dir (which may be empty for the store root).

Identical uploads collapse onto one file, which is usually what an app wants — and is why an app using it must count the rows pointing at a name before deleting the file behind it.

func Dated

func Dated(dir, kind string) Namer

Dated names a file dir/yyyy/mm/kind-<nanos>-<hash8>.<ext>, which keeps a directory listing browsable as the count grows and sorts naturally by time. dir is typically the owner's id, so one account's images stay together.

type Options

type Options struct {
	// MaxEdge is the longest side of the stored image. Images already within
	// it are not upscaled. Zero means DefaultMaxEdge; a negative value
	// disables downscaling.
	MaxEdge int
	// ThumbEdge is the longest side of the thumbnail. Zero means no thumbnail
	// is produced.
	ThumbEdge int
	// Quality is the JPEG quality of the stored image (1-100). Zero means
	// DefaultQuality.
	Quality int
	// ThumbQuality is the JPEG quality of the thumbnail. Zero means
	// DefaultThumbQuality.
	ThumbQuality int
	// MaxBytes rejects an input larger than this. Zero means no limit.
	MaxBytes int64
	// ForceRecompress stores the re-encode even when it is larger than the
	// input, so every stored image is one this package produced.
	//
	// Set it where the guarantee matters more than the bytes: a re-encode is
	// what strips EXIF, so an app that promises uploaded photos carry no
	// location data cannot afford the fallback that keeps a phone's original
	// JPEG because it happened to be smaller.
	ForceRecompress bool
	// KeepUnsupported stores a file that could not be decoded as an image
	// byte-for-byte instead of rejecting it, so an app that accepts PDFs
	// alongside photos does not lose the document.
	//
	// It is off by default: keeping bytes nobody parsed is a decision an app
	// should make deliberately, and one that comes with serving them back
	// under a Content-Type the app chose rather than the uploader.
	KeepUnsupported bool
}

Options controls how an image is processed.

The zero value is usable: 1600px longest edge, quality 82, no thumbnail, no size limit, and an unsupported file rejected.

type Saved

type Saved struct {
	*Image
	// RelPath is the stored image's path relative to the store root, in slash
	// form. It is what an app should persist.
	RelPath string
	// ThumbPath is the thumbnail's path, or empty when none was produced.
	ThumbPath string
}

Saved is an image that has been written to disk.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store writes processed images beneath a single root directory.

It owns the filesystem side only. Which user an image belongs to, and what it is of, are the app's business and belong in the app's database — the store is handed a Namer and told where to put the bytes.

func NewStore

func NewStore(dir string, opt Options) (*Store, error)

NewStore creates a store rooted at dir, creating the directory if needed.

func (*Store) Open

func (s *Store) Open(rel string) (*os.File, error)

Open returns a reader for a stored path.

func (*Store) Options

func (s *Store) Options() Options

Options returns the processing options the store applies.

func (*Store) Path

func (s *Store) Path(rel string) (string, error)

Path resolves a stored relative path to an absolute one, refusing anything that escapes the root.

A stored name comes back out of a database, so it is not trusted input. A name that tries to climb out is rejected rather than quietly rewritten into something inside the root: silently rewriting turns a sign of tampering into a successful read of an unrelated file.

func (*Store) Remove

func (s *Store) Remove(paths ...string) error

Remove deletes stored files, ignoring empty names and missing files.

It returns the first real error, having attempted every path — a failure to delete one image should not leave the rest behind.

func (*Store) RemoveWithThumb

func (s *Store) RemoveWithThumb(rel string) error

RemoveWithThumb deletes a stored image together with its thumbnail.

func (*Store) Root

func (s *Store) Root() string

Root returns the directory the store writes into.

func (*Store) Save

func (s *Store) Save(r io.Reader, filename string, namer Namer) (*Saved, error)

Save reads r, processes it, and writes the result under the name the Namer chooses. A nil Namer means ContentAddressed("").

filename is a hint used only for type sniffing, and may be empty.

func (*Store) SaveImage

func (s *Store) SaveImage(img *Image, namer Namer) (*Saved, error)

SaveImage writes an already-processed image, for an app that wants to inspect or reject it before it hits the disk — passing the bytes to a vision model, say, and storing them only if the answer was usable.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL