├── .envrc ├── go.mod.sri ├── test └── fixtures │ ├── file │ ├── 1.jpg │ ├── 1_500.jpg │ ├── metadata.json │ ├── invalid_metadata.json │ └── metadata_multiple.json │ ├── fixture.jpg │ ├── api │ ├── all_linux.jpg │ ├── all_darwin.jpg │ ├── all_darwin.webp │ ├── all_linux.webp │ ├── blur_darwin.jpg │ ├── blur_linux.jpg │ ├── blur_linux.webp │ ├── blur_darwin.webp │ ├── grayscale_darwin.jpg │ ├── grayscale_linux.jpg │ ├── grayscale_linux.webp │ ├── grayscale_darwin.webp │ ├── max_allowed_darwin.jpg │ ├── max_allowed_darwin.webp │ ├── max_allowed_linux.jpg │ ├── max_allowed_linux.webp │ ├── width_height_darwin.jpg │ ├── width_height_linux.jpg │ ├── width_height_linux.webp │ └── width_height_darwin.webp │ ├── vips │ ├── blur_result_darwin.jpg │ ├── blur_result_linux.jpg │ ├── blur_result_linux.webp │ ├── blur_result_darwin.webp │ ├── resize_result_darwin.jpg │ ├── resize_result_linux.jpg │ ├── resize_result_linux.webp │ ├── grayscale_result_darwin.jpg │ ├── grayscale_result_linux.jpg │ ├── grayscale_result_linux.webp │ ├── resize_result_darwin.webp │ └── grayscale_result_darwin.webp │ └── image │ ├── complete_result_darwin.jpg │ ├── complete_result_linux.jpg │ ├── complete_result_linux.webp │ └── complete_result_darwin.webp ├── tools ├── tools.go ├── go.mod └── go.sum ├── internal ├── web │ ├── embed │ │ ├── favicon.ico │ │ ├── robots.txt │ │ ├── assets │ │ │ ├── images │ │ │ │ ├── favicon │ │ │ │ │ ├── favicon-16x16.png │ │ │ │ │ └── favicon-32x32.png │ │ │ │ ├── icon.svg │ │ │ │ └── fastly.svg │ │ │ └── js │ │ │ │ └── images.js │ │ ├── images.html │ │ └── index.html │ ├── embed.go │ ├── tailwind.config.js │ └── style.css ├── image │ ├── image.go │ ├── mock │ │ └── mock.go │ ├── cache.go │ ├── task.go │ └── vips │ │ ├── image.go │ │ ├── vips_test.go │ │ └── vips.go ├── cmd │ └── main.go ├── storage │ ├── mock │ │ └── mock.go │ ├── storage.go │ └── file │ │ ├── file.go │ │ └── file_test.go ├── tracing │ ├── test │ │ └── tracing.go │ └── tracing.go ├── hmac │ ├── hmac_test.go │ └── hmac.go ├── handler │ ├── recovery.go │ ├── recovery_test.go │ ├── tracing.go │ ├── health.go │ ├── route_matcher.go │ ├── logger.go │ ├── handler.go │ ├── handler_test.go │ └── metrics.go ├── database │ ├── database.go │ ├── mock │ │ └── mock.go │ └── file │ │ ├── file.go │ │ └── file_test.go ├── cache │ ├── mock │ │ └── mock.go │ ├── memory │ │ ├── memory_test.go │ │ └── memory.go │ ├── cache_test.go │ └── cache.go ├── vips │ ├── vips-bridge.h │ ├── vips-bridge.c │ ├── vips.go │ └── vips_test.go ├── params │ ├── hmac.go │ ├── query.go │ └── params.go ├── api │ ├── params.go │ ├── deprecated.go │ ├── image.go │ ├── list.go │ └── api.go ├── metrics │ └── metrics.go ├── logger │ └── logger.go ├── queue │ ├── queue_test.go │ └── queue.go ├── imageapi │ ├── api.go │ ├── image.go │ └── api_test.go └── health │ ├── health_test.go │ └── health.go ├── .gitignore ├── README.md ├── .vscode └── launch.json ├── LICENSE.md ├── Makefile ├── flake.lock ├── cmd ├── image-manifest │ └── main.go ├── picsum-photos │ └── main.go └── image-service │ └── main.go ├── go.mod ├── flake.nix └── go.sum /.envrc: -------------------------------------------------------------------------------- 1 | watch_file ./go.mod.sri 2 | use flake 3 | -------------------------------------------------------------------------------- /go.mod.sri: -------------------------------------------------------------------------------- 1 | sha256-kDL65ShZ2d97f1ch57o4HmA7i/22ol9SX+uLNzig4Rk= 2 | -------------------------------------------------------------------------------- /test/fixtures/file/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/file/1.jpg -------------------------------------------------------------------------------- /test/fixtures/fixture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/fixture.jpg -------------------------------------------------------------------------------- /test/fixtures/file/1_500.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/file/1_500.jpg -------------------------------------------------------------------------------- /tools/tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | package tools 3 | 4 | import ( 5 | _ "tailscale.com/cmd/nardump" 6 | ) 7 | -------------------------------------------------------------------------------- /internal/web/embed/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/internal/web/embed/favicon.ico -------------------------------------------------------------------------------- /test/fixtures/api/all_linux.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/api/all_linux.jpg -------------------------------------------------------------------------------- /test/fixtures/api/all_darwin.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/api/all_darwin.jpg -------------------------------------------------------------------------------- /test/fixtures/api/all_darwin.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/api/all_darwin.webp -------------------------------------------------------------------------------- /test/fixtures/api/all_linux.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/api/all_linux.webp -------------------------------------------------------------------------------- /test/fixtures/api/blur_darwin.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/api/blur_darwin.jpg -------------------------------------------------------------------------------- /test/fixtures/api/blur_linux.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/api/blur_linux.jpg -------------------------------------------------------------------------------- /test/fixtures/api/blur_linux.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/api/blur_linux.webp -------------------------------------------------------------------------------- /tools/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/DMarby/picsum-photos/tools 2 | 3 | go 1.21.2 4 | 5 | require tailscale.com v1.58.2 6 | -------------------------------------------------------------------------------- /test/fixtures/api/blur_darwin.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/api/blur_darwin.webp -------------------------------------------------------------------------------- /test/fixtures/api/grayscale_darwin.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/api/grayscale_darwin.jpg -------------------------------------------------------------------------------- /test/fixtures/api/grayscale_linux.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/api/grayscale_linux.jpg -------------------------------------------------------------------------------- /test/fixtures/api/grayscale_linux.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/api/grayscale_linux.webp -------------------------------------------------------------------------------- /test/fixtures/api/grayscale_darwin.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/api/grayscale_darwin.webp -------------------------------------------------------------------------------- /test/fixtures/api/max_allowed_darwin.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/api/max_allowed_darwin.jpg -------------------------------------------------------------------------------- /test/fixtures/api/max_allowed_darwin.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/api/max_allowed_darwin.webp -------------------------------------------------------------------------------- /test/fixtures/api/max_allowed_linux.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/api/max_allowed_linux.jpg -------------------------------------------------------------------------------- /test/fixtures/api/max_allowed_linux.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/api/max_allowed_linux.webp -------------------------------------------------------------------------------- /test/fixtures/api/width_height_darwin.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/api/width_height_darwin.jpg -------------------------------------------------------------------------------- /test/fixtures/api/width_height_linux.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/api/width_height_linux.jpg -------------------------------------------------------------------------------- /test/fixtures/api/width_height_linux.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/api/width_height_linux.webp -------------------------------------------------------------------------------- /test/fixtures/vips/blur_result_darwin.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/vips/blur_result_darwin.jpg -------------------------------------------------------------------------------- /test/fixtures/vips/blur_result_linux.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/vips/blur_result_linux.jpg -------------------------------------------------------------------------------- /test/fixtures/vips/blur_result_linux.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/vips/blur_result_linux.webp -------------------------------------------------------------------------------- /test/fixtures/api/width_height_darwin.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/api/width_height_darwin.webp -------------------------------------------------------------------------------- /test/fixtures/vips/blur_result_darwin.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/vips/blur_result_darwin.webp -------------------------------------------------------------------------------- /test/fixtures/vips/resize_result_darwin.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/vips/resize_result_darwin.jpg -------------------------------------------------------------------------------- /test/fixtures/vips/resize_result_linux.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/vips/resize_result_linux.jpg -------------------------------------------------------------------------------- /test/fixtures/vips/resize_result_linux.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/vips/resize_result_linux.webp -------------------------------------------------------------------------------- /test/fixtures/image/complete_result_darwin.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/image/complete_result_darwin.jpg -------------------------------------------------------------------------------- /test/fixtures/image/complete_result_linux.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/image/complete_result_linux.jpg -------------------------------------------------------------------------------- /test/fixtures/image/complete_result_linux.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/image/complete_result_linux.webp -------------------------------------------------------------------------------- /test/fixtures/vips/grayscale_result_darwin.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/vips/grayscale_result_darwin.jpg -------------------------------------------------------------------------------- /test/fixtures/vips/grayscale_result_linux.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/vips/grayscale_result_linux.jpg -------------------------------------------------------------------------------- /test/fixtures/vips/grayscale_result_linux.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/vips/grayscale_result_linux.webp -------------------------------------------------------------------------------- /test/fixtures/vips/resize_result_darwin.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/vips/resize_result_darwin.webp -------------------------------------------------------------------------------- /test/fixtures/image/complete_result_darwin.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/image/complete_result_darwin.webp -------------------------------------------------------------------------------- /test/fixtures/vips/grayscale_result_darwin.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/test/fixtures/vips/grayscale_result_darwin.webp -------------------------------------------------------------------------------- /internal/web/embed/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: /$ 3 | Allow: /?* 4 | Allow: /images 5 | Allow: /images?* 6 | Allow: /id/237/250 7 | Disallow: / 8 | -------------------------------------------------------------------------------- /tools/go.sum: -------------------------------------------------------------------------------- 1 | tailscale.com v1.58.2 h1:5trkhh/fpUn7f6TUcGUQYJ0GokdNNfNrjh9ONJhoc5A= 2 | tailscale.com v1.58.2/go.mod h1:faWR8XaXemnSKCDjHC7SAQzaagkUjA5x4jlLWiwxtuk= 3 | -------------------------------------------------------------------------------- /internal/web/embed/assets/images/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/internal/web/embed/assets/images/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /internal/web/embed/assets/images/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DMarby/picsum-photos/HEAD/internal/web/embed/assets/images/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /test/fixtures/file/metadata.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "1", 4 | "author": "John Doe", 5 | "url": "https://picsum.photos", 6 | "width": 300, 7 | "height": 400 8 | } 9 | ] 10 | -------------------------------------------------------------------------------- /test/fixtures/file/invalid_metadata.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "1", 4 | "author": "John Doe", 5 | "url": "https://picsum.photos", 6 | "width": 100, 7 | "height": 200 8 | }, 9 | ] 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/debug 2 | **/debug.test 3 | 4 | /picsum-photos 5 | 6 | /image-service 7 | 8 | .vscode/c_cpp_properties.json 9 | .vscode/settings.json 10 | 11 | .DS_Store 12 | 13 | .direnv 14 | result 15 | -------------------------------------------------------------------------------- /internal/web/embed.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import "embed" 4 | 5 | //go:generate tailwindcss -c tailwind.config.js -i style.css -o embed/assets/css/style.css --minify 6 | //go:embed embed 7 | var Static embed.FS 8 | -------------------------------------------------------------------------------- /internal/image/image.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import "context" 4 | 5 | // Processor is an image processor 6 | type Processor interface { 7 | ProcessImage(ctx context.Context, task *Task) (processedImage []byte, err error) 8 | } 9 | -------------------------------------------------------------------------------- /internal/cmd/main.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Http timeouts 8 | const ( 9 | ReadTimeout = 5 * time.Second 10 | WriteTimeout = time.Minute 11 | HandlerTimeout = 45 * time.Second 12 | ) 13 | -------------------------------------------------------------------------------- /internal/storage/mock/mock.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // Provider implements a mock image storage 8 | type Provider struct { 9 | } 10 | 11 | // Get returns the image data for an image id 12 | func (p *Provider) Get(ctx context.Context, id string) ([]byte, error) { 13 | return []byte("foo"), nil 14 | } 15 | -------------------------------------------------------------------------------- /internal/storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | ) 7 | 8 | // Provider is an interface for retrieving images 9 | type Provider interface { 10 | Get(ctx context.Context, id string) ([]byte, error) 11 | } 12 | 13 | // Errors 14 | var ( 15 | ErrNotFound = errors.New("Image does not exist") 16 | ) 17 | -------------------------------------------------------------------------------- /test/fixtures/file/metadata_multiple.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "1", 4 | "author": "John Doe", 5 | "url": "https://picsum.photos", 6 | "width": 300, 7 | "height": 400 8 | }, 9 | { 10 | "id": "2", 11 | "author": "John Doe", 12 | "url": "https://picsum.photos", 13 | "width": 300, 14 | "height": 400 15 | } 16 | ] 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Lorem Picsum 2 | =========== 3 | 4 | Lorem Ipsum... but for photos. 5 | Lorem Picsum is a service providing easy to use, stylish placeholders. 6 | 7 | ## Sponsors 8 | 9 | Proudly powered by [Fastly](https://fastly.com) 10 | 11 | 12 | 13 | 14 | ## License 15 | MIT. See [LICENSE](./LICENSE.md) 16 | -------------------------------------------------------------------------------- /internal/image/mock/mock.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/DMarby/picsum-photos/internal/image" 8 | ) 9 | 10 | // Processor implements a mock image processor 11 | type Processor struct { 12 | } 13 | 14 | // ProcessImage returns an error instead of process an image 15 | func (p *Processor) ProcessImage(ctx context.Context, task *image.Task) (processedImage []byte, err error) { 16 | return nil, fmt.Errorf("processing error") 17 | } 18 | -------------------------------------------------------------------------------- /internal/tracing/test/tracing.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/DMarby/picsum-photos/internal/logger" 7 | "github.com/DMarby/picsum-photos/internal/tracing" 8 | "go.opentelemetry.io/otel/trace" 9 | ) 10 | 11 | func Tracer(log *logger.Logger) *tracing.Tracer { 12 | tp := trace.NewNoopTracerProvider() 13 | return &tracing.Tracer{ 14 | ServiceName: "test", 15 | Log: log, 16 | TracerProvider: tp, 17 | ShutdownFunc: func(context.Context) error { 18 | return nil 19 | }, 20 | TracerInstance: tp.Tracer("test"), 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Attach to picsum", 6 | "type": "go", 7 | "request": "attach", 8 | "mode": "remote", 9 | "port": 2345, 10 | "host": "127.0.0.1", 11 | "showLog": true 12 | }, 13 | { 14 | "name": "Attach to image-service", 15 | "type": "go", 16 | "request": "attach", 17 | "mode": "remote", 18 | "port": 2346, 19 | "host": "127.0.0.1", 20 | "showLog": true 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /internal/hmac/hmac_test.go: -------------------------------------------------------------------------------- 1 | package hmac_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/DMarby/picsum-photos/internal/hmac" 7 | ) 8 | 9 | var key = []byte("foobar") 10 | var message = "test" 11 | 12 | func TestHMAC(t *testing.T) { 13 | h := &hmac.HMAC{ 14 | Key: key, 15 | } 16 | 17 | mac, err := h.Create(message) 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | 22 | matches, err := h.Validate(message, mac) 23 | if err != nil { 24 | t.Fatal(err) 25 | } 26 | 27 | if !matches { 28 | t.Error("hmac does not match") 29 | } 30 | 31 | matches, err = h.Validate("doesnotmatch", mac) 32 | if err != nil { 33 | t.Fatal(err) 34 | } 35 | 36 | if matches { 37 | t.Error("hmac matches when it should not") 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/image/cache.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/DMarby/picsum-photos/internal/cache" 7 | "github.com/DMarby/picsum-photos/internal/storage" 8 | "github.com/DMarby/picsum-photos/internal/tracing" 9 | ) 10 | 11 | // Cache is an image cache 12 | type Cache = cache.Auto 13 | 14 | // NewCache instantiates a new cache 15 | func NewCache(tracer *tracing.Tracer, cacheProvider cache.Provider, storageProvider storage.Provider) *Cache { 16 | return &Cache{ 17 | Tracer: tracer, 18 | Provider: cacheProvider, 19 | Loader: func(ctx context.Context, key string) (data []byte, err error) { 20 | ctx, span := tracer.Start(ctx, "image.Cache.Loader") 21 | defer span.End() 22 | 23 | return storageProvider.Get(ctx, key) 24 | }, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /internal/handler/recovery.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/http" 5 | "runtime/debug" 6 | 7 | "github.com/DMarby/picsum-photos/internal/logger" 8 | "github.com/DMarby/picsum-photos/internal/tracing" 9 | ) 10 | 11 | // Recovery is a handler for handling panics 12 | func Recovery(log *logger.Logger, next http.Handler) http.Handler { 13 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 14 | defer func() { 15 | if err := recover(); err != nil { 16 | w.WriteHeader(http.StatusInternalServerError) 17 | ctx := r.Context() 18 | traceID, spanID := tracing.TraceInfo(ctx) 19 | log.Errorw("panic handling request", 20 | "trace-id", traceID, 21 | "span-id", spanID, 22 | "stacktrace", string(debug.Stack()), 23 | ) 24 | } 25 | }() 26 | 27 | next.ServeHTTP(w, r) 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /internal/handler/recovery_test.go: -------------------------------------------------------------------------------- 1 | package handler_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/DMarby/picsum-photos/internal/handler" 9 | "github.com/DMarby/picsum-photos/internal/logger" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | func TestRecovery(t *testing.T) { 14 | log := logger.New(zap.FatalLevel) 15 | defer log.Sync() 16 | 17 | ts := httptest.NewServer(handler.Recovery(log, http.HandlerFunc(panicHandler))) 18 | defer ts.Close() 19 | 20 | res, err := http.Get(ts.URL) 21 | if err != nil { 22 | t.Fatal(err) 23 | } 24 | defer res.Body.Close() 25 | 26 | if res.StatusCode != http.StatusInternalServerError { 27 | t.Errorf("wrong status code %#v", res.StatusCode) 28 | } 29 | } 30 | 31 | func panicHandler(rw http.ResponseWriter, req *http.Request) { 32 | panic("panicking handler") 33 | } 34 | -------------------------------------------------------------------------------- /internal/database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | ) 7 | 8 | // Image contains metadata about an image 9 | type Image struct { 10 | ID string `json:"id"` 11 | Author string `json:"author"` 12 | Width int `json:"width"` 13 | Height int `json:"height"` 14 | URL string `json:"url"` 15 | } 16 | 17 | // Provider is an interface for listing and retrieving images 18 | type Provider interface { 19 | Get(ctx context.Context, id string) (i *Image, err error) 20 | GetRandom(ctx context.Context) (i *Image, err error) 21 | GetRandomWithSeed(ctx context.Context, seed int64) (i *Image, err error) 22 | ListAll(ctx context.Context) ([]Image, error) 23 | List(ctx context.Context, offset, limit int) ([]Image, error) 24 | } 25 | 26 | // Errors 27 | var ( 28 | ErrNotFound = errors.New("Image does not exist") 29 | ) 30 | -------------------------------------------------------------------------------- /internal/cache/mock/mock.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/DMarby/picsum-photos/internal/cache" 8 | ) 9 | 10 | // Provider is a mock cache 11 | type Provider struct{} 12 | 13 | // Get returns an object from the cache if it exists 14 | func (p *Provider) Get(ctx context.Context, key string) (data []byte, err error) { 15 | if key == "notfound" || key == "notfounderr" || key == "seterror" { 16 | return nil, cache.ErrNotFound 17 | } 18 | 19 | if key == "error" { 20 | return nil, fmt.Errorf("error") 21 | } 22 | 23 | return []byte("foo"), nil 24 | } 25 | 26 | // Set adds an object to the cache 27 | func (p *Provider) Set(ctx context.Context, key string, data []byte) (err error) { 28 | if key == "seterror" { 29 | return fmt.Errorf("seterror") 30 | } 31 | 32 | return nil 33 | } 34 | 35 | // Shutdown shuts down the cache 36 | func (p *Provider) Shutdown() {} 37 | -------------------------------------------------------------------------------- /internal/handler/tracing.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/DMarby/picsum-photos/internal/tracing" 7 | "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" 8 | "go.opentelemetry.io/otel/propagation" 9 | ) 10 | 11 | // Tracer is a handler that adds tracing for handlers 12 | func Tracer(tracer *tracing.Tracer, h http.Handler, routeMatcher RouteMatcher) http.Handler { 13 | traceHandler := otelhttp.NewHandler( 14 | h, 15 | "http", 16 | otelhttp.WithTracerProvider(tracer), 17 | otelhttp.WithPropagators(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{})), 18 | otelhttp.WithSpanNameFormatter(func(operation string, r *http.Request) string { 19 | return routeMatcher.Match(r) 20 | }), 21 | ) 22 | 23 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 24 | traceHandler.ServeHTTP(w, r) 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /internal/handler/health.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/DMarby/picsum-photos/internal/health" 8 | ) 9 | 10 | // Health is a handler for health check status 11 | func Health(healthChecker *health.Checker) Handler { 12 | return Handler(newHandler(healthChecker)) 13 | } 14 | 15 | func newHandler(healthChecker *health.Checker) func(w http.ResponseWriter, r *http.Request) *Error { 16 | return func(w http.ResponseWriter, r *http.Request) *Error { 17 | status := healthChecker.Status() 18 | 19 | if !status.Healthy { 20 | w.WriteHeader(http.StatusInternalServerError) 21 | } 22 | 23 | w.Header().Set("Cache-Control", "private, no-cache, no-store, must-revalidate") 24 | w.Header().Set("Content-Type", "application/json") 25 | if err := json.NewEncoder(w).Encode(status); err != nil { 26 | return InternalServerError() 27 | } 28 | 29 | return nil 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /internal/hmac/hmac.go: -------------------------------------------------------------------------------- 1 | package hmac 2 | 3 | import ( 4 | cryptoHMAC "crypto/hmac" 5 | "crypto/sha256" 6 | "encoding/base64" 7 | ) 8 | 9 | // HMAC is a utility for creating and verifying HMACs 10 | type HMAC struct { 11 | Key []byte 12 | } 13 | 14 | // Create creates a HMAC based on the parameter values, encoded as urlsafe base64 15 | func (h *HMAC) Create(message string) (string, error) { 16 | mac := cryptoHMAC.New(sha256.New, h.Key) 17 | 18 | _, err := mac.Write([]byte(message)) 19 | if err != nil { 20 | return "", err 21 | } 22 | 23 | return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil 24 | } 25 | 26 | // Validate validates that the parameter values matches a given HMAC 27 | func (h *HMAC) Validate(message, mac string) (bool, error) { 28 | expectedMAC, err := h.Create(message) 29 | if err != nil { 30 | return false, err 31 | } 32 | 33 | return cryptoHMAC.Equal([]byte(mac), []byte(expectedMAC)), nil 34 | } 35 | -------------------------------------------------------------------------------- /internal/storage/file/file.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/DMarby/picsum-photos/internal/storage" 11 | ) 12 | 13 | // Provider implements a file-based image storage 14 | type Provider struct { 15 | path string 16 | } 17 | 18 | // New returns a new Provider instance 19 | func New(path string) (*Provider, error) { 20 | if _, err := os.Stat(path); err != nil { 21 | return nil, err 22 | } 23 | 24 | return &Provider{ 25 | path, 26 | }, nil 27 | } 28 | 29 | // Get returns the image data for an image id 30 | func (p *Provider) Get(ctx context.Context, id string) ([]byte, error) { 31 | imageData, err := os.ReadFile(filepath.Join(p.path, fmt.Sprintf("%s.jpg", id))) 32 | if err != nil { 33 | if errors.Is(err, os.ErrNotExist) { 34 | return nil, storage.ErrNotFound 35 | } 36 | 37 | return nil, err 38 | } 39 | 40 | return imageData, nil 41 | } 42 | -------------------------------------------------------------------------------- /internal/handler/route_matcher.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gorilla/mux" 7 | ) 8 | 9 | // RouteMatcher matches routes 10 | type RouteMatcher interface { 11 | Match(r *http.Request) string 12 | } 13 | 14 | // MuxRouteMatcher maxes routes for a mux router 15 | type MuxRouteMatcher struct { 16 | Router *mux.Router 17 | } 18 | 19 | // Match returns the mux route name of a given request, falling back to the path template if not set 20 | func (m *MuxRouteMatcher) Match(r *http.Request) string { 21 | var routeMatch mux.RouteMatch 22 | // The Route can be nil even on a Match, if a NotFoundHandler is specified 23 | if m.Router.Match(r, &routeMatch) && routeMatch.Route != nil { 24 | if routeName := routeMatch.Route.GetName(); routeName != "" { 25 | return routeName 26 | } 27 | 28 | if tmpl, err := routeMatch.Route.GetPathTemplate(); err == nil { 29 | return tmpl 30 | } 31 | } 32 | 33 | return "unknown" 34 | } 35 | -------------------------------------------------------------------------------- /internal/vips/vips-bridge.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | // Require libvips 8 at compile time 7 | #if (VIPS_MAJOR_VERSION != 8 || VIPS_MINOR_VERSION < 6) 8 | #error "unsupported libvips version" 9 | #endif 10 | 11 | 12 | void setup_logging(); 13 | void log_handler(char const* log_domain, GLogLevelFlags log_level, char const* message, void* ignore); 14 | extern void log_callback(char* message); 15 | 16 | int save_image_to_jpeg_buffer(VipsImage *image, void **buf, size_t *len); 17 | int save_image_to_webp_buffer(VipsImage *image, void **buf, size_t *len); 18 | int resize_image(void *buf, size_t len, VipsImage **out, int width, int height, VipsInteresting interesting); 19 | int change_colorspace(VipsImage *in, VipsImage **out, VipsInterpretation colorspace); 20 | int blur_image(VipsImage *in, VipsImage **out, double blur); 21 | void set_user_comment(VipsImage *image, char const* comment); 22 | -------------------------------------------------------------------------------- /internal/params/hmac.go: -------------------------------------------------------------------------------- 1 | package params 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | 7 | "github.com/DMarby/picsum-photos/internal/hmac" 8 | ) 9 | 10 | // HMAC generates and appends an HMAC to a URL path + query params 11 | func HMAC(h *hmac.HMAC, path string, query url.Values) (string, error) { 12 | hmac, err := h.Create(path + BuildQuery(query)) 13 | if err != nil { 14 | return "", err 15 | } 16 | 17 | query.Set("hmac", hmac) 18 | return path + BuildQuery(query), nil 19 | } 20 | 21 | // ValidateHMAC validates the URL path/query params, given an hmac in a query parameter named hmac 22 | func ValidateHMAC(h *hmac.HMAC, r *http.Request) (bool, error) { 23 | // Get the query params in the request 24 | query := r.URL.Query() 25 | 26 | // Get the HMAC query param and remove it from the request query params 27 | hmac := query.Get("hmac") 28 | query.Del("hmac") 29 | 30 | encodedQuery := BuildQuery(query) 31 | return h.Validate(r.URL.Path+encodedQuery, hmac) 32 | } 33 | -------------------------------------------------------------------------------- /internal/cache/memory/memory_test.go: -------------------------------------------------------------------------------- 1 | package memory_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/DMarby/picsum-photos/internal/cache" 8 | "github.com/DMarby/picsum-photos/internal/cache/memory" 9 | ) 10 | 11 | func TestMemory(t *testing.T) { 12 | ctx, cancel := context.WithCancel(context.Background()) 13 | defer cancel() 14 | 15 | provider := memory.New() 16 | 17 | t.Run("get item", func(t *testing.T) { 18 | // Add item to the cache 19 | provider.Set(ctx, "foo", []byte("bar")) 20 | 21 | // Get item from the cache 22 | data, err := provider.Get(ctx, "foo") 23 | if err != nil { 24 | t.Fatal(err) 25 | } 26 | 27 | if string(data) != "bar" { 28 | t.Fatal("wrong data") 29 | } 30 | }) 31 | 32 | t.Run("get nonexistant item", func(t *testing.T) { 33 | _, err := provider.Get(ctx, "notfound") 34 | if err == nil { 35 | t.Fatal("no error") 36 | } 37 | 38 | if err != cache.ErrNotFound { 39 | t.Fatalf("wrong error %s", err) 40 | } 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /internal/cache/memory/memory.go: -------------------------------------------------------------------------------- 1 | package memory 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | 7 | "github.com/DMarby/picsum-photos/internal/cache" 8 | ) 9 | 10 | // Provider implements a simple in-memory cache 11 | type Provider struct { 12 | cache map[string][]byte 13 | mutex sync.RWMutex 14 | } 15 | 16 | // New returns a new Provider instance 17 | func New() *Provider { 18 | return &Provider{ 19 | cache: make(map[string][]byte), 20 | } 21 | } 22 | 23 | // Get returns an object from the cache if it exists 24 | func (p *Provider) Get(ctx context.Context, key string) (data []byte, err error) { 25 | p.mutex.RLock() 26 | data, exists := p.cache[key] 27 | p.mutex.RUnlock() 28 | 29 | if !exists { 30 | return nil, cache.ErrNotFound 31 | } 32 | 33 | return data, nil 34 | } 35 | 36 | // Set adds an object to the cache 37 | func (p *Provider) Set(ctx context.Context, key string, data []byte) (err error) { 38 | p.mutex.Lock() 39 | p.cache[key] = data 40 | p.mutex.Unlock() 41 | 42 | return nil 43 | } 44 | 45 | // Shutdown shuts down the cache 46 | func (p *Provider) Shutdown() {} 47 | -------------------------------------------------------------------------------- /internal/storage/file/file_test.go: -------------------------------------------------------------------------------- 1 | package file_test 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "reflect" 7 | 8 | "github.com/DMarby/picsum-photos/internal/storage" 9 | "github.com/DMarby/picsum-photos/internal/storage/file" 10 | 11 | "testing" 12 | ) 13 | 14 | func TestFile(t *testing.T) { 15 | provider, err := file.New("../../../test/fixtures/file") 16 | if err != nil { 17 | t.Fatal(err) 18 | } 19 | 20 | t.Run("Get an image by id", func(t *testing.T) { 21 | buf, err := provider.Get(context.Background(), "1") 22 | if err != nil { 23 | t.Fatal(err) 24 | } 25 | 26 | resultFixture, _ := os.ReadFile("../../../test/fixtures/file/1.jpg") 27 | if !reflect.DeepEqual(buf, resultFixture) { 28 | t.Error("image data doesn't match") 29 | } 30 | }) 31 | 32 | t.Run("Returns error on a nonexistant path", func(t *testing.T) { 33 | _, err := file.New("") 34 | if err == nil { 35 | t.FailNow() 36 | } 37 | }) 38 | 39 | t.Run("Returns error on a nonexistant image", func(t *testing.T) { 40 | _, err := provider.Get(context.Background(), "nonexistant") 41 | if err == nil || err != storage.ErrNotFound { 42 | t.FailNow() 43 | } 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-2024 David Marby & Nijiko Yonskai 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/params/query.go: -------------------------------------------------------------------------------- 1 | package params 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "sort" 7 | "strings" 8 | ) 9 | 10 | // Utilities for building a URL with query params 11 | 12 | // BuildQuery builds a query parameter string for the given values 13 | // It differs from the stdlib url.Values.Encode in that it encodes query parameters with an empty value as "?key" instead of "?key=" 14 | func BuildQuery(v url.Values) string { 15 | var buf strings.Builder 16 | 17 | keys := make([]string, 0, len(v)) 18 | for k := range v { 19 | keys = append(keys, k) 20 | } 21 | 22 | sort.Strings(keys) 23 | 24 | for _, key := range keys { 25 | value := v.Get(key) 26 | 27 | if value != "" { 28 | addQueryParam(&buf, fmt.Sprintf("%s=%s", url.QueryEscape(key), url.QueryEscape(value))) 29 | } else { 30 | addQueryParam(&buf, fmt.Sprintf("%s", url.QueryEscape(key))) 31 | } 32 | } 33 | 34 | return buf.String() 35 | } 36 | 37 | // addQueryParam adds a query parameter to a byte buffer 38 | func addQueryParam(buf *strings.Builder, param string) { 39 | if buf.Len() > 0 { 40 | buf.WriteByte('&') 41 | } else { 42 | buf.WriteByte('?') 43 | } 44 | 45 | buf.WriteString(param) 46 | } 47 | -------------------------------------------------------------------------------- /internal/web/embed/assets/images/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /internal/image/task.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | // Task is an image processing task 4 | type Task struct { 5 | ImageID string 6 | Width int 7 | Height int 8 | ApplyBlur bool 9 | BlurAmount int 10 | ApplyGrayscale bool 11 | UserComment string 12 | OutputFormat OutputFormat 13 | } 14 | 15 | // OutputFormat is the image format to output to 16 | type OutputFormat int 17 | 18 | const ( 19 | // JPEG represents the JPEG format 20 | JPEG OutputFormat = iota 21 | // WebP represents the WebP format 22 | WebP 23 | ) 24 | 25 | // NewTask creates a new image processing task 26 | func NewTask(imageID string, width int, height int, userComment string, format OutputFormat) *Task { 27 | return &Task{ 28 | ImageID: imageID, 29 | Width: width, 30 | Height: height, 31 | UserComment: userComment, 32 | OutputFormat: format, 33 | } 34 | } 35 | 36 | // Blur applies gaussian blur to the image 37 | func (t *Task) Blur(amount int) *Task { 38 | t.ApplyBlur = true 39 | t.BlurAmount = amount 40 | return t 41 | } 42 | 43 | // Grayscale turns the image into grayscale 44 | func (t *Task) Grayscale() *Task { 45 | t.ApplyGrayscale = true 46 | return t 47 | } 48 | -------------------------------------------------------------------------------- /internal/database/mock/mock.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/DMarby/picsum-photos/internal/database" 8 | ) 9 | 10 | // Provider implements a mock image storage 11 | type Provider struct { 12 | } 13 | 14 | // Get returns the image data for an image id 15 | func (p *Provider) Get(ctx context.Context, id string) (i *database.Image, err error) { 16 | return nil, fmt.Errorf("get error") 17 | } 18 | 19 | // GetRandom returns a random image 20 | func (p *Provider) GetRandom(ctx context.Context) (i *database.Image, err error) { 21 | return nil, fmt.Errorf("random error") 22 | } 23 | 24 | // GetRandomWithSeed returns a random image based on the given seed 25 | func (p *Provider) GetRandomWithSeed(ctx context.Context, seed int64) (i *database.Image, err error) { 26 | return nil, fmt.Errorf("random error") 27 | } 28 | 29 | // ListAll returns a list of all the images 30 | func (p *Provider) ListAll(ctx context.Context) ([]database.Image, error) { 31 | return nil, fmt.Errorf("list error") 32 | } 33 | 34 | // List returns a list of all the images with an offset/limit 35 | func (p *Provider) List(ctx context.Context, offset, limit int) ([]database.Image, error) { 36 | return nil, fmt.Errorf("list error") 37 | } 38 | -------------------------------------------------------------------------------- /internal/api/params.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/DMarby/picsum-photos/internal/database" 7 | "github.com/DMarby/picsum-photos/internal/params" 8 | ) 9 | 10 | // Errors 11 | var ( 12 | ErrInvalidBlurAmount = fmt.Errorf("Invalid blur amount") 13 | ) 14 | 15 | const ( 16 | minBlurAmount = 1 17 | maxBlurAmount = 10 18 | maxImageSize = 5000 // The max allowed image width/height that can be requested 19 | ) 20 | 21 | func validateImageParams(p *params.Params) error { 22 | if p.Width > maxImageSize { 23 | return params.ErrInvalidSize 24 | } 25 | 26 | if p.Height > maxImageSize { 27 | return params.ErrInvalidSize 28 | } 29 | 30 | if p.Blur && p.BlurAmount < minBlurAmount { 31 | return ErrInvalidBlurAmount 32 | } 33 | 34 | if p.Blur && p.BlurAmount > maxBlurAmount { 35 | return ErrInvalidBlurAmount 36 | } 37 | 38 | return nil 39 | } 40 | 41 | func getImageDimensions(p *params.Params, databaseImage *database.Image) (width, height int) { 42 | // Default to the image width/height if 0 is passed 43 | width = p.Width 44 | height = p.Height 45 | 46 | if width == 0 { 47 | width = databaseImage.Width 48 | } 49 | 50 | if height == 0 { 51 | height = databaseImage.Height 52 | } 53 | 54 | return 55 | } 56 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GO ?= go 2 | GOTOOLRUN = $(GO) run -modfile=./tools/go.mod 3 | 4 | .PHONY: test 5 | test: 6 | $(GO) test ./... 7 | 8 | .PHONY: fixtures 9 | fixtures: generate_fixtures 10 | docker run --rm -v $(PWD):/picsum-photos docker.io/golang:1.19-alpine sh -c 'apk add make && cd /picsum-photos && make docker_fixtures generate_fixtures' 11 | 12 | .PHONY: generate_fixtures 13 | generate_fixtures: 14 | GENERATE_FIXTURES=1 $(GO) test ./... -run '^(TestFixtures)$$' 15 | 16 | .PHONY: docker_fixtures 17 | docker_fixtures: 18 | apk add --update --repository http://dl-cdn.alpinelinux.org/alpine/edge/testing vips-dev 19 | apk add \ 20 | git \ 21 | gcc \ 22 | musl-dev 23 | 24 | .PHONY: generate 25 | generate: go.mod.sri 26 | 27 | go.mod.sri: go.mod 28 | $(GO) mod vendor -o .tmp-vendor 29 | $(GOTOOLRUN) tailscale.com/cmd/nardump -sri .tmp-vendor >$@ 30 | rm -rf .tmp-vendor 31 | 32 | .PHONY: upgrade 33 | upgrade: 34 | # https://github.com/golang/go/issues/28424 35 | $(GO) list -f '{{if not (or .Main .Indirect)}}{{.Path}}{{end}}' -m all | xargs $(GO) get 36 | $(GO) mod tidy -v 37 | 38 | .PHONY: upgradetools 39 | upgradetools: 40 | cd tools && $(GO) list -e -f '{{range .Imports}}{{.}}@latest {{end}}' -tags tools | xargs $(GO) get 41 | cd tools && $(GO) mod tidy -v 42 | -------------------------------------------------------------------------------- /internal/metrics/metrics.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/pprof" 7 | 8 | "github.com/DMarby/picsum-photos/internal/handler" 9 | "github.com/DMarby/picsum-photos/internal/health" 10 | "github.com/DMarby/picsum-photos/internal/logger" 11 | ) 12 | 13 | // Serve starts an http server for metrics and healthchecks 14 | func Serve(ctx context.Context, log *logger.Logger, healthChecker *health.Checker, listenAddress string) { 15 | router := http.NewServeMux() 16 | router.HandleFunc("/metrics", handler.VarzHandler) 17 | router.Handle("/health", handler.Health(healthChecker)) 18 | 19 | router.HandleFunc("/debug/pprof/", pprof.Index) 20 | router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) 21 | router.HandleFunc("/debug/pprof/profile", pprof.Profile) 22 | router.HandleFunc("/debug/pprof/symbol", pprof.Symbol) 23 | router.HandleFunc("/debug/pprof/trace", pprof.Trace) 24 | 25 | server := &http.Server{ 26 | Addr: listenAddress, 27 | Handler: router, 28 | } 29 | 30 | go func() { 31 | if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { 32 | log.Infof("shutting down the metrics http server: %s", err) 33 | } 34 | }() 35 | 36 | log.Infof("metrics http server listening on %s", listenAddress) 37 | 38 | <-ctx.Done() 39 | 40 | if err := server.Close(); err != nil { 41 | log.Warnf("error shutting down metrics http server: %s", err) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /internal/handler/logger.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/DMarby/picsum-photos/internal/logger" 8 | "github.com/DMarby/picsum-photos/internal/tracing" 9 | "github.com/felixge/httpsnoop" 10 | ) 11 | 12 | // Logger is a handler that logs requests using Zap 13 | func Logger(log *logger.Logger, h http.Handler) http.Handler { 14 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 15 | respMetrics := httpsnoop.CaptureMetricsFn(w, func(ww http.ResponseWriter) { 16 | h.ServeHTTP(ww, r) 17 | }) 18 | 19 | ctx := r.Context() 20 | traceID, spanID := tracing.TraceInfo(ctx) 21 | 22 | logFields := []interface{}{ 23 | "trace-id", traceID, 24 | "span-id", spanID, 25 | "http-method", r.Method, 26 | "remote-addr", r.RemoteAddr, 27 | "user-agent", r.UserAgent(), 28 | "uri", r.URL.String(), 29 | "status-code", respMetrics.Code, 30 | "elapsed", fmt.Sprintf("%.9fs", respMetrics.Duration.Seconds()), 31 | } 32 | 33 | switch { 34 | case respMetrics.Code >= 500: 35 | log.Errorw("Request completed", logFields...) 36 | default: 37 | log.Debugw("Request completed", logFields...) 38 | } 39 | }) 40 | } 41 | 42 | // LogFields logs the given keys and values for a request 43 | func LogFields(r *http.Request, keysAndValues ...interface{}) []interface{} { 44 | ctx := r.Context() 45 | traceID, spanID := tracing.TraceInfo(ctx) 46 | 47 | return append([]interface{}{ 48 | "trace-id", traceID, 49 | "span-id", spanID, 50 | }, keysAndValues...) 51 | } 52 | -------------------------------------------------------------------------------- /internal/handler/handler.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | // Error is the message and http status code to return 9 | type Error struct { 10 | Message string 11 | Code int 12 | } 13 | 14 | // InternalServerError is a convenience function for returning an internal server error 15 | func InternalServerError() *Error { 16 | return &Error{ 17 | Message: "Something went wrong", 18 | Code: http.StatusInternalServerError, 19 | } 20 | } 21 | 22 | // BadRequest is a convenience function for returning a bad request error 23 | func BadRequest(message string) *Error { 24 | return &Error{ 25 | Message: message, 26 | Code: http.StatusBadRequest, 27 | } 28 | } 29 | 30 | const jsonMediaType = "application/json" 31 | 32 | // Handler wraps a http handler and deals with responding to errors 33 | type Handler func(w http.ResponseWriter, r *http.Request) *Error 34 | 35 | func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 36 | err := h(w, r) 37 | if err != nil { 38 | w.Header().Set("Cache-Control", "private, no-cache, no-store, must-revalidate") 39 | 40 | if r.Header.Get("accept") == jsonMediaType { 41 | var data = struct { 42 | Error string `json:"error"` 43 | }{err.Message} 44 | 45 | w.Header().Set("Content-Type", "application/json") 46 | w.WriteHeader(err.Code) 47 | if err := json.NewEncoder(w).Encode(data); err != nil { 48 | http.Error(w, "Something went wrong", http.StatusInternalServerError) 49 | return 50 | } 51 | } else { 52 | http.Error(w, err.Message, err.Code) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /internal/cache/cache_test.go: -------------------------------------------------------------------------------- 1 | package cache_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/DMarby/picsum-photos/internal/cache" 9 | "github.com/DMarby/picsum-photos/internal/cache/mock" 10 | "github.com/DMarby/picsum-photos/internal/logger" 11 | "github.com/DMarby/picsum-photos/internal/tracing/test" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | var mockLoaderFunc cache.LoaderFunc = func(ctx context.Context, key string) (data []byte, err error) { 16 | if key == "notfounderr" { 17 | return nil, fmt.Errorf("notfounderr") 18 | } 19 | 20 | return []byte("notfound"), nil 21 | } 22 | 23 | func TestAuto(t *testing.T) { 24 | log := logger.New(zap.ErrorLevel) 25 | defer log.Sync() 26 | 27 | tracer := test.Tracer(log) 28 | 29 | auto := &cache.Auto{ 30 | Tracer: tracer, 31 | Provider: &mock.Provider{}, 32 | Loader: mockLoaderFunc, 33 | } 34 | 35 | tests := []struct { 36 | Key string 37 | ExpectedError error 38 | }{ 39 | {"foo", nil}, 40 | {"notfound", nil}, 41 | {"notfounderr", fmt.Errorf("notfounderr")}, 42 | {"seterror", fmt.Errorf("seterror")}, 43 | } 44 | 45 | for _, test := range tests { 46 | data, err := auto.Get(context.Background(), test.Key) 47 | if err != nil { 48 | if test.ExpectedError == nil { 49 | t.Errorf("%s: %s", test.Key, err) 50 | continue 51 | } 52 | 53 | if test.ExpectedError.Error() != err.Error() { 54 | t.Errorf("%s: wrong error: %s", test.Key, err) 55 | continue 56 | } 57 | 58 | continue 59 | } 60 | 61 | if string(data) != test.Key { 62 | t.Errorf("%s: wrong data", test.Key) 63 | } 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1705309234, 9 | "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1707268954, 24 | "narHash": "sha256-2en1kvde3cJVc3ZnTy8QeD2oKcseLFjYPLKhIGDanQ0=", 25 | "owner": "nixos", 26 | "repo": "nixpkgs", 27 | "rev": "f8e2ebd66d097614d51a56a755450d4ae1632df1", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "nixos", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /cmd/image-manifest/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | "image" 8 | "log" 9 | "os" 10 | "path/filepath" 11 | 12 | "github.com/DMarby/picsum-photos/internal/database" 13 | 14 | _ "image/jpeg" 15 | ) 16 | 17 | // Comandline flags 18 | var ( 19 | imagePath = flag.String("image-path", ".", "path to image directory") 20 | imageManifestPath = flag.String("image-manifest-path", "./image-manifest.json", "path to the image manifest to update") 21 | ) 22 | 23 | func main() { 24 | flag.Parse() 25 | 26 | resolvedManifestPath, err := filepath.Abs(*imageManifestPath) 27 | if err != nil { 28 | log.Fatal(err) 29 | } 30 | 31 | manifestData, err := os.ReadFile(resolvedManifestPath) 32 | if err != nil { 33 | log.Fatal(err) 34 | } 35 | 36 | var images []database.Image 37 | err = json.Unmarshal(manifestData, &images) 38 | if err != nil { 39 | log.Fatal(err) 40 | } 41 | 42 | for i, img := range images { 43 | resolvedImagePath, err := filepath.Abs(filepath.Join(*imagePath, fmt.Sprintf("%s.jpg", img.ID))) 44 | if err != nil { 45 | log.Fatal(err) 46 | } 47 | 48 | reader, err := os.Open(resolvedImagePath) 49 | if err != nil { 50 | log.Fatal(err) 51 | } 52 | defer reader.Close() 53 | 54 | imageMetadata, _, err := image.DecodeConfig(reader) 55 | if err != nil { 56 | log.Fatal(err) 57 | } 58 | 59 | images[i].Width = imageMetadata.Width 60 | images[i].Height = imageMetadata.Height 61 | } 62 | 63 | file, _ := os.OpenFile(resolvedManifestPath, os.O_WRONLY, 0644) 64 | defer file.Close() 65 | 66 | encoder := json.NewEncoder(file) 67 | encoder.SetEscapeHTML(false) 68 | encoder.SetIndent("", " ") 69 | 70 | if err := encoder.Encode(images); err != nil { 71 | log.Fatal(err) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /internal/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | stdlog "log" 5 | "os" 6 | "strings" 7 | 8 | "go.uber.org/zap" 9 | "go.uber.org/zap/zapcore" 10 | ) 11 | 12 | // Logger is a logger 13 | type Logger struct { 14 | *zap.SugaredLogger 15 | } 16 | 17 | // New creates a new logger 18 | func New(loglevel zapcore.Level) *Logger { 19 | // Configure console output. 20 | encoderConfig := zap.NewProductionEncoderConfig() 21 | encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder 22 | consoleEncoder := zapcore.NewJSONEncoder(encoderConfig) 23 | 24 | // Log errors to stderr 25 | stderrLevel := zap.LevelEnablerFunc(func(lvl zapcore.Level) bool { 26 | return lvl >= loglevel && lvl >= zapcore.ErrorLevel 27 | }) 28 | 29 | stdoutLevel := zap.LevelEnablerFunc(func(lvl zapcore.Level) bool { 30 | return lvl >= loglevel && lvl < zapcore.ErrorLevel 31 | }) 32 | stdout := zapcore.Lock(os.Stdout) 33 | stderr := zapcore.Lock(os.Stderr) 34 | 35 | // Merge the outputs, encoders, and level-handling functions 36 | core := zapcore.NewTee( 37 | zapcore.NewCore(consoleEncoder, stderr, stderrLevel), 38 | zapcore.NewCore(consoleEncoder, stdout, stdoutLevel), 39 | ) 40 | 41 | // Construct our logger 42 | log := zap.New(core, zap.AddCaller()) 43 | 44 | // Redirect stdlib log package to zap 45 | _, _ = zap.RedirectStdLogAt(log, zapcore.ErrorLevel) 46 | 47 | return &Logger{ 48 | log.Sugar(), 49 | } 50 | } 51 | 52 | type httpErrorLog struct { 53 | log *Logger 54 | } 55 | 56 | func (h *httpErrorLog) Write(p []byte) (int, error) { 57 | m := string(p) 58 | 59 | if strings.HasPrefix(m, "http: URL query contains semicolon") { 60 | h.log.Debug(m) 61 | } else { 62 | h.log.Error(m) 63 | } 64 | 65 | return len(p), nil 66 | } 67 | 68 | func NewHTTPErrorLog(logger *Logger) *stdlog.Logger { 69 | return stdlog.New(&httpErrorLog{logger}, "", 0) 70 | } 71 | -------------------------------------------------------------------------------- /internal/vips/vips-bridge.c: -------------------------------------------------------------------------------- 1 | #include "vips-bridge.h" 2 | 3 | void setup_logging() { 4 | g_log_set_handler("VIPS", G_LOG_LEVEL_WARNING, log_handler, NULL); 5 | } 6 | 7 | void log_handler(char const* log_domain, GLogLevelFlags log_level, char const* message, void* ignore) { 8 | log_callback((char*)message); 9 | } 10 | 11 | int save_image_to_jpeg_buffer(VipsImage *image, void **buf, size_t *len) { 12 | return vips_jpegsave_buffer(image, buf, len, "interlace", TRUE, "optimize_coding", TRUE, NULL); 13 | } 14 | 15 | int save_image_to_webp_buffer(VipsImage *image, void **buf, size_t *len) { 16 | return vips_webpsave_buffer(image, buf, len, NULL); 17 | } 18 | 19 | int resize_image(void *buf, size_t len, VipsImage **out, int width, int height, VipsInteresting interesting) { 20 | return vips_thumbnail_buffer(buf, len, out, width, "height", height, "crop", interesting, NULL); 21 | } 22 | 23 | int change_colorspace(VipsImage *in, VipsImage **out, VipsInterpretation colorspace) { 24 | return vips_call("colourspace", in, out, colorspace, NULL); 25 | } 26 | 27 | int blur_image(VipsImage *in, VipsImage **out, double blur) { 28 | return vips_call("gaussblur", in, out, blur, NULL); 29 | } 30 | 31 | static void * remove_metadata(VipsImage *image, const char *field, GValue *value, void *my_data) { 32 | if (vips_isprefix("exif-", field)) { 33 | vips_image_remove(image, field); 34 | } 35 | 36 | return (NULL); 37 | } 38 | 39 | void set_user_comment(VipsImage *image, char const* comment) { 40 | // Strip all the metadata 41 | vips_image_remove(image, VIPS_META_EXIF_NAME); 42 | vips_image_remove(image, VIPS_META_XMP_NAME); 43 | vips_image_remove(image, VIPS_META_IPTC_NAME); 44 | vips_image_remove(image, VIPS_META_ICC_NAME); 45 | vips_image_remove(image, VIPS_META_ORIENTATION); 46 | vips_image_remove(image, "jpeg-thumbnail-data"); 47 | vips_image_map(image, remove_metadata, NULL); 48 | 49 | // Set the user comment 50 | vips_image_set_string(image, "exif-ifd2-UserComment", comment); 51 | } 52 | -------------------------------------------------------------------------------- /internal/image/vips/image.go: -------------------------------------------------------------------------------- 1 | package vips 2 | 3 | import "github.com/DMarby/picsum-photos/internal/vips" 4 | 5 | // resizedImage is a resized image 6 | type resizedImage struct { 7 | vipsImage vips.Image 8 | } 9 | 10 | // resizeImage loads an image from a byte buffer, resizes it and returns an Image object for further use 11 | // Note that it does not use the processor worker queue, use ProcessImage for that 12 | func resizeImage(buffer []byte, width int, height int) (*resizedImage, error) { 13 | image, err := vips.ResizeImage(buffer, width, height) 14 | 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | return &resizedImage{ 20 | vipsImage: image, 21 | }, nil 22 | } 23 | 24 | // grayscale turns an image into grayscale 25 | func (i *resizedImage) grayscale() (*resizedImage, error) { 26 | image, err := vips.Grayscale(i.vipsImage) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | return &resizedImage{ 32 | vipsImage: image, 33 | }, nil 34 | } 35 | 36 | // blur applies gaussian blur to an image 37 | func (i *resizedImage) blur(blur int) (*resizedImage, error) { 38 | image, err := vips.Blur(i.vipsImage, blur) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | return &resizedImage{ 44 | vipsImage: image, 45 | }, nil 46 | } 47 | 48 | // setUserComment sets the exif usercomment 49 | func (i *resizedImage) setUserComment(comment string) { 50 | vips.SetUserComment(i.vipsImage, comment) 51 | } 52 | 53 | // saveToJpegBuffer returns the image as a JPEG byte buffer 54 | func (i *resizedImage) saveToJpegBuffer() ([]byte, error) { 55 | imageBuffer, err := vips.SaveToJpegBuffer(i.vipsImage) 56 | 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | return imageBuffer, nil 62 | } 63 | 64 | // saveToWebPBuffer returns the image as a WebP byte buffer 65 | func (i *resizedImage) saveToWebPBuffer() ([]byte, error) { 66 | imageBuffer, err := vips.SaveToWebPBuffer(i.vipsImage) 67 | 68 | if err != nil { 69 | return nil, err 70 | } 71 | 72 | return imageBuffer, nil 73 | } 74 | -------------------------------------------------------------------------------- /internal/cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/DMarby/picsum-photos/internal/tracing" 8 | "golang.org/x/sync/singleflight" 9 | ) 10 | 11 | // Provider is an interface for getting and setting cached objects 12 | type Provider interface { 13 | Get(ctx context.Context, key string) (data []byte, err error) 14 | Set(ctx context.Context, key string, data []byte) (err error) 15 | Shutdown() 16 | } 17 | 18 | // LoaderFunc is a function for loading data into a cache 19 | type LoaderFunc func(ctx context.Context, key string) (data []byte, err error) 20 | 21 | // Auto is a cache that automatically attempts to load objects if they don't exist 22 | type Auto struct { 23 | Tracer *tracing.Tracer 24 | Provider Provider 25 | Loader LoaderFunc 26 | lookupGroup singleflight.Group 27 | } 28 | 29 | // Get returns an object from the cache if it exists, otherwise it loads it into the cache and returns it 30 | func (a *Auto) Get(ctx context.Context, key string) (data []byte, err error) { 31 | ctx, span := a.Tracer.Start(ctx, "cache.Auto.Get") 32 | defer span.End() 33 | 34 | // Attempt to get the data from the cache 35 | data, err = a.Provider.Get(ctx, key) 36 | // Exit early if the error is nil as we got data from the cache 37 | // Or if there's an error indicating that something went wrong 38 | if err != ErrNotFound { 39 | return 40 | } 41 | 42 | // Use singleflight to avoid concurrent requests 43 | var v interface{} 44 | v, err, _ = a.lookupGroup.Do(key, func() (interface{}, error) { 45 | // Get the data 46 | data, err := a.Loader(ctx, key) 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | // Store the data in the cache 52 | err = a.Provider.Set(ctx, key, data) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | return data, nil 58 | }) 59 | 60 | if err != nil { 61 | return 62 | } 63 | 64 | data, _ = v.([]byte) 65 | return 66 | } 67 | 68 | // Errors 69 | var ( 70 | ErrNotFound = errors.New("not found in cache") 71 | ) 72 | -------------------------------------------------------------------------------- /internal/queue/queue_test.go: -------------------------------------------------------------------------------- 1 | package queue_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | queue "github.com/DMarby/picsum-photos/internal/queue" 9 | ) 10 | 11 | func setupQueue(f func(ctx context.Context, data interface{}) (interface{}, error)) (*queue.Queue, context.CancelFunc) { 12 | ctx, cancel := context.WithCancel(context.Background()) 13 | workerQueue := queue.New(ctx, 3, f) 14 | go workerQueue.Run() 15 | return workerQueue, cancel 16 | } 17 | 18 | func TestProcess(t *testing.T) { 19 | workerQueue, cancel := setupQueue(func(ctx context.Context, data interface{}) (interface{}, error) { 20 | stringData, _ := data.(string) 21 | return stringData, nil 22 | }) 23 | 24 | defer cancel() 25 | 26 | data, err := workerQueue.Process(context.Background(), "test") 27 | if err != nil { 28 | t.Fatal(err) 29 | } 30 | 31 | if data != "test" { 32 | t.Fatal(err) 33 | } 34 | } 35 | 36 | func TestShutdown(t *testing.T) { 37 | workerQueue, cancel := setupQueue(func(ctx context.Context, data interface{}) (interface{}, error) { 38 | return "", nil 39 | }) 40 | 41 | cancel() 42 | 43 | _, err := workerQueue.Process(context.Background(), "test") 44 | if err == nil || err.Error() != "queue has been shutdown" { 45 | t.FailNow() 46 | } 47 | } 48 | 49 | func TestTaskWithError(t *testing.T) { 50 | errorQueue, cancel := setupQueue(func(ctx context.Context, data interface{}) (interface{}, error) { 51 | return nil, fmt.Errorf("custom error") 52 | }) 53 | 54 | defer cancel() 55 | _, err := errorQueue.Process(context.Background(), "test") 56 | 57 | if err == nil || err.Error() != "custom error" { 58 | t.Fatal("Invalid error") 59 | } 60 | } 61 | 62 | func TestTaskWithCancelledContext(t *testing.T) { 63 | errorQueue, cancel := setupQueue(func(ctx context.Context, data interface{}) (interface{}, error) { 64 | return nil, fmt.Errorf("custom error") 65 | }) 66 | 67 | defer cancel() 68 | 69 | ctx, ctxCancel := context.WithCancel(context.Background()) 70 | ctxCancel() 71 | 72 | _, err := errorQueue.Process(ctx, "test") 73 | 74 | if err == nil || err.Error() != "context canceled" { 75 | t.Fatal("Invalid error") 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/DMarby/picsum-photos 2 | 3 | go 1.22rc2 4 | 5 | require ( 6 | github.com/felixge/httpsnoop v1.0.4 7 | github.com/go-logr/stdr v1.2.2 8 | github.com/gorilla/mux v1.8.1 9 | github.com/jamiealquiza/envy v1.1.0 10 | github.com/prometheus/client_golang v1.18.0 11 | github.com/prometheus/common v0.46.0 12 | github.com/rs/cors v1.10.1 13 | github.com/twmb/murmur3 v1.1.8 14 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.48.0 15 | go.opentelemetry.io/otel v1.23.1 16 | go.opentelemetry.io/otel/sdk v1.23.1 17 | go.opentelemetry.io/otel/trace v1.23.1 18 | go.uber.org/automaxprocs v1.5.3 19 | go.uber.org/zap v1.26.0 20 | golang.org/x/sync v0.6.0 21 | tailscale.com v1.58.2 22 | ) 23 | 24 | require ( 25 | google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917 // indirect 26 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917 // indirect 27 | ) 28 | 29 | require ( 30 | github.com/beorn7/perks v1.0.1 // indirect 31 | github.com/cenkalti/backoff/v4 v4.2.1 // indirect 32 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 33 | github.com/go-logr/logr v1.4.1 // indirect 34 | github.com/golang/protobuf v1.5.3 // indirect 35 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 // indirect 36 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 37 | github.com/prometheus/client_model v0.5.0 // indirect 38 | github.com/prometheus/procfs v0.12.0 // indirect 39 | github.com/spf13/cobra v1.7.0 // indirect 40 | github.com/spf13/pflag v1.0.5 // indirect 41 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.23.1 // indirect 42 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.23.1 43 | go.opentelemetry.io/otel/metric v1.23.1 // indirect 44 | go.opentelemetry.io/proto/otlp v1.1.0 // indirect 45 | go.uber.org/multierr v1.11.0 // indirect 46 | go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect 47 | golang.org/x/crypto v0.18.0 // indirect 48 | golang.org/x/net v0.20.0 // indirect 49 | golang.org/x/sys v0.16.0 // indirect 50 | golang.org/x/text v0.14.0 // indirect 51 | google.golang.org/grpc v1.61.0 // indirect 52 | google.golang.org/protobuf v1.32.0 // indirect 53 | ) 54 | -------------------------------------------------------------------------------- /internal/tracing/tracing.go: -------------------------------------------------------------------------------- 1 | package tracing 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/DMarby/picsum-photos/internal/logger" 9 | "github.com/go-logr/stdr" 10 | "go.uber.org/zap" 11 | 12 | "go.opentelemetry.io/otel" 13 | "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" 14 | "go.opentelemetry.io/otel/sdk/resource" 15 | sdktrace "go.opentelemetry.io/otel/sdk/trace" 16 | semconv "go.opentelemetry.io/otel/semconv/v1.4.0" 17 | "go.opentelemetry.io/otel/trace" 18 | ) 19 | 20 | const tracerIdentifier = "github.com/DMarby/picsum-photos/internal/tracing" 21 | 22 | type Tracer struct { 23 | ServiceName string 24 | Log *logger.Logger 25 | 26 | trace.TracerProvider 27 | 28 | ShutdownFunc func(context.Context) error 29 | TracerInstance trace.Tracer 30 | } 31 | 32 | func New(ctx context.Context, log *logger.Logger, serviceName string) (*Tracer, error) { 33 | exporter, err := otlptracegrpc.New(ctx) 34 | if err != nil { 35 | return nil, fmt.Errorf("failed to create opentelemetry grpc exporter: %w", err) 36 | } 37 | 38 | tp := sdktrace.NewTracerProvider( 39 | sdktrace.WithBatcher(exporter), 40 | sdktrace.WithResource(resource.NewWithAttributes(semconv.SchemaURL, semconv.ServiceNameKey.String(serviceName))), 41 | ) 42 | 43 | // Override the global otel logging 44 | otel.SetLogger(stdr.New(zap.NewStdLog(log.Desugar()))) 45 | otel.SetErrorHandler(otel.ErrorHandlerFunc(func(err error) { 46 | log.Error(err) 47 | })) 48 | 49 | return &Tracer{ 50 | serviceName, 51 | log, 52 | tp, 53 | tp.Shutdown, 54 | tp.Tracer(tracerIdentifier), 55 | }, nil 56 | } 57 | 58 | func (t *Tracer) Start(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, trace.Span) { 59 | return t.TracerInstance.Start(ctx, spanName, opts...) 60 | } 61 | 62 | func (t *Tracer) Shutdown(ctx context.Context) { 63 | if err := t.ShutdownFunc(ctx); err != nil { 64 | log.Fatal("failed to shutdown tracer: %w", err) 65 | } 66 | } 67 | 68 | func TraceInfo(ctx context.Context) (string, string) { 69 | traceID := trace.SpanContextFromContext(ctx).TraceID().String() 70 | spanID := trace.SpanContextFromContext(ctx).SpanID().String() 71 | return traceID, spanID 72 | } 73 | -------------------------------------------------------------------------------- /internal/imageapi/api.go: -------------------------------------------------------------------------------- 1 | package imageapi 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/DMarby/picsum-photos/internal/handler" 8 | "github.com/DMarby/picsum-photos/internal/hmac" 9 | "github.com/DMarby/picsum-photos/internal/tracing" 10 | "github.com/rs/cors" 11 | 12 | "github.com/DMarby/picsum-photos/internal/image" 13 | "github.com/DMarby/picsum-photos/internal/logger" 14 | "github.com/gorilla/mux" 15 | ) 16 | 17 | // API is a http api 18 | type API struct { 19 | ImageProcessor image.Processor 20 | Log *logger.Logger 21 | Tracer *tracing.Tracer 22 | HandlerTimeout time.Duration 23 | HMAC *hmac.HMAC 24 | } 25 | 26 | // Utility methods for logging 27 | func (a *API) logError(r *http.Request, message string, err error) { 28 | a.Log.Errorw(message, handler.LogFields(r, "error", err)...) 29 | } 30 | 31 | // Router returns a http router 32 | func (a *API) Router() http.Handler { 33 | router := mux.NewRouter() 34 | 35 | router.NotFoundHandler = handler.Handler(a.notFoundHandler) 36 | 37 | // Redirect trailing slashes 38 | router.StrictSlash(true) 39 | 40 | // Image by ID routes 41 | router.Handle("/id/{id}/{width:[0-9]+}/{height:[0-9]+}{extension:\\..*}", handler.Handler(a.imageHandler)).Methods("GET").Name("imageapi.image") 42 | 43 | // Query parameters: 44 | // ?grayscale - Grayscale the image 45 | // ?blur={amount} - Blur the image by {amount} 46 | 47 | // ?hmac - HMAC signature of the path and URL parameters 48 | 49 | // Set up handlers 50 | cors := cors.New(cors.Options{ 51 | AllowedMethods: []string{"GET"}, 52 | AllowedOrigins: []string{"*"}, 53 | ExposedHeaders: []string{"Content-Type", "Picsum-ID"}, 54 | }) 55 | 56 | httpHandler := cors.Handler(router) 57 | httpHandler = handler.Recovery(a.Log, httpHandler) 58 | httpHandler = http.TimeoutHandler(httpHandler, a.HandlerTimeout, "Something went wrong. Timed out.") 59 | httpHandler = handler.Logger(a.Log, httpHandler) 60 | 61 | routeMatcher := &handler.MuxRouteMatcher{Router: router} 62 | httpHandler = handler.Tracer(a.Tracer, httpHandler, routeMatcher) 63 | httpHandler = handler.Metrics(httpHandler, routeMatcher) 64 | 65 | return httpHandler 66 | } 67 | 68 | // Handle not found errors 69 | var notFoundError = &handler.Error{ 70 | Message: "page not found", 71 | Code: http.StatusNotFound, 72 | } 73 | 74 | func (a *API) notFoundHandler(w http.ResponseWriter, r *http.Request) *handler.Error { 75 | return notFoundError 76 | } 77 | -------------------------------------------------------------------------------- /internal/handler/handler_test.go: -------------------------------------------------------------------------------- 1 | package handler_test 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/http/httptest" 7 | "reflect" 8 | "testing" 9 | 10 | "github.com/DMarby/picsum-photos/internal/handler" 11 | ) 12 | 13 | func TestHandler(t *testing.T) { 14 | tests := []struct { 15 | Name string 16 | AcceptHeader string 17 | ExpectedContentType string 18 | ExpectedStatus int 19 | ExpectedResponse []byte 20 | Handler handler.Handler 21 | }{ 22 | {"internal server error", "text/html", "text/plain; charset=utf-8", http.StatusInternalServerError, []byte("Something went wrong\n"), errorHandler}, 23 | {"internal server error json", "application/json", "application/json", http.StatusInternalServerError, []byte("{\"error\":\"Something went wrong\"}\n"), errorHandler}, 24 | {"bad request", "text/html", "text/plain; charset=utf-8", http.StatusBadRequest, []byte("Bad request test\n"), badRequestHandler}, 25 | {"bad request json", "application/json", "application/json", http.StatusBadRequest, []byte("{\"error\":\"Bad request test\"}\n"), badRequestHandler}, 26 | } 27 | 28 | for _, test := range tests { 29 | ts := httptest.NewServer(handler.Handler(test.Handler)) 30 | defer ts.Close() 31 | 32 | req, err := http.NewRequest("GET", ts.URL, nil) 33 | if err != nil { 34 | t.Errorf("%s: %s", test.Name, err) 35 | continue 36 | } 37 | 38 | req.Header.Set("Accept", test.AcceptHeader) 39 | 40 | res, err := http.DefaultClient.Do(req) 41 | if err != nil { 42 | t.Errorf("%s: %s", test.Name, err) 43 | continue 44 | } 45 | 46 | defer res.Body.Close() 47 | 48 | if res.StatusCode != test.ExpectedStatus { 49 | t.Errorf("%s: wrong response code, %#v", test.Name, res.StatusCode) 50 | continue 51 | } 52 | 53 | contentType := res.Header.Get("Content-Type") 54 | if contentType != test.ExpectedContentType { 55 | t.Errorf("%s: wrong content type, %#v", test.Name, contentType) 56 | continue 57 | } 58 | 59 | body, err := io.ReadAll(res.Body) 60 | if err != nil { 61 | t.Errorf("%s: %s", test.Name, err) 62 | continue 63 | } 64 | 65 | if !reflect.DeepEqual(body, test.ExpectedResponse) { 66 | t.Errorf("%s: wrong response %s", test.Name, body) 67 | } 68 | } 69 | 70 | } 71 | 72 | func errorHandler(rw http.ResponseWriter, req *http.Request) *handler.Error { 73 | return handler.InternalServerError() 74 | } 75 | 76 | func badRequestHandler(rw http.ResponseWriter, req *http.Request) *handler.Error { 77 | return handler.BadRequest("Bad request test") 78 | } 79 | -------------------------------------------------------------------------------- /internal/handler/metrics.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "expvar" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/felixge/httpsnoop" 9 | "github.com/prometheus/client_golang/prometheus" 10 | "github.com/prometheus/common/expfmt" 11 | "tailscale.com/tsweb/varz" 12 | ) 13 | 14 | var ( 15 | httpRequestsInFlight = expvar.NewInt("gauge_http_requests_in_flight") 16 | 17 | registry = prometheus.NewRegistry() 18 | httpRequestDurationSeconds = prometheus.NewHistogramVec(prometheus.HistogramOpts{ 19 | Subsystem: "http", 20 | Name: "request_duration_seconds", 21 | Buckets: durationBuckets, 22 | }, []string{"path", "code"}) 23 | ) 24 | 25 | var durationBuckets = []float64{ 26 | .01, // 10 ms 27 | .025, // 25 ms 28 | .05, // 50 ms 29 | .1, // 100 ms 30 | .25, // 250 ms 31 | .5, // 500 ms 32 | 1, // 1 s 33 | 2.5, // 2.5 s 34 | 3, // 3 s 35 | 4, // 4 s 36 | 5, // 5 s 37 | 10, // 10 s 38 | 30, // 30 s 39 | 45, // 45 s 40 | } 41 | 42 | func init() { 43 | registry.MustRegister(httpRequestDurationSeconds) 44 | } 45 | 46 | func VarzHandler(w http.ResponseWriter, r *http.Request) { 47 | w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") 48 | expvar.Do(func(kv expvar.KeyValue) { 49 | varz.WritePrometheusExpvar(w, kv) 50 | }) 51 | 52 | mfs, _ := registry.Gather() 53 | enc := expfmt.NewEncoder(w, expfmt.FmtText) 54 | 55 | for _, mf := range mfs { 56 | enc.Encode(mf) 57 | } 58 | 59 | if closer, ok := enc.(expfmt.Closer); ok { 60 | closer.Close() 61 | } 62 | } 63 | 64 | // Metrics is a handler that collects performance metrics 65 | func Metrics(h http.Handler, routeMatcher RouteMatcher) http.Handler { 66 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 67 | route := routeMatcher.Match(r) 68 | 69 | httpRequestsInFlight.Add(1) 70 | defer httpRequestsInFlight.Add(-1) 71 | 72 | respMetrics := httpsnoop.CaptureMetricsFn(w, func(ww http.ResponseWriter) { 73 | h.ServeHTTP(ww, r) 74 | }) 75 | 76 | // Exclude metrics for certain statuscodes to reduce cardinality 77 | switch respMetrics.Code { 78 | // Only set by mux's strict slash redirect 79 | case http.StatusMovedPermanently: 80 | return 81 | // Produced by http.ServeFile when serving the static assets for the website 82 | case http.StatusPartialContent, http.StatusNotModified, http.StatusRequestedRangeNotSatisfiable: 83 | return 84 | } 85 | 86 | histogram := httpRequestDurationSeconds.WithLabelValues(route, strconv.Itoa(respMetrics.Code)) 87 | histogram.Observe(respMetrics.Duration.Seconds()) 88 | }) 89 | } 90 | -------------------------------------------------------------------------------- /internal/queue/queue.go: -------------------------------------------------------------------------------- 1 | package queue 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "runtime" 7 | ) 8 | 9 | // Queue is a worker queue with a fixed amount of workers 10 | type Queue struct { 11 | workers int 12 | queue chan job 13 | handler func(context.Context, interface{}) (interface{}, error) 14 | ctx context.Context 15 | } 16 | 17 | type job struct { 18 | data interface{} 19 | result chan jobResult 20 | context context.Context 21 | } 22 | 23 | type jobResult struct { 24 | result interface{} 25 | err error 26 | } 27 | 28 | // New creates a new Queue with the specified amount of workers 29 | func New(ctx context.Context, workers int, handler func(context.Context, interface{}) (interface{}, error)) *Queue { 30 | queue := &Queue{ 31 | workers: workers, 32 | queue: make(chan job), 33 | handler: handler, 34 | ctx: ctx, 35 | } 36 | 37 | return queue 38 | } 39 | 40 | // Run starts the queue and blocks until it's shut down 41 | func (q *Queue) Run() { 42 | for i := 0; i < q.workers; i++ { 43 | go q.worker() 44 | } 45 | 46 | <-q.ctx.Done() 47 | close(q.queue) 48 | } 49 | 50 | func (q *Queue) worker() { 51 | // Lock the thread to ensure that we get our own thread, and that tasks aren't moved between threads 52 | // We won't unlock since it's uncertain how libvips would react 53 | runtime.LockOSThread() 54 | 55 | for { 56 | select { 57 | case job, open := <-q.queue: 58 | if !open { 59 | return 60 | } 61 | 62 | select { 63 | // End early if the job context was cancelled 64 | case <-job.context.Done(): 65 | job.result <- jobResult{ 66 | result: nil, 67 | err: job.context.Err(), 68 | } 69 | // Otherwise run the job 70 | default: 71 | result, err := q.handler(job.context, job.data) 72 | job.result <- jobResult{ 73 | result: result, 74 | err: err, 75 | } 76 | } 77 | 78 | case <-q.ctx.Done(): 79 | return 80 | } 81 | } 82 | } 83 | 84 | // Process adds a job to the queue, waits for it to process, and returns the result 85 | func (q *Queue) Process(ctx context.Context, data interface{}) (interface{}, error) { 86 | if q.ctx.Err() != nil { 87 | return nil, fmt.Errorf("queue has been shutdown") 88 | } 89 | 90 | resultChan := make(chan jobResult) 91 | 92 | q.queue <- job{ 93 | data: data, 94 | result: resultChan, 95 | context: ctx, 96 | } 97 | 98 | result := <-resultChan 99 | close(resultChan) 100 | 101 | if result.err != nil { 102 | return nil, result.err 103 | } 104 | 105 | return result.result, nil 106 | } 107 | -------------------------------------------------------------------------------- /internal/imageapi/image.go: -------------------------------------------------------------------------------- 1 | package imageapi 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/DMarby/picsum-photos/internal/handler" 9 | "github.com/DMarby/picsum-photos/internal/image" 10 | "github.com/DMarby/picsum-photos/internal/params" 11 | "github.com/gorilla/mux" 12 | ) 13 | 14 | func (a *API) imageHandler(w http.ResponseWriter, r *http.Request) *handler.Error { 15 | // Validate the path and query parameters 16 | valid, err := params.ValidateHMAC(a.HMAC, r) 17 | if err != nil { 18 | return handler.InternalServerError() 19 | } 20 | 21 | if !valid { 22 | return handler.BadRequest("Invalid parameters") 23 | } 24 | 25 | // Get the path and query parameters 26 | p, err := params.GetParams(r) 27 | if err != nil { 28 | return handler.BadRequest(err.Error()) 29 | } 30 | 31 | // Get the image ID from the path param 32 | vars := mux.Vars(r) 33 | imageID := vars["id"] 34 | 35 | // Build the image task 36 | task := image.NewTask(imageID, p.Width, p.Height, fmt.Sprintf("Picsum ID: %s", imageID), getOutputFormat(p.Extension)) 37 | if p.Blur { 38 | task.Blur(p.BlurAmount) 39 | } 40 | 41 | if p.Grayscale { 42 | task.Grayscale() 43 | } 44 | 45 | // Process the image 46 | processedImage, err := a.ImageProcessor.ProcessImage(r.Context(), task) 47 | if err != nil { 48 | a.logError(r, "error processing image", err) 49 | return handler.InternalServerError() 50 | } 51 | 52 | // Set the headers 53 | w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", buildFilename(imageID, p))) 54 | w.Header().Set("Content-Type", getContentType(p.Extension)) 55 | w.Header().Set("Content-Length", strconv.Itoa(len(processedImage))) 56 | w.Header().Set("Cache-Control", "public, max-age=2592000, stale-while-revalidate=60, stale-if-error=43200, immutable") // Cache for a month 57 | w.Header().Set("Picsum-ID", imageID) 58 | w.Header().Set("Timing-Allow-Origin", "*") // Allow all origins to see timing resources 59 | 60 | // Return the image 61 | w.Write(processedImage) 62 | 63 | return nil 64 | } 65 | 66 | func getOutputFormat(extension string) image.OutputFormat { 67 | switch extension { 68 | case ".webp": 69 | return image.WebP 70 | default: 71 | return image.JPEG 72 | } 73 | } 74 | 75 | func getContentType(extension string) string { 76 | switch extension { 77 | case ".webp": 78 | return "image/webp" 79 | default: 80 | return "image/jpeg" 81 | } 82 | } 83 | 84 | func buildFilename(imageID string, p *params.Params) string { 85 | filename := fmt.Sprintf("%s-%dx%d", imageID, p.Width, p.Height) 86 | 87 | if p.Blur { 88 | filename += fmt.Sprintf("-blur_%d", p.BlurAmount) 89 | } 90 | 91 | if p.Grayscale { 92 | filename += "-grayscale" 93 | } 94 | 95 | filename += p.Extension 96 | 97 | return filename 98 | } 99 | -------------------------------------------------------------------------------- /internal/web/embed/assets/images/fastly.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 10 | 12 | 14 | 19 | 23 | 25 | 29 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /internal/database/file/file.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "math/rand" 7 | "os" 8 | "sort" 9 | "strconv" 10 | "sync" 11 | "time" 12 | 13 | "github.com/DMarby/picsum-photos/internal/database" 14 | ) 15 | 16 | // Provider implements a file-based image storage 17 | type Provider struct { 18 | images []database.Image 19 | sortedImages []database.Image 20 | 21 | random *rand.Rand 22 | mu sync.Mutex 23 | } 24 | 25 | // New returns a new Provider instance 26 | func New(path string) (*Provider, error) { 27 | data, err := os.ReadFile(path) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | var images []database.Image 33 | err = json.Unmarshal(data, &images) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | sortedImages := make([]database.Image, len(images)) 39 | copy(sortedImages, images) 40 | sort.Slice(sortedImages, func(i, j int) bool { 41 | ii, _ := strconv.Atoi(sortedImages[i].ID) 42 | jj, _ := strconv.Atoi(sortedImages[j].ID) 43 | return ii < jj 44 | }) 45 | 46 | source := rand.NewSource(time.Now().UnixNano()) 47 | random := rand.New(source) 48 | 49 | return &Provider{ 50 | images: images, 51 | sortedImages: sortedImages, 52 | random: random, 53 | }, nil 54 | } 55 | 56 | func (p *Provider) getImage(id string) (*database.Image, error) { 57 | for _, image := range p.images { 58 | if image.ID == id { 59 | return &image, nil 60 | } 61 | } 62 | 63 | return nil, database.ErrNotFound 64 | } 65 | 66 | // Get returns the image data for an image id 67 | func (p *Provider) Get(ctx context.Context, id string) (i *database.Image, err error) { 68 | image, err := p.getImage(id) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | return image, nil 74 | } 75 | 76 | // GetRandom returns a random image ID 77 | func (p *Provider) GetRandom(ctx context.Context) (i *database.Image, err error) { 78 | p.mu.Lock() 79 | image := &p.images[p.random.Intn(len(p.images))] 80 | p.mu.Unlock() 81 | return image, nil 82 | } 83 | 84 | // GetRandomWithSeed returns a random image ID based on the given seed 85 | func (p *Provider) GetRandomWithSeed(ctx context.Context, seed int64) (i *database.Image, err error) { 86 | source := rand.NewSource(seed) 87 | random := rand.New(source) 88 | 89 | return &p.images[random.Intn(len(p.images))], nil 90 | } 91 | 92 | // ListAll returns a list of all the images 93 | func (p *Provider) ListAll(ctx context.Context) ([]database.Image, error) { 94 | return p.sortedImages, nil 95 | } 96 | 97 | // List returns a list of all the images with an offset/limit 98 | func (p *Provider) List(ctx context.Context, offset, limit int) ([]database.Image, error) { 99 | images := len(p.sortedImages) 100 | if offset > images { 101 | offset = images 102 | } 103 | 104 | limit = offset + limit 105 | if limit > images { 106 | limit = images 107 | } 108 | 109 | return p.sortedImages[offset:limit], nil 110 | } 111 | -------------------------------------------------------------------------------- /internal/database/file/file_test.go: -------------------------------------------------------------------------------- 1 | package file_test 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | 7 | "github.com/DMarby/picsum-photos/internal/database" 8 | "github.com/DMarby/picsum-photos/internal/database/file" 9 | 10 | "testing" 11 | ) 12 | 13 | var image = database.Image{ 14 | ID: "1", 15 | Author: "John Doe", 16 | URL: "https://picsum.photos", 17 | Width: 300, 18 | Height: 400, 19 | } 20 | 21 | var secondImage = database.Image{ 22 | ID: "2", 23 | Author: "John Doe", 24 | URL: "https://picsum.photos", 25 | Width: 300, 26 | Height: 400, 27 | } 28 | 29 | func TestFile(t *testing.T) { 30 | ctx, cancel := context.WithCancel(context.Background()) 31 | defer cancel() 32 | 33 | provider, err := file.New("../../../test/fixtures/file/metadata_multiple.json") 34 | if err != nil { 35 | t.Fatal(err) 36 | } 37 | 38 | t.Run("Get an image by id", func(t *testing.T) { 39 | buf, err := provider.Get(ctx, "1") 40 | if err != nil { 41 | t.Fatal(err) 42 | } 43 | 44 | if !reflect.DeepEqual(buf, &image) { 45 | t.Error("image data doesn't match") 46 | } 47 | }) 48 | 49 | t.Run("Returns error on a nonexistant image", func(t *testing.T) { 50 | _, err := provider.Get(ctx, "nonexistant") 51 | if err == nil || err.Error() != database.ErrNotFound.Error() { 52 | t.FailNow() 53 | } 54 | }) 55 | 56 | t.Run("Returns a random image", func(t *testing.T) { 57 | image, err := provider.GetRandom(ctx) 58 | if err != nil { 59 | t.Fatal(err) 60 | } 61 | 62 | if image.ID != "1" && image.ID != "2" { 63 | t.Error("wrong image") 64 | } 65 | }) 66 | 67 | t.Run("Returns a random based on the seed", func(t *testing.T) { 68 | image, err := provider.GetRandomWithSeed(ctx, 0) 69 | if err != nil { 70 | t.Fatal(err) 71 | } 72 | 73 | if image.ID != "1" { 74 | t.Error("wrong image") 75 | } 76 | }) 77 | 78 | t.Run("Returns a list of all the images", func(t *testing.T) { 79 | images, err := provider.ListAll(ctx) 80 | if err != nil { 81 | t.Fatal(err) 82 | } 83 | 84 | if !reflect.DeepEqual(images, []database.Image{image, secondImage}) { 85 | t.Error("image data doesn't match") 86 | } 87 | }) 88 | 89 | t.Run("Returns a list of images", func(t *testing.T) { 90 | images, err := provider.List(ctx, 1, 1) 91 | if err != nil { 92 | t.Fatal(err) 93 | } 94 | 95 | if !reflect.DeepEqual(images, []database.Image{secondImage}) { 96 | t.Error("image data doesn't match") 97 | } 98 | }) 99 | 100 | t.Run("Handles offset and limit larger then db", func(t *testing.T) { 101 | _, err := provider.List(ctx, 10, 30) 102 | if err != nil { 103 | t.Fatal(err) 104 | } 105 | }) 106 | } 107 | 108 | func TestMissingMetadata(t *testing.T) { 109 | _, err := file.New("") 110 | if err == nil { 111 | t.FailNow() 112 | } 113 | } 114 | 115 | func TestInvalidJson(t *testing.T) { 116 | _, err := file.New("../../../test/fixtures/file/invalid_metadata.json") 117 | if err == nil { 118 | t.FailNow() 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /internal/health/health_test.go: -------------------------------------------------------------------------------- 1 | package health_test 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/DMarby/picsum-photos/internal/health" 9 | "github.com/DMarby/picsum-photos/internal/logger" 10 | "go.uber.org/zap" 11 | 12 | fileDatabase "github.com/DMarby/picsum-photos/internal/database/file" 13 | mockDatabase "github.com/DMarby/picsum-photos/internal/database/mock" 14 | 15 | fileStorage "github.com/DMarby/picsum-photos/internal/storage/file" 16 | mockStorage "github.com/DMarby/picsum-photos/internal/storage/mock" 17 | 18 | memoryCache "github.com/DMarby/picsum-photos/internal/cache/memory" 19 | mockCache "github.com/DMarby/picsum-photos/internal/cache/mock" 20 | ) 21 | 22 | func TestHealth(t *testing.T) { 23 | log := logger.New(zap.ErrorLevel) 24 | defer log.Sync() 25 | 26 | ctx, cancel := context.WithCancel(context.Background()) 27 | defer cancel() 28 | 29 | storage, _ := fileStorage.New("../../test/fixtures/file") 30 | db, _ := fileDatabase.New("../../test/fixtures/file/metadata.json") 31 | cache := memoryCache.New() 32 | 33 | checker := &health.Checker{Ctx: ctx, Storage: storage, Cache: cache, Log: log} 34 | mockStorageChecker := &health.Checker{Ctx: ctx, Storage: &mockStorage.Provider{}, Cache: cache, Log: log} 35 | mockCacheChecker := &health.Checker{Ctx: ctx, Storage: storage, Cache: &mockCache.Provider{}, Log: log} 36 | 37 | dbOnlyChecker := &health.Checker{Ctx: ctx, Database: db, Log: log} 38 | mockDbOnlyChecker := &health.Checker{Ctx: ctx, Database: &mockDatabase.Provider{}, Log: log} 39 | 40 | tests := []struct { 41 | Name string 42 | ExpectedStatus health.Status 43 | Checker *health.Checker 44 | }{ 45 | { 46 | Name: "runs checks and returns correct status", 47 | ExpectedStatus: health.Status{ 48 | Healthy: true, 49 | Cache: "healthy", 50 | Storage: "healthy", 51 | }, 52 | Checker: checker, 53 | }, 54 | { 55 | Name: "runs checks and returns correct status with broken storage", 56 | ExpectedStatus: health.Status{ 57 | Healthy: false, 58 | Cache: "healthy", 59 | Storage: "unhealthy", 60 | }, 61 | Checker: mockStorageChecker, 62 | }, 63 | { 64 | Name: "runs checks and returns correct status with broken cache", 65 | ExpectedStatus: health.Status{ 66 | Healthy: false, 67 | Cache: "unhealthy", 68 | Storage: "healthy", 69 | }, 70 | Checker: mockCacheChecker, 71 | }, 72 | { 73 | Name: "runs checks and returns correct status with only a database", 74 | ExpectedStatus: health.Status{ 75 | Healthy: true, 76 | Database: "healthy", 77 | }, 78 | Checker: dbOnlyChecker, 79 | }, 80 | { 81 | Name: "runs checks and returns correct status with only a broken database", 82 | ExpectedStatus: health.Status{ 83 | Healthy: false, 84 | Database: "unhealthy", 85 | }, 86 | Checker: mockDbOnlyChecker, 87 | }, 88 | } 89 | 90 | for _, test := range tests { 91 | test.Checker.Run() 92 | status := test.Checker.Status() 93 | 94 | if !reflect.DeepEqual(status, test.ExpectedStatus) { 95 | t.Errorf("%s: wrong status %+v", test.Name, status) 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /internal/params/params.go: -------------------------------------------------------------------------------- 1 | package params 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/gorilla/mux" 10 | ) 11 | 12 | // Errors 13 | var ( 14 | ErrInvalidSize = fmt.Errorf("Invalid size") 15 | ErrInvalidFileExtension = fmt.Errorf("Invalid file extension") 16 | ) 17 | 18 | const defaultBlurAmount = 5 19 | 20 | // Params contains all the parameters for a request 21 | type Params struct { 22 | Width int 23 | Height int 24 | Blur bool 25 | BlurAmount int 26 | Grayscale bool 27 | Extension string 28 | } 29 | 30 | // GetParams parses and returns all the path and query parameters 31 | func GetParams(r *http.Request) (*Params, error) { 32 | // Get and validate the width and height from the path parameters 33 | width, height, err := getSize(r) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | // Get the optional file extension from the path parameters 39 | extension, err := getFileExtension(r) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | // Get and validate the query parameters for grayscale and blur 45 | grayscale, blur, blurAmount := getQueryParams(r) 46 | 47 | params := &Params{ 48 | Width: width, 49 | Height: height, 50 | Blur: blur, 51 | BlurAmount: blurAmount, 52 | Grayscale: grayscale, 53 | Extension: extension, 54 | } 55 | 56 | return params, nil 57 | } 58 | 59 | // getSize gets the image size from the size or the width/height path params, and validates it 60 | func getSize(r *http.Request) (width int, height int, err error) { 61 | // Check for the size parameter first 62 | if size, ok := intParam(r, "size"); ok { 63 | width, height = size, size 64 | } else { 65 | // If size doesn't exist, check for width/height 66 | width, ok = intParam(r, "width") 67 | if !ok { 68 | return -1, -1, ErrInvalidSize 69 | } 70 | 71 | height, ok = intParam(r, "height") 72 | if !ok { 73 | return -1, -1, ErrInvalidSize 74 | } 75 | } 76 | 77 | return 78 | } 79 | 80 | // intParam tries to get a param and convert it to an Integer 81 | func intParam(r *http.Request, name string) (int, bool) { 82 | vars := mux.Vars(r) 83 | 84 | if val, ok := vars[name]; ok { 85 | val, err := strconv.Atoi(val) 86 | return val, err == nil 87 | } 88 | 89 | return -1, false 90 | } 91 | 92 | // getFileExtension gets the file extension (if present) from the path params, and validates it 93 | func getFileExtension(r *http.Request) (extension string, err error) { 94 | vars := mux.Vars(r) 95 | 96 | // We only allow the .jpg and .webp extensions, as we only serve jpg and webp images 97 | // We normalize having no extension since it's an optional path param 98 | val := strings.ToLower(vars["extension"]) 99 | 100 | if val == "" { 101 | val = ".jpg" 102 | } 103 | 104 | if val != ".jpg" && val != ".webp" { 105 | return "", ErrInvalidFileExtension 106 | } 107 | 108 | return val, nil 109 | } 110 | 111 | // getQueryParams returns whether the grayscale and blur queryparams are present 112 | func getQueryParams(r *http.Request) (grayscale bool, blur bool, blurAmount int) { 113 | if _, ok := r.URL.Query()["grayscale"]; ok { 114 | grayscale = true 115 | } 116 | 117 | if _, ok := r.URL.Query()["blur"]; ok { 118 | blur = true 119 | blurAmount = defaultBlurAmount 120 | 121 | if val, err := strconv.Atoi(r.URL.Query().Get("blur")); err == nil { 122 | blurAmount = val 123 | return 124 | } 125 | } 126 | 127 | return 128 | } 129 | -------------------------------------------------------------------------------- /internal/health/health.go: -------------------------------------------------------------------------------- 1 | package health 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | 8 | "github.com/DMarby/picsum-photos/internal/cache" 9 | "github.com/DMarby/picsum-photos/internal/database" 10 | "github.com/DMarby/picsum-photos/internal/logger" 11 | "github.com/DMarby/picsum-photos/internal/storage" 12 | ) 13 | 14 | const checkInterval = 10 * time.Second 15 | const checkTimeout = 8 * time.Second 16 | 17 | // Checker is a periodic health checker 18 | type Checker struct { 19 | Ctx context.Context 20 | Storage storage.Provider 21 | Database database.Provider 22 | Cache cache.Provider 23 | status Status 24 | mutex sync.RWMutex 25 | Log *logger.Logger 26 | } 27 | 28 | // Status contains the healtcheck status 29 | type Status struct { 30 | Healthy bool `json:"healthy"` 31 | Cache string `json:"cache,omitempty"` 32 | Database string `json:"database,omitempty"` 33 | Storage string `json:"storage,omitempty"` 34 | } 35 | 36 | // Run starts the health checker 37 | func (c *Checker) Run() { 38 | ticker := time.NewTicker(checkInterval) 39 | go func() { 40 | for { 41 | select { 42 | case <-ticker.C: 43 | c.runCheck() 44 | case <-c.Ctx.Done(): 45 | ticker.Stop() 46 | return 47 | } 48 | } 49 | }() 50 | 51 | c.runCheck() 52 | } 53 | 54 | // Status returns the status of the health checks 55 | func (c *Checker) Status() Status { 56 | c.mutex.RLock() 57 | defer c.mutex.RUnlock() 58 | 59 | return c.status 60 | } 61 | 62 | func (c *Checker) runCheck() { 63 | ctx, cancel := context.WithTimeout(context.Background(), checkTimeout) 64 | defer cancel() 65 | 66 | channel := make(chan Status, 1) 67 | go func() { 68 | c.check(ctx, channel) 69 | }() 70 | 71 | select { 72 | case <-ctx.Done(): 73 | c.mutex.Lock() 74 | 75 | c.status = Status{ 76 | Healthy: false, 77 | } 78 | if c.Database != nil { 79 | c.status.Database = "unknown" 80 | } 81 | if c.Cache != nil { 82 | c.status.Cache = "unknown" 83 | } 84 | if c.Storage != nil { 85 | c.status.Storage = "unknown" 86 | } 87 | 88 | c.mutex.Unlock() 89 | c.Log.Errorw("healthcheck timed out") 90 | case status, ok := <-channel: 91 | if !ok { 92 | return 93 | } 94 | 95 | c.mutex.Lock() 96 | c.status = status 97 | c.mutex.Unlock() 98 | if !status.Healthy { 99 | c.Log.Errorw("healthcheck error", 100 | "status", status, 101 | ) 102 | } 103 | } 104 | } 105 | 106 | func (c *Checker) check(ctx context.Context, channel chan Status) { 107 | defer close(channel) 108 | 109 | if ctx.Err() != nil { 110 | return 111 | } 112 | 113 | status := Status{ 114 | Healthy: true, 115 | } 116 | if c.Database != nil { 117 | status.Database = "unknown" 118 | } 119 | if c.Cache != nil { 120 | status.Cache = "unknown" 121 | } 122 | if c.Storage != nil { 123 | status.Storage = "unknown" 124 | } 125 | 126 | if c.Database != nil { 127 | if _, err := c.Database.GetRandom(ctx); err != nil { 128 | status.Healthy = false 129 | status.Database = "unhealthy" 130 | } else { 131 | status.Database = "healthy" 132 | } 133 | } 134 | 135 | if ctx.Err() != nil { 136 | return 137 | } 138 | 139 | if c.Cache != nil { 140 | if _, err := c.Cache.Get(ctx, "healthcheck"); err != cache.ErrNotFound { 141 | status.Healthy = false 142 | status.Cache = "unhealthy" 143 | } else { 144 | status.Cache = "healthy" 145 | } 146 | } 147 | 148 | if ctx.Err() != nil { 149 | return 150 | } 151 | 152 | if c.Storage != nil { 153 | if _, err := c.Storage.Get(ctx, "healthcheck"); err != storage.ErrNotFound { 154 | status.Healthy = false 155 | status.Storage = "unhealthy" 156 | } else { 157 | status.Storage = "healthy" 158 | } 159 | } 160 | 161 | channel <- status 162 | } 163 | -------------------------------------------------------------------------------- /internal/web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: { 4 | relative: true, 5 | files: [ 6 | './embed/**/*.{html,js}', 7 | ], 8 | }, 9 | theme: { 10 | screens: { 11 | sm: '576px', 12 | md: '768px', 13 | lg: '992px', 14 | xl: '1200px', 15 | }, 16 | colors: { 17 | transparent: 'transparent', 18 | current: 'currentColor', 19 | 20 | black: '#000', 21 | white: '#fff', 22 | 23 | gray: { 24 | 100: '#f7fafc', 25 | 200: '#edf2f7', 26 | 300: '#e2e8f0', 27 | 400: '#cbd5e0', 28 | 500: '#a0aec0', 29 | 600: '#718096', 30 | 700: '#4a5568', 31 | 800: '#2d3748', 32 | 900: '#1a202c', 33 | }, 34 | red: { 35 | 100: '#fff5f5', 36 | 200: '#fed7d7', 37 | 300: '#feb2b2', 38 | 400: '#fc8181', 39 | 500: '#f56565', 40 | 600: '#e53e3e', 41 | 700: '#c53030', 42 | 800: '#9b2c2c', 43 | 900: '#742a2a', 44 | }, 45 | orange: { 46 | 100: '#fffaf0', 47 | 200: '#feebc8', 48 | 300: '#fbd38d', 49 | 400: '#f6ad55', 50 | 500: '#ed8936', 51 | 600: '#dd6b20', 52 | 700: '#c05621', 53 | 800: '#9c4221', 54 | 900: '#7b341e', 55 | }, 56 | yellow: { 57 | 100: '#fffff0', 58 | 200: '#fefcbf', 59 | 300: '#faf089', 60 | 400: '#f6e05e', 61 | 500: '#ecc94b', 62 | 600: '#d69e2e', 63 | 700: '#b7791f', 64 | 800: '#975a16', 65 | 900: '#744210', 66 | }, 67 | green: { 68 | 100: '#f0fff4', 69 | 200: '#c6f6d5', 70 | 300: '#9ae6b4', 71 | 400: '#68d391', 72 | 500: '#48bb78', 73 | 600: '#38a169', 74 | 700: '#2f855a', 75 | 800: '#276749', 76 | 900: '#22543d', 77 | }, 78 | teal: { 79 | 100: '#e6fffa', 80 | 200: '#b2f5ea', 81 | 300: '#81e6d9', 82 | 400: '#4fd1c5', 83 | 500: '#38b2ac', 84 | 600: '#319795', 85 | 700: '#2c7a7b', 86 | 800: '#285e61', 87 | 900: '#234e52', 88 | }, 89 | blue: { 90 | 100: '#ebf8ff', 91 | 200: '#bee3f8', 92 | 300: '#90cdf4', 93 | 400: '#63b3ed', 94 | 500: '#4299e1', 95 | 600: '#3182ce', 96 | 700: '#2b6cb0', 97 | 800: '#2c5282', 98 | 900: '#2a4365', 99 | }, 100 | indigo: { 101 | 100: '#ebf4ff', 102 | 200: '#c3dafe', 103 | 300: '#a3bffa', 104 | 400: '#7f9cf5', 105 | 500: '#667eea', 106 | 600: '#5a67d8', 107 | 700: '#4c51bf', 108 | 800: '#434190', 109 | 900: '#3c366b', 110 | }, 111 | purple: { 112 | 100: '#faf5ff', 113 | 200: '#e9d8fd', 114 | 300: '#d6bcfa', 115 | 400: '#b794f4', 116 | 500: '#9f7aea', 117 | 600: '#805ad5', 118 | 700: '#6b46c1', 119 | 800: '#553c9a', 120 | 900: '#44337a', 121 | }, 122 | pink: { 123 | 100: '#fff5f7', 124 | 200: '#fed7e2', 125 | 300: '#fbb6ce', 126 | 400: '#f687b3', 127 | 500: '#ed64a6', 128 | 600: '#d53f8c', 129 | 700: '#b83280', 130 | 800: '#97266d', 131 | 900: '#702459', 132 | }, 133 | }, 134 | fontSize: { 135 | xs: '0.75rem', 136 | sm: '0.875rem', 137 | base: '1rem', 138 | lg: '1.125rem', 139 | xl: '1.25rem', 140 | '2xl': '1.5rem', 141 | '3xl': '1.875rem', 142 | '4xl': '2.25rem', 143 | '5xl': '3rem', 144 | '6xl': '4rem', 145 | }, 146 | 147 | extend: {}, 148 | }, 149 | plugins: [], 150 | } 151 | -------------------------------------------------------------------------------- /internal/web/embed/assets/js/images.js: -------------------------------------------------------------------------------- 1 | window.addEventListener('DOMContentLoaded', function () { 2 | document.getElementById('prev').addEventListener('click', handleNavigationButton) 3 | document.getElementById('next').addEventListener('click', handleNavigationButton) 4 | 5 | loadPageFromhHash() 6 | }) 7 | 8 | window.addEventListener('hashchange', loadPageFromhHash) 9 | 10 | function loadPageFromhHash() { 11 | var page = 1 12 | 13 | if (window.location.hash) { 14 | page = window.location.hash.substring(1) 15 | } 16 | 17 | loadPage(page) 18 | } 19 | 20 | function handleNavigationButton (event) { 21 | event.preventDefault() 22 | 23 | var page = event.target.getAttribute('data-page') 24 | if (!page) { 25 | return 26 | } 27 | 28 | // Set the hash, this will change the page by triggering the 'hashchange' event 29 | window.location.hash = page 30 | window.scrollTo({ top: 0 }) 31 | } 32 | 33 | function loadPage (page) { 34 | var xhr = new XMLHttpRequest() 35 | xhr.open('GET', '/v2/list?page=' + page, true) 36 | xhr.onreadystatechange = function () { 37 | if (xhr.readyState === 4 && xhr.status === 200) { 38 | var images = JSON.parse(xhr.responseText) 39 | 40 | var container = document.querySelector('.image-list') 41 | container.innerHTML = '' 42 | 43 | for (var image of images) { 44 | var template = document.querySelector('#image-template') 45 | var clone = document.importNode(template.content, true) 46 | 47 | // Image 48 | clone.querySelector('img').src = '/id/' + image.id + '/367/267' 49 | clone.querySelector('.download-url').href = image.download_url 50 | 51 | // Author 52 | clone.querySelector('.author').innerHTML = image.author 53 | clone.querySelector('.author-url').href = image.url 54 | 55 | // Image id indicator 56 | clone.querySelector('.image-id').innerHTML = '#' + image.id 57 | clone.querySelector('.image-id').href = image.download_url 58 | 59 | container.appendChild(clone) 60 | } 61 | 62 | var linkHeaders = parseLinkHeader(xhr.getResponseHeader('Link')) 63 | 64 | updateButton('prev', linkHeaders.prev) 65 | updateButton('next', linkHeaders.next) 66 | } 67 | } 68 | 69 | xhr.send() 70 | } 71 | 72 | function updateButton (id, page_url) { 73 | var button = document.getElementById(id) 74 | 75 | if (page_url) { 76 | var url = new URL(page_url) 77 | var urlParams = new URLSearchParams(url.search) 78 | button.setAttribute('data-page', urlParams.get('page')) 79 | button.classList.add('hover:text-white', 'hover:bg-gray-500') 80 | button.classList.remove('cursor-not-allowed', 'opacity-50') 81 | } else { 82 | button.removeAttribute('data-page') 83 | button.classList.add('cursor-not-allowed', 'opacity-50') 84 | button.classList.remove('hover:text-white', 'hover:bg-gray-500') 85 | } 86 | } 87 | 88 | // From https://gist.github.com/deiu/9335803 89 | function parseLinkHeader (header) { 90 | var linkexp = /<[^>]*>\s*(\s*;\s*[^\(\)<>@,;:"\/\[\]\?={} \t]+=(([^\(\)<>@,;:"\/\[\]\?={} \t]+)|("[^"]*")))*(,|$)/g 91 | var paramexp = /[^\(\)<>@,;:"\/\[\]\?={} \t]+=(([^\(\)<>@,;:"\/\[\]\?={} \t]+)|("[^"]*"))/g 92 | 93 | var matches = header.match(linkexp) 94 | var rels = {} 95 | 96 | for (var i = 0; i < matches.length; i++) { 97 | var split = matches[i].split('>') 98 | var href = split[0].substring(1) 99 | var ps = split[1] 100 | 101 | var s = ps.match(paramexp) 102 | for (var j = 0; j < s.length; j++) { 103 | var p = s[j] 104 | var paramsplit = p.split('=') 105 | var name = paramsplit[0] 106 | var rel = paramsplit[1].replace(/["']/g, '') 107 | rels[rel] = href 108 | } 109 | } 110 | 111 | return rels 112 | } 113 | -------------------------------------------------------------------------------- /internal/api/deprecated.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "strconv" 8 | 9 | "github.com/DMarby/picsum-photos/internal/database" 10 | 11 | "github.com/DMarby/picsum-photos/internal/handler" 12 | "github.com/DMarby/picsum-photos/internal/params" 13 | ) 14 | 15 | // DeprecatedImage contains info about an image, in the old deprecated /list style 16 | type DeprecatedImage struct { 17 | Format string `json:"format"` 18 | Width int `json:"width"` 19 | Height int `json:"height"` 20 | Filename string `json:"filename"` 21 | ID int `json:"id"` 22 | Author string `json:"author"` 23 | AuthorURL string `json:"author_url"` 24 | PostURL string `json:"post_url"` 25 | } 26 | 27 | func (a *API) deprecatedListHandler(w http.ResponseWriter, r *http.Request) *handler.Error { 28 | list, err := a.Database.ListAll(r.Context()) 29 | if err != nil { 30 | a.logError(r, "error getting image list from database", err) 31 | return handler.InternalServerError() 32 | } 33 | 34 | var images []DeprecatedImage 35 | for _, image := range list { 36 | numericID, err := strconv.Atoi(image.ID) 37 | if err != nil { 38 | continue 39 | } 40 | 41 | images = append(images, DeprecatedImage{ 42 | Format: "jpeg", 43 | Width: image.Width, 44 | Height: image.Height, 45 | Filename: fmt.Sprintf("%s.jpeg", image.ID), 46 | ID: numericID, 47 | Author: image.Author, 48 | AuthorURL: image.URL, 49 | PostURL: image.URL, 50 | }) 51 | } 52 | 53 | w.Header().Set("Content-Type", "application/json") 54 | w.Header().Set("Cache-Control", "private, no-cache, no-store, must-revalidate") 55 | if err := json.NewEncoder(w).Encode(images); err != nil { 56 | a.logError(r, "error encoding deprecate image list", err) 57 | return handler.InternalServerError() 58 | } 59 | 60 | return nil 61 | } 62 | 63 | // Handles deprecated image routes 64 | func (a *API) deprecatedImageHandler(w http.ResponseWriter, r *http.Request) *handler.Error { 65 | // Get the params 66 | p, err := params.GetParams(r) 67 | if err != nil { 68 | return handler.BadRequest(err.Error()) 69 | } 70 | 71 | var image *database.Image 72 | 73 | // Look for the deprecated ?image query parameter 74 | if id := r.URL.Query().Get("image"); id != "" { 75 | var handlerErr *handler.Error 76 | image, handlerErr = a.getImage(r, id) 77 | if handlerErr != nil { 78 | return handlerErr 79 | } 80 | } else { 81 | image, err = a.Database.GetRandom(r.Context()) 82 | if err != nil { 83 | a.logError(r, "error getting random image from database", err) 84 | return handler.InternalServerError() 85 | } 86 | } 87 | 88 | // Set grayscale to true as this is the deprecated /g/ endpoint 89 | p.Grayscale = true 90 | 91 | return a.validateAndRedirect(w, r, p, image) 92 | } 93 | 94 | // deprecatedParams is a handler to handle deprecated query params for regular routes 95 | func (a *API) deprecatedParams(next http.Handler) http.Handler { 96 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 97 | // Look for the deprecated ?image query parameter 98 | if id := r.URL.Query().Get("image"); id != "" { 99 | w.Header().Set("Cache-Control", "private, no-cache, no-store, must-revalidate") 100 | 101 | p, err := params.GetParams(r) 102 | if err != nil { 103 | http.Error(w, err.Error(), http.StatusBadRequest) 104 | return 105 | } 106 | 107 | image, handlerErr := a.getImage(r, id) 108 | if handlerErr != nil { 109 | http.Error(w, handlerErr.Message, handlerErr.Code) 110 | return 111 | } 112 | 113 | handlerErr = a.validateAndRedirect(w, r, p, image) 114 | if handlerErr != nil { 115 | http.Error(w, handlerErr.Message, handlerErr.Code) 116 | } 117 | 118 | return 119 | } 120 | 121 | next.ServeHTTP(w, r) 122 | }) 123 | } 124 | -------------------------------------------------------------------------------- /internal/web/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | 3 | html { 4 | line-height: 1.15; 5 | font-family: sans-serif; 6 | } 7 | 8 | @tailwind components; 9 | @tailwind utilities; 10 | 11 | /* Typography */ 12 | body { 13 | font-family: 'Open Sans', sans-serif; 14 | } 15 | 16 | p { 17 | font-size: 16px; 18 | line-height: 1.6em; 19 | } 20 | 21 | h2 { 22 | font-family: 'Roboto', sans-serif; 23 | font-weight: 600; 24 | margin-bottom: 0.5em; 25 | } 26 | 27 | a { 28 | color: #386BF3; 29 | font-weight: 400; 30 | } 31 | 32 | a:hover { 33 | color: hsla(0, 100%, 100%, .6); 34 | } 35 | 36 | pre, 37 | code { 38 | font-family: SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; 39 | } 40 | 41 | pre { 42 | font-size: 14px; 43 | background-color: hsl(210, 14.3%, 93.3%); 44 | border-radius: 4px; 45 | padding: 1em; 46 | margin-top: 1em; 47 | margin-bottom: 2em; 48 | width: 100%; 49 | } 50 | 51 | 52 | code { 53 | padding: 4px 6px; 54 | font-size: 90%; 55 | color: hsl(0, 0%, 23.7%); 56 | background-color: hsl(0, 0%, 94.9%); 57 | border-radius: 4px; 58 | } 59 | 60 | pre code { 61 | padding: 0; 62 | font-size: inherit; 63 | color: inherit; 64 | white-space: pre-wrap; 65 | background-color: transparent; 66 | border-radius: 0; 67 | } 68 | 69 | .break-all { 70 | word-break: break-all; 71 | } 72 | 73 | /* Header */ 74 | header { 75 | padding-top: 100px; 76 | padding-bottom: 100px; 77 | } 78 | 79 | header .brand-icon { 80 | position: relative; 81 | width: 42px; 82 | margin-right: 10px; 83 | top: 7px; 84 | display: inline; 85 | vertical-align: unset; 86 | } 87 | 88 | header h1 { 89 | font-family: 'Work Sans', sans-serif; 90 | font-weight: 600; 91 | font-size: 42px; 92 | display: inline-block; 93 | margin: 0; 94 | } 95 | 96 | header h2 { 97 | margin-top: .5em; 98 | font-family: 'Open Sans', sans-serif; 99 | font-weight: 300; 100 | } 101 | 102 | header a { 103 | color: #fff; 104 | } 105 | 106 | header a:hover { 107 | opacity: 0.6; 108 | } 109 | 110 | /* Main content */ 111 | .content-section-light, 112 | .content-section-dark { 113 | padding: 100px 0; 114 | } 115 | 116 | .content-section-dark { 117 | background: #F4F7FC; 118 | } 119 | 120 | .content-section-dark + .content-section-light { 121 | padding-top: 150px; 122 | } 123 | 124 | .content-section-light a:hover, 125 | .content-section-dark a:hover, 126 | .content-section-images a:hover { 127 | color: hsla(0, 0%, 60%, 1); 128 | } 129 | 130 | .content-section-dark pre { 131 | background: white; 132 | } 133 | 134 | /* Images */ 135 | img.resize { 136 | max-width: 100%; 137 | height: auto; 138 | border-radius: 8px; 139 | box-shadow: 0 13px 27px -5px hsla(240, 30.1%, 28%, 0.25),0 8px 16px -8px hsla(0, 0%, 0%, 0.3),0 -6px 16px -6px hsla(0, 0%, 0%, 0.03); 140 | } 141 | 142 | /* Custom Pre Code Box */ 143 | 144 | pre.code-box { 145 | box-shadow: 0 0 0 1px hsla(240, 30.1%, 28%, 0.05),0 2px 5px 0 hsla(240, 30.1%, 28%, 0.1),0 1px 1px 0 hsla(0, 0%, 0%, 0.07); 146 | background: white; 147 | padding: 18px; 148 | } 149 | 150 | /* Image gallery */ 151 | .content-section-images { 152 | padding: 50px 0; 153 | } 154 | 155 | .content-section-images a { 156 | color: hsla(0, 0%, 0%, 1); 157 | } 158 | 159 | /* Footer */ 160 | footer { 161 | text-align: center; 162 | background: hsl(0, 0%, 0%); 163 | padding: 100px 0; 164 | margin-top: 100px; 165 | } 166 | 167 | footer p { 168 | color: hsla(0, 100%, 100%, 0.6); 169 | font-size: 14px; 170 | } 171 | 172 | footer a { 173 | color: hsla(0, 100%, 100%, 0.8); 174 | } 175 | 176 | .sponsor { 177 | display: inline; 178 | width: 230px; 179 | height: auto; 180 | } 181 | 182 | .sponsor:hover { 183 | opacity: 0.6; 184 | } 185 | 186 | .fastly { 187 | margin-top: -20px; 188 | } 189 | 190 | @media (max-width: 1000px) { 191 | header { 192 | text-align: center; 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /internal/image/vips/vips_test.go: -------------------------------------------------------------------------------- 1 | package vips_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "reflect" 8 | "runtime" 9 | 10 | "github.com/DMarby/picsum-photos/internal/cache/memory" 11 | "github.com/DMarby/picsum-photos/internal/image" 12 | "github.com/DMarby/picsum-photos/internal/image/vips" 13 | "github.com/DMarby/picsum-photos/internal/logger" 14 | "github.com/DMarby/picsum-photos/internal/storage/file" 15 | "github.com/DMarby/picsum-photos/internal/tracing/test" 16 | "go.uber.org/zap" 17 | 18 | "testing" 19 | ) 20 | 21 | var ( 22 | jpegFixture = fmt.Sprintf("../../../test/fixtures/image/complete_result_%s.jpg", runtime.GOOS) 23 | webpFixture = fmt.Sprintf("../../../test/fixtures/image/complete_result_%s.webp", runtime.GOOS) 24 | ) 25 | 26 | func TestVips(t *testing.T) { 27 | cancel, processor, buf, err := setup() 28 | if err != nil { 29 | t.Fatal(err) 30 | } 31 | defer cancel() 32 | defer processor.Shutdown() 33 | 34 | t.Run("Processor", func(t *testing.T) { 35 | t.Run("process image", func(t *testing.T) { 36 | _, err := processor.ProcessImage(context.Background(), image.NewTask("1", 500, 500, "testing", image.JPEG)) 37 | if err != nil { 38 | t.Error(err) 39 | } 40 | }) 41 | 42 | t.Run("process image handles errors", func(t *testing.T) { 43 | _, err := processor.ProcessImage(context.Background(), image.NewTask("foo", 500, 500, "testing", image.JPEG)) 44 | if err == nil || err.Error() != "error getting image from cache: Image does not exist" { 45 | t.Error() 46 | } 47 | }) 48 | 49 | t.Run("full test jpeg", func(t *testing.T) { 50 | resultFixture, _ := os.ReadFile(jpegFixture) 51 | testResult := fullTest(processor, buf, image.JPEG) 52 | if !reflect.DeepEqual(testResult, resultFixture) { 53 | t.Error("image data doesn't match") 54 | } 55 | }) 56 | 57 | t.Run("full test webp", func(t *testing.T) { 58 | resultFixture, _ := os.ReadFile(webpFixture) 59 | testResult := fullTest(processor, buf, image.WebP) 60 | if !reflect.DeepEqual(testResult, resultFixture) { 61 | t.Error("image data doesn't match") 62 | } 63 | }) 64 | }) 65 | } 66 | 67 | func BenchmarkVips(b *testing.B) { 68 | cancel, processor, buf, err := setup() 69 | if err != nil { 70 | b.Fatal(err) 71 | } 72 | defer cancel() 73 | defer processor.Shutdown() 74 | 75 | b.Run("full test jpeg", func(b *testing.B) { 76 | fullTest(processor, buf, image.JPEG) 77 | }) 78 | 79 | b.Run("full test webp", func(b *testing.B) { 80 | fullTest(processor, buf, image.WebP) 81 | }) 82 | } 83 | 84 | // Utility function for regenerating the fixtures 85 | func TestFixtures(t *testing.T) { 86 | if os.Getenv("GENERATE_FIXTURES") != "1" { 87 | t.SkipNow() 88 | } 89 | 90 | cancel, processor, buf, err := setup() 91 | if err != nil { 92 | t.Fatal(err) 93 | } 94 | 95 | defer cancel() 96 | defer processor.Shutdown() 97 | 98 | jpeg := fullTest(processor, buf, image.JPEG) 99 | os.WriteFile(jpegFixture, jpeg, 0644) 100 | 101 | webp := fullTest(processor, buf, image.WebP) 102 | os.WriteFile(webpFixture, webp, 0644) 103 | } 104 | 105 | func setup() (context.CancelFunc, *vips.Processor, []byte, error) { 106 | log := logger.New(zap.ErrorLevel) 107 | defer log.Sync() 108 | 109 | tracer := test.Tracer(log) 110 | 111 | ctx, cancel := context.WithCancel(context.Background()) 112 | storage, err := file.New("../../../test/fixtures/file") 113 | if err != nil { 114 | cancel() 115 | return nil, nil, nil, err 116 | } 117 | 118 | cache := image.NewCache(tracer, memory.New(), storage) 119 | 120 | processor, err := vips.New(ctx, log, tracer, 3, cache) 121 | if err != nil { 122 | cancel() 123 | return nil, nil, nil, err 124 | } 125 | 126 | buf, err := os.ReadFile("../../../test/fixtures/fixture.jpg") 127 | if err != nil { 128 | cancel() 129 | return nil, nil, nil, err 130 | } 131 | 132 | return cancel, processor, buf, nil 133 | } 134 | 135 | func fullTest(processor *vips.Processor, buf []byte, format image.OutputFormat) []byte { 136 | task := image.NewTask("1", 500, 500, "testing", format).Grayscale().Blur(5) 137 | imageBuffer, _ := processor.ProcessImage(context.Background(), task) 138 | return imageBuffer 139 | } 140 | -------------------------------------------------------------------------------- /cmd/picsum-photos/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "net" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | 12 | "github.com/DMarby/picsum-photos/internal/api" 13 | "github.com/DMarby/picsum-photos/internal/cmd" 14 | "github.com/DMarby/picsum-photos/internal/hmac" 15 | "github.com/DMarby/picsum-photos/internal/metrics" 16 | "github.com/DMarby/picsum-photos/internal/tracing/test" 17 | 18 | fileDatabase "github.com/DMarby/picsum-photos/internal/database/file" 19 | "github.com/DMarby/picsum-photos/internal/health" 20 | "github.com/DMarby/picsum-photos/internal/logger" 21 | 22 | "github.com/jamiealquiza/envy" 23 | "go.uber.org/automaxprocs/maxprocs" 24 | "go.uber.org/zap" 25 | ) 26 | 27 | // Comandline flags 28 | var ( 29 | // Global 30 | listen = flag.String("listen", "", "unix socket path") 31 | metricsListen = flag.String("metrics-listen", "127.0.0.1:8082", "metrics listen address") 32 | rootURL = flag.String("root-url", "https://picsum.photos", "root url") 33 | imageServiceURL = flag.String("image-service-url", "https://fastly.picsum.photos", "image service url") 34 | loglevel = zap.LevelFlag("log-level", zap.InfoLevel, "log level (default \"info\") (debug, info, warn, error, dpanic, panic, fatal)") 35 | 36 | // Database - File 37 | databaseFilePath = flag.String("database-file-path", "./test/fixtures/file/metadata.json", "path to the database file") 38 | 39 | // HMAC 40 | hmacKey = flag.String("hmac-key", "", "hmac key to use for authentication between services") 41 | ) 42 | 43 | func main() { 44 | ctx := context.Background() 45 | 46 | // Parse environment variables 47 | envy.Parse("PICSUM") 48 | 49 | // Parse commandline flags 50 | flag.Parse() 51 | 52 | // Initialize the logger 53 | log := logger.New(*loglevel) 54 | defer log.Sync() 55 | 56 | // Initialize tracing 57 | tracer := test.Tracer(log) 58 | 59 | // Set GOMAXPROCS 60 | maxprocs.Set(maxprocs.Logger(log.Infof)) 61 | 62 | // Set up context for shutting down 63 | shutdownCtx, shutdown := signal.NotifyContext(ctx, os.Interrupt, os.Kill, syscall.SIGTERM) 64 | defer shutdown() 65 | 66 | // Initialize the database 67 | database, err := fileDatabase.New(*databaseFilePath) 68 | if err != nil { 69 | log.Fatalf("error initializing database: %s", err) 70 | } 71 | 72 | // Initialize and start the health checker 73 | checkerCtx, checkerCancel := context.WithCancel(ctx) 74 | defer checkerCancel() 75 | 76 | checker := &health.Checker{ 77 | Ctx: checkerCtx, 78 | Database: database, 79 | Log: log, 80 | } 81 | go checker.Run() 82 | 83 | // Start and listen on http 84 | api := &api.API{ 85 | Database: database, 86 | Log: log, 87 | Tracer: tracer, 88 | RootURL: *rootURL, 89 | ImageServiceURL: *imageServiceURL, 90 | HandlerTimeout: cmd.HandlerTimeout, 91 | HMAC: &hmac.HMAC{ 92 | Key: []byte(*hmacKey), 93 | }, 94 | } 95 | router, err := api.Router() 96 | if err != nil { 97 | log.Fatalf("error initializing router: %s", err) 98 | } 99 | 100 | server := &http.Server{ 101 | Handler: router, 102 | ReadTimeout: cmd.ReadTimeout, 103 | WriteTimeout: cmd.WriteTimeout, 104 | ErrorLog: logger.NewHTTPErrorLog(log), 105 | } 106 | 107 | os.Remove(*listen) 108 | unixListener, err := net.Listen("unix", *listen) 109 | if err != nil { 110 | log.Fatalf("error creating unix socket listener: %s", err.Error()) 111 | } 112 | go func() { 113 | if err := server.Serve(unixListener); err != nil && err != http.ErrServerClosed { 114 | log.Errorf("error shutting down the http server: %s", err) 115 | } 116 | }() 117 | 118 | log.Infof("http server listening on %s", *listen) 119 | 120 | // Start the metrics http server 121 | go metrics.Serve(shutdownCtx, log, checker, *metricsListen) 122 | 123 | // Wait for shutdown 124 | <-shutdownCtx.Done() 125 | log.Infof("shutting down: %s", shutdownCtx.Err()) 126 | 127 | // Shut down http server 128 | serverCtx, serverCancel := context.WithTimeout(ctx, cmd.WriteTimeout) 129 | defer serverCancel() 130 | if err := server.Shutdown(serverCtx); err != nil { 131 | log.Warnf("error shutting down: %s", err) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /internal/api/image.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "expvar" 5 | "fmt" 6 | "math" 7 | "net/http" 8 | "net/url" 9 | "strconv" 10 | 11 | "github.com/DMarby/picsum-photos/internal/database" 12 | "github.com/DMarby/picsum-photos/internal/handler" 13 | "github.com/DMarby/picsum-photos/internal/params" 14 | "github.com/gorilla/mux" 15 | "github.com/twmb/murmur3" 16 | ) 17 | 18 | var ( 19 | imageRequests = expvar.NewMap("counter_labelmap_dimensions_image_requests_dimension") 20 | imageRequestsBlur = expvar.NewInt("image_requests_blur") 21 | imageRequestsGrayscale = expvar.NewInt("image_requests_grayscale") 22 | ) 23 | 24 | func (a *API) imageRedirectHandler(w http.ResponseWriter, r *http.Request) *handler.Error { 25 | // Get the path and query parameters 26 | p, err := params.GetParams(r) 27 | if err != nil { 28 | return handler.BadRequest(err.Error()) 29 | } 30 | 31 | // Get the image from the database 32 | vars := mux.Vars(r) 33 | imageID := vars["id"] 34 | image, handlerErr := a.getImage(r, imageID) 35 | if handlerErr != nil { 36 | return handlerErr 37 | } 38 | 39 | // Validate the params and redirect to the image service 40 | return a.validateAndRedirect(w, r, p, image) 41 | } 42 | 43 | func (a *API) randomImageRedirectHandler(w http.ResponseWriter, r *http.Request) *handler.Error { 44 | // Get the path and query parameters 45 | p, err := params.GetParams(r) 46 | if err != nil { 47 | return handler.BadRequest(err.Error()) 48 | } 49 | 50 | // Get a random image 51 | image, err := a.Database.GetRandom(r.Context()) 52 | if err != nil { 53 | a.logError(r, "error getting random image from database", err) 54 | return handler.InternalServerError() 55 | } 56 | 57 | // Validate the params and redirect to the image service 58 | return a.validateAndRedirect(w, r, p, image) 59 | } 60 | 61 | func (a *API) seedImageRedirectHandler(w http.ResponseWriter, r *http.Request) *handler.Error { 62 | // Get the path and query parameters 63 | p, err := params.GetParams(r) 64 | if err != nil { 65 | return handler.BadRequest(err.Error()) 66 | } 67 | 68 | // Get the image seed 69 | vars := mux.Vars(r) 70 | imageSeed := vars["seed"] 71 | 72 | image, handlerErr := a.getImageFromSeed(r, imageSeed) 73 | if handlerErr != nil { 74 | return handlerErr 75 | } 76 | 77 | // Validate the params and redirect to the image service 78 | return a.validateAndRedirect(w, r, p, image) 79 | } 80 | 81 | func (a *API) getImage(r *http.Request, imageID string) (*database.Image, *handler.Error) { 82 | databaseImage, err := a.Database.Get(r.Context(), imageID) 83 | if err != nil { 84 | if err == database.ErrNotFound { 85 | return nil, &handler.Error{Message: err.Error(), Code: http.StatusNotFound} 86 | } 87 | 88 | a.logError(r, "error getting image from database", err) 89 | return nil, handler.InternalServerError() 90 | } 91 | 92 | return databaseImage, nil 93 | } 94 | 95 | func (a *API) getImageFromSeed(r *http.Request, imageSeed string) (*database.Image, *handler.Error) { 96 | // Hash the input using murmur3 97 | murmurHash := murmur3.StringSum64(imageSeed) 98 | 99 | // Get a random image by the hash 100 | image, err := a.Database.GetRandomWithSeed(r.Context(), int64(murmurHash)) 101 | if err != nil { 102 | a.logError(r, "error getting random image from database", err) 103 | return nil, handler.InternalServerError() 104 | } 105 | 106 | return image, nil 107 | } 108 | 109 | func (a *API) validateAndRedirect(w http.ResponseWriter, r *http.Request, p *params.Params, image *database.Image) *handler.Error { 110 | if err := validateImageParams(p); err != nil { 111 | return handler.BadRequest(err.Error()) 112 | } 113 | 114 | width, height := getImageDimensions(p, image) 115 | 116 | w.Header().Set("Cache-Control", "private, no-cache, no-store, must-revalidate") 117 | w.Header()["Content-Type"] = nil 118 | 119 | path := fmt.Sprintf("/id/%s/%d/%d%s", image.ID, width, height, p.Extension) 120 | query := url.Values{} 121 | 122 | if p.Blur { 123 | query.Add("blur", strconv.Itoa(p.BlurAmount)) 124 | imageRequestsBlur.Add(1) 125 | } 126 | 127 | if p.Grayscale { 128 | query.Add("grayscale", "") 129 | imageRequestsGrayscale.Add(1) 130 | } 131 | 132 | url, err := params.HMAC(a.HMAC, path, query) 133 | if err != nil { 134 | return handler.InternalServerError() 135 | } 136 | 137 | imageRequests.Add(fmt.Sprintf("%0.f", math.Max(math.Round(float64(width)/500)*500, math.Round(float64(height)/500)*500)), 1) 138 | 139 | http.Redirect(w, r, fmt.Sprintf("%s%s", a.ImageServiceURL, url), http.StatusFound) 140 | 141 | return nil 142 | } 143 | -------------------------------------------------------------------------------- /cmd/image-service/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "net" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | 12 | "github.com/DMarby/picsum-photos/internal/cache/memory" 13 | "github.com/DMarby/picsum-photos/internal/cmd" 14 | "github.com/DMarby/picsum-photos/internal/health" 15 | "github.com/DMarby/picsum-photos/internal/hmac" 16 | "github.com/DMarby/picsum-photos/internal/image" 17 | "github.com/DMarby/picsum-photos/internal/image/vips" 18 | "github.com/DMarby/picsum-photos/internal/logger" 19 | "github.com/DMarby/picsum-photos/internal/metrics" 20 | "github.com/DMarby/picsum-photos/internal/storage/file" 21 | "github.com/DMarby/picsum-photos/internal/tracing/test" 22 | 23 | api "github.com/DMarby/picsum-photos/internal/imageapi" 24 | 25 | "github.com/jamiealquiza/envy" 26 | "go.uber.org/automaxprocs/maxprocs" 27 | "go.uber.org/zap" 28 | ) 29 | 30 | // Comandline flags 31 | var ( 32 | // Global 33 | listen = flag.String("listen", "", "unix socket path") 34 | metricsListen = flag.String("metrics-listen", "127.0.0.1:8083", "metrics listen address") 35 | loglevel = zap.LevelFlag("log-level", zap.InfoLevel, "log level (default \"info\") (debug, info, warn, error, dpanic, panic, fatal)") 36 | 37 | // Storage - File 38 | storagePath = flag.String("storage-path", "", "path to the storage directory") 39 | 40 | // HMAC 41 | hmacKey = flag.String("hmac-key", "", "hmac key to use for authentication between services") 42 | 43 | // Image processor 44 | workers = flag.Int("workers", 3, "worker queue concurrency") 45 | ) 46 | 47 | func main() { 48 | ctx := context.Background() 49 | 50 | // Parse environment variables 51 | envy.Parse("IMAGE") 52 | 53 | // Parse commandline flags 54 | flag.Parse() 55 | 56 | // Initialize the logger 57 | log := logger.New(*loglevel) 58 | defer log.Sync() 59 | 60 | // Set GOMAXPROCS 61 | maxprocs.Set(maxprocs.Logger(log.Infof)) 62 | 63 | // Set up context for shutting down 64 | shutdownCtx, shutdown := signal.NotifyContext(ctx, os.Interrupt, os.Kill, syscall.SIGTERM) 65 | defer shutdown() 66 | 67 | // Initialize tracing 68 | // tracerCtx, tracerCancel := context.WithCancel(ctx) 69 | // defer tracerCancel() 70 | 71 | // tracer, err := tracing.New(tracerCtx, log, "image-service") 72 | // if err != nil { 73 | // log.Fatalf("error initializing tracing: %s", err) 74 | // } 75 | // defer tracer.Shutdown(tracerCtx) 76 | tracer := test.Tracer(log) 77 | 78 | // Initialize the storage 79 | storage, err := file.New(*storagePath) 80 | if err != nil { 81 | log.Fatalf("error initializing storage: %s", err) 82 | } 83 | 84 | // Initialize the cache 85 | cache := memory.New() 86 | defer cache.Shutdown() 87 | 88 | // Initialize the image processor 89 | imageProcessor, err := vips.New(shutdownCtx, log, tracer, *workers, image.NewCache(tracer, cache, storage)) 90 | if err != nil { 91 | log.Fatalf("error initializing image processor %s", err.Error()) 92 | } 93 | 94 | // Initialize and start the health checker 95 | checker := &health.Checker{ 96 | Ctx: shutdownCtx, 97 | Storage: storage, 98 | Cache: cache, 99 | Log: log, 100 | } 101 | go checker.Run() 102 | 103 | // Start and listen on http 104 | api := &api.API{ 105 | ImageProcessor: imageProcessor, 106 | Log: log, 107 | Tracer: tracer, 108 | HandlerTimeout: cmd.HandlerTimeout, 109 | HMAC: &hmac.HMAC{ 110 | Key: []byte(*hmacKey), 111 | }, 112 | } 113 | server := &http.Server{ 114 | Handler: api.Router(), 115 | ReadTimeout: cmd.ReadTimeout, 116 | WriteTimeout: cmd.WriteTimeout, 117 | ErrorLog: logger.NewHTTPErrorLog(log), 118 | } 119 | 120 | os.Remove(*listen) 121 | unixListener, err := net.Listen("unix", *listen) 122 | if err != nil { 123 | log.Fatalf("error creating unix socket listener: %s", err.Error()) 124 | } 125 | go func() { 126 | if err := server.Serve(unixListener); err != nil && err != http.ErrServerClosed { 127 | log.Errorf("error shutting down the http server: %s", err) 128 | } 129 | }() 130 | 131 | log.Infof("http server listening on %s", *listen) 132 | 133 | // Start the metrics http server 134 | go metrics.Serve(shutdownCtx, log, checker, *metricsListen) 135 | 136 | // Wait for shutdown 137 | <-shutdownCtx.Done() 138 | log.Infof("shutting down: %s", shutdownCtx.Err()) 139 | 140 | // Shut down http server 141 | serverCtx, serverCancel := context.WithTimeout(context.Background(), cmd.WriteTimeout) 142 | defer serverCancel() 143 | if err := server.Shutdown(serverCtx); err != nil { 144 | log.Warnf("error shutting down: %s", err) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /internal/api/list.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "strconv" 8 | 9 | "github.com/DMarby/picsum-photos/internal/database" 10 | "github.com/DMarby/picsum-photos/internal/handler" 11 | "github.com/gorilla/mux" 12 | ) 13 | 14 | const ( 15 | // Default number of items per page 16 | defaultLimit = 30 17 | // Max number of items per page 18 | maxLimit = 100 19 | ) 20 | 21 | // ListImage contains metadata and download information about an image 22 | type ListImage struct { 23 | database.Image 24 | DownloadURL string `json:"download_url"` 25 | } 26 | 27 | // Returns info about an image 28 | func (a *API) infoHandler(w http.ResponseWriter, r *http.Request) *handler.Error { 29 | vars := mux.Vars(r) 30 | imageID := vars["id"] 31 | image, handlerErr := a.getImage(r, imageID) 32 | if handlerErr != nil { 33 | return handlerErr 34 | } 35 | 36 | listImage := a.getListImage(*image) 37 | 38 | w.Header().Set("Content-Type", "application/json") 39 | w.Header().Set("Cache-Control", "private, no-cache, no-store, must-revalidate") 40 | 41 | if err := json.NewEncoder(w).Encode(listImage); err != nil { 42 | a.logError(r, "error encoding image info", err) 43 | return handler.InternalServerError() 44 | } 45 | 46 | return nil 47 | } 48 | 49 | // Returns info about an image based on the seed 50 | func (a *API) infoSeedHandler(w http.ResponseWriter, r *http.Request) *handler.Error { 51 | vars := mux.Vars(r) 52 | imageSeed := vars["seed"] 53 | 54 | image, handlerErr := a.getImageFromSeed(r, imageSeed) 55 | if handlerErr != nil { 56 | return handlerErr 57 | } 58 | 59 | listImage := a.getListImage(*image) 60 | 61 | w.Header().Set("Content-Type", "application/json") 62 | w.Header().Set("Cache-Control", "private, no-cache, no-store, must-revalidate") 63 | 64 | if err := json.NewEncoder(w).Encode(listImage); err != nil { 65 | a.logError(r, "error encoding image info", err) 66 | return handler.InternalServerError() 67 | } 68 | 69 | return nil 70 | } 71 | 72 | // Paginated list, with `page` and `limit` query parameters 73 | func (a *API) listHandler(w http.ResponseWriter, r *http.Request) *handler.Error { 74 | limit := getLimit(r) 75 | page := getPage(r) 76 | 77 | offset := limit * (page - 1) 78 | 79 | databaseList, err := a.Database.List(r.Context(), offset, limit) 80 | if err != nil { 81 | a.logError(r, "error getting image list from database", err) 82 | return handler.InternalServerError() 83 | } 84 | 85 | list := []ListImage{} 86 | 87 | for _, image := range databaseList { 88 | list = append(list, a.getListImage(image)) 89 | } 90 | 91 | w.Header().Set("Content-Type", "application/json") 92 | w.Header().Set("Cache-Control", "private, no-cache, no-store, must-revalidate") 93 | 94 | // If we've ran out of items, don't include the next page in the Link header 95 | end := len(list) < limit 96 | w.Header().Set("Access-Control-Expose-Headers", "Link") 97 | w.Header().Set("Link", a.getLinkHeader(page, limit, end)) 98 | 99 | if err := json.NewEncoder(w).Encode(list); err != nil { 100 | a.logError(r, "error encoding image list", err) 101 | return handler.InternalServerError() 102 | } 103 | 104 | return nil 105 | } 106 | 107 | func getLimit(r *http.Request) int { 108 | limit, err := strconv.Atoi(r.URL.Query().Get("limit")) 109 | if err != nil || limit < 1 { 110 | limit = defaultLimit 111 | } 112 | 113 | if limit > maxLimit { 114 | limit = maxLimit 115 | } 116 | 117 | return limit 118 | } 119 | 120 | func getPage(r *http.Request) int { 121 | page, err := strconv.Atoi(r.URL.Query().Get("page")) 122 | if err != nil || page < 1 { 123 | page = 1 124 | } 125 | 126 | return page 127 | } 128 | 129 | func (a *API) getLinkHeader(page, limit int, end bool) string { 130 | // This will return a next even if there's only enough items for a single page, but lets ignore that for now 131 | if page == 1 { 132 | return fmt.Sprintf("<%s/v2/list?page=%d&limit=%d>; rel=\"next\"", a.RootURL, page+1, limit) 133 | } 134 | 135 | if end { 136 | return fmt.Sprintf("<%s/v2/list?page=%d&limit=%d>; rel=\"prev\"", a.RootURL, page-1, limit) 137 | } 138 | 139 | return fmt.Sprintf("<%s/v2/list?page=%d&limit=%d>; rel=\"prev\", <%s/v2/list?page=%d&limit=%d>; rel=\"next\"", 140 | a.RootURL, page-1, limit, a.RootURL, page+1, limit, 141 | ) 142 | } 143 | 144 | func (a *API) getListImage(image database.Image) ListImage { 145 | return ListImage{ 146 | Image: database.Image{ 147 | ID: image.ID, 148 | Author: image.Author, 149 | Width: image.Width, 150 | Height: image.Height, 151 | URL: image.URL, 152 | }, 153 | DownloadURL: fmt.Sprintf("%s/id/%s/%d/%d", a.RootURL, image.ID, image.Width, image.Height), 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /internal/image/vips/vips.go: -------------------------------------------------------------------------------- 1 | package vips 2 | 3 | import ( 4 | "context" 5 | "expvar" 6 | "fmt" 7 | "math" 8 | 9 | "github.com/DMarby/picsum-photos/internal/image" 10 | "github.com/DMarby/picsum-photos/internal/logger" 11 | "github.com/DMarby/picsum-photos/internal/queue" 12 | "github.com/DMarby/picsum-photos/internal/tracing" 13 | "github.com/DMarby/picsum-photos/internal/vips" 14 | "go.opentelemetry.io/otel/attribute" 15 | "go.opentelemetry.io/otel/trace" 16 | ) 17 | 18 | // Processor is an image processor that uses vips to process images 19 | type Processor struct { 20 | queue *queue.Queue 21 | tracer *tracing.Tracer 22 | } 23 | 24 | var ( 25 | queueSize = expvar.NewInt("gauge_image_processor_queue_size") 26 | processedImages = expvar.NewMap("counter_labelmap_dimensions_image_processor_processed_images") 27 | ) 28 | 29 | // New initializes a new processor instance 30 | func New(ctx context.Context, log *logger.Logger, tracer *tracing.Tracer, workers int, cache *image.Cache) (*Processor, error) { 31 | err := vips.Initialize(log) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | workerQueue := queue.New(ctx, workers, taskProcessor(cache, tracer)) 37 | instance := &Processor{ 38 | queue: workerQueue, 39 | tracer: tracer, 40 | } 41 | 42 | go workerQueue.Run() 43 | log.Infof("starting vips worker queue with %d workers", workers) 44 | 45 | return instance, err 46 | } 47 | 48 | // ProcessImage loads an image from a byte buffer, processes it, and returns a buffer containing the processed image 49 | func (p *Processor) ProcessImage(ctx context.Context, task *image.Task) (processedImage []byte, err error) { 50 | ctx, span := p.tracer.Start( 51 | ctx, 52 | "image.ProcessImage", 53 | trace.WithAttributes(attribute.Int("width", task.Width)), 54 | trace.WithAttributes(attribute.Int("height", task.Height)), 55 | trace.WithAttributes(attribute.Int("format", int(task.OutputFormat))), 56 | ) 57 | defer span.End() 58 | 59 | queueSize.Add(1) 60 | defer queueSize.Add(-1) 61 | 62 | defer processedImages.Add(fmt.Sprintf("%0.f", math.Max(math.Round(float64(task.Width)/500)*500, math.Round(float64(task.Height)/500)*500)), 1) 63 | 64 | result, err := p.queue.Process(ctx, task) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | image, ok := result.([]byte) 70 | if !ok { 71 | return nil, fmt.Errorf("error getting result") 72 | } 73 | 74 | return image, nil 75 | } 76 | 77 | func taskProcessor(cache *image.Cache, tracer *tracing.Tracer) func(ctx context.Context, data interface{}) (interface{}, error) { 78 | return func(ctx context.Context, data interface{}) (interface{}, error) { 79 | task, ok := data.(*image.Task) 80 | if !ok { 81 | return nil, fmt.Errorf("invalid data") 82 | } 83 | 84 | // Use a pre-processed source image closer to the desired size then the original 85 | imageKey := task.ImageID 86 | width := math.Ceil(float64(task.Width)/500) * 500 87 | height := math.Ceil(float64(task.Height)/500) * 500 88 | size := math.Max(width, height) 89 | if size <= 4500 { // Files larger then 4500 doesn't have a suffix 90 | imageKey = fmt.Sprintf("%s_%0.f", task.ImageID, size) 91 | } 92 | 93 | imageBuffer, err := cache.Get(ctx, imageKey) 94 | if err != nil { 95 | return nil, fmt.Errorf("error getting image from cache: %s", err) 96 | } 97 | 98 | _, span := tracer.Start(ctx, "image.resizeImage") 99 | processedImage, err := resizeImage(imageBuffer, task.Width, task.Height) 100 | span.End() 101 | if err != nil { 102 | return nil, err 103 | } 104 | 105 | if task.ApplyBlur { 106 | _, span := tracer.Start(ctx, "image.blur") 107 | processedImage, err = processedImage.blur(task.BlurAmount) 108 | span.End() 109 | if err != nil { 110 | return nil, err 111 | } 112 | } 113 | 114 | if task.ApplyGrayscale { 115 | _, span := tracer.Start(ctx, "image.grayscale") 116 | processedImage, err = processedImage.grayscale() 117 | span.End() 118 | if err != nil { 119 | return nil, err 120 | } 121 | } 122 | 123 | processedImage.setUserComment(task.UserComment) 124 | 125 | var buffer []byte 126 | switch task.OutputFormat { 127 | case image.JPEG: 128 | _, span := tracer.Start(ctx, "image.saveToJpegBuffer") 129 | buffer, err = processedImage.saveToJpegBuffer() 130 | span.End() 131 | case image.WebP: 132 | _, span := tracer.Start(ctx, "image.saveToWebPBuffer") 133 | buffer, err = processedImage.saveToWebPBuffer() 134 | span.End() 135 | } 136 | 137 | if err != nil { 138 | return nil, err 139 | } 140 | 141 | return buffer, nil 142 | } 143 | } 144 | 145 | // Shutdown shuts down the image processor and deinitialises vips 146 | func (p *Processor) Shutdown() { 147 | vips.Shutdown() 148 | } 149 | -------------------------------------------------------------------------------- /internal/vips/vips.go: -------------------------------------------------------------------------------- 1 | package vips 2 | 3 | /* 4 | #cgo pkg-config: vips 5 | #include "vips-bridge.h" 6 | */ 7 | import "C" 8 | 9 | import ( 10 | "fmt" 11 | "runtime" 12 | "sync" 13 | "unsafe" 14 | 15 | "github.com/DMarby/picsum-photos/internal/logger" 16 | ) 17 | 18 | // Image is a representation of the *C.VipsImage type 19 | type Image *C.VipsImage 20 | 21 | var ( 22 | once sync.Once 23 | log *logger.Logger 24 | ) 25 | 26 | // Initialize libvips if it's not already started 27 | func Initialize(logger *logger.Logger) error { 28 | var err error 29 | 30 | once.Do(func() { 31 | // vips_init needs to run on the main thread 32 | runtime.LockOSThread() 33 | defer runtime.UnlockOSThread() 34 | 35 | if C.VIPS_MAJOR_VERSION != 8 || C.VIPS_MINOR_VERSION < 6 { 36 | err = fmt.Errorf("unsupported libvips version") 37 | } 38 | 39 | errorCode := C.vips_init(C.CString("picsum-photos")) 40 | if errorCode != 0 { 41 | err = fmt.Errorf("unable to initialize vips: %v", catchVipsError()) 42 | return 43 | } 44 | 45 | // Catch vips logging/warnings 46 | log = logger 47 | C.setup_logging() 48 | 49 | // Set concurrency to 1 so that each job only uses one thread 50 | C.vips_concurrency_set(1) 51 | 52 | // Disable the cache 53 | C.vips_cache_set_max_mem(0) 54 | C.vips_cache_set_max(0) 55 | 56 | // Disable SIMD vector instructions due to g_object_unref segfault 57 | C.vips_vector_set_enabled(C.int(0)) 58 | }) 59 | 60 | return err 61 | } 62 | 63 | // log_callback catches logs from libvips 64 | // 65 | //export log_callback 66 | func log_callback(message *C.char) { 67 | log.Debug(C.GoString(message)) 68 | } 69 | 70 | // Shutdown libvips 71 | func Shutdown() { 72 | C.vips_shutdown() 73 | } 74 | 75 | // PrintDebugInfo prints libvips debug info to stdout 76 | func PrintDebugInfo() { 77 | C.vips_object_print_all() 78 | } 79 | 80 | // catchVipsError returns the vips error buffer as an error 81 | func catchVipsError() error { 82 | defer C.vips_error_clear() 83 | 84 | s := C.GoString(C.vips_error_buffer()) 85 | return fmt.Errorf("%s", s) 86 | } 87 | 88 | // ResizeImage loads an image from a buffer and resizes it. 89 | func ResizeImage(buffer []byte, width int, height int) (Image, error) { 90 | if len(buffer) == 0 { 91 | return nil, fmt.Errorf("empty buffer") 92 | } 93 | 94 | imageBuffer := unsafe.Pointer(&buffer[0]) 95 | imageBufferSize := C.size_t(len(buffer)) 96 | 97 | var image *C.VipsImage 98 | 99 | errCode := C.resize_image(imageBuffer, imageBufferSize, &image, C.int(width), C.int(height), C.VIPS_INTERESTING_CENTRE) 100 | 101 | // Prevent buffer from being garbage collected until after resize_image has been called 102 | runtime.KeepAlive(buffer) 103 | 104 | if errCode != 0 { 105 | return nil, fmt.Errorf("error processing image from buffer %s", catchVipsError()) 106 | } 107 | 108 | return image, nil 109 | } 110 | 111 | // SaveToJpegBuffer saves an image as JPEG to a buffer 112 | func SaveToJpegBuffer(image Image) ([]byte, error) { 113 | defer UnrefImage(image) 114 | 115 | var bufferPointer unsafe.Pointer 116 | bufferLength := C.size_t(0) 117 | 118 | err := C.save_image_to_jpeg_buffer(image, &bufferPointer, &bufferLength) 119 | 120 | if err != 0 { 121 | return nil, fmt.Errorf("error saving to jpeg buffer %s", catchVipsError()) 122 | } 123 | 124 | buffer := C.GoBytes(bufferPointer, C.int(bufferLength)) 125 | 126 | C.g_free(C.gpointer(bufferPointer)) 127 | 128 | return buffer, nil 129 | } 130 | 131 | // SaveToWebPBuffer saves an image as WebP to a buffer 132 | func SaveToWebPBuffer(image Image) ([]byte, error) { 133 | defer UnrefImage(image) 134 | 135 | var bufferPointer unsafe.Pointer 136 | bufferLength := C.size_t(0) 137 | 138 | err := C.save_image_to_webp_buffer(image, &bufferPointer, &bufferLength) 139 | 140 | if err != 0 { 141 | return nil, fmt.Errorf("error saving to webp buffer %s", catchVipsError()) 142 | } 143 | 144 | buffer := C.GoBytes(bufferPointer, C.int(bufferLength)) 145 | 146 | C.g_free(C.gpointer(bufferPointer)) 147 | 148 | return buffer, nil 149 | } 150 | 151 | // Grayscale converts an image to grayscale 152 | func Grayscale(image Image) (Image, error) { 153 | defer UnrefImage(image) 154 | 155 | var result *C.VipsImage 156 | 157 | err := C.change_colorspace(image, &result, C.VIPS_INTERPRETATION_B_W) 158 | 159 | if err != 0 { 160 | return nil, fmt.Errorf("error changing image colorspace %s", catchVipsError()) 161 | } 162 | 163 | return result, nil 164 | } 165 | 166 | // Blur applies gaussian blur to an image 167 | func Blur(image Image, blur int) (Image, error) { 168 | defer UnrefImage(image) 169 | 170 | var result *C.VipsImage 171 | 172 | err := C.blur_image(image, &result, C.double(blur)) 173 | 174 | if err != 0 { 175 | return nil, fmt.Errorf("error applying blur to image %s", catchVipsError()) 176 | } 177 | 178 | return result, nil 179 | } 180 | 181 | // SetUserComment sets the UserComment field in the exif metadata for an image 182 | func SetUserComment(image Image, comment string) { 183 | C.set_user_comment(image, C.CString(comment)) 184 | } 185 | 186 | // UnrefImage unrefs an image object 187 | func UnrefImage(image Image) { 188 | C.g_object_unref(C.gpointer(image)) 189 | } 190 | 191 | // NewEmptyImage returns an empty image object 192 | func NewEmptyImage() Image { 193 | return C.vips_image_new() 194 | } 195 | -------------------------------------------------------------------------------- /internal/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "io/fs" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/DMarby/picsum-photos/internal/handler" 9 | "github.com/DMarby/picsum-photos/internal/hmac" 10 | "github.com/DMarby/picsum-photos/internal/tracing" 11 | "github.com/DMarby/picsum-photos/internal/web" 12 | "github.com/rs/cors" 13 | 14 | "github.com/DMarby/picsum-photos/internal/database" 15 | "github.com/DMarby/picsum-photos/internal/logger" 16 | "github.com/gorilla/mux" 17 | 18 | _ "embed" 19 | ) 20 | 21 | // API is a http api 22 | type API struct { 23 | Database database.Provider 24 | Log *logger.Logger 25 | Tracer *tracing.Tracer 26 | RootURL string 27 | ImageServiceURL string 28 | HandlerTimeout time.Duration 29 | HMAC *hmac.HMAC 30 | } 31 | 32 | // Utility methods for logging 33 | func (a *API) logError(r *http.Request, message string, err error) { 34 | a.Log.Errorw(message, handler.LogFields(r, "error", err)...) 35 | } 36 | 37 | // Router returns a http router 38 | func (a *API) Router() (http.Handler, error) { 39 | router := mux.NewRouter() 40 | 41 | router.NotFoundHandler = handler.Handler(a.notFoundHandler) 42 | 43 | // Redirect trailing slashes 44 | router.StrictSlash(true) 45 | 46 | // Image list 47 | router.Handle("/v2/list", handler.Handler(a.listHandler)).Methods("GET").Name("api.list") 48 | 49 | // Query parameters: 50 | // ?page={page} - What page to display 51 | // ?limit={limit} - How many entries to display per page 52 | 53 | // Image routes 54 | oldRouter := router.PathPrefix("").Subrouter() 55 | oldRouter.Use(a.deprecatedParams) 56 | 57 | oldRouter.Handle("/{size:[0-9]+}{extension:(?:\\..*)?}", handler.Handler(a.randomImageRedirectHandler)).Methods("GET").Name("api.randomImageRedirect") 58 | oldRouter.Handle("/{width:[0-9]+}/{height:[0-9]+}{extension:(?:\\..*)?}", handler.Handler(a.randomImageRedirectHandler)).Methods("GET").Name("api.randomImageRedirect") 59 | 60 | // Image by ID routes 61 | router.Handle("/id/{id}/{size:[0-9]+}{extension:(?:\\..*)?}", handler.Handler(a.imageRedirectHandler)).Methods("GET").Name("api.imageRedirect") 62 | router.Handle("/id/{id}/{width:[0-9]+}/{height:[0-9]+}{extension:(?:\\..*)?}", handler.Handler(a.imageRedirectHandler)).Methods("GET").Name("api.imageRedirect") 63 | 64 | // Image info routes 65 | router.Handle("/id/{id}/info", handler.Handler(a.infoHandler)).Methods("GET").Name("api.info") 66 | router.Handle("/seed/{seed}/info", handler.Handler(a.infoSeedHandler)).Methods("GET").Name("api.infoSeed") 67 | 68 | // Image by seed routes 69 | router.Handle("/seed/{seed}/{size:[0-9]+}{extension:(?:\\..*)?}", handler.Handler(a.seedImageRedirectHandler)).Methods("GET").Name("api.seedImageRedirect") 70 | router.Handle("/seed/{seed}/{width:[0-9]+}/{height:[0-9]+}{extension:(?:\\..*)?}", handler.Handler(a.seedImageRedirectHandler)).Methods("GET").Name("api.seedImageRedirect") 71 | 72 | // Query parameters: 73 | // ?grayscale - Grayscale the image 74 | // ?blur - Blur the image 75 | // ?blur={amount} - Blur the image by {amount} 76 | 77 | // Deprecated query parameters: 78 | // ?image={id} - Get image by id 79 | 80 | // Deprecated routes 81 | router.Handle("/list", handler.Handler(a.deprecatedListHandler)).Methods("GET").Name("api.deprecatedList") 82 | router.Handle("/g/{size:[0-9]+}{extension:(?:\\..*)?}", handler.Handler(a.deprecatedImageHandler)).Methods("GET").Name("api.deprecatedImage") 83 | router.Handle("/g/{width:[0-9]+}/{height:[0-9]+}{extension:(?:\\..*)?}", handler.Handler(a.deprecatedImageHandler)).Methods("GET").Name("api.deprecatedImage") 84 | 85 | // Static files 86 | staticFS, err := fs.Sub(web.Static, "embed") 87 | if err != nil { 88 | return nil, err 89 | } 90 | fileServer := http.FileServer(http.FS(staticFS)) 91 | 92 | router.HandleFunc("/", serveFile(fileServer, "/")).Name("api.serveFile") 93 | router.HandleFunc("/images", serveFile(fileServer, "/images.html")).Name("api.serveFile") 94 | router.HandleFunc("/favicon.ico", serveFile(fileServer, "/favicon.ico")).Name("api.serveFile") 95 | router.HandleFunc("/robots.txt", serveFile(fileServer, "/robots.txt")).Name("api.serveFile") 96 | router.PathPrefix("/assets/").HandlerFunc(fileHeaders(fileServer.ServeHTTP)).Name("api.serveFile") 97 | 98 | // Set up handlers 99 | cors := cors.New(cors.Options{ 100 | AllowedMethods: []string{"GET"}, 101 | AllowedOrigins: []string{"*"}, 102 | }) 103 | 104 | httpHandler := cors.Handler(router) 105 | httpHandler = handler.Recovery(a.Log, httpHandler) 106 | httpHandler = http.TimeoutHandler(httpHandler, a.HandlerTimeout, "Something went wrong. Timed out.") 107 | httpHandler = handler.Logger(a.Log, httpHandler) 108 | 109 | routeMatcher := &handler.MuxRouteMatcher{Router: router} 110 | httpHandler = handler.Tracer(a.Tracer, httpHandler, routeMatcher) 111 | httpHandler = handler.Metrics(httpHandler, routeMatcher) 112 | 113 | return httpHandler, nil 114 | } 115 | 116 | // Handle not found errors 117 | var notFoundError = &handler.Error{ 118 | Message: "page not found", 119 | Code: http.StatusNotFound, 120 | } 121 | 122 | func (a *API) notFoundHandler(w http.ResponseWriter, r *http.Request) *handler.Error { 123 | return notFoundError 124 | } 125 | 126 | // Set headers for static file handlers 127 | func fileHeaders(handler func(w http.ResponseWriter, r *http.Request)) func(w http.ResponseWriter, r *http.Request) { 128 | return func(w http.ResponseWriter, r *http.Request) { 129 | w.Header().Set("Cache-Control", "public, max-age=7200, stale-while-revalidate=60, stale-if-error=43200") 130 | handler(w, r) 131 | } 132 | } 133 | 134 | // Serve a static file 135 | func serveFile(h http.Handler, name string) func(w http.ResponseWriter, r *http.Request) { 136 | return fileHeaders(func(w http.ResponseWriter, r *http.Request) { 137 | r.URL.Path = name 138 | h.ServeHTTP(w, r) 139 | }) 140 | } 141 | -------------------------------------------------------------------------------- /internal/web/embed/images.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Lorem Picsum - Images 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 39 | 40 | 41 |
42 |
43 |
44 | 45 |

Lorem Picsum

46 |

The Lorem Ipsum for photos.

47 |
48 |
49 | 50 |
51 |
52 |
53 | 54 |
55 |
56 |
57 |
58 |

Image Gallery

59 |

Here you can view all the images Lorem Picsum provides.

60 |

Get a specific image by adding /id/{image} to the start of the url.

61 |
https://picsum.photos/id/1/200/300
62 |

More detailed instructions can be found on the main page.

63 |
64 |
65 |
66 |
67 | 68 |
69 |
70 | 71 |
72 |
73 | 76 | 79 |
80 |
81 |
82 | 83 | 104 | 105 | 121 | 122 | 123 | -------------------------------------------------------------------------------- /internal/vips/vips_test.go: -------------------------------------------------------------------------------- 1 | package vips_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "reflect" 7 | "runtime" 8 | "strings" 9 | 10 | "github.com/DMarby/picsum-photos/internal/logger" 11 | "github.com/DMarby/picsum-photos/internal/vips" 12 | "go.uber.org/zap" 13 | 14 | "testing" 15 | ) 16 | 17 | func TestVips(t *testing.T) { 18 | imageBuffer := setup(t) 19 | defer vips.Shutdown() 20 | 21 | t.Run("SaveToJpegBuffer", func(t *testing.T) { 22 | t.Run("saves an image to buffer", func(t *testing.T) { 23 | _, err := vips.SaveToJpegBuffer(resizeImage(t, imageBuffer)) 24 | if err != nil { 25 | t.Error(err) 26 | } 27 | }) 28 | 29 | t.Run("errors on an invalid image", func(t *testing.T) { 30 | _, err := vips.SaveToJpegBuffer(vips.NewEmptyImage()) 31 | if err == nil || !strings.Contains(err.Error(), "error saving to jpeg buffer") || !strings.Contains(err.Error(), "vips_image_pio_input: no image data") { 32 | t.Error(err) 33 | } 34 | }) 35 | }) 36 | 37 | t.Run("SaveToWebPBuffer", func(t *testing.T) { 38 | t.Run("saves an image to buffer", func(t *testing.T) { 39 | _, err := vips.SaveToWebPBuffer(resizeImage(t, imageBuffer)) 40 | if err != nil { 41 | t.Error(err) 42 | } 43 | }) 44 | 45 | t.Run("errors on an invalid image", func(t *testing.T) { 46 | _, err := vips.SaveToWebPBuffer(vips.NewEmptyImage()) 47 | if err == nil || !strings.Contains(err.Error(), "error saving to webp buffer") || !strings.Contains(err.Error(), "vips_image_pio_input: no image data") { 48 | t.Error(err) 49 | } 50 | }) 51 | }) 52 | 53 | t.Run("ResizeImage", func(t *testing.T) { 54 | t.Run("loads and resizes an image as jpeg", func(t *testing.T) { 55 | image, err := vips.ResizeImage(imageBuffer, 500, 500) 56 | if err != nil { 57 | t.Error(err) 58 | } 59 | 60 | buf, _ := vips.SaveToJpegBuffer(image) 61 | resultFixture := readFixture("resize", "jpg") 62 | if !reflect.DeepEqual(buf, resultFixture) { 63 | t.Error("image data doesn't match") 64 | } 65 | }) 66 | 67 | t.Run("loads and resizes an image as webp", func(t *testing.T) { 68 | image, err := vips.ResizeImage(imageBuffer, 500, 500) 69 | if err != nil { 70 | t.Error(err) 71 | } 72 | 73 | buf, _ := vips.SaveToWebPBuffer(image) 74 | resultFixture := readFixture("resize", "webp") 75 | if !reflect.DeepEqual(buf, resultFixture) { 76 | t.Error("image data doesn't match") 77 | } 78 | }) 79 | 80 | t.Run("errors when given an empty buffer", func(t *testing.T) { 81 | var buf []byte 82 | _, err := vips.ResizeImage(buf, 500, 500) 83 | if err == nil || err.Error() != "empty buffer" { 84 | t.Error(err) 85 | } 86 | }) 87 | 88 | t.Run("errors when given an invalid image", func(t *testing.T) { 89 | _, err := vips.ResizeImage(make([]byte, 5), 500, 500) 90 | if err == nil || err.Error() != "error processing image from buffer VipsForeignLoad: buffer is not in a known format\n" { 91 | t.Error(err) 92 | } 93 | }) 94 | }) 95 | 96 | t.Run("Grayscale", func(t *testing.T) { 97 | t.Run("converts an image to grayscale as jpeg", func(t *testing.T) { 98 | image, err := vips.Grayscale(resizeImage(t, imageBuffer)) 99 | if err != nil { 100 | t.Error(err) 101 | } 102 | 103 | buf, _ := vips.SaveToJpegBuffer(image) 104 | resultFixture := readFixture("grayscale", "jpg") 105 | if !reflect.DeepEqual(buf, resultFixture) { 106 | t.Error("image data doesn't match") 107 | } 108 | }) 109 | 110 | t.Run("converts an image to grayscale as webp", func(t *testing.T) { 111 | image, err := vips.Grayscale(resizeImage(t, imageBuffer)) 112 | if err != nil { 113 | t.Error(err) 114 | } 115 | 116 | buf, _ := vips.SaveToWebPBuffer(image) 117 | resultFixture := readFixture("grayscale", "webp") 118 | if !reflect.DeepEqual(buf, resultFixture) { 119 | t.Error("image data doesn't match") 120 | } 121 | }) 122 | 123 | t.Run("errors when given an invalid image", func(t *testing.T) { 124 | _, err := vips.Grayscale(vips.NewEmptyImage()) 125 | if err == nil || err.Error() != "error changing image colorspace vips_image_pio_input: no image data\n" { 126 | t.Error(err) 127 | } 128 | }) 129 | }) 130 | 131 | t.Run("Blur", func(t *testing.T) { 132 | t.Run("blurs an image as jpeg", func(t *testing.T) { 133 | image, err := vips.Blur(resizeImage(t, imageBuffer), 5) 134 | if err != nil { 135 | t.Error(err) 136 | } 137 | 138 | buf, _ := vips.SaveToJpegBuffer(image) 139 | resultFixture := readFixture("blur", "jpg") 140 | if !reflect.DeepEqual(buf, resultFixture) { 141 | t.Error("image data doesn't match") 142 | } 143 | }) 144 | 145 | t.Run("blurs an image as webp", func(t *testing.T) { 146 | image, err := vips.Blur(resizeImage(t, imageBuffer), 5) 147 | if err != nil { 148 | t.Error(err) 149 | } 150 | 151 | buf, _ := vips.SaveToWebPBuffer(image) 152 | resultFixture := readFixture("blur", "webp") 153 | if !reflect.DeepEqual(buf, resultFixture) { 154 | t.Error("image data doesn't match") 155 | } 156 | }) 157 | 158 | t.Run("errors when given an invalid image", func(t *testing.T) { 159 | _, err := vips.Blur(vips.NewEmptyImage(), 5) 160 | if err == nil || err.Error() != "error applying blur to image vips_image_pio_input: no image data\n" { 161 | t.Error(err) 162 | } 163 | }) 164 | }) 165 | } 166 | 167 | // Utility function for regenerating the fixtures 168 | func TestFixtures(t *testing.T) { 169 | if os.Getenv("GENERATE_FIXTURES") != "1" { 170 | t.SkipNow() 171 | } 172 | 173 | imageBuffer := setup(t) 174 | defer vips.Shutdown() 175 | 176 | // Resize 177 | image, _ := vips.ResizeImage(imageBuffer, 500, 500) 178 | resizeJpeg, _ := vips.SaveToJpegBuffer(image) 179 | os.WriteFile(fixturePath("resize", "jpg"), resizeJpeg, 0644) 180 | 181 | image, _ = vips.ResizeImage(imageBuffer, 500, 500) 182 | resizeWebP, _ := vips.SaveToWebPBuffer(image) 183 | os.WriteFile(fixturePath("resize", "webp"), resizeWebP, 0644) 184 | 185 | // Grayscale 186 | image, _ = vips.Grayscale(resizeImage(t, imageBuffer)) 187 | grayscaleJpeg, _ := vips.SaveToJpegBuffer(image) 188 | os.WriteFile(fixturePath("grayscale", "jpg"), grayscaleJpeg, 0644) 189 | 190 | image, _ = vips.Grayscale(resizeImage(t, imageBuffer)) 191 | grayscaleWebP, _ := vips.SaveToWebPBuffer(image) 192 | os.WriteFile(fixturePath("grayscale", "webp"), grayscaleWebP, 0644) 193 | 194 | // Blur 195 | image, _ = vips.Blur(resizeImage(t, imageBuffer), 5) 196 | blurJpeg, _ := vips.SaveToJpegBuffer(image) 197 | os.WriteFile(fixturePath("blur", "jpg"), blurJpeg, 0644) 198 | 199 | image, _ = vips.Blur(resizeImage(t, imageBuffer), 5) 200 | blurWebP, _ := vips.SaveToWebPBuffer(image) 201 | os.WriteFile(fixturePath("blur", "webp"), blurWebP, 0644) 202 | } 203 | 204 | func setup(t *testing.T) []byte { 205 | log := logger.New(zap.FatalLevel) 206 | defer log.Sync() 207 | 208 | err := vips.Initialize(log) 209 | if err != nil { 210 | t.Fatal(err) 211 | } 212 | 213 | imageBuffer, err := os.ReadFile("../../test/fixtures/fixture.jpg") 214 | if err != nil { 215 | t.Fatal(err) 216 | } 217 | 218 | return imageBuffer 219 | } 220 | 221 | func resizeImage(t *testing.T, imageBuffer []byte) vips.Image { 222 | resizedImage, err := vips.ResizeImage(imageBuffer, 500, 500) 223 | if err != nil { 224 | t.Fatal(err) 225 | } 226 | 227 | vips.SetUserComment(resizedImage, "Test") 228 | 229 | return resizedImage 230 | } 231 | 232 | func readFixture(fixtureName string, extension string) []byte { 233 | fixture, _ := os.ReadFile(fixturePath(fixtureName, extension)) 234 | return fixture 235 | } 236 | func fixturePath(fixtureName string, extension string) string { 237 | return fmt.Sprintf("../../test/fixtures/vips/%s_result_%s.%s", fixtureName, runtime.GOOS, extension) 238 | } 239 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "picsum.photos"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | }; 8 | 9 | outputs = inputs@{ 10 | self, 11 | nixpkgs, 12 | flake-utils, 13 | ... 14 | }: 15 | flake-utils.lib.eachSystem [ 16 | "x86_64-linux" 17 | "aarch64-linux" 18 | "aarch64-darwin" 19 | ] (system: 20 | let pkgs = nixpkgs.legacyPackages.${system}; in { 21 | packages = rec { 22 | default = everything; 23 | 24 | everything = pkgs.symlinkJoin { 25 | name = "picsum-photos-composite"; 26 | paths = [ 27 | picsum-photos 28 | image-service 29 | ]; 30 | }; 31 | 32 | picsum-photos = pkgs.buildGo122Module { 33 | name = "picsum-photos"; 34 | src = ./.; 35 | CGO_ENABLED = 0; 36 | subPackages = ["cmd/picsum-photos"]; 37 | doCheck = false; # Prevent make test from being ran 38 | vendorHash = (pkgs.lib.fileContents ./go.mod.sri); 39 | nativeBuildInputs = with pkgs; [ 40 | tailwindcss 41 | ]; 42 | preBuild = '' 43 | go generate ./... 44 | ''; 45 | }; 46 | 47 | image-service = pkgs.buildGo122Module { 48 | name = "image-service"; 49 | src = ./.; 50 | subPackages = ["cmd/image-service"]; 51 | doCheck = false; # Prevent make test from being ran 52 | vendorHash = (pkgs.lib.fileContents ./go.mod.sri); 53 | nativeBuildInputs = with pkgs; [ 54 | pkg-config 55 | ]; 56 | buildInputs = with pkgs; [ 57 | vips 58 | ]; 59 | }; 60 | }; 61 | 62 | devShells.default = pkgs.mkShell { 63 | packages = with pkgs; [ 64 | go_1_22 65 | gotools 66 | go-tools 67 | gopls 68 | delve 69 | ]; 70 | }; 71 | } 72 | ) // { 73 | nixosModules.default = { config, lib, pkgs, ... }: 74 | with lib; 75 | let cfg = config.picsum-photos.services; 76 | in { 77 | options.picsum-photos.services = { 78 | picsum-photos = { 79 | enable = mkEnableOption "Enable the picsum-photos service"; 80 | 81 | logLevel = mkOption { 82 | type = with types; enum [ "debug" "info" "warn" "error" "dpanic" "panic" "fatal" ]; 83 | example = "debug"; 84 | default = "info"; 85 | description = "log level"; 86 | }; 87 | 88 | domain = mkOption { 89 | type = types.str; 90 | description = "Domain to listen to"; 91 | }; 92 | 93 | sockPath = mkOption rec { 94 | type = types.path; 95 | default = "/run/picsum-photos/picsum-photos.sock"; 96 | example = default; 97 | description = "Unix domain socket to listen on"; 98 | }; 99 | 100 | environmentFile = mkOption { 101 | type = types.path; 102 | description = "Environment file"; 103 | }; 104 | 105 | databaseFilePath = mkOption rec { 106 | type = types.path; 107 | default = "/var/lib/picsum-photos/image-manifest.json"; 108 | example = default; 109 | description = "Image database file path"; 110 | }; 111 | }; 112 | 113 | image-service = { 114 | enable = mkEnableOption "Enable the image-service service"; 115 | 116 | logLevel = mkOption { 117 | type = with types; enum [ "debug" "info" "warn" "error" "dpanic" "panic" "fatal" ]; 118 | example = "debug"; 119 | default = "info"; 120 | description = "log level"; 121 | }; 122 | 123 | workers = mkOption rec { 124 | type = types.number; 125 | default = 16; 126 | example = default; 127 | description = "worker queue concurrency"; 128 | }; 129 | 130 | domain = mkOption { 131 | type = types.str; 132 | description = "Domain to listen to"; 133 | }; 134 | 135 | sockPath = mkOption rec { 136 | type = types.path; 137 | default = "/run/image-service/image-service.sock"; 138 | example = default; 139 | description = "Unix domain socket to listen on"; 140 | }; 141 | 142 | environmentFile = mkOption { 143 | type = types.path; 144 | description = "Environment file"; 145 | }; 146 | 147 | storagePath = mkOption rec { 148 | type = types.path; 149 | default = "/var/lib/image-service"; 150 | example = default; 151 | description = "Storage path"; 152 | }; 153 | }; 154 | }; 155 | 156 | config = mkMerge([ 157 | (mkIf cfg.picsum-photos.enable { 158 | users.groups.picsum-photos = {}; 159 | 160 | users.users.picsum-photos = { 161 | createHome = true; 162 | isSystemUser = true; 163 | group = "picsum-photos"; 164 | home = "/var/lib/picsum-photos"; 165 | }; 166 | 167 | systemd.services.picsum-photos = { 168 | description = "picsum-photos"; 169 | wantedBy = [ "multi-user.target" ]; 170 | 171 | script = '' 172 | exec ${self.packages.${pkgs.system}.picsum-photos}/bin/picsum-photos \ 173 | -log-level=${cfg.picsum-photos.logLevel} \ 174 | -listen=${cfg.picsum-photos.sockPath} \ 175 | -database-file-path=${cfg.picsum-photos.databaseFilePath} 176 | ''; 177 | 178 | serviceConfig = { 179 | EnvironmentFile = cfg.picsum-photos.environmentFile; 180 | User = "picsum-photos"; 181 | Group = "picsum-photos"; 182 | Restart = "always"; 183 | RestartSec = "30s"; 184 | WorkingDirectory = "/var/lib/picsum-photos"; 185 | RuntimeDirectory = "picsum-photos"; 186 | RuntimeDirectoryMode = "0770"; 187 | UMask = "007"; 188 | }; 189 | }; 190 | 191 | services.nginx.virtualHosts."${cfg.picsum-photos.domain}" = { 192 | locations."/" = { 193 | proxyPass = "http://unix:${cfg.picsum-photos.sockPath}"; 194 | }; 195 | }; 196 | }) 197 | 198 | (mkIf cfg.image-service.enable { 199 | users.groups.image-service = {}; 200 | 201 | users.users.image-service = { 202 | createHome = true; 203 | isSystemUser = true; 204 | group = "image-service"; 205 | home = "/var/lib/image-service"; 206 | }; 207 | 208 | systemd.services.image-service = { 209 | description = "image-service"; 210 | wantedBy = [ "multi-user.target" ]; 211 | 212 | script = '' 213 | exec ${self.packages.${pkgs.system}.image-service}/bin/image-service \ 214 | -log-level=${cfg.image-service.logLevel} \ 215 | -listen=${cfg.image-service.sockPath} \ 216 | -storage-path=${cfg.image-service.storagePath} \ 217 | -workers=${toString cfg.image-service.workers} 218 | ''; 219 | 220 | serviceConfig = { 221 | EnvironmentFile = cfg.image-service.environmentFile; 222 | User = "image-service"; 223 | Group = "image-service"; 224 | Restart = "always"; 225 | RestartSec = "30s"; 226 | WorkingDirectory = "/var/lib/image-service"; 227 | RuntimeDirectory = "image-service"; 228 | RuntimeDirectoryMode = "0770"; 229 | UMask = "007"; 230 | }; 231 | }; 232 | 233 | services.nginx.virtualHosts."${cfg.image-service.domain}" = { 234 | locations."/" = { 235 | proxyPass = "http://unix:${cfg.image-service.sockPath}"; 236 | }; 237 | }; 238 | }) 239 | ]); 240 | }; 241 | }; 242 | } 243 | -------------------------------------------------------------------------------- /internal/imageapi/api_test.go: -------------------------------------------------------------------------------- 1 | package imageapi_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "os" 10 | "reflect" 11 | "runtime" 12 | "time" 13 | 14 | "github.com/DMarby/picsum-photos/internal/hmac" 15 | "github.com/DMarby/picsum-photos/internal/image" 16 | api "github.com/DMarby/picsum-photos/internal/imageapi" 17 | "github.com/DMarby/picsum-photos/internal/logger" 18 | "github.com/DMarby/picsum-photos/internal/params" 19 | "github.com/DMarby/picsum-photos/internal/tracing" 20 | "github.com/DMarby/picsum-photos/internal/tracing/test" 21 | "go.uber.org/zap" 22 | 23 | mockProcessor "github.com/DMarby/picsum-photos/internal/image/mock" 24 | vipsProcessor "github.com/DMarby/picsum-photos/internal/image/vips" 25 | 26 | fileStorage "github.com/DMarby/picsum-photos/internal/storage/file" 27 | mockStorage "github.com/DMarby/picsum-photos/internal/storage/mock" 28 | 29 | memoryCache "github.com/DMarby/picsum-photos/internal/cache/memory" 30 | 31 | "testing" 32 | ) 33 | 34 | func TestAPI(t *testing.T) { 35 | ctx, cancel := context.WithCancel(context.Background()) 36 | defer cancel() 37 | 38 | log, tracer, imageProcessor, hmac := setup(t, ctx) 39 | 40 | mockStorageImageProcessor, _ := vipsProcessor.New(ctx, log, tracer, 3, image.NewCache(tracer, memoryCache.New(), &mockStorage.Provider{})) 41 | 42 | router := (&api.API{imageProcessor, log, tracer, time.Minute, hmac}).Router() 43 | mockStorageRouter := (&api.API{mockStorageImageProcessor, log, tracer, time.Minute, hmac}).Router() 44 | mockProcessorRouter := (&api.API{&mockProcessor.Processor{}, log, tracer, time.Minute, hmac}).Router() 45 | 46 | tests := []struct { 47 | Name string 48 | URL string 49 | Router http.Handler 50 | ExpectedStatus int 51 | ExpectedResponse []byte 52 | ExpectedHeaders map[string]string 53 | HMAC bool 54 | }{ 55 | // Errors 56 | {"invalid parameters", "/id/nonexistant/200/300.jpg", router, http.StatusBadRequest, []byte("Invalid parameters\n"), map[string]string{"Content-Type": "text/plain; charset=utf-8", "Cache-Control": "private, no-cache, no-store, must-revalidate"}, false}, 57 | // Storage errors 58 | {"Get() storage", "/id/1/100/100.jpg", mockStorageRouter, http.StatusInternalServerError, []byte("Something went wrong\n"), map[string]string{"Content-Type": "text/plain; charset=utf-8", "Cache-Control": "private, no-cache, no-store, must-revalidate"}, true}, 59 | // 404 60 | {"404", "/asdf", router, http.StatusNotFound, []byte("page not found\n"), map[string]string{"Content-Type": "text/plain; charset=utf-8", "Cache-Control": "private, no-cache, no-store, must-revalidate"}, true}, 61 | // Processor errors 62 | {"processor error", "/id/1/100/100.jpg", mockProcessorRouter, http.StatusInternalServerError, []byte("Something went wrong\n"), map[string]string{"Content-Type": "text/plain; charset=utf-8", "Cache-Control": "private, no-cache, no-store, must-revalidate"}, true}, 63 | } 64 | 65 | for _, test := range tests { 66 | w := httptest.NewRecorder() 67 | 68 | if test.HMAC { 69 | url, err := params.HMAC(hmac, test.URL, url.Values{}) 70 | if err != nil { 71 | t.Errorf("%s: hmac error %s", test.Name, err) 72 | continue 73 | } 74 | 75 | test.URL = url 76 | } 77 | 78 | req, _ := http.NewRequest("GET", test.URL, nil) 79 | test.Router.ServeHTTP(w, req) 80 | if w.Code != test.ExpectedStatus { 81 | t.Errorf("%s: wrong response code, %#v", test.Name, w.Code) 82 | continue 83 | } 84 | 85 | if test.ExpectedHeaders != nil { 86 | for expectedHeader, expectedValue := range test.ExpectedHeaders { 87 | headerValue := w.Header().Get(expectedHeader) 88 | if headerValue != expectedValue { 89 | t.Errorf("%s: wrong header value for %s, %#v", test.Name, expectedHeader, headerValue) 90 | } 91 | } 92 | } 93 | 94 | if !reflect.DeepEqual(w.Body.Bytes(), test.ExpectedResponse) { 95 | t.Errorf("%s: wrong response %#v", test.Name, w.Body.String()) 96 | } 97 | } 98 | 99 | imageTests := []struct { 100 | Name string 101 | URL string 102 | ExpectedResponse []byte 103 | ExpectedContentDisposition string 104 | ExpectedContentType string 105 | }{ 106 | // Images 107 | 108 | // JPEG 109 | {"/id/:id/:width/:height.jpg", "/id/1/200/120.jpg", readFixture("width_height", "jpg"), "inline; filename=\"1-200x120.jpg\"", "image/jpeg"}, 110 | {"/id/:id/:width/:height.jpg?blur=5", "/id/1/200/200.jpg?blur=5", readFixture("blur", "jpg"), "inline; filename=\"1-200x200-blur_5.jpg\"", "image/jpeg"}, 111 | {"/id/:id/:width/:height.jpg?grayscale", "/id/1/200/200.jpg?grayscale", readFixture("grayscale", "jpg"), "inline; filename=\"1-200x200-grayscale.jpg\"", "image/jpeg"}, 112 | {"/id/:id/:width/:height.jpg?blur=5&grayscale", "/id/1/200/200.jpg?blur=5&grayscale", readFixture("all", "jpg"), "inline; filename=\"1-200x200-blur_5-grayscale.jpg\"", "image/jpeg"}, 113 | 114 | // WebP 115 | {"/id/:id/:width/:height.webp", "/id/1/200/120.webp", readFixture("width_height", "webp"), "inline; filename=\"1-200x120.webp\"", "image/webp"}, 116 | {"/id/:id/:width/:height.webp?blur=5", "/id/1/200/200.webp?blur=5", readFixture("blur", "webp"), "inline; filename=\"1-200x200-blur_5.webp\"", "image/webp"}, 117 | {"/id/:id/:width/:height.webp?grayscale", "/id/1/200/200.webp?grayscale", readFixture("grayscale", "webp"), "inline; filename=\"1-200x200-grayscale.webp\"", "image/webp"}, 118 | {"/id/:id/:width/:height.webp?blur=5&grayscale", "/id/1/200/200.webp?blur=5&grayscale", readFixture("all", "webp"), "inline; filename=\"1-200x200-blur_5-grayscale.webp\"", "image/webp"}, 119 | } 120 | 121 | for _, test := range imageTests { 122 | w := httptest.NewRecorder() 123 | 124 | u, err := url.Parse(test.URL) 125 | if err != nil { 126 | t.Errorf("%s: url error %s", test.Name, err) 127 | continue 128 | } 129 | 130 | url, err := params.HMAC(hmac, u.Path, u.Query()) 131 | if err != nil { 132 | t.Errorf("%s: hmac error %s", test.Name, err) 133 | continue 134 | } 135 | 136 | req, _ := http.NewRequest("GET", url, nil) 137 | router.ServeHTTP(w, req) 138 | if w.Code != http.StatusOK { 139 | t.Errorf("%s: wrong response code, %#v", test.Name, w.Code) 140 | continue 141 | } 142 | 143 | if contentType := w.Header().Get("Content-Type"); contentType != test.ExpectedContentType { 144 | t.Errorf("%s: wrong content type, %#v", test.Name, contentType) 145 | } 146 | 147 | if cacheControl := w.Header().Get("Cache-Control"); cacheControl != "public, max-age=2592000, stale-while-revalidate=60, stale-if-error=43200, immutable" { 148 | t.Errorf("%s: wrong cache header, %#v", test.Name, cacheControl) 149 | } 150 | 151 | if contentDisposition := w.Header().Get("Content-Disposition"); contentDisposition != test.ExpectedContentDisposition { 152 | t.Errorf("%s: wrong content disposition header, %#v", test.Name, contentDisposition) 153 | } 154 | 155 | if imageID := w.Header().Get("Picsum-ID"); imageID != "1" { 156 | t.Errorf("%s: wrong image id header, %#v", test.Name, imageID) 157 | } 158 | 159 | if !reflect.DeepEqual(w.Body.Bytes(), test.ExpectedResponse) { 160 | t.Errorf("%s: wrong response/image data", test.Name) 161 | } 162 | } 163 | 164 | redirectTests := []struct { 165 | Name string 166 | URL string 167 | ExpectedURL string 168 | }{ 169 | // Trailing slashes 170 | {"/id/:id/:width/:height/", "/id/1/200/120.jpg/", "/id/1/200/120.jpg"}, 171 | } 172 | 173 | for _, test := range redirectTests { 174 | w := httptest.NewRecorder() 175 | req, _ := http.NewRequest("GET", test.URL, nil) 176 | router.ServeHTTP(w, req) 177 | if w.Code != http.StatusFound && w.Code != http.StatusMovedPermanently { 178 | t.Errorf("%s: wrong response code, %#v", test.Name, w.Code) 179 | continue 180 | } 181 | 182 | location := w.Header().Get("Location") 183 | if location != test.ExpectedURL { 184 | t.Errorf("%s: wrong redirect %s", test.Name, location) 185 | } 186 | } 187 | } 188 | 189 | func readFixture(fixtureName string, extension string) []byte { 190 | return readFile(fixturePath(fixtureName, extension)) 191 | } 192 | 193 | // Utility function for regenerating the fixtures 194 | func TestFixtures(t *testing.T) { 195 | if os.Getenv("GENERATE_FIXTURES") != "1" { 196 | t.SkipNow() 197 | } 198 | 199 | ctx, cancel := context.WithCancel(context.Background()) 200 | defer cancel() 201 | 202 | log, tracer, imageProcessor, hmac := setup(t, ctx) 203 | 204 | router := (&api.API{imageProcessor, log, tracer, time.Minute, hmac}).Router() 205 | 206 | // JPEG 207 | createFixture(router, hmac, "/id/1/200/120.jpg", "width_height", "jpg") 208 | createFixture(router, hmac, "/id/1/200/200.jpg?blur=5", "blur", "jpg") 209 | createFixture(router, hmac, "/id/1/200/200.jpg?grayscale", "grayscale", "jpg") 210 | createFixture(router, hmac, "/id/1/200/200.jpg?blur=5&grayscale", "all", "jpg") 211 | createFixture(router, hmac, "/id/1/300/400.jpg", "max_allowed", "jpg") 212 | 213 | // WebP 214 | createFixture(router, hmac, "/id/1/200/120.webp", "width_height", "webp") 215 | createFixture(router, hmac, "/id/1/200/200.webp?blur=5", "blur", "webp") 216 | createFixture(router, hmac, "/id/1/200/200.webp?grayscale", "grayscale", "webp") 217 | createFixture(router, hmac, "/id/1/200/200.webp?blur=5&grayscale", "all", "webp") 218 | createFixture(router, hmac, "/id/1/300/400.webp", "max_allowed", "webp") 219 | } 220 | 221 | func setup(t *testing.T, ctx context.Context) (*logger.Logger, *tracing.Tracer, image.Processor, *hmac.HMAC) { 222 | t.Helper() 223 | 224 | log := logger.New(zap.FatalLevel) 225 | tracer := test.Tracer(log) 226 | 227 | storage, _ := fileStorage.New("../../test/fixtures/file") 228 | cache := memoryCache.New() 229 | imageCache := image.NewCache(tracer, cache, storage) 230 | imageProcessor, _ := vipsProcessor.New(ctx, log, tracer, 3, imageCache) 231 | 232 | hmac := &hmac.HMAC{ 233 | Key: []byte("test"), 234 | } 235 | 236 | t.Cleanup(func() { 237 | log.Sync() 238 | }) 239 | 240 | return log, tracer, imageProcessor, hmac 241 | } 242 | 243 | func createFixture(router http.Handler, hmac *hmac.HMAC, URL string, fixtureName string, extension string) { 244 | w := httptest.NewRecorder() 245 | 246 | u, _ := url.Parse(URL) 247 | url, _ := params.HMAC(hmac, u.Path, u.Query()) 248 | 249 | req, _ := http.NewRequest("GET", url, nil) 250 | router.ServeHTTP(w, req) 251 | os.WriteFile(fixturePath(fixtureName, extension), w.Body.Bytes(), 0644) 252 | } 253 | 254 | func fixturePath(fixtureName string, extension string) string { 255 | return fmt.Sprintf("../../test/fixtures/api/%s_%s.%s", fixtureName, runtime.GOOS, extension) 256 | } 257 | 258 | func readFile(path string) []byte { 259 | fixture, _ := os.ReadFile(path) 260 | return fixture 261 | } 262 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 2 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 3 | github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= 4 | github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 5 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 6 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 7 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 11 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 12 | github.com/frankban/quicktest v1.14.5 h1:dfYrrRyLtiqT9GyKXgdh+k4inNeTvmGbuSgZ3lx3GhA= 13 | github.com/frankban/quicktest v1.14.5/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 14 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 15 | github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= 16 | github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 17 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 18 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 19 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 20 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 21 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 22 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 23 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 24 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 25 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 26 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 27 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 h1:Wqo399gCIufwto+VfwCSvsnfGpF/w5E9CNxSwbpD6No= 28 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0/go.mod h1:qmOFXW2epJhM0qSnUUYpldc7gVz2KMQwJ/QYCDIa7XU= 29 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 30 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 31 | github.com/jamiealquiza/envy v1.1.0 h1:Nwh4wqTZ28gDA8zB+wFkhnUpz3CEcO12zotjeqqRoKE= 32 | github.com/jamiealquiza/envy v1.1.0/go.mod h1:MP36BriGCLwEHhi1OU8E9569JNZrjWfCvzG7RsPnHus= 33 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 34 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 35 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 36 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 37 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 38 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 39 | github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= 40 | github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= 41 | github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk= 42 | github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA= 43 | github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= 44 | github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= 45 | github.com/prometheus/common v0.46.0 h1:doXzt5ybi1HBKpsZOL0sSkaNHJJqkyfEWZGGqqScV0Y= 46 | github.com/prometheus/common v0.46.0/go.mod h1:Tp0qkxpb9Jsg54QMe+EAmqXkSV7Evdy1BTn+g2pa/hQ= 47 | github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= 48 | github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 49 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= 50 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 51 | github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo= 52 | github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= 53 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 54 | github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= 55 | github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= 56 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 57 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 58 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 59 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 60 | github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg= 61 | github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= 62 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.48.0 h1:doUP+ExOpH3spVTLS0FcWGLnQrPct/hD/bCPbDRUEAU= 63 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.48.0/go.mod h1:rdENBZMT2OE6Ne/KLwpiXudnAsbdrdBaqBvTN8M8BgA= 64 | go.opentelemetry.io/otel v1.23.1 h1:Za4UzOqJYS+MUczKI320AtqZHZb7EqxO00jAHE0jmQY= 65 | go.opentelemetry.io/otel v1.23.1/go.mod h1:Td0134eafDLcTS4y+zQ26GE8u3dEuRBiBCTUIRHaikA= 66 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.23.1 h1:o8iWeVFa1BcLtVEV0LzrCxV2/55tB3xLxADr6Kyoey4= 67 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.23.1/go.mod h1:SEVfdK4IoBnbT2FXNM/k8yC08MrfbhWk3U4ljM8B3HE= 68 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.23.1 h1:p3A5+f5l9e/kuEBwLOrnpkIDHQFlHmbiVxMURWRK6gQ= 69 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.23.1/go.mod h1:OClrnXUjBqQbInvjJFjYSnMxBSCXBF8r3b34WqjiIrQ= 70 | go.opentelemetry.io/otel/metric v1.23.1 h1:PQJmqJ9u2QaJLBOELl1cxIdPcpbwzbkjfEyelTl2rlo= 71 | go.opentelemetry.io/otel/metric v1.23.1/go.mod h1:mpG2QPlAfnK8yNhNJAxDZruU9Y1/HubbC+KyH8FaCWI= 72 | go.opentelemetry.io/otel/sdk v1.23.1 h1:O7JmZw0h76if63LQdsBMKQDWNb5oEcOThG9IrxscV+E= 73 | go.opentelemetry.io/otel/sdk v1.23.1/go.mod h1:LzdEVR5am1uKOOwfBWFef2DCi1nu3SA8XQxx2IerWFk= 74 | go.opentelemetry.io/otel/trace v1.23.1 h1:4LrmmEd8AU2rFvU1zegmvqW7+kWarxtNOPyeL6HmYY8= 75 | go.opentelemetry.io/otel/trace v1.23.1/go.mod h1:4IpnpJFwr1mo/6HL8XIPJaE9y0+u1KcVmuW7dwFSVrI= 76 | go.opentelemetry.io/proto/otlp v1.1.0 h1:2Di21piLrCqJ3U3eXGCTPHE9R8Nh+0uglSnOyxikMeI= 77 | go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7ej/RNTae6MdY= 78 | go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8= 79 | go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0= 80 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 81 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 82 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 83 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 84 | go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= 85 | go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= 86 | go4.org/mem v0.0.0-20220726221520-4f986261bf13 h1:CbZeCBZ0aZj8EfVgnqQcYZgf0lpZ3H9rmp5nkDTAst8= 87 | go4.org/mem v0.0.0-20220726221520-4f986261bf13/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= 88 | golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= 89 | golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= 90 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= 91 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= 92 | golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= 93 | golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 94 | golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= 95 | golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= 96 | golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= 97 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 98 | golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= 99 | golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 100 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 101 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 102 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 103 | google.golang.org/genproto v0.0.0-20231212172506-995d672761c0 h1:YJ5pD9rF8o9Qtta0Cmy9rdBwkSjrTCT6XTiUQVOtIos= 104 | google.golang.org/genproto v0.0.0-20231212172506-995d672761c0/go.mod h1:l/k7rMz0vFTBPy+tFSGvXEd3z+BcoG1k7EHbqm+YBsY= 105 | google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917 h1:rcS6EyEaoCO52hQDupoSfrxI3R6C2Tq741is7X8OvnM= 106 | google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917/go.mod h1:CmlNWB9lSezaYELKS5Ym1r44VrrbPUa7JTvw+6MbpJ0= 107 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917 h1:6G8oQ016D88m1xAKljMlBOOGWDZkes4kMhgGFlf8WcQ= 108 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917/go.mod h1:xtjpI3tXFPP051KaWnhvxkiubL/6dJ18vLVf7q2pTOU= 109 | google.golang.org/grpc v1.61.0 h1:TOvOcuXn30kRao+gfcvsebNEa5iZIiLkisYEkf7R7o0= 110 | google.golang.org/grpc v1.61.0/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs= 111 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 112 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 113 | google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= 114 | google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 115 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 116 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 117 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 118 | tailscale.com v1.58.2 h1:5trkhh/fpUn7f6TUcGUQYJ0GokdNNfNrjh9ONJhoc5A= 119 | tailscale.com v1.58.2/go.mod h1:faWR8XaXemnSKCDjHC7SAQzaagkUjA5x4jlLWiwxtuk= 120 | -------------------------------------------------------------------------------- /internal/web/embed/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Lorem Picsum 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 38 | 39 | 40 |
41 |
42 |
43 | 44 |

Lorem Picsum

45 |

The Lorem Ipsum for photos.

46 |
47 |
48 | 49 |
50 |
51 |
52 | 53 |
54 |
55 |
56 |
57 |

Easy to use, stylish placeholders

58 |

Just add your desired image size (width & height) after our URL, and you'll get a random image.

59 |
https://picsum.photos/200/300
60 |

To get a square image, just add the size.

61 |
https://picsum.photos/200
62 |
63 |
64 |
65 |
66 | 67 |
68 |
69 |
70 |

Specific Image

71 |

Get a specific image by adding /id/{image} to the start of the url.

72 |
https://picsum.photos/id/237/200/300
73 |

You can find a list of all the images here.

74 |
75 |
76 | 77 |
78 |
79 |
80 | 81 |
82 |
83 |
84 |

Static Random Image

85 |

Get the same random image every time based on a seed, by adding /seed/{seed} to the start of the url.

86 |
https://picsum.photos/seed/picsum/200/300
87 |
88 |
89 | 90 |
91 |
92 |
93 | 94 |
95 |
96 |
97 |

Grayscale

98 |

Get a grayscale image by appending ?grayscale to the end of the url.

99 |
https://picsum.photos/200/300?grayscale
100 |
101 |
102 | 103 |
104 |
105 |
106 | 107 |
108 |
109 |
110 |

Blur

111 |

Get a blurred image by appending ?blur to the end of the url.

112 |
https://picsum.photos/200/300/?blur
113 |

You can adjust the amount of blur by providing a number between 1 and 10.

114 |
https://picsum.photos/200/300/?blur=2
115 |
116 |
117 | 118 |
119 |
120 |
121 | 122 |
123 |
124 |
125 |

Advanced Usage

126 |

You may combine any of the options above.

127 |

For example, to get a specific image that is grayscale and blurred.

128 |
https://picsum.photos/id/870/200/300?grayscale&blur=2
129 |

To request multiple images of the same size in your browser, add the random query param to prevent the images from being cached:

130 |
<img src="https://picsum.photos/200/300?random=1">
131 | <img src="https://picsum.photos/200/300?random=2">
132 |

If you need a file ending, you can add .jpg to the end of the url.

133 |
https://picsum.photos/200/300.jpg
134 |

To get an image in the WebP format, you can add .webp to the end of the url.

135 |
https://picsum.photos/200/300.webp
136 |
137 |
138 | 139 |
140 |
141 |
142 | 143 |
144 |
145 |
146 |

List Images

147 |

Get a list of images by using the /v2/list endpoint.

148 |
https://picsum.photos/v2/list
149 |

The API will return 30 items per page by default.

150 |

To request another page, use the ?page parameter.

151 |

To change the amount of items per page, use the ?limit parameter.

152 |
https://picsum.photos/v2/list?page=2&limit=100
153 |

The Link header includes pagination information about the next/previous pages

154 |
155 |
156 |
[
157 |     {
158 |         "id": "0",
159 |         "author": "Alejandro Escamilla",
160 |         "width": 5616,
161 |         "height": 3744,
162 |         "url": "https://unsplash.com/...",
163 |         "download_url": "https://picsum.photos/..."
164 |     }
165 | ]
166 |
167 |
168 |
169 | 170 |
171 |
172 |
173 |

Image Details

174 |

Get information about a specific image by using the /id/{id}/info and /seed/{seed}/info endpoints.

175 |
https://picsum.photos/id/0/info
176 | https://picsum.photos/seed/picsum/info
177 |

You can find out the ID of an image by looking at the Picsum-ID header, or the User Comment field in the EXIF metadata.

178 |
179 |
180 |
{
181 |         "id": "0",
182 |         "author": "Alejandro Escamilla",
183 |         "width": 5616,
184 |         "height": 3744,
185 |         "url": "https://unsplash.com/...",
186 |         "download_url": "https://picsum.photos/..."
187 | }
188 |
189 |
190 |
191 | 192 | 208 | 209 | 210 | --------------------------------------------------------------------------------