diff --git a/.air.toml b/.air.toml
new file mode 100644
index 0000000..1265e5b
--- /dev/null
+++ b/.air.toml
@@ -0,0 +1,47 @@
+root = "."
+tmp_dir = "tmp"
+
+[build]
+ pre_cmd = ["go generate ./internal/web"]
+ cmd = "CGO_ENABLED=0 go build -o ./tmp/picsum-photos ./cmd/picsum-photos"
+ bin = "./tmp/picsum-photos"
+ full_bin = "./tmp/picsum-photos -listen :8080"
+ delay = 0
+ exclude_dir = ["tmp", "vendor", "node_modules", ".git"]
+ exclude_regex = ["_test.go"]
+ exclude_unchanged = false
+ follow_symlink = false
+ include_dir = []
+ include_ext = ["go", "html", "css", "js"]
+ include_file = []
+ kill_delay = "0s"
+ log = "build-errors.log"
+ poll = false
+ poll_interval = 0
+ rerun = false
+ rerun_delay = 500
+ send_interrupt = false
+ stop_on_error = false
+
+[color]
+ app = ""
+ build = "yellow"
+ main = "magenta"
+ runner = "green"
+ watcher = "cyan"
+
+[log]
+ main_only = false
+ time = false
+
+[misc]
+ clean_on_exit = true
+
+[screen]
+ clear_on_rebuild = false
+ keep_scroll = true
+
+[proxy]
+ enabled = true
+ proxy_port = 8090
+ app_port = 8080
diff --git a/.envrc b/.envrc
new file mode 100644
index 0000000..f97be3e
--- /dev/null
+++ b/.envrc
@@ -0,0 +1,2 @@
+watch_file ./go.mod.sri
+use flake
diff --git a/.gitignore b/.gitignore
index 55db636..3ecbbd5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,12 +1,17 @@
-cache
-node_modules
-images.json
-stats.json
-cache.json
-ansible/files/unsplash.key
-ansible/files/unsplash.crt
-ansible/files/unsplash_rsa
-ansible/files/vnstat.db
-ansible/files/photos.zip
-ansible/files/photos.json
-ansible/files/stats.json
\ No newline at end of file
+**/debug
+**/debug.test
+
+/picsum-photos
+/tmp
+
+/image-service
+
+.vscode/c_cpp_properties.json
+.vscode/settings.json
+
+.DS_Store
+
+.direnv
+result
+
+internal/web/embed/assets/css/style.css
diff --git a/.jshintignore b/.jshintignore
deleted file mode 100644
index b512c09..0000000
--- a/.jshintignore
+++ /dev/null
@@ -1 +0,0 @@
-node_modules
\ No newline at end of file
diff --git a/.jshintrc b/.jshintrc
deleted file mode 100644
index 01b45a5..0000000
--- a/.jshintrc
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "asi": true
-}
\ No newline at end of file
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..dca7b28
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,23 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Attach to picsum",
+ "type": "go",
+ "request": "attach",
+ "mode": "remote",
+ "port": 2345,
+ "host": "127.0.0.1",
+ "showLog": true
+ },
+ {
+ "name": "Attach to image-service",
+ "type": "go",
+ "request": "attach",
+ "mode": "remote",
+ "port": 2346,
+ "host": "127.0.0.1",
+ "showLog": true
+ }
+ ]
+}
diff --git a/LICENSE.md b/LICENSE.md
index 6ef8531..716238d 100644
--- a/LICENSE.md
+++ b/LICENSE.md
@@ -1,6 +1,6 @@
The MIT License (MIT)
-Copyright (c) 2014 David Marby & Nijiko Yonskai
+Copyright (c) 2014-2026 David Marby & Nijiko Yonskai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
\ No newline at end of file
+SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..ff3fd58
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,46 @@
+GO ?= go
+GOTOOLRUN = $(GO) run -modfile=./tools/go.mod
+
+.PHONY: test
+test:
+ $(GO) test ./...
+
+.PHONY: fixtures
+fixtures: generate_fixtures
+ 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'
+
+.PHONY: generate_fixtures
+generate_fixtures:
+ GENERATE_FIXTURES=1 $(GO) test ./... -run '^(TestFixtures)$$'
+
+.PHONY: docker_fixtures
+docker_fixtures:
+ apk add --update --repository http://dl-cdn.alpinelinux.org/alpine/edge/testing vips-dev
+ apk add \
+ git \
+ gcc \
+ musl-dev
+
+.PHONY: generate
+generate: go.mod.sri
+
+go.mod.sri: go.mod
+ $(GO) mod vendor -o .tmp-vendor
+ $(GOTOOLRUN) tailscale.com/cmd/nardump -sri .tmp-vendor >$@
+ rm -rf .tmp-vendor
+
+.PHONY: upgrade
+upgrade:
+# https://github.com/golang/go/issues/28424
+ $(GO) list -f '{{if not (or .Main .Indirect)}}{{.Path}}{{end}}' -m all | xargs $(GO) get
+ $(GO) mod tidy -v
+
+.PHONY: upgradetools
+upgradetools:
+ cd tools && $(GO) list -e -f '{{range .Imports}}{{.}}@latest {{end}}' -tags tools | xargs $(GO) get
+ cd tools && $(GO) mod tidy -v
+
+.PHONY: run
+run:
+ @(sleep 3 && open http://localhost:8090) &
+ $(GOTOOLRUN) github.com/air-verse/air
diff --git a/README.md b/README.md
index 6a7028c..3be5b12 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,15 @@
-Unsplash It
+Lorem Picsum
===========
-Beautiful placeholders using images from [unsplash](http://unsplash.com)
\ No newline at end of file
+Lorem Ipsum... but for photos.
+Lorem Picsum is a service providing easy to use, stylish placeholders.
+
+## Sponsors
+
+Proudly powered by [Fastly](https://fastly.com)
+
+
+
+
+## License
+MIT. See [LICENSE](./LICENSE.md)
diff --git a/ansible/build-cache.yml b/ansible/build-cache.yml
deleted file mode 100644
index 1b08ecb..0000000
--- a/ansible/build-cache.yml
+++ /dev/null
@@ -1,9 +0,0 @@
----
-- hosts: all
- tasks:
- - name: Stop unsplash-it
- service: name=unsplash-it state=stopped
- - name: Build cache
- shell: "cd /opt/unsplash-it && su unsplash -c 'node buildcache.js'"
- - name: Start unsplash-it
- service: name=unsplash-it state=started
\ No newline at end of file
diff --git a/ansible/copy.yml b/ansible/copy.yml
deleted file mode 100644
index 8404992..0000000
--- a/ansible/copy.yml
+++ /dev/null
@@ -1,17 +0,0 @@
----
-- hosts: all
- tasks:
- - name: Stop vnstat
- service: name=vnstat state=stopped
- - name: Copy vnstat db
- fetch: src=/var/lib/vnstat/eth0 dest=files/vnstat.db flat=yes
- - name: Start vnstat
- service: name=vnstat state=started
- #- name: Copy photos.zip
- # fetch: src=/opt/unsplash-downloader/photos.zip dest=files/photos.zip flat=yes
- - name: Stop unsplash-it
- service: name=unsplash-it state=stopped
- - name: Copy stats.json
- fetch: src=/opt/unsplash-it/stats.json dest=files/stats.json flat=yes
- - name: Start unsplash-it
- service: name=unsplash-it state=started
\ No newline at end of file
diff --git a/ansible/handlers/handlers.yml b/ansible/handlers/handlers.yml
deleted file mode 100644
index d3ddf7f..0000000
--- a/ansible/handlers/handlers.yml
+++ /dev/null
@@ -1,18 +0,0 @@
----
-- name: reload initctl
- command: initctl reload-configuration
-
-- name: restart nginx
- service: name=nginx state=restarted
-
-- name: start unsplash-it
- command: initctl start unsplash-it
-
-- name: build cache
- shell: "cd /opt/unsplash-it && su unsplash -c 'node buildcache.js'"
-
-- name: restart unsplash-it
- service: name=unsplash-it state=restarted
-
-- name: restart sshd
- service: name=ssh state=restarted
\ No newline at end of file
diff --git a/ansible/restore.yml b/ansible/restore.yml
deleted file mode 100644
index 7325d08..0000000
--- a/ansible/restore.yml
+++ /dev/null
@@ -1,17 +0,0 @@
----
-- hosts: all
- tasks:
- - name: Stop vnstat
- service: name=vnstat state=stopped
- - name: Copy vnstat db
- copy: src=files/vnstat.db dest=/var/lib/vnstat/eth0 owner=vnstat group=vnstat
- - name: Start vnstat
- service: name=vnstat state=started
- #- name: Copy photos.zip
- # copy: src=files/photos.zip dest=/opt/unsplash-downloader/photos.zip owner=unsplash group=unsplash
- - name: Stop unsplash-it
- service: name=unsplash-it state=stopped
- - name: Copy stats.json
- copy: src=files/stats.json dest=/opt/unsplash-it/stats.json owner=unsplash group=unsplash
- - name: Start unsplash-it
- service: name=unsplash-it state=started
\ No newline at end of file
diff --git a/ansible/roles/node/tasks/main.yml b/ansible/roles/node/tasks/main.yml
deleted file mode 100644
index 92f97fa..0000000
--- a/ansible/roles/node/tasks/main.yml
+++ /dev/null
@@ -1,24 +0,0 @@
----
-- name: Install dependencies
- apt: name={{ item }} state=latest update_cache=yes
- with_items:
- - git
- - curl
- - build-essential
- tags: nodejs
-
-- name: Clone n
- git: repo=https://github.com/tj/n.git dest=~/.n
- tags: nodejs
-
-- name: Install n
- shell: make install
- args:
- chdir: ~/.n
- tags: nodejs
-
-- name: Install nodejs
- command: n 0.12.4
- register: nodejs_install_result
- changed_when: "'installed : ' in nodejs_install_result.stdout"
- tags: nodejs
\ No newline at end of file
diff --git a/ansible/roles/unsplash-downloader/files/unsplash-downloader.conf b/ansible/roles/unsplash-downloader/files/unsplash-downloader.conf
deleted file mode 100644
index 259365b..0000000
--- a/ansible/roles/unsplash-downloader/files/unsplash-downloader.conf
+++ /dev/null
@@ -1,9 +0,0 @@
-module.exports = exports = {
- concurrent_downloads: 5,
- folder_path: '/opt/photos',
- git_push: true,
- post_command: 'sudo service unsplash-it restart',
- create_zip: './photos.zip',
- all: false,
- check_for_deleted: true
-}
\ No newline at end of file
diff --git a/ansible/roles/unsplash-downloader/tasks/main.yml b/ansible/roles/unsplash-downloader/tasks/main.yml
deleted file mode 100644
index 93cf822..0000000
--- a/ansible/roles/unsplash-downloader/tasks/main.yml
+++ /dev/null
@@ -1,21 +0,0 @@
----
-- name: Create unsplash-downloader directory
- file: path=/opt/unsplash-downloader state=directory owner=unsplash group=unsplash
-
-- name: Clone the repo
- shell: "cd /opt/unsplash-downloader && su unsplash -c 'git clone https://github.com/DMarby/unsplash-downloader.git /opt/unsplash-downloader'"
-
-- name: Set git name
- shell: cd /opt/unsplash-downloader && su unsplash -c 'git config --global user.name "Unsplash"'
-
-- name: Set git email
- shell: cd /opt/unsplash-downloader && su unsplash -c 'git config --global user.email "david@dmarby.se"'
-
-- name: Install dependencies for unsplash-downloader
- shell: "su unsplash -c 'cd /opt/unsplash-downloader && npm install'"
-
-- name: Copy unsplash-downloader config
- copy: src=files/unsplash-downloader.conf dest=/opt/unsplash-downloader/config.js owner=unsplash group=unsplash
-
-- name: Add cronjob for unsplash-downloader
- action: 'cron name="unsplash-downloader" special_time=daily user=unsplash job="cd /opt/unsplash-downloader && /usr/bin/node index.js"'
\ No newline at end of file
diff --git a/ansible/roles/unsplash-it/files/unsplash-it.conf b/ansible/roles/unsplash-it/files/unsplash-it.conf
deleted file mode 100644
index f90a81c..0000000
--- a/ansible/roles/unsplash-it/files/unsplash-it.conf
+++ /dev/null
@@ -1,22 +0,0 @@
-description "Unsplash-it"
-
-start on runlevel [2345]
-stop on runlevel [0156]
-
-expect fork
-
-env APPLICATION_START="/opt/unsplash-it/index.js"
-env LOG="/var/log/unsplash-it.log"
-env HOME=/home/unsplash
-
-setuid unsplash
-
-script
- cd $HOME
- exec forever -l $LOG -a --minUptime 5000 --spinSleepTime 2000 --killSignal SIGTERM start $APPLICATION_START
-end script
-
-pre-stop script
- cd $HOME
- exec forever stop $APPLICATION_START >> $LOG
-end script
\ No newline at end of file
diff --git a/ansible/roles/unsplash-it/tasks/main.yml b/ansible/roles/unsplash-it/tasks/main.yml
deleted file mode 100644
index dffe3f0..0000000
--- a/ansible/roles/unsplash-it/tasks/main.yml
+++ /dev/null
@@ -1,25 +0,0 @@
----
-- name: Install libvips
- shell: "curl -s https://raw.githubusercontent.com/lovell/sharp/master/preinstall.sh | bash -"
-
-- name: Install global package forever
- shell: "npm install -g forever"
-
-- name: Create unsplash-it directory
- file: path=/opt/unsplash-it state=directory owner=unsplash group=unsplash
-
-- name: Clone the repo
- shell: "cd /opt/unsplash-it && su unsplash -c 'git clone https://github.com/DMarby/unsplash-it.git /opt/unsplash-it'"
-
-- name: Install dependencies for unsplash-it
- shell: "su unsplash -c 'cd /opt/unsplash-it && npm install'"
-
-- name: Create unsplash-it.log
- file: path=/var/log/unsplash-it.log owner=unsplash group=unsplash mode=0644 state=touch
-
-- name: Copy upstart job for unsplash-it
- copy: src=files/unsplash-it.conf dest=/etc/init/unsplash-it.conf owner=root group=root mode=0644
- notify:
- - reload initctl
- - start unsplash-it
- - build cache
\ No newline at end of file
diff --git a/ansible/roles/unsplash-lb/files/nginx.conf b/ansible/roles/unsplash-lb/files/nginx.conf
deleted file mode 100644
index e4d7fda..0000000
--- a/ansible/roles/unsplash-lb/files/nginx.conf
+++ /dev/null
@@ -1,130 +0,0 @@
-server {
- listen 80 default_server;
- listen 443 default_server ssl;
- listen [::]:80 default_server;
- listen [::]:443 default_server ssl;
- server_name _;
-
- ssl_certificate /etc/ssl/unsplash.crt;
- ssl_certificate_key /etc/ssl/unsplash.key;
-
- ssl_ciphers 'ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA:DES-CBC3-SHA:HIGH:!aNULL:!eNULL:!EXPORT:!CAMELLIA:!DES:!MD5:!PSK:!RC4';
-
- ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
- ssl_session_cache builtin:1000 shared:SSL:10m;
-
- ssl_prefer_server_ciphers on;
- ssl_dhparam /etc/ssl/certs/dhparam.pem;
-
- add_header Strict-Transport-Security "max-age=31536000; includeSubdomains";
-
- access_log off;
- return 301 http://unsplash.it$request_uri;
-}
-
-upstream app {
- server 127.0.0.1:5000;
-}
-
-upstream socket {
- server 127.0.0.1:3000;
-}
-
-server {
- listen 80;
- listen 443 ssl;
- listen [::]:80;
- listen [::]:443 ssl;
- server_name unsplash.it;
-
- ssl_certificate /etc/ssl/unsplash.crt;
- ssl_certificate_key /etc/ssl/unsplash.key;
-
- access_log /var/log/nginx/unsplash.log;
-
- ssl_ciphers 'ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA:DES-CBC3-SHA:HIGH:!aNULL:!eNULL:!EXPORT:!CAMELLIA:!DES:!MD5:!PSK:!RC4';
-
- ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
- ssl_session_cache builtin:1000 shared:SSL:10m;
-
- ssl_prefer_server_ciphers on;
- ssl_dhparam /etc/ssl/certs/dhparam.pem;
-
- add_header Strict-Transport-Security "max-age=31536000; includeSubdomains";
-
- location / {
- gzip on;
- root /opt/unsplash-it/public;
- index index.html;
- try_files $uri.html $uri $uri/ @node;
- }
-
- location /photos.zip {
- gzip on;
- alias /opt/unsplash-downloader/photos.zip;
- }
-
- location /robots.txt {
- return 404 "{\"error\":\"Resource not found\"}";
- }
-
- location @node {
- sendfile off;
- proxy_pass http://app;
- proxy_redirect off;
-
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_max_temp_file_size 0;
-
- proxy_connect_timeout 90;
- proxy_send_timeout 90;
- proxy_read_timeout 90;
-
- proxy_buffer_size 4k;
- proxy_buffers 4 32k;
- proxy_busy_buffers_size 64k;
- proxy_temp_file_write_size 64k;
- }
-}
-
-map $http_upgrade $connection_upgrade {
- default upgrade;
- '' close;
-}
-
-server {
- listen 4000 ssl;
- listen [::]:4000 ssl;
- server_name unsplash.it;
-
- ssl_certificate /etc/ssl/unsplash.crt;
- ssl_certificate_key /etc/ssl/unsplash.key;
-
- ssl_ciphers 'ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA:DES-CBC3-SHA:HIGH:!aNULL:!eNULL:!EXPORT:!CAMELLIA:!DES:!MD5:!PSK:!RC4';
-
- ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
- ssl_session_cache builtin:1000 shared:SSL:10m;
-
- ssl_prefer_server_ciphers on;
- ssl_dhparam /etc/ssl/certs/dhparam.pem;
-
- add_header Strict-Transport-Security "max-age=31536000; includeSubdomains";
-
- location / {
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header Host $http_host;
- proxy_set_header X-NginX-Proxy true;
-
- proxy_pass http://socket/;
- proxy_redirect off;
-
- proxy_http_version 1.1;
- proxy_set_header Upgrade $http_upgrade;
- proxy_set_header Connection $connection_upgrade;
-
- access_log off;
- }
-}
\ No newline at end of file
diff --git a/ansible/roles/unsplash-lb/tasks/ip6tables.yml b/ansible/roles/unsplash-lb/tasks/ip6tables.yml
deleted file mode 100644
index c5c602c..0000000
--- a/ansible/roles/unsplash-lb/tasks/ip6tables.yml
+++ /dev/null
@@ -1,38 +0,0 @@
----
-- name: Install iptables-persistent
- apt: name=iptables-persistent state=latest
-
-- name: Get ip6tables rules
- shell: /sbin/ip6tables -L
- register: ip6tablesrules
- always_run: yes
-
-- name: Add ipv6 conntrack rule
- command: /sbin/ip6tables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT -m comment --comment "Conntrack"
- when: ip6tablesrules.stdout.find("Conntrack") == -1
-
-- name: Add ipv6 ssh iptables rule
- command: /sbin/ip6tables -A INPUT -p tcp --dport 22 -j ACCEPT -m comment --comment "SSH"
- when: ip6tablesrules.stdout.find("SSH") == -1
-
-- name: Add ipv6 nginx iptables rule
- command: /sbin/ip6tables -I INPUT -p tcp --dport 80 -j ACCEPT -m comment --comment "Nginx_HTTP"
- when: ip6tablesrules.stdout.find("Nginx_HTTP") == -1
-
-- name: Add ipv6 nginx ssl iptables rule
- command: /sbin/ip6tables -I INPUT -p tcp --dport 443 -j ACCEPT -m comment --comment "Nginx_SSL"
- when: ip6tablesrules.stdout.find("Nginx_SSL") == -1
-
-- name: Add ipv6 local interface iptables rule
- command: /sbin/ip6tables -I INPUT -i lo -j ACCEPT -m comment --comment "Local"
- when: ip6tablesrules.stdout.find("Local") == -1
-
-- name: Add ipv6 socket.io iptables rule
- command: /sbin/ip6tables -I INPUT -p tcp --dport 4000 -j ACCEPT -m comment --comment "SocketIO"
- when: ip6tablesrules.stdout.find("SocketIO") == -1
-
-- name: Add ipv6 default iptables policy
- command: /sbin/ip6tables -P INPUT DROP
-
-- name: Save ip6tables
- shell: /sbin/ip6tables-save > /etc/iptables/rules.v6
\ No newline at end of file
diff --git a/ansible/roles/unsplash-lb/tasks/iptables.yml b/ansible/roles/unsplash-lb/tasks/iptables.yml
deleted file mode 100644
index edeba2c..0000000
--- a/ansible/roles/unsplash-lb/tasks/iptables.yml
+++ /dev/null
@@ -1,38 +0,0 @@
----
-- name: Install iptables-persistent
- apt: name=iptables-persistent state=latest
-
-- name: Get iptables rules
- shell: /sbin/iptables -L
- register: iptablesrules
- always_run: yes
-
-- name: Add conntrack rule
- command: /sbin/iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT -m comment --comment "Conntrack"
- when: iptablesrules.stdout.find("Conntrack") == -1
-
-- name: Add ssh iptables rule
- command: /sbin/iptables -A INPUT -p tcp --dport 22 -j ACCEPT -m comment --comment "SSH"
- when: iptablesrules.stdout.find("SSH") == -1
-
-- name: Add nginx iptables rule
- command: /sbin/iptables -I INPUT -p tcp --dport 80 -j ACCEPT -m comment --comment "Nginx_HTTP"
- when: iptablesrules.stdout.find("Nginx_HTTP") == -1
-
-- name: Add nginx ssl iptables rule
- command: /sbin/iptables -I INPUT -p tcp --dport 443 -j ACCEPT -m comment --comment "Nginx_SSL"
- when: iptablesrules.stdout.find("Nginx_SSL") == -1
-
-- name: Add local interface iptables rule
- command: /sbin/iptables -I INPUT -i lo -j ACCEPT -m comment --comment "Local"
- when: iptablesrules.stdout.find("Local") == -1
-
-- name: Add socket.io iptables rule
- command: /sbin/iptables -I INPUT -p tcp --dport 4000 -j ACCEPT -m comment --comment "SocketIO"
- when: iptablesrules.stdout.find("SocketIO") == -1
-
-- name: Add default iptables policy
- command: /sbin/iptables -P INPUT DROP
-
-- name: Save iptables
- shell: /sbin/iptables-save > /etc/iptables/rules.v4
\ No newline at end of file
diff --git a/ansible/roles/unsplash-lb/tasks/main.yml b/ansible/roles/unsplash-lb/tasks/main.yml
deleted file mode 100644
index ce8a1a7..0000000
--- a/ansible/roles/unsplash-lb/tasks/main.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-- include: nginx.yml
-- include: iptables.yml
-- include: ip6tables.yml
-- include: unsplash.yml
\ No newline at end of file
diff --git a/ansible/roles/unsplash-lb/tasks/nginx.yml b/ansible/roles/unsplash-lb/tasks/nginx.yml
deleted file mode 100644
index f1b7e8a..0000000
--- a/ansible/roles/unsplash-lb/tasks/nginx.yml
+++ /dev/null
@@ -1,39 +0,0 @@
-- name: Add nginx repo
- apt_repository: repo='deb http://nginx.org/packages/ubuntu/ trusty nginx' state=present
-
-- name: Add nginx source repo
- apt_repository: repo='deb-src http://nginx.org/packages/ubuntu/ trusty nginx' state=present
-
-- name: Add nginx repo pubkey
- get_url: url=http://nginx.org/keys/nginx_signing.key dest=/root/nginx_signing.key mode=0440
-
-- name: Install nginx
- apt: name=nginx state=latest force=yes update_cache=yes
-
-- name: Install nginx repo pubkey
- command: apt-key add /root/nginx_signing.key
-
-- name: Remove default nginx config
- file: path=/etc/nginx/conf.d/default.conf state=absent
-
-- name: Remove default nginx config
- file: path=/etc/nginx/sites-enabled/default state=absent
-
-- name: Remove default nginx config
- file: path=/etc/nginx/conf.d/example_ssl.conf state=absent
-
-- name: Copy nginx config
- copy: src=files/nginx.conf dest=/etc/nginx/conf.d/unsplash.conf
-
-- name: Copy nginx certificate
- copy: src=files/unsplash.crt dest=/etc/ssl/unsplash.crt owner=root group=root mode=0644
-
-- name: Copy nginx certificate key
- copy: src=files/unsplash.key dest=/etc/ssl/unsplash.key owner=root group=root mode=0644
-
-- name: Generate dhparam
- command: chdir=/etc/ssl/certs openssl dhparam -out dhparam.pem 4096
- when: dhparam
-
-- name: Ensure nginx is running
- service: name=nginx state=restarted
\ No newline at end of file
diff --git a/ansible/roles/unsplash-lb/tasks/unsplash.yml b/ansible/roles/unsplash-lb/tasks/unsplash.yml
deleted file mode 100644
index e69de29..0000000
diff --git a/ansible/tasks/general.yml b/ansible/tasks/general.yml
deleted file mode 100644
index 3775ed6..0000000
--- a/ansible/tasks/general.yml
+++ /dev/null
@@ -1,48 +0,0 @@
----
-- name: Upgrade existing packages
- apt: upgrade=full update_cache=yes
-
-- name: Prefer ipv4
- lineinfile: dest=/etc/gai.conf line="precedence ::ffff:0:0/96 100" state="present"
-
-- name: Prefer ipv4
- lineinfile: dest=/etc/network/interfaces regexp="^.*dns-nameservers.*$" line=" dns-nameservers 8.8.8.8 8.8.4.4 2001:4860:4860::8844 2001:4860:4860::8888" state="present"
-
-- name: Create unsplash user
- user: name=unsplash shell=/bin/bash createhome=yes system=yes
-
-- name: Create ssh directory
- file: path=/home/unsplash/.ssh state=directory owner=unsplash group=unsplash
-
-- name: Copy private key
- copy: src=files/unsplash_rsa dest=/home/unsplash/.ssh/id_rsa owner=unsplash group=unsplash mode=0400
-
-- name: Disable password login for ssh
- lineinfile: dest=/etc/ssh/sshd_config state=absent regexp="^PasswordAuthentication yes"
-
-- name: Disable password login for ssh
- lineinfile: dest=/etc/ssh/sshd_config line="PasswordAuthentication no"
- notify:
- - restart sshd
-
-- name: Install required packages
- apt: name={{ item }} state=latest update_cache=yes
- with_items:
- - curl
- - git
- - vnstat
- - htop
- - pkg-config
-
-
-- name: Add github to known_hosts file
- shell: "su unsplash -c 'ssh-keyscan -t rsa github.com > /home/unsplash/.ssh/known_hosts'"
-
-- name: Add dmarby to known_hosts file
- shell: "su unsplash -c 'ssh-keyscan -t rsa dmarby.se > /home/unsplash/.ssh/known_hosts'"
-
-- name: Create photos folder
- file: path=/opt/photos state=directory owner=unsplash group=unsplash
-
-- name: Clone photos repo
- shell: "cd /opt/photos && su unsplash -c 'git clone upload@dmarby.se:photos.git /opt/photos'"
diff --git a/ansible/unsplash-it.yml b/ansible/unsplash-it.yml
deleted file mode 100644
index 3f18b58..0000000
--- a/ansible/unsplash-it.yml
+++ /dev/null
@@ -1,14 +0,0 @@
----
-- hosts: all
- vars:
- dhparam: true
- tasks:
- - include: tasks/node.yml
- - include: tasks/general.yml
- - include: tasks/iptables.yml
- - include: tasks/ip6tables.yml
- - include: tasks/nginx.yml
- - include: tasks/unsplash-downloader.yml
- - include: tasks/unsplash-it.yml
- handlers:
- - include: handlers/handlers.yml
\ No newline at end of file
diff --git a/ansible/upgrade-nginx-config.yml b/ansible/upgrade-nginx-config.yml
deleted file mode 100644
index 7c13a3f..0000000
--- a/ansible/upgrade-nginx-config.yml
+++ /dev/null
@@ -1,9 +0,0 @@
----
-- hosts: all
- tasks:
- - name: Update nginx config
- copy: src=files/nginx.conf dest=/etc/nginx/conf.d/unsplash.conf
- notify:
- - restart nginx
- handlers:
- - include: handlers/handlers.yml
\ No newline at end of file
diff --git a/ansible/upgrade-unsplash-downloader.yml b/ansible/upgrade-unsplash-downloader.yml
deleted file mode 100644
index 7bbd716..0000000
--- a/ansible/upgrade-unsplash-downloader.yml
+++ /dev/null
@@ -1,9 +0,0 @@
----
-- hosts: all
- tasks:
- - name: Update repo
- shell: "cd /opt/unsplash-downloader && su unsplash -c 'cd /opt/unsplash-downloader && git pull && npm install'"
- - name: Update config
- copy: src=files/unsplash-downloader.conf dest=/opt/unsplash-downloader/config.js
- handlers:
- - include: handlers/handlers.yml
\ No newline at end of file
diff --git a/ansible/upgrade-unsplash-it.yml b/ansible/upgrade-unsplash-it.yml
deleted file mode 100644
index f580a2c..0000000
--- a/ansible/upgrade-unsplash-it.yml
+++ /dev/null
@@ -1,9 +0,0 @@
----
-- hosts: all
- tasks:
- - name: Update repo
- shell: "cd /opt/unsplash-it && su unsplash -c 'cd /opt/unsplash-it && git pull && npm install'"
- notify:
- - restart unsplash-it
- handlers:
- - include: handlers/handlers.yml
\ No newline at end of file
diff --git a/buildcache.js b/buildcache.js
deleted file mode 100644
index f31ad9f..0000000
--- a/buildcache.js
+++ /dev/null
@@ -1,45 +0,0 @@
-var sharp = require('sharp')
-var path = require('path')
-var async = require('async')
-var config = require('./config')()
-var fs = require('fs')
-
-try {
- var cache = require(config.cache_metadata_path)
-} catch (error) {
- var cache = {}
-}
-
-sharp.cache(0)
-
-var imageProcessor = require('./imageProcessor')(sharp, path, config, fs)
-var images = require(config.image_store_path)
-
-fs.mkdir(config.cache_folder_path, function (error) {
- var index = process.argv[2] || 0
- console.log('Start: %s', index)
-
- if (index > 0) {
- images.splice(0, index)
- }
-
- async.eachLimit(images, 5, function (image, next) {
- var width = 458
- var height = 354
- imageProcessor.getProcessedImage(width, height, null, false, false, image.filename, false, function (error, imagePath) {
- if (error) {
- console.log('filePath: ' + image.filename)
- console.log('imagePath: ' + imagePath)
- console.log('error: ' + err)
- }
-
- console.log('%s done', image.id)
- cache[imagePath] = new Date()
- next()
- })
- }, function (error) {
- fs.writeFile(config.cache_metadata_path, JSON.stringify(cache), 'utf-8', function (error) {
- console.log('Done')
- })
- })
-})
\ No newline at end of file
diff --git a/cmd/image-manifest/main.go b/cmd/image-manifest/main.go
new file mode 100644
index 0000000..a38c9d5
--- /dev/null
+++ b/cmd/image-manifest/main.go
@@ -0,0 +1,73 @@
+package main
+
+import (
+ "encoding/json"
+ "flag"
+ "fmt"
+ "image"
+ "log"
+ "os"
+ "path/filepath"
+
+ "github.com/DMarby/picsum-photos/internal/database"
+
+ _ "image/jpeg"
+)
+
+// Comandline flags
+var (
+ imagePath = flag.String("image-path", ".", "path to image directory")
+ imageManifestPath = flag.String("image-manifest-path", "./image-manifest.json", "path to the image manifest to update")
+)
+
+func main() {
+ flag.Parse()
+
+ resolvedManifestPath, err := filepath.Abs(*imageManifestPath)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ manifestData, err := os.ReadFile(resolvedManifestPath)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ var images []database.Image
+ err = json.Unmarshal(manifestData, &images)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ for i, img := range images {
+ resolvedImagePath, err := filepath.Abs(filepath.Join(*imagePath, fmt.Sprintf("%s.jpg", img.ID)))
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ reader, err := os.Open(resolvedImagePath)
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer reader.Close()
+
+ imageMetadata, _, err := image.DecodeConfig(reader)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ images[i].Width = imageMetadata.Width
+ images[i].Height = imageMetadata.Height
+ }
+
+ file, _ := os.OpenFile(resolvedManifestPath, os.O_WRONLY, 0644)
+ defer file.Close()
+
+ encoder := json.NewEncoder(file)
+ encoder.SetEscapeHTML(false)
+ encoder.SetIndent("", " ")
+
+ if err := encoder.Encode(images); err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/cmd/image-service/main.go b/cmd/image-service/main.go
new file mode 100644
index 0000000..26c1424
--- /dev/null
+++ b/cmd/image-service/main.go
@@ -0,0 +1,152 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "net"
+ "net/http"
+ "os"
+ "os/signal"
+ "strings"
+ "syscall"
+
+ "github.com/DMarby/picsum-photos/internal/cache/memory"
+ "github.com/DMarby/picsum-photos/internal/cmd"
+ "github.com/DMarby/picsum-photos/internal/health"
+ "github.com/DMarby/picsum-photos/internal/hmac"
+ "github.com/DMarby/picsum-photos/internal/image"
+ "github.com/DMarby/picsum-photos/internal/image/vips"
+ "github.com/DMarby/picsum-photos/internal/logger"
+ "github.com/DMarby/picsum-photos/internal/metrics"
+ "github.com/DMarby/picsum-photos/internal/storage/file"
+ "github.com/DMarby/picsum-photos/internal/tracing/test"
+
+ api "github.com/DMarby/picsum-photos/internal/imageapi"
+
+ "github.com/jamiealquiza/envy"
+ "go.uber.org/automaxprocs/maxprocs"
+ "go.uber.org/zap"
+)
+
+// Comandline flags
+var (
+ // Global
+ listen = flag.String("listen", "", "listen address (tcp host:port or unix socket path)")
+ metricsListen = flag.String("metrics-listen", "127.0.0.1:8083", "metrics listen address")
+ loglevel = zap.LevelFlag("log-level", zap.InfoLevel, "log level (default \"info\") (debug, info, warn, error, dpanic, panic, fatal)")
+
+ // Storage - File
+ storagePath = flag.String("storage-path", "", "path to the storage directory")
+
+ // HMAC
+ hmacKey = flag.String("hmac-key", "", "hmac key to use for authentication between services")
+
+ // Image processor
+ workers = flag.Int("workers", 3, "worker queue concurrency")
+)
+
+func main() {
+ ctx := context.Background()
+
+ // Parse environment variables
+ envy.Parse("IMAGE")
+
+ // Parse commandline flags
+ flag.Parse()
+
+ // Initialize the logger
+ log := logger.New(*loglevel)
+ defer log.Sync()
+
+ // Set GOMAXPROCS
+ maxprocs.Set(maxprocs.Logger(log.Infof))
+
+ // Set up context for shutting down
+ shutdownCtx, shutdown := signal.NotifyContext(ctx, os.Interrupt, os.Kill, syscall.SIGTERM)
+ defer shutdown()
+
+ // Initialize tracing
+ // tracerCtx, tracerCancel := context.WithCancel(ctx)
+ // defer tracerCancel()
+
+ // tracer, err := tracing.New(tracerCtx, log, "image-service")
+ // if err != nil {
+ // log.Fatalf("error initializing tracing: %s", err)
+ // }
+ // defer tracer.Shutdown(tracerCtx)
+ tracer := test.Tracer(log)
+
+ // Initialize the storage
+ storage, err := file.New(*storagePath)
+ if err != nil {
+ log.Fatalf("error initializing storage: %s", err)
+ }
+
+ // Initialize the cache
+ cache := memory.New()
+ defer cache.Shutdown()
+
+ // Initialize the image processor
+ imageProcessor, err := vips.New(shutdownCtx, log, tracer, *workers, image.NewCache(tracer, cache, storage))
+ if err != nil {
+ log.Fatalf("error initializing image processor %s", err.Error())
+ }
+
+ // Initialize and start the health checker
+ checker := &health.Checker{
+ Ctx: shutdownCtx,
+ Storage: storage,
+ Cache: cache,
+ Log: log,
+ }
+ go checker.Run()
+
+ // Start and listen on http
+ api := api.NewAPI(imageProcessor, log, tracer, cmd.HandlerTimeout, &hmac.HMAC{
+ Key: []byte(*hmacKey),
+ })
+ server := &http.Server{
+ Handler: api.Router(),
+ ReadTimeout: cmd.ReadTimeout,
+ WriteTimeout: cmd.WriteTimeout,
+ IdleTimeout: cmd.IdleTimeout,
+ ErrorLog: logger.NewHTTPErrorLog(log),
+ }
+
+ // Determine network type: TCP if address contains ":", otherwise Unix socket
+ network := "unix"
+ if strings.Contains(*listen, ":") {
+ network = "tcp"
+ } else {
+ os.Remove(*listen)
+ }
+
+ // Use ListenConfig to pass context for cancellation support
+ // Socket backlog is controlled by the kernel's net.core.somaxconn
+ lc := net.ListenConfig{}
+ listener, err := lc.Listen(ctx, network, *listen)
+ if err != nil {
+ log.Fatalf("error creating %s listener: %s", network, err.Error())
+ }
+ go func() {
+ if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
+ log.Errorf("error shutting down the http server: %s", err)
+ }
+ }()
+
+ log.Infof("http server listening on %s", *listen)
+
+ // Start the metrics http server
+ go metrics.Serve(shutdownCtx, log, checker, *metricsListen)
+
+ // Wait for shutdown
+ <-shutdownCtx.Done()
+ log.Infof("shutting down: %s", shutdownCtx.Err())
+
+ // Shut down http server
+ serverCtx, serverCancel := context.WithTimeout(context.Background(), cmd.WriteTimeout)
+ defer serverCancel()
+ if err := server.Shutdown(serverCtx); err != nil {
+ log.Warnf("error shutting down: %s", err)
+ }
+}
diff --git a/cmd/picsum-photos/main.go b/cmd/picsum-photos/main.go
new file mode 100644
index 0000000..7255c1e
--- /dev/null
+++ b/cmd/picsum-photos/main.go
@@ -0,0 +1,145 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "net"
+ "net/http"
+ "os"
+ "os/signal"
+ "strings"
+ "syscall"
+
+ "github.com/DMarby/picsum-photos/internal/api"
+ "github.com/DMarby/picsum-photos/internal/cmd"
+ "github.com/DMarby/picsum-photos/internal/hmac"
+ "github.com/DMarby/picsum-photos/internal/metrics"
+ "github.com/DMarby/picsum-photos/internal/tracing/test"
+
+ fileDatabase "github.com/DMarby/picsum-photos/internal/database/file"
+ "github.com/DMarby/picsum-photos/internal/health"
+ "github.com/DMarby/picsum-photos/internal/logger"
+
+ "github.com/jamiealquiza/envy"
+ "go.uber.org/automaxprocs/maxprocs"
+ "go.uber.org/zap"
+)
+
+// Comandline flags
+var (
+ // Global
+ listen = flag.String("listen", "", "listen address (tcp host:port or unix socket path)")
+ metricsListen = flag.String("metrics-listen", "127.0.0.1:8082", "metrics listen address")
+ rootURL = flag.String("root-url", "https://picsum.photos", "root url")
+ imageServiceURL = flag.String("image-service-url", "https://fastly.picsum.photos", "image service url")
+ loglevel = zap.LevelFlag("log-level", zap.InfoLevel, "log level (default \"info\") (debug, info, warn, error, dpanic, panic, fatal)")
+
+ // Database - File
+ databaseFilePath = flag.String("database-file-path", "./test/fixtures/file/metadata.json", "path to the database file")
+
+ // HMAC
+ hmacKey = flag.String("hmac-key", "", "hmac key to use for authentication between services")
+)
+
+func main() {
+ ctx := context.Background()
+
+ // Parse environment variables
+ envy.Parse("PICSUM")
+
+ // Parse commandline flags
+ flag.Parse()
+
+ // Initialize the logger
+ log := logger.New(*loglevel)
+ defer log.Sync()
+
+ // Initialize tracing
+ tracer := test.Tracer(log)
+
+ // Set GOMAXPROCS
+ maxprocs.Set(maxprocs.Logger(log.Infof))
+
+ // Set up context for shutting down
+ shutdownCtx, shutdown := signal.NotifyContext(ctx, os.Interrupt, os.Kill, syscall.SIGTERM)
+ defer shutdown()
+
+ // Initialize the database
+ database, err := fileDatabase.New(*databaseFilePath)
+ if err != nil {
+ log.Fatalf("error initializing database: %s", err)
+ }
+
+ // Initialize and start the health checker
+ checkerCtx, checkerCancel := context.WithCancel(ctx)
+ defer checkerCancel()
+
+ checker := &health.Checker{
+ Ctx: checkerCtx,
+ Database: database,
+ Log: log,
+ }
+ go checker.Run()
+
+ // Start and listen on http
+ api := &api.API{
+ Database: database,
+ Log: log,
+ Tracer: tracer,
+ RootURL: *rootURL,
+ ImageServiceURL: *imageServiceURL,
+ HandlerTimeout: cmd.HandlerTimeout,
+ HMAC: &hmac.HMAC{
+ Key: []byte(*hmacKey),
+ },
+ }
+ router, err := api.Router()
+ if err != nil {
+ log.Fatalf("error initializing router: %s", err)
+ }
+
+ server := &http.Server{
+ Handler: router,
+ ReadTimeout: cmd.ReadTimeout,
+ WriteTimeout: cmd.WriteTimeout,
+ IdleTimeout: cmd.IdleTimeout,
+ ErrorLog: logger.NewHTTPErrorLog(log),
+ }
+
+ // Determine network type: TCP if address contains ":", otherwise Unix socket
+ network := "unix"
+ if strings.Contains(*listen, ":") {
+ network = "tcp"
+ } else {
+ os.Remove(*listen)
+ }
+
+ // Use ListenConfig to pass context for cancellation support
+ // Socket backlog is controlled by the kernel's net.core.somaxconn
+ lc := net.ListenConfig{}
+ listener, err := lc.Listen(ctx, network, *listen)
+ if err != nil {
+ log.Fatalf("error creating %s listener: %s", network, err.Error())
+ }
+ go func() {
+ if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
+ log.Errorf("error shutting down the http server: %s", err)
+ }
+ }()
+
+ log.Infof("http server listening on %s", *listen)
+
+ // Start the metrics http server
+ go metrics.Serve(shutdownCtx, log, checker, *metricsListen)
+
+ // Wait for shutdown
+ <-shutdownCtx.Done()
+ log.Infof("shutting down: %s", shutdownCtx.Err())
+
+ // Shut down http server
+ serverCtx, serverCancel := context.WithTimeout(ctx, cmd.WriteTimeout)
+ defer serverCancel()
+ if err := server.Shutdown(serverCtx); err != nil {
+ log.Warnf("error shutting down: %s", err)
+ }
+}
diff --git a/config.js b/config.js
deleted file mode 100644
index ce1fb62..0000000
--- a/config.js
+++ /dev/null
@@ -1,7 +0,0 @@
-module.exports = exports = function () {
- var configFile = require('./config.json')
- var node_env = process.env.NODE_ENV || 'production'
- var config = configFile[node_env]
- config.env = node_env
- return config
-}
\ No newline at end of file
diff --git a/config.json b/config.json
deleted file mode 100644
index a91cff8..0000000
--- a/config.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
- "production": {
- "folder_path": "/opt/photos",
- "cache_folder_path": "/opt/unsplash-it/cache",
- "image_store_path": "/opt/unsplash-it/images.json",
- "stats_path": "/opt/unsplash-it/stats.json",
- "metadata_path": "/opt/photos/metadata.json",
- "max_height": 5000,
- "max_width": 5000,
- "stats_port": 3000,
- "port": 5000,
- "cache_metadata_path": "/opt/unsplash-it/cache.json"
- },
- "development": {
- "folder_path": "../unsplash-downloader/photos",
- "cache_folder_path": "/Users/DMarby/Projects/2014/unsplash-it/cache",
- "image_store_path": "./images.json",
- "stats_path": "./stats.json",
- "metadata_path": "../unsplash-downloader/photos/metadata.json",
- "max_height": 5000,
- "max_width": 5000,
- "stats_port": 3000,
- "port": 5000,
- "cache_metadata_path": "./cache.json"
- }
-}
\ No newline at end of file
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..ac88bc8
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,61 @@
+{
+ "nodes": {
+ "flake-utils": {
+ "inputs": {
+ "systems": "systems"
+ },
+ "locked": {
+ "lastModified": 1731533236,
+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
+ "type": "github"
+ },
+ "original": {
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1769268028,
+ "narHash": "sha256-mAdJpV0e5IGZjnE4f/8uf0E4hQR7ptRP00gnZKUOdMo=",
+ "owner": "nixos",
+ "repo": "nixpkgs",
+ "rev": "ab9fbbcf4858bd6d40ba2bbec37ceb4ab6e1f562",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nixos",
+ "ref": "nixpkgs-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "flake-utils": "flake-utils",
+ "nixpkgs": "nixpkgs"
+ }
+ },
+ "systems": {
+ "locked": {
+ "lastModified": 1681028828,
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+ "owner": "nix-systems",
+ "repo": "default",
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-systems",
+ "repo": "default",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..d546dcb
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,274 @@
+{
+ description = "picsum.photos";
+
+ inputs = {
+ nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
+ flake-utils.url = "github:numtide/flake-utils";
+ };
+
+ outputs = inputs@{
+ self,
+ nixpkgs,
+ flake-utils,
+ ...
+ }:
+ flake-utils.lib.eachSystem [
+ "x86_64-linux"
+ "x86_64-darwin"
+ "aarch64-linux"
+ "aarch64-darwin"
+ ] (system:
+ let pkgs = nixpkgs.legacyPackages.${system}; in {
+ packages = rec {
+ default = everything;
+
+ everything = pkgs.symlinkJoin {
+ name = "picsum-photos-composite";
+ paths = [
+ picsum-photos
+ image-service
+ ];
+ };
+
+ picsum-photos = pkgs.buildGo125Module {
+ name = "picsum-photos";
+ src = ./.;
+ env.CGO_ENABLED = "0";
+ subPackages = ["cmd/picsum-photos"];
+ doCheck = false; # Prevent make test from being ran
+ vendorHash = (pkgs.lib.fileContents ./go.mod.sri);
+ proxyVendor = true;
+ nativeBuildInputs = with pkgs; [
+ tailwindcss
+ ];
+ preBuild = ''
+ go generate ./...
+ '';
+ };
+
+ image-service = pkgs.buildGo125Module {
+ name = "image-service";
+ src = ./.;
+ subPackages = ["cmd/image-service"];
+ doCheck = false; # Prevent make test from being ran
+ vendorHash = (pkgs.lib.fileContents ./go.mod.sri);
+ proxyVendor = true;
+ nativeBuildInputs = with pkgs; [
+ pkg-config
+ ];
+ buildInputs = with pkgs; [
+ vips
+ ];
+ };
+ };
+
+ devShells.default = pkgs.mkShell {
+ packages = with pkgs; [
+ go_1_25
+ gotools
+ go-tools
+ gopls
+ delve
+ ];
+ };
+ }
+ ) // {
+ nixosModules.default = { config, lib, pkgs, ... }:
+ with lib;
+ let cfg = config.picsum-photos.services;
+ in {
+ options.picsum-photos.services = {
+ picsum-photos = {
+ enable = mkEnableOption "Enable the picsum-photos service";
+
+ logLevel = mkOption {
+ type = with types; enum [ "debug" "info" "warn" "error" "dpanic" "panic" "fatal" ];
+ example = "debug";
+ default = "info";
+ description = "log level";
+ };
+
+ domain = mkOption {
+ type = types.str;
+ description = "Domain to listen to";
+ };
+
+ sockPath = mkOption rec {
+ type = types.path;
+ default = "/run/picsum-photos/picsum-photos.sock";
+ example = default;
+ description = "Unix domain socket to listen on";
+ };
+
+ environmentFile = mkOption {
+ type = types.path;
+ description = "Environment file";
+ };
+
+ databaseFilePath = mkOption rec {
+ type = types.path;
+ default = "/var/lib/picsum-photos/image-manifest.json";
+ example = default;
+ description = "Image database file path";
+ };
+ };
+
+ image-service = {
+ enable = mkEnableOption "Enable the image-service service";
+
+ logLevel = mkOption {
+ type = with types; enum [ "debug" "info" "warn" "error" "dpanic" "panic" "fatal" ];
+ example = "debug";
+ default = "info";
+ description = "log level";
+ };
+
+ workers = mkOption rec {
+ type = types.number;
+ default = 16;
+ example = default;
+ description = "worker queue concurrency";
+ };
+
+ domain = mkOption {
+ type = types.str;
+ description = "Domain to listen to";
+ };
+
+ sockPath = mkOption rec {
+ type = types.path;
+ default = "/run/image-service/image-service.sock";
+ example = default;
+ description = "Unix domain socket to listen on";
+ };
+
+ environmentFile = mkOption {
+ type = types.path;
+ description = "Environment file";
+ };
+
+ storagePath = mkOption rec {
+ type = types.path;
+ default = "/var/lib/image-service";
+ example = default;
+ description = "Storage path";
+ };
+ };
+ };
+
+ config = mkMerge([
+ (mkIf cfg.picsum-photos.enable {
+ users.groups.picsum-photos = {};
+
+ users.users.picsum-photos = {
+ createHome = true;
+ isSystemUser = true;
+ group = "picsum-photos";
+ home = "/var/lib/picsum-photos";
+ };
+
+ systemd.services.picsum-photos = {
+ description = "picsum-photos";
+ wantedBy = [ "multi-user.target" ];
+
+ script = ''
+ exec ${self.packages.${pkgs.system}.picsum-photos}/bin/picsum-photos \
+ -log-level=${cfg.picsum-photos.logLevel} \
+ -listen=${cfg.picsum-photos.sockPath} \
+ -database-file-path=${cfg.picsum-photos.databaseFilePath}
+ '';
+
+ serviceConfig = {
+ EnvironmentFile = cfg.picsum-photos.environmentFile;
+ User = "picsum-photos";
+ Group = "picsum-photos";
+ Restart = "always";
+ RestartSec = "30s";
+ WorkingDirectory = "/var/lib/picsum-photos";
+ RuntimeDirectory = "picsum-photos";
+ RuntimeDirectoryMode = "0770";
+ UMask = "007";
+ };
+ };
+
+ services.nginx.virtualHosts."${cfg.picsum-photos.domain}" = {
+ locations."/" = {
+ proxyPass = "http://picsum_photos";
+ extraConfig = ''
+ proxy_http_version 1.1;
+ proxy_set_header Connection "";
+ '';
+ };
+ };
+ })
+
+ (mkIf cfg.image-service.enable {
+ users.groups.image-service = {};
+
+ users.users.image-service = {
+ createHome = true;
+ isSystemUser = true;
+ group = "image-service";
+ home = "/var/lib/image-service";
+ };
+
+ systemd.services.image-service = {
+ description = "image-service";
+ wantedBy = [ "multi-user.target" ];
+
+ script = ''
+ exec ${self.packages.${pkgs.system}.image-service}/bin/image-service \
+ -log-level=${cfg.image-service.logLevel} \
+ -listen=${cfg.image-service.sockPath} \
+ -storage-path=${cfg.image-service.storagePath} \
+ -workers=${toString cfg.image-service.workers}
+ '';
+
+ serviceConfig = {
+ EnvironmentFile = cfg.image-service.environmentFile;
+ User = "image-service";
+ Group = "image-service";
+ Restart = "always";
+ RestartSec = "30s";
+ WorkingDirectory = "/var/lib/image-service";
+ RuntimeDirectory = "image-service";
+ RuntimeDirectoryMode = "0770";
+ UMask = "007";
+ };
+ };
+
+ services.nginx.virtualHosts."${cfg.image-service.domain}" = {
+ locations."/" = {
+ proxyPass = http://image_service;
+ extraConfig = ''
+ proxy_http_version 1.1;
+ proxy_set_header Connection "";
+ '';
+ };
+ };
+ })
+
+ (mkIf (cfg.picsum-photos.enable || cfg.image-service.enable) {
+ services.nginx.appendHttpConfig = mkBefore ''
+ ${optionalString cfg.picsum-photos.enable ''
+ upstream picsum_photos {
+ server unix:${cfg.picsum-photos.sockPath};
+ keepalive 256;
+ keepalive_requests 1000;
+ keepalive_timeout 60s;
+ }
+ ''}
+ ${optionalString cfg.image-service.enable ''
+ upstream image_service {
+ server unix:${cfg.image-service.sockPath};
+ keepalive 256;
+ keepalive_requests 1000;
+ keepalive_timeout 60s;
+ }
+ ''}
+ '';
+ })
+ ]);
+ };
+ };
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..997be46
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,59 @@
+module github.com/DMarby/picsum-photos
+
+go 1.25.5
+
+require (
+ github.com/felixge/httpsnoop v1.0.4
+ github.com/go-logr/stdr v1.2.2
+ github.com/gorilla/mux v1.8.1
+ github.com/jamiealquiza/envy v1.1.0
+ github.com/prometheus/client_golang v1.23.2
+ github.com/prometheus/common v0.67.5
+ github.com/rs/cors v1.11.1
+ github.com/twmb/murmur3 v1.1.8
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0
+ go.opentelemetry.io/otel v1.39.0
+ go.opentelemetry.io/otel/sdk v1.39.0
+ go.opentelemetry.io/otel/trace v1.39.0
+ go.uber.org/automaxprocs v1.6.0
+ go.uber.org/zap v1.27.1
+ golang.org/x/sync v0.19.0
+ tailscale.com v1.94.1
+)
+
+require (
+ github.com/cenkalti/backoff/v5 v5.0.3 // indirect
+ github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.yaml.in/yaml/v2 v2.4.3 // indirect
+ golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20260122232226-8e98ce8d340d // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d // indirect
+)
+
+require (
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.5 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/procfs v0.19.2 // indirect
+ github.com/spf13/cobra v1.10.2 // indirect
+ github.com/spf13/pflag v1.0.10 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0
+ go.opentelemetry.io/otel/metric v1.39.0 // indirect
+ go.opentelemetry.io/proto/otlp v1.9.0 // indirect
+ go.uber.org/multierr v1.11.0 // indirect
+ go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
+ golang.org/x/crypto v0.47.0 // indirect
+ golang.org/x/net v0.49.0 // indirect
+ golang.org/x/sys v0.40.0 // indirect
+ golang.org/x/text v0.33.0 // indirect
+ google.golang.org/grpc v1.78.0 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
+)
diff --git a/go.mod.sri b/go.mod.sri
new file mode 100644
index 0000000..bd9d15a
--- /dev/null
+++ b/go.mod.sri
@@ -0,0 +1 @@
+sha256-70EA2tWO1xkue5J2X6S610t7rp6Qa1q71VC3aSQtMJk=
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..7f09744
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,132 @@
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
+github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
+github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
+github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.5 h1:jP1RStw811EvUDzsUQ9oESqw2e4RqCjSAD9qIL8eMns=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.5/go.mod h1:WXNBZ64q3+ZUemCMXD9kYnr56H7CgZxDBHCVwstfl3s=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/jamiealquiza/envy v1.1.0 h1:Nwh4wqTZ28gDA8zB+wFkhnUpz3CEcO12zotjeqqRoKE=
+github.com/jamiealquiza/envy v1.1.0/go.mod h1:MP36BriGCLwEHhi1OU8E9569JNZrjWfCvzG7RsPnHus=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
+github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
+github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
+github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
+github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
+github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
+github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
+github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg=
+github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
+go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
+go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c=
+go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
+go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
+go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
+go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
+go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
+go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
+go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
+go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
+go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
+go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
+go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
+go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
+go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
+go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
+go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
+golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
+golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
+golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
+golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
+golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
+golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
+golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
+golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
+golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
+golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
+gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
+gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
+google.golang.org/genproto/googleapis/api v0.0.0-20260122232226-8e98ce8d340d h1:tUKoKfdZnSjTf5LW7xpG4c6SZ3Ozisn5eumcoTuMEN4=
+google.golang.org/genproto/googleapis/api v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d h1:xXzuihhT3gL/ntduUZwHECzAn57E8dA6l8SOtYWdD8Q=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
+google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
+google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+tailscale.com v1.94.1 h1:0dAst/ozTuFkgmxZULc3oNwR9+qPIt5ucvzH7kaM0Jw=
+tailscale.com v1.94.1/go.mod h1:gLnVrEOP32GWvroaAHHGhjSGMPJ1i4DvqNwEg+Yuov4=
diff --git a/imageProcessor.js b/imageProcessor.js
deleted file mode 100644
index 02bb97e..0000000
--- a/imageProcessor.js
+++ /dev/null
@@ -1,76 +0,0 @@
-module.exports = exports = function (sharp, path, config, fs) {
- var ImageProcessor = {
- 'getProcessedImage': function (width, height, gravity, gray, blur, filePath, shortName, callback) {
- gravity = ImageProcessor.getGravity(gravity)
- ImageProcessor.getAndCheckDestination(width, height, gravity, blur, filePath, gray ? 'gray-' : '', shortName, function (exists, destination) {
- if (exists) {
- return callback(null, destination)
- }
-
- ImageProcessor.imageResize(width, height, gravity, filePath, destination, gray, blur, function (error, destination) {
- if (error) {
- ImageProcessor.deleteFile(destination)
- return callback(error)
- }
-
- callback(null, destination)
- })
- })
- },
-
- 'getGravity': function(gravity) {
- gravity = gravity ? gravity : 'center'
- gravity = gravity == 'centre' ? 'center' : gravity
- return gravity
- },
-
- 'getAndCheckDestination': function (width, height, gravity, blur, filePath, prefix, shortName, callback) {
- var destination = shortName ? ImageProcessor.getShortDestination(width, height, gravity, blur, filePath, prefix) : ImageProcessor.getDestination(width, height, gravity, blur, filePath, prefix)
- fs.exists(destination, function (exists) {
- callback(exists, destination)
- })
- },
-
- 'getDestination': function (width, height, gravity, blur, filePath, prefix) {
- return config.cache_folder_path + '/' + prefix + path.basename(filePath, path.extname(filePath)) + '-' + width + 'x' + height + '-' + gravity + (blur ? '-blur' : '') + '.jpeg'
- },
-
- 'getShortDestination': function (width, height, gravity, blur, filePath, prefix) {
- return config.cache_folder_path + '/' + prefix + width + '^' + height + '-' + gravity + (blur ? '-blurred' : '') + '.jpeg'
- },
-
- 'imageResize': function (width, height, gravity, filePath, destination, gray, blur, callback) {
- try {
- var image = sharp(filePath).rotate().resize(width, height).crop(sharp.gravity[gravity]);
-
- if (gray) {
- image.grayscale()
- }
-
- if (blur) {
- image.blur(10)
- }
-
- image.jpeg().progressive().toFile(destination, function (error) {
- callback(error, destination)
- })
- } catch (error) {
- callback(error, null)
- }
- },
-
- 'deleteFile': function (destination) {
- fs.unlink(destination, function (error) {
- console.log('Error, deleted file')
- })
- },
-
- 'getWidthAndHeight': function (params, square, callback) {
- var width = square ? params.size : params.width
- var height = square ? params.size : params.height
- callback(width, height)
- }
- }
-
- return ImageProcessor
-}
\ No newline at end of file
diff --git a/index.js b/index.js
deleted file mode 100644
index 87416e5..0000000
--- a/index.js
+++ /dev/null
@@ -1,255 +0,0 @@
-var cluster = require('cluster')
-
-if (cluster.isMaster) {
- var async = require('async')
- var config = require('./config')()
- var fs = require('fs')
- var path = require('path')
- var sharp = require('sharp')
- var io = require('socket.io')(config.stats_port)
- var vnstat = require('vnstat-dumpdb')
- var metadata = require(config.metadata_path)
- var moment = require('moment')
- console.log('Config:')
- console.log(config)
-
- try {
- var stats = require(config.stats_path)
- } catch (error) {
- var stats = { count: 0 }
- }
-
- try {
- var images = require(config.image_store_path)
- } catch (e) {
- var images = []
- }
-
- try {
- var cache = require(config.cache_metadata_path)
- } catch (e) {
- var cache = {}
- }
-
- var publicStats = {}
- var bandWidth = 0
-
- io.on('connection', function (socket) {
- socket.emit('stats', publicStats)
- })
-
- var fetchStats = function () {
- publicStats = {
- count: stats.count,
- bandWidth: bandWidth,
- images: images.length
- }
-
- io.emit('stats', publicStats)
- }
-
- var fetchBandwidth = function () {
- vnstat.dumpdb(function (error, data) {
- if (error) {
- console.log('Couldn\'t fetch bandwidth: ' + error)
- } else {
- bandWidth = data.traffic ? data.traffic.total.tx : data.eth0.traffic.total.tx
- }
- })
- }
-
- setInterval(fetchBandwidth, 1000 * 30)
- setInterval(fetchStats, 1000)
- fetchBandwidth()
- fetchStats()
-
- var exited = false
-
- var saveToFileAndExit = function () {
- if (exited) {
- return
- } else {
- exited = true
- }
-
- console.log('Current stats:', stats)
-
- fs.writeFileSync(config.stats_path, JSON.stringify(stats), 'utf8')
- fs.writeFileSync(config.cache_metadata_path, JSON.stringify(cache), 'utf8')
- process.exit(0)
- }
-
- var saveToFile = function (callback) {
- fs.writeFile(config.stats_path, JSON.stringify(stats), 'utf8', function (error) {
- fs.writeFile(config.cache_metadata_path, JSON.stringify(cache), 'utf8', function (error) {
- callback()
- })
- })
- }
-
- process.on('exit', saveToFileAndExit)
- process.on('SIGINT', saveToFileAndExit)
- process.on('SIGTERM', saveToFileAndExit)
- process.on('uncaughtException', function (error) {
- console.log('Uncaught exception: ')
- console.trace(error)
- saveToFileAndExit()
- })
-
- var loadImages = function () {
- var newImages = []
-
- async.each(metadata, function (image, next) {
- if (image.deleted) {
- return setImmediate(next)
- }
-
- var existingImage = imageExists(image)
-
- if (existingImage) {
- existingImage.post_url = image.post_url
- existingImage.author = image.author
- existingImage.author_url = image.author_url
- newImages.push(existingImage)
-
- return setImmediate(next)
- }
-
- var filename = path.resolve(config.folder_path, image.filename)
-
- console.log('Getting info for new image %s', filename)
-
- sharp(filename).metadata(function (error, result) {
- if (error) {
- console.trace('imageScan error: %s filename: %s', error, filename)
- return setImmediate(next)
- }
-
- result.filename = filename
- result.id = image.id
- result.post_url = image.post_url
- result.author = image.author
- result.author_url = image.author_url
- newImages.push(result)
-
- next()
- })
- }, function (error) {
- writeImagesToFile(newImages)
- })
- }
-
- var imageExists = function (image) {
- for (var i in images) {
- if (images[i].id === image.id) {
- return images[i]
- }
- }
-
- return false
- }
-
- var writeImagesToFile = function (newImages) {
- newImages.sort(function (a, b) {
- return a.id - b.id
- })
-
- images = newImages
-
- fs.writeFile(config.image_store_path, JSON.stringify(newImages), 'utf8', function (error) {
- findMissingCacheFiles(function () {
- startWebServers()
- })
- })
- }
-
- var findMissingCacheFiles = function (callback) {
- fs.readdir(config.cache_folder_path, function (error, list) {
- if (error) {
- console.log('Error reading cache directory!')
- return callback()
- }
-
- async.each(list, function (filename, next) {
- filename = path.resolve(config.cache_folder_path, filename)
- if (cache[filename] === undefined) {
- fs.unlink(filename, function (error) {
- setImmediate(next)
- })
- } else {
- setImmediate(next)
- }
- }, function (error) {
- callback()
- })
- })
- }
-
- var startWebServers = function () {
- var cpuCount = require('os').cpus().length - 1
-
- if (cpuCount < 2) {
- cpuCount = 2
- }
-
- for (var i = 0, il=cpuCount; i < il; i++) {
- startWorker()
- }
-
- cluster.on('exit', function (worker) {
- console.log('Worker ' + worker.id + ' died')
- startWorker()
- })
-
- var triggerSaveToFile = function () {
- saveToFile(function () {
- setImmediate(setTimeout, triggerSaveToFile, 1000 * 5)
- })
- }
-
- setTimeout(triggerSaveToFile, 1000 * 5)
-
- var triggerCacheCleanup = function () {
- cleanupCache(function () {
- setImmediate(setTimeout, triggerCacheCleanup, 1000 * 60 * 5)
- })
- }
-
- setTimeout(triggerCacheCleanup, 1000 * 60 * 5)
- }
-
- var cleanupCache = function (callback) {
- async.eachLimit(Object.keys(cache), 100, function (filename, next) {
- if (moment().diff(cache[filename], 'days') >= 14) {
- fs.unlink(filename, function (error) {
- delete cache[filename]
- setImmediate(next)
- })
- } else {
- setImmediate(next)
- }
- }, function (error) {
- callback()
- })
- }
-
- var startWorker = function () {
- var worker = cluster.fork()
- worker.on('message', handleWorkerMessage)
- console.log('Worker ' + worker.id + ' started')
- }
-
- var handleWorkerMessage = function (msg) {
- stats.count++
- cache[msg] = new Date()
- }
-
- fs.mkdir(config.cache_folder_path, function (error) {
- loadImages()
- })
-} else {
- var config = require('./config')()
- require('./server')(function (callback) {
- callback.listen(process.env.PORT || config.port, '0.0.0.0')
- })
-}
\ No newline at end of file
diff --git a/internal/api/api.go b/internal/api/api.go
new file mode 100644
index 0000000..ec44741
--- /dev/null
+++ b/internal/api/api.go
@@ -0,0 +1,140 @@
+package api
+
+import (
+ "io/fs"
+ "net/http"
+ "time"
+
+ "github.com/DMarby/picsum-photos/internal/handler"
+ "github.com/DMarby/picsum-photos/internal/hmac"
+ "github.com/DMarby/picsum-photos/internal/tracing"
+ "github.com/DMarby/picsum-photos/internal/web"
+ "github.com/rs/cors"
+
+ "github.com/DMarby/picsum-photos/internal/database"
+ "github.com/DMarby/picsum-photos/internal/logger"
+ "github.com/gorilla/mux"
+
+ _ "embed"
+)
+
+// API is a http api
+type API struct {
+ Database database.Provider
+ Log *logger.Logger
+ Tracer *tracing.Tracer
+ RootURL string
+ ImageServiceURL string
+ HandlerTimeout time.Duration
+ HMAC *hmac.HMAC
+}
+
+// Utility methods for logging
+func (a *API) logError(r *http.Request, message string, err error) {
+ a.Log.Errorw(message, handler.LogFields(r, "error", err)...)
+}
+
+// Router returns a http router
+func (a *API) Router() (http.Handler, error) {
+ router := mux.NewRouter()
+
+ router.NotFoundHandler = handler.Handler(a.notFoundHandler)
+
+ // Redirect trailing slashes
+ router.StrictSlash(true)
+
+ // Image list
+ router.Handle("/v2/list", handler.Handler(a.listHandler)).Methods("GET").Name("api.list")
+
+ // Query parameters:
+ // ?page={page} - What page to display
+ // ?limit={limit} - How many entries to display per page
+
+ // Image routes
+ oldRouter := router.PathPrefix("").Subrouter()
+ oldRouter.Use(a.deprecatedParams)
+
+ oldRouter.Handle("/{size:[0-9]+}{extension:(?:\\..*)?}", handler.Handler(a.randomImageRedirectHandler)).Methods("GET").Name("api.randomImageRedirect")
+ oldRouter.Handle("/{width:[0-9]+}/{height:[0-9]+}{extension:(?:\\..*)?}", handler.Handler(a.randomImageRedirectHandler)).Methods("GET").Name("api.randomImageRedirect")
+
+ // Image by ID routes
+ router.Handle("/id/{id}/{size:[0-9]+}{extension:(?:\\..*)?}", handler.Handler(a.imageRedirectHandler)).Methods("GET").Name("api.imageRedirect")
+ router.Handle("/id/{id}/{width:[0-9]+}/{height:[0-9]+}{extension:(?:\\..*)?}", handler.Handler(a.imageRedirectHandler)).Methods("GET").Name("api.imageRedirect")
+
+ // Image info routes
+ router.Handle("/id/{id}/info", handler.Handler(a.infoHandler)).Methods("GET").Name("api.info")
+ router.Handle("/seed/{seed}/info", handler.Handler(a.infoSeedHandler)).Methods("GET").Name("api.infoSeed")
+
+ // Image by seed routes
+ router.Handle("/seed/{seed}/{size:[0-9]+}{extension:(?:\\..*)?}", handler.Handler(a.seedImageRedirectHandler)).Methods("GET").Name("api.seedImageRedirect")
+ router.Handle("/seed/{seed}/{width:[0-9]+}/{height:[0-9]+}{extension:(?:\\..*)?}", handler.Handler(a.seedImageRedirectHandler)).Methods("GET").Name("api.seedImageRedirect")
+
+ // Query parameters:
+ // ?grayscale - Grayscale the image
+ // ?blur - Blur the image
+ // ?blur={amount} - Blur the image by {amount}
+
+ // Deprecated query parameters:
+ // ?image={id} - Get image by id
+
+ // Deprecated routes
+ router.Handle("/list", handler.Handler(a.deprecatedListHandler)).Methods("GET").Name("api.deprecatedList")
+ router.Handle("/g/{size:[0-9]+}{extension:(?:\\..*)?}", handler.Handler(a.deprecatedImageHandler)).Methods("GET").Name("api.deprecatedImage")
+ router.Handle("/g/{width:[0-9]+}/{height:[0-9]+}{extension:(?:\\..*)?}", handler.Handler(a.deprecatedImageHandler)).Methods("GET").Name("api.deprecatedImage")
+
+ // Static files
+ staticFS, err := fs.Sub(web.Static, "embed")
+ if err != nil {
+ return nil, err
+ }
+ fileServer := http.FileServer(http.FS(staticFS))
+
+ router.HandleFunc("/", serveFile(fileServer, "/")).Name("api.serveFile")
+ router.HandleFunc("/images", serveFile(fileServer, "/images.html")).Name("api.serveFile")
+ router.HandleFunc("/favicon.ico", serveFile(fileServer, "/favicon.ico")).Name("api.serveFile")
+ router.HandleFunc("/robots.txt", serveFile(fileServer, "/robots.txt")).Name("api.serveFile")
+ router.PathPrefix("/assets/").HandlerFunc(fileHeaders(fileServer.ServeHTTP)).Name("api.serveFile")
+
+ // Set up handlers
+ cors := cors.New(cors.Options{
+ AllowedMethods: []string{"GET"},
+ AllowedOrigins: []string{"*"},
+ })
+
+ httpHandler := cors.Handler(router)
+ httpHandler = handler.Recovery(a.Log, httpHandler)
+ httpHandler = http.TimeoutHandler(httpHandler, a.HandlerTimeout, "Something went wrong. Timed out.")
+ httpHandler = handler.Logger(a.Log, httpHandler)
+
+ routeMatcher := &handler.MuxRouteMatcher{Router: router}
+ httpHandler = handler.Tracer(a.Tracer, httpHandler, routeMatcher)
+ httpHandler = handler.Metrics(httpHandler, routeMatcher)
+
+ return httpHandler, nil
+}
+
+// Handle not found errors
+var notFoundError = &handler.Error{
+ Message: "page not found",
+ Code: http.StatusNotFound,
+}
+
+func (a *API) notFoundHandler(w http.ResponseWriter, r *http.Request) *handler.Error {
+ return notFoundError
+}
+
+// Set headers for static file handlers
+func fileHeaders(handler func(w http.ResponseWriter, r *http.Request)) func(w http.ResponseWriter, r *http.Request) {
+ return func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "public, max-age=7200, stale-while-revalidate=60, stale-if-error=43200")
+ handler(w, r)
+ }
+}
+
+// Serve a static file
+func serveFile(h http.Handler, name string) func(w http.ResponseWriter, r *http.Request) {
+ return fileHeaders(func(w http.ResponseWriter, r *http.Request) {
+ r.URL.Path = name
+ h.ServeHTTP(w, r)
+ })
+}
diff --git a/internal/api/api_test.go b/internal/api/api_test.go
new file mode 100644
index 0000000..afd8171
--- /dev/null
+++ b/internal/api/api_test.go
@@ -0,0 +1,468 @@
+package api_test
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "reflect"
+ "strings"
+ "time"
+
+ "github.com/DMarby/picsum-photos/internal/api"
+ "github.com/DMarby/picsum-photos/internal/database"
+ "github.com/DMarby/picsum-photos/internal/hmac"
+ "github.com/DMarby/picsum-photos/internal/logger"
+ "github.com/DMarby/picsum-photos/internal/tracing"
+ "go.opentelemetry.io/otel/trace"
+ "go.uber.org/zap"
+
+ fileDatabase "github.com/DMarby/picsum-photos/internal/database/file"
+ mockDatabase "github.com/DMarby/picsum-photos/internal/database/mock"
+
+ "testing"
+)
+
+const rootURL = "https://example.com"
+const imageServiceURL = "https://i.example.com"
+
+func TestAPI(t *testing.T) {
+ log := logger.New(zap.FatalLevel)
+ defer log.Sync()
+
+ db, _ := fileDatabase.New("../../test/fixtures/file/metadata.json")
+ dbMultiple, _ := fileDatabase.New("../../test/fixtures/file/metadata_multiple.json")
+
+ hmac := &hmac.HMAC{
+ Key: []byte("test"),
+ }
+
+ tp := trace.NewNoopTracerProvider()
+ tracer := &tracing.Tracer{
+ ServiceName: "test",
+ Log: log,
+ TracerProvider: tp,
+ ShutdownFunc: func(context.Context) error {
+ return nil
+ },
+ }
+
+ router, _ := (&api.API{db, log, tracer, rootURL, imageServiceURL, time.Minute, hmac}).Router()
+ paginationRouter, _ := (&api.API{dbMultiple, log, tracer, rootURL, imageServiceURL, time.Minute, hmac}).Router()
+ mockDatabaseRouter, _ := (&api.API{&mockDatabase.Provider{}, log, tracer, rootURL, imageServiceURL, time.Minute, hmac}).Router()
+
+ tests := []struct {
+ Name string
+ URL string
+ Router http.Handler
+ ExpectedStatus int
+ ExpectedResponse []byte
+ ExpectedHeaders map[string]string
+ }{
+ {
+ Name: "/v2/list lists images",
+ URL: "/v2/list",
+ Router: paginationRouter,
+ ExpectedStatus: http.StatusOK,
+ ExpectedResponse: marshalJson([]api.ListImage{
+ {
+ Image: database.Image{
+ ID: "1",
+ Author: "John Doe",
+ URL: "https://picsum.photos",
+ Width: 300,
+ Height: 400,
+ },
+ DownloadURL: fmt.Sprintf("%s/id/1/300/400", rootURL),
+ },
+ {
+ Image: database.Image{
+ ID: "2",
+ Author: "John Doe",
+ URL: "https://picsum.photos",
+ Width: 300,
+ Height: 400,
+ },
+ DownloadURL: fmt.Sprintf("%s/id/2/300/400", rootURL),
+ },
+ }),
+ ExpectedHeaders: map[string]string{
+ "Content-Type": "application/json",
+ "Link": fmt.Sprintf("<%s/v2/list?page=2&limit=30>; rel=\"next\"", rootURL),
+ "Cache-Control": "private, no-cache, no-store, must-revalidate",
+ "Access-Control-Expose-Headers": "Link",
+ },
+ },
+ {
+ Name: "/v2/list lists images with limit",
+ URL: "/v2/list?limit=1000",
+ Router: paginationRouter,
+ ExpectedStatus: http.StatusOK,
+ ExpectedResponse: marshalJson([]api.ListImage{
+ {
+ Image: database.Image{
+ ID: "1",
+ Author: "John Doe",
+ URL: "https://picsum.photos",
+ Width: 300,
+ Height: 400,
+ },
+ DownloadURL: fmt.Sprintf("%s/id/1/300/400", rootURL),
+ },
+ {
+ Image: database.Image{
+ ID: "2",
+ Author: "John Doe",
+ URL: "https://picsum.photos",
+ Width: 300,
+ Height: 400,
+ },
+ DownloadURL: fmt.Sprintf("%s/id/2/300/400", rootURL),
+ },
+ }),
+ ExpectedHeaders: map[string]string{
+ "Content-Type": "application/json",
+ "Link": fmt.Sprintf("<%s/v2/list?page=2&limit=100>; rel=\"next\"", rootURL),
+ "Cache-Control": "private, no-cache, no-store, must-revalidate",
+ },
+ },
+ {
+ Name: "/v2/list pagination page 1",
+ URL: "/v2/list?page=1&limit=1",
+ Router: paginationRouter,
+ ExpectedStatus: http.StatusOK,
+ ExpectedResponse: marshalJson([]api.ListImage{
+ {
+ Image: database.Image{
+ ID: "1",
+ Author: "John Doe",
+ URL: "https://picsum.photos",
+ Width: 300,
+ Height: 400,
+ },
+ DownloadURL: fmt.Sprintf("%s/id/1/300/400", rootURL),
+ },
+ }),
+ ExpectedHeaders: map[string]string{
+ "Content-Type": "application/json",
+ "Link": fmt.Sprintf("<%s/v2/list?page=2&limit=1>; rel=\"next\"", rootURL),
+ "Cache-Control": "private, no-cache, no-store, must-revalidate",
+ "Access-Control-Expose-Headers": "Link",
+ },
+ },
+ {
+ Name: "/v2/list pagination page 2",
+ URL: "/v2/list?page=2&limit=1",
+ Router: paginationRouter,
+ ExpectedStatus: http.StatusOK,
+ ExpectedResponse: marshalJson([]api.ListImage{
+ {
+ Image: database.Image{
+ ID: "2",
+ Author: "John Doe",
+ URL: "https://picsum.photos",
+ Width: 300,
+ Height: 400,
+ },
+ DownloadURL: fmt.Sprintf("%s/id/2/300/400", rootURL),
+ },
+ }),
+ ExpectedHeaders: map[string]string{
+ "Content-Type": "application/json",
+ "Link": fmt.Sprintf("<%s/v2/list?page=1&limit=1>; rel=\"prev\", <%s/v2/list?page=3&limit=1>; rel=\"next\"", rootURL, rootURL),
+ "Cache-Control": "private, no-cache, no-store, must-revalidate",
+ "Access-Control-Expose-Headers": "Link",
+ },
+ },
+ {
+ Name: "/v2/list pagination page 3",
+ URL: "/v2/list?page=3&limit=1",
+ Router: paginationRouter,
+ ExpectedStatus: http.StatusOK,
+ ExpectedResponse: marshalJson([]api.ListImage{}),
+ ExpectedHeaders: map[string]string{
+ "Content-Type": "application/json",
+ "Link": fmt.Sprintf("<%s/v2/list?page=2&limit=1>; rel=\"prev\"", rootURL),
+ "Cache-Control": "private, no-cache, no-store, must-revalidate",
+ "Access-Control-Expose-Headers": "Link",
+ },
+ },
+ {
+ Name: "Deprecated /list lists images",
+ URL: "/list",
+ Router: router,
+ ExpectedStatus: http.StatusOK,
+ ExpectedResponse: marshalJson([]api.DeprecatedImage{
+ {
+ Format: "jpeg",
+ Width: 300,
+ Height: 400,
+ Filename: "1.jpeg",
+ ID: 1,
+ Author: "John Doe",
+ AuthorURL: "https://picsum.photos",
+ PostURL: "https://picsum.photos",
+ },
+ }),
+ ExpectedHeaders: map[string]string{
+ "Content-Type": "application/json",
+ "Cache-Control": "private, no-cache, no-store, must-revalidate",
+ },
+ },
+ {
+ Name: "/id/{id}/info returns info about an image",
+ URL: "/id/1/info",
+ Router: paginationRouter,
+ ExpectedStatus: http.StatusOK,
+ ExpectedResponse: marshalJson(
+ api.ListImage{
+ Image: database.Image{
+ ID: "1",
+ Author: "John Doe",
+ URL: "https://picsum.photos",
+ Width: 300,
+ Height: 400,
+ },
+ DownloadURL: fmt.Sprintf("%s/id/1/300/400", rootURL),
+ },
+ ),
+ ExpectedHeaders: map[string]string{
+ "Content-Type": "application/json",
+ "Cache-Control": "private, no-cache, no-store, must-revalidate",
+ },
+ },
+ {
+ Name: "/seed/{seed}/info returns info about an image",
+ URL: "/seed/1/info",
+ Router: paginationRouter,
+ ExpectedStatus: http.StatusOK,
+ ExpectedResponse: marshalJson(
+ api.ListImage{
+ Image: database.Image{
+ ID: "2",
+ Author: "John Doe",
+ URL: "https://picsum.photos",
+ Width: 300,
+ Height: 400,
+ },
+ DownloadURL: fmt.Sprintf("%s/id/2/300/400", rootURL),
+ },
+ ),
+ ExpectedHeaders: map[string]string{
+ "Content-Type": "application/json",
+ "Cache-Control": "private, no-cache, no-store, must-revalidate",
+ },
+ },
+
+ // Errors
+ {"invalid image id", "/id/nonexistant/200/300", router, http.StatusNotFound, []byte("Image does not exist\n"), map[string]string{"Content-Type": "text/plain; charset=utf-8", "Cache-Control": "private, no-cache, no-store, must-revalidate"}},
+ {"invalid image id", "/id/nonexistant/info", router, http.StatusNotFound, []byte("Image does not exist\n"), map[string]string{"Content-Type": "text/plain; charset=utf-8", "Cache-Control": "private, no-cache, no-store, must-revalidate"}},
+ {"invalid size", "/id/1/1/9223372036854775808", router, http.StatusBadRequest, []byte("Invalid size\n"), map[string]string{"Content-Type": "text/plain; charset=utf-8", "Cache-Control": "private, no-cache, no-store, must-revalidate"}}, // Number larger then max int size to fail int parsing
+ {"invalid size", "/id/1/9223372036854775808/1", router, http.StatusBadRequest, []byte("Invalid size\n"), map[string]string{"Content-Type": "text/plain; charset=utf-8", "Cache-Control": "private, no-cache, no-store, must-revalidate"}}, // Number larger then max int size to fail int parsing
+ {"invalid size", "/id/1/5500/1", router, http.StatusBadRequest, []byte("Invalid size\n"), map[string]string{"Content-Type": "text/plain; charset=utf-8", "Cache-Control": "private, no-cache, no-store, must-revalidate"}}, // Number larger then maxImageSize to fail int parsing
+ {"invalid size", "/seed/1/9223372036854775808/1", router, http.StatusBadRequest, []byte("Invalid size\n"), map[string]string{"Content-Type": "text/plain; charset=utf-8", "Cache-Control": "private, no-cache, no-store, must-revalidate"}}, // Number larger then maxImageSize to fail int parsing
+ {"invalid size", "/9223372036854775808", router, http.StatusBadRequest, []byte("Invalid size\n"), map[string]string{"Content-Type": "text/plain; charset=utf-8", "Cache-Control": "private, no-cache, no-store, must-revalidate"}}, // Number larger then maxImageSize to fail int parsing
+ {"invalid blur amount", "/id/1/100/100?blur=11", router, http.StatusBadRequest, []byte("Invalid blur amount\n"), map[string]string{"Content-Type": "text/plain; charset=utf-8", "Cache-Control": "private, no-cache, no-store, must-revalidate"}},
+ {"invalid blur amount", "/id/1/100/100?blur=0", router, http.StatusBadRequest, []byte("Invalid blur amount\n"), map[string]string{"Content-Type": "text/plain; charset=utf-8", "Cache-Control": "private, no-cache, no-store, must-revalidate"}},
+ {"invalid file extension", "/id/1/100/100.png", router, http.StatusBadRequest, []byte("Invalid file extension\n"), map[string]string{"Content-Type": "text/plain; charset=utf-8", "Cache-Control": "private, no-cache, no-store, must-revalidate"}},
+ // Deprecated handler errors
+ {"invalid size", "/g/9223372036854775808", router, http.StatusBadRequest, []byte("Invalid size\n"), map[string]string{"Content-Type": "text/plain; charset=utf-8", "Cache-Control": "private, no-cache, no-store, must-revalidate"}}, // Number larger then max int size to fail int parsing
+ // Database errors
+ {"List()", "/list", mockDatabaseRouter, 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"}},
+ {"List()", "/v2/list", mockDatabaseRouter, 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"}},
+ {"GetRandom()", "/200", mockDatabaseRouter, 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"}},
+ {"GetRandom()", "/g/200", mockDatabaseRouter, 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"}},
+ {"GetRandomWithSeed()", "/seed/1/200", mockDatabaseRouter, 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"}},
+ {"Get() database", "/id/1/100/100", mockDatabaseRouter, 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"}},
+ {"Get() database", "/g/100?image=1", mockDatabaseRouter, 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"}},
+ {"Get() database info", "/id/1/info", mockDatabaseRouter, 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"}},
+ // 404
+ {"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"}},
+ }
+
+ for _, test := range tests {
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", test.URL, nil)
+ test.Router.ServeHTTP(w, req)
+ if w.Code != test.ExpectedStatus {
+ t.Errorf("%s: wrong response code, %#v", test.Name, w.Code)
+ continue
+ }
+
+ if test.ExpectedHeaders != nil {
+ for expectedHeader, expectedValue := range test.ExpectedHeaders {
+ headerValue := w.Header().Get(expectedHeader)
+ if headerValue != expectedValue {
+ t.Errorf("%s: wrong header value for %s, %#v", test.Name, expectedHeader, headerValue)
+ }
+ }
+ }
+
+ if !reflect.DeepEqual(w.Body.Bytes(), test.ExpectedResponse) {
+ t.Errorf("%s: wrong response %#v", test.Name, w.Body.String())
+ }
+ }
+
+ noCacheHeader := "private, no-cache, no-store, must-revalidate"
+ cacheableHeader := "public, max-age=86400, stale-while-revalidate=60, stale-if-error=43200"
+
+ redirectTests := []struct {
+ Name string
+ URL string
+ ExpectedURL string
+ ExpectedCacheHeader string
+ LocalRedirect bool
+ }{
+ // /id/:id/:size to /id/:id/:width/:height (cacheable - deterministic)
+ {"/id/:id/:size", "/id/1/200", "/id/1/200/200.jpg", cacheableHeader, false},
+ {"/id/:id/:size.jpg", "/id/1/200.jpg", "/id/1/200/200.jpg", cacheableHeader, false},
+ {"/id/:id/:size.webp", "/id/1/200.webp", "/id/1/200/200.webp", cacheableHeader, false},
+ {"/id/:id/:size?blur", "/id/1/200?blur", "/id/1/200/200.jpg?blur=5", cacheableHeader, false},
+ {"/id/:id/:size?blur", "/id/1/200?blur=10", "/id/1/200/200.jpg?blur=10", cacheableHeader, false},
+ {"/id/:id/:size?grayscale", "/id/1/200?grayscale", "/id/1/200/200.jpg?grayscale", cacheableHeader, false},
+ {"/id/:id/:size?blur&grayscale", "/id/1/200?blur&grayscale", "/id/1/200/200.jpg?blur=5&grayscale", cacheableHeader, false},
+
+ // General (random - not cacheable)
+ {"/:size", "/200", "/id/1/200/200.jpg", noCacheHeader, false},
+ {"/:width/:height", "/200/300", "/id/1/200/300.jpg", noCacheHeader, false},
+ {"/:size.jpg", "/200.jpg", "/id/1/200/200.jpg", noCacheHeader, false},
+ {"/:width/:height.jpg", "/200/300.jpg", "/id/1/200/300.jpg", noCacheHeader, false},
+ {"/:size.webp", "/200.webp", "/id/1/200/200.webp", noCacheHeader, false},
+ {"/:width/:height.webp", "/200/300.webp", "/id/1/200/300.webp", noCacheHeader, false},
+ {"/:size?grayscale", "/200?grayscale", "/id/1/200/200.jpg?grayscale", noCacheHeader, false},
+ {"/:width/:height?grayscale", "/200/300?grayscale", "/id/1/200/300.jpg?grayscale", noCacheHeader, false},
+ // JPG (cacheable - deterministic)
+ {"/id/:id/:width/:height", "/id/1/200/120", "/id/1/200/120.jpg", cacheableHeader, false},
+ {"/id/:id/:width/:height.jpg", "/id/1/200/120.jpg", "/id/1/200/120.jpg", cacheableHeader, false},
+ {"/id/:id/:width/:height?blur", "/id/1/200/200?blur", "/id/1/200/200.jpg?blur=5", cacheableHeader, false},
+ {"/id/:id/:width/:height.jpg?blur", "/id/1/200/200.jpg?blur", "/id/1/200/200.jpg?blur=5", cacheableHeader, false},
+ {"/id/:id/:width/:height?grayscale", "/id/1/200/200?grayscale", "/id/1/200/200.jpg?grayscale", cacheableHeader, false},
+ {"/id/:id/:width/:height.jpg?grayscale", "/id/1/200/200.jpg?grayscale", "/id/1/200/200.jpg?grayscale", cacheableHeader, false},
+ {"/id/:id/:width/:height?blur&grayscale", "/id/1/200/200?blur&grayscale", "/id/1/200/200.jpg?blur=5&grayscale", cacheableHeader, false},
+ {"/id/:id/:width/:height.jpg?blur&grayscale", "/id/1/200/200.jpg?blur&grayscale", "/id/1/200/200.jpg?blur=5&grayscale", cacheableHeader, false},
+ {"width/height larger then max allowed but same size as image", "/id/1/300/400", "/id/1/300/400.jpg", cacheableHeader, false},
+ {"width/height larger then max allowed but same size as image", "/id/1/300/400.jpg", "/id/1/300/400.jpg", cacheableHeader, false},
+ {"width/height of 0 returns original image width", "/id/1/0/0", "/id/1/300/400.jpg", cacheableHeader, false},
+ {"width/height of 0 returns original image width", "/id/1/0/0.jpg", "/id/1/300/400.jpg", cacheableHeader, false},
+ // WebP (cacheable - deterministic)
+ {"/id/:id/:width/:height.webp", "/id/1/200/120.webp", "/id/1/200/120.webp", cacheableHeader, false},
+ {"/id/:id/:width/:height.webp?blur", "/id/1/200/200.webp?blur", "/id/1/200/200.webp?blur=5", cacheableHeader, false},
+ {"/id/:id/:width/:height.webp?grayscale", "/id/1/200/200.webp?grayscale", "/id/1/200/200.webp?grayscale", cacheableHeader, false},
+ {"/id/:id/:width/:height.webp?blur&grayscale", "/id/1/200/200.webp?blur&grayscale", "/id/1/200/200.webp?blur=5&grayscale", cacheableHeader, false},
+ {"width/height larger then max allowed but same size as image", "/id/1/300/400.webp", "/id/1/300/400.webp", cacheableHeader, false},
+ {"width/height of 0 returns original image width", "/id/1/0/0.webp", "/id/1/300/400.webp", cacheableHeader, false},
+
+ // Default blur amount (random - not cacheable)
+ {"/:size?blur", "/200?blur", "/id/1/200/200.jpg?blur=5", noCacheHeader, false},
+ {"/:width/:height?blur", "/200/300?blur", "/id/1/200/300.jpg?blur=5", noCacheHeader, false},
+ {"/:size?grayscale&blur", "/200?grayscale&blur", "/id/1/200/200.jpg?blur=5&grayscale", noCacheHeader, false},
+ {"/:width/:height?grayscale&blur", "/200/300?grayscale&blur", "/id/1/200/300.jpg?blur=5&grayscale", noCacheHeader, false},
+
+ // Custom blur amount (random - not cacheable)
+ {"/:size?blur=10", "/200?blur=10", "/id/1/200/200.jpg?blur=10", noCacheHeader, false},
+ {"/:width/:height?blur=10", "/200/300?blur=10", "/id/1/200/300.jpg?blur=10", noCacheHeader, false},
+ {"/:size?grayscale&blur=10", "/200?grayscale&blur=10", "/id/1/200/200.jpg?blur=10&grayscale", noCacheHeader, false},
+ {"/:width/:height?grayscale&blur=10", "/200/300?grayscale&blur=10", "/id/1/200/300.jpg?blur=10&grayscale", noCacheHeader, false},
+
+ // Deprecated routes (not cacheable)
+ {"/g/:size", "/g/200", "/id/1/200/200.jpg?grayscale", noCacheHeader, false},
+ {"/g/:width/:height", "/g/200/300", "/id/1/200/300.jpg?grayscale", noCacheHeader, false},
+ {"/g/:size.jpg", "/g/200.jpg", "/id/1/200/200.jpg?grayscale", noCacheHeader, false},
+ {"/g/:width/:height.jpg", "/g/200/300.jpg", "/id/1/200/300.jpg?grayscale", noCacheHeader, false},
+ {"/g/:size.webp", "/g/200.webp", "/id/1/200/200.webp?grayscale", noCacheHeader, false},
+ {"/g/:width/:height.webp", "/g/200/300.webp", "/id/1/200/300.webp?grayscale", noCacheHeader, false},
+ {"/g/:size?blur", "/g/200?blur", "/id/1/200/200.jpg?blur=5&grayscale", noCacheHeader, false},
+ {"/g/:width/:height?blur", "/g/200/300?blur", "/id/1/200/300.jpg?blur=5&grayscale", noCacheHeader, false},
+ {"/g/:size?image=:id", "/g/200?image=1", "/id/1/200/200.jpg?grayscale", noCacheHeader, false},
+ {"/g/:width/:height?image=:id", "/g/200/300?image=1", "/id/1/200/300.jpg?grayscale", noCacheHeader, false},
+ {"/g/:size.jpg?image=:id", "/g/200.jpg?image=1", "/id/1/200/200.jpg?grayscale", noCacheHeader, false},
+ {"/g/:width/:height.jpg?image=:id", "/g/200/300.jpg?image=1", "/id/1/200/300.jpg?grayscale", noCacheHeader, false},
+ {"/g/:size.webp?image=:id", "/g/200.webp?image=1", "/id/1/200/200.webp?grayscale", noCacheHeader, false},
+ {"/g/:width/:height.webp?image=:id", "/g/200/300.webp?image=1", "/id/1/200/300.webp?grayscale", noCacheHeader, false},
+
+ // Deprecated query params (not cacheable)
+ {"/:size?image=:id", "/200?image=1", "/id/1/200/200.jpg", noCacheHeader, false},
+ {"/:width/:height?image=:id", "/200/300?image=1", "/id/1/200/300.jpg", noCacheHeader, false},
+ {"/:size?image=:id&grayscale", "/200?image=1&grayscale", "/id/1/200/200.jpg?grayscale", noCacheHeader, false},
+ {"/:width/:height?image=:id&grayscale", "/200/300?image=1&grayscale", "/id/1/200/300.jpg?grayscale", noCacheHeader, false},
+ {"/:size?image=:id&blur", "/200?image=1&blur", "/id/1/200/200.jpg?blur=5", noCacheHeader, false},
+ {"/:width/:height?image=:id&blur", "/200/300?image=1&blur", "/id/1/200/300.jpg?blur=5", noCacheHeader, false},
+ {"/:size?image=:id&grayscale&blur", "/200?image=1&grayscale&blur", "/id/1/200/200.jpg?blur=5&grayscale", noCacheHeader, false},
+ {"/:width/:height?image=:id&grayscale&blur", "/200/300?image=1&grayscale&blur", "/id/1/200/300.jpg?blur=5&grayscale", noCacheHeader, false},
+
+ // By seed (cacheable - deterministic)
+ {"/seed/:seed/:size", "/seed/1/200", "/id/1/200/200.jpg", cacheableHeader, false},
+ {"/seed/:seed/:size.jpg", "/seed/1/200.jpg", "/id/1/200/200.jpg", cacheableHeader, false},
+ {"/seed/:seed/:size.webp", "/seed/1/200.webp", "/id/1/200/200.webp", cacheableHeader, false},
+ {"/seed/:seed/:size?blur", "/seed/1/200?blur", "/id/1/200/200.jpg?blur=5", cacheableHeader, false},
+ {"/seed/:seed/:size?blur=10", "/seed/1/200?blur=10", "/id/1/200/200.jpg?blur=10", cacheableHeader, false},
+ {"/seed/:seed/:size?grayscale", "/seed/1/200?grayscale", "/id/1/200/200.jpg?grayscale", cacheableHeader, false},
+ {"/seed/:seed/:size?blur&grayscale", "/seed/1/200?blur&grayscale", "/id/1/200/200.jpg?blur=5&grayscale", cacheableHeader, false},
+ {"/seed/:seed/:size?blur=10&grayscale", "/seed/1/200?blur=10&grayscale", "/id/1/200/200.jpg?blur=10&grayscale", cacheableHeader, false},
+ {"/seed/:seed/:width/:height", "/seed/1/200/300", "/id/1/200/300.jpg", cacheableHeader, false},
+ {"/seed/:seed/:width/:height.jpg", "/seed/1/200/300.jpg", "/id/1/200/300.jpg", cacheableHeader, false},
+ {"/seed/:seed/:width/:height.webp", "/seed/1/200/300.webp", "/id/1/200/300.webp", cacheableHeader, false},
+ {"/seed/:seed/:width/:height?blur", "/seed/1/200/300?blur", "/id/1/200/300.jpg?blur=5", cacheableHeader, false},
+ {"/seed/:seed/:width/:height?blur=10", "/seed/1/200/300?blur=10", "/id/1/200/300.jpg?blur=10", cacheableHeader, false},
+ {"/seed/:seed/:width/:height?grayscale", "/seed/1/200/300?grayscale", "/id/1/200/300.jpg?grayscale", cacheableHeader, false},
+ {"/seed/:seed/:width/:height?blur&grayscale", "/seed/1/200/300?blur&grayscale", "/id/1/200/300.jpg?blur=5&grayscale", cacheableHeader, false},
+ {"/seed/:seed/:width/:height?blur=10&grayscale", "/seed/1/200/300?blur=10&grayscale", "/id/1/200/300.jpg?blur=10&grayscale", cacheableHeader, false},
+
+ // Trailing slashes
+ {"/:size/", "/200/", "/200", "", true},
+ {"/:width/:height/", "/200/300/", "/200/300", "", true},
+ {"/id/:id/:size/", "/id/1/200/", "/id/1/200", "", true},
+ {"/id/:id/:width/:height/", "/id/1/200/120/", "/id/1/200/120", "", true},
+ {"/seed/:seed/:size/", "/seed/1/200/", "/seed/1/200", "", true},
+ {"/seed/:seed/:width/:height/", "/seed/1/200/120/", "/seed/1/200/120", "", true},
+ }
+
+ for _, test := range redirectTests {
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", test.URL, nil)
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusFound && w.Code != http.StatusMovedPermanently {
+ t.Errorf("%s: wrong response code, %#v", test.Name, w.Code)
+ continue
+ }
+
+ location := w.Header().Get("Location")
+
+ expectedURL := test.ExpectedURL
+ if !test.LocalRedirect {
+ expectedHMAC, err := hmac.Create(test.ExpectedURL)
+ if err != nil {
+ t.Errorf("%s: hmac error %s", test.Name, err)
+ continue
+ }
+
+ if strings.Contains(test.ExpectedURL, "?") {
+ expectedURL = imageServiceURL + test.ExpectedURL + "&hmac=" + expectedHMAC
+ } else {
+ expectedURL = imageServiceURL + test.ExpectedURL + "?hmac=" + expectedHMAC
+ }
+ }
+
+ if location != expectedURL {
+ t.Errorf("%s: wrong redirect %s, expected %s", test.Name, location, expectedURL)
+ }
+
+ if test.ExpectedCacheHeader != "" {
+ if cacheControl := w.Header().Get("Cache-Control"); cacheControl != test.ExpectedCacheHeader {
+ t.Errorf("%s: wrong cache header, got %#v, expected %#v", test.Name, cacheControl, test.ExpectedCacheHeader)
+ }
+ }
+ }
+}
+
+func marshalJson(v interface{}) []byte {
+ fixture, _ := json.Marshal(v)
+ return append(fixture[:], []byte("\n")...)
+}
+
+func readFile(path string) []byte {
+ fixture, _ := os.ReadFile(path)
+ return fixture
+}
diff --git a/internal/api/deprecated.go b/internal/api/deprecated.go
new file mode 100644
index 0000000..329ab3e
--- /dev/null
+++ b/internal/api/deprecated.go
@@ -0,0 +1,129 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+
+ "github.com/DMarby/picsum-photos/internal/database"
+
+ "github.com/DMarby/picsum-photos/internal/handler"
+ "github.com/DMarby/picsum-photos/internal/params"
+)
+
+// DeprecatedImage contains info about an image, in the old deprecated /list style
+type DeprecatedImage struct {
+ Format string `json:"format"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ Filename string `json:"filename"`
+ ID int `json:"id"`
+ Author string `json:"author"`
+ AuthorURL string `json:"author_url"`
+ PostURL string `json:"post_url"`
+}
+
+func (a *API) deprecatedListHandler(w http.ResponseWriter, r *http.Request) *handler.Error {
+ list, err := a.Database.ListAll(r.Context())
+ if err != nil {
+ a.logError(r, "error getting image list from database", err)
+ return handler.InternalServerError()
+ }
+
+ var images []DeprecatedImage
+ for _, image := range list {
+ numericID, err := strconv.Atoi(image.ID)
+ if err != nil {
+ continue
+ }
+
+ images = append(images, DeprecatedImage{
+ Format: "jpeg",
+ Width: image.Width,
+ Height: image.Height,
+ Filename: fmt.Sprintf("%s.jpeg", image.ID),
+ ID: numericID,
+ Author: image.Author,
+ AuthorURL: image.URL,
+ PostURL: image.URL,
+ })
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "private, no-cache, no-store, must-revalidate")
+ if err := json.NewEncoder(w).Encode(images); err != nil {
+ if !errors.Is(err, context.Canceled) {
+ a.logError(r, "error encoding deprecate image list", err)
+ }
+ return handler.InternalServerError()
+ }
+
+ return nil
+}
+
+// Handles deprecated image routes
+func (a *API) deprecatedImageHandler(w http.ResponseWriter, r *http.Request) *handler.Error {
+ // Get the params
+ p, err := params.GetParams(r)
+ if err != nil {
+ return handler.BadRequest(err.Error())
+ }
+
+ var image *database.Image
+
+ // Look for the deprecated ?image query parameter
+ if id := r.URL.Query().Get("image"); id != "" {
+ var handlerErr *handler.Error
+ image, handlerErr = a.getImage(r, id)
+ if handlerErr != nil {
+ return handlerErr
+ }
+ } else {
+ image, err = a.Database.GetRandom(r.Context())
+ if err != nil {
+ a.logError(r, "error getting random image from database", err)
+ return handler.InternalServerError()
+ }
+ }
+
+ // Set grayscale to true as this is the deprecated /g/ endpoint
+ p.Grayscale = true
+
+ // Deprecated endpoint - don't cache since it may be random
+ return a.validateAndRedirect(w, r, p, image, false)
+}
+
+// deprecatedParams is a handler to handle deprecated query params for regular routes
+func (a *API) deprecatedParams(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Look for the deprecated ?image query parameter
+ if id := r.URL.Query().Get("image"); id != "" {
+ w.Header().Set("Cache-Control", "private, no-cache, no-store, must-revalidate")
+
+ p, err := params.GetParams(r)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ image, handlerErr := a.getImage(r, id)
+ if handlerErr != nil {
+ http.Error(w, handlerErr.Message, handlerErr.Code)
+ return
+ }
+
+ // Deprecated endpoint - don't change caching behavior
+ handlerErr = a.validateAndRedirect(w, r, p, image, false)
+ if handlerErr != nil {
+ http.Error(w, handlerErr.Message, handlerErr.Code)
+ }
+
+ return
+ }
+
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/internal/api/image.go b/internal/api/image.go
new file mode 100644
index 0000000..7f422fa
--- /dev/null
+++ b/internal/api/image.go
@@ -0,0 +1,147 @@
+package api
+
+import (
+ "expvar"
+ "fmt"
+ "math"
+ "net/http"
+ "net/url"
+ "strconv"
+
+ "github.com/DMarby/picsum-photos/internal/database"
+ "github.com/DMarby/picsum-photos/internal/handler"
+ "github.com/DMarby/picsum-photos/internal/params"
+ "github.com/gorilla/mux"
+ "github.com/twmb/murmur3"
+)
+
+var (
+ imageRequests = expvar.NewMap("counter_labelmap_dimensions_image_requests_dimension")
+ imageRequestsBlur = expvar.NewInt("image_requests_blur")
+ imageRequestsGrayscale = expvar.NewInt("image_requests_grayscale")
+)
+
+func (a *API) imageRedirectHandler(w http.ResponseWriter, r *http.Request) *handler.Error {
+ // Get the path and query parameters
+ p, err := params.GetParams(r)
+ if err != nil {
+ return handler.BadRequest(err.Error())
+ }
+
+ // Get the image from the database
+ vars := mux.Vars(r)
+ imageID := vars["id"]
+ image, handlerErr := a.getImage(r, imageID)
+ if handlerErr != nil {
+ return handlerErr
+ }
+
+ // Validate the params and redirect to the image service (cacheable since ID is deterministic)
+ return a.validateAndRedirect(w, r, p, image, true)
+}
+
+func (a *API) randomImageRedirectHandler(w http.ResponseWriter, r *http.Request) *handler.Error {
+ // Get the path and query parameters
+ p, err := params.GetParams(r)
+ if err != nil {
+ return handler.BadRequest(err.Error())
+ }
+
+ // Get a random image
+ image, err := a.Database.GetRandom(r.Context())
+ if err != nil {
+ a.logError(r, "error getting random image from database", err)
+ return handler.InternalServerError()
+ }
+
+ // Validate the params and redirect to the image service (not cacheable since it's random)
+ return a.validateAndRedirect(w, r, p, image, false)
+}
+
+func (a *API) seedImageRedirectHandler(w http.ResponseWriter, r *http.Request) *handler.Error {
+ // Get the path and query parameters
+ p, err := params.GetParams(r)
+ if err != nil {
+ return handler.BadRequest(err.Error())
+ }
+
+ // Get the image seed
+ vars := mux.Vars(r)
+ imageSeed := vars["seed"]
+
+ image, handlerErr := a.getImageFromSeed(r, imageSeed)
+ if handlerErr != nil {
+ return handlerErr
+ }
+
+ // Validate the params and redirect to the image service (cacheable since seed is deterministic)
+ return a.validateAndRedirect(w, r, p, image, true)
+}
+
+func (a *API) getImage(r *http.Request, imageID string) (*database.Image, *handler.Error) {
+ databaseImage, err := a.Database.Get(r.Context(), imageID)
+ if err != nil {
+ if err == database.ErrNotFound {
+ return nil, &handler.Error{Message: err.Error(), Code: http.StatusNotFound}
+ }
+
+ a.logError(r, "error getting image from database", err)
+ return nil, handler.InternalServerError()
+ }
+
+ return databaseImage, nil
+}
+
+func (a *API) getImageFromSeed(r *http.Request, imageSeed string) (*database.Image, *handler.Error) {
+ // Hash the input using murmur3
+ murmurHash := murmur3.StringSum64(imageSeed)
+
+ // Get a random image by the hash
+ image, err := a.Database.GetRandomWithSeed(r.Context(), int64(murmurHash))
+ if err != nil {
+ a.logError(r, "error getting random image from database", err)
+ return nil, handler.InternalServerError()
+ }
+
+ return image, nil
+}
+
+func (a *API) validateAndRedirect(w http.ResponseWriter, r *http.Request, p *params.Params, image *database.Image, cacheable bool) *handler.Error {
+ if err := validateImageParams(p); err != nil {
+ return handler.BadRequest(err.Error())
+ }
+
+ width, height := getImageDimensions(p, image)
+
+ if cacheable {
+ // Cache for 1 day for deterministic endpoints (id and seed)
+ w.Header().Set("Cache-Control", "public, max-age=86400, stale-while-revalidate=60, stale-if-error=43200")
+ } else {
+ w.Header().Set("Cache-Control", "private, no-cache, no-store, must-revalidate")
+ }
+ w.Header()["Content-Type"] = nil
+
+ path := fmt.Sprintf("/id/%s/%d/%d%s", image.ID, width, height, p.Extension)
+ query := url.Values{}
+
+ if p.Blur {
+ query.Add("blur", strconv.Itoa(p.BlurAmount))
+ imageRequestsBlur.Add(1)
+ }
+
+ if p.Grayscale {
+ query.Add("grayscale", "")
+ imageRequestsGrayscale.Add(1)
+ }
+
+ url, err := params.HMAC(a.HMAC, path, query)
+ if err != nil {
+ return handler.InternalServerError()
+ }
+
+ imageRequests.Add(fmt.Sprintf("%0.f", math.Max(math.Round(float64(width)/500)*500, math.Round(float64(height)/500)*500)), 1)
+
+ http.Redirect(w, r, fmt.Sprintf("%s%s", a.ImageServiceURL, url), http.StatusFound)
+
+ return nil
+}
diff --git a/internal/api/list.go b/internal/api/list.go
new file mode 100644
index 0000000..12b7d6e
--- /dev/null
+++ b/internal/api/list.go
@@ -0,0 +1,163 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+
+ "github.com/DMarby/picsum-photos/internal/database"
+ "github.com/DMarby/picsum-photos/internal/handler"
+ "github.com/gorilla/mux"
+)
+
+const (
+ // Default number of items per page
+ defaultLimit = 30
+ // Max number of items per page
+ maxLimit = 100
+)
+
+// ListImage contains metadata and download information about an image
+type ListImage struct {
+ database.Image
+ DownloadURL string `json:"download_url"`
+}
+
+// Returns info about an image
+func (a *API) infoHandler(w http.ResponseWriter, r *http.Request) *handler.Error {
+ vars := mux.Vars(r)
+ imageID := vars["id"]
+ image, handlerErr := a.getImage(r, imageID)
+ if handlerErr != nil {
+ return handlerErr
+ }
+
+ listImage := a.getListImage(*image)
+
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "private, no-cache, no-store, must-revalidate")
+
+ if err := json.NewEncoder(w).Encode(listImage); err != nil {
+ if !errors.Is(err, context.Canceled) {
+ a.logError(r, "error encoding image info", err)
+ }
+ return handler.InternalServerError()
+ }
+
+ return nil
+}
+
+// Returns info about an image based on the seed
+func (a *API) infoSeedHandler(w http.ResponseWriter, r *http.Request) *handler.Error {
+ vars := mux.Vars(r)
+ imageSeed := vars["seed"]
+
+ image, handlerErr := a.getImageFromSeed(r, imageSeed)
+ if handlerErr != nil {
+ return handlerErr
+ }
+
+ listImage := a.getListImage(*image)
+
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "private, no-cache, no-store, must-revalidate")
+
+ if err := json.NewEncoder(w).Encode(listImage); err != nil {
+ if !errors.Is(err, context.Canceled) {
+ a.logError(r, "error encoding image info", err)
+ }
+ return handler.InternalServerError()
+ }
+
+ return nil
+}
+
+// Paginated list, with `page` and `limit` query parameters
+func (a *API) listHandler(w http.ResponseWriter, r *http.Request) *handler.Error {
+ limit := getLimit(r)
+ page := getPage(r)
+
+ offset := limit * (page - 1)
+
+ databaseList, err := a.Database.List(r.Context(), offset, limit)
+ if err != nil {
+ a.logError(r, "error getting image list from database", err)
+ return handler.InternalServerError()
+ }
+
+ list := []ListImage{}
+
+ for _, image := range databaseList {
+ list = append(list, a.getListImage(image))
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "private, no-cache, no-store, must-revalidate")
+
+ // If we've ran out of items, don't include the next page in the Link header
+ end := len(list) < limit
+ w.Header().Set("Access-Control-Expose-Headers", "Link")
+ w.Header().Set("Link", a.getLinkHeader(page, limit, end))
+
+ if err := json.NewEncoder(w).Encode(list); err != nil {
+ if !errors.Is(err, context.Canceled) {
+ a.logError(r, "error encoding image list", err)
+ }
+ return handler.InternalServerError()
+ }
+
+ return nil
+}
+
+func getLimit(r *http.Request) int {
+ limit, err := strconv.Atoi(r.URL.Query().Get("limit"))
+ if err != nil || limit < 1 {
+ limit = defaultLimit
+ }
+
+ if limit > maxLimit {
+ limit = maxLimit
+ }
+
+ return limit
+}
+
+func getPage(r *http.Request) int {
+ page, err := strconv.Atoi(r.URL.Query().Get("page"))
+ if err != nil || page < 1 {
+ page = 1
+ }
+
+ return page
+}
+
+func (a *API) getLinkHeader(page, limit int, end bool) string {
+ // This will return a next even if there's only enough items for a single page, but lets ignore that for now
+ if page == 1 {
+ return fmt.Sprintf("<%s/v2/list?page=%d&limit=%d>; rel=\"next\"", a.RootURL, page+1, limit)
+ }
+
+ if end {
+ return fmt.Sprintf("<%s/v2/list?page=%d&limit=%d>; rel=\"prev\"", a.RootURL, page-1, limit)
+ }
+
+ return fmt.Sprintf("<%s/v2/list?page=%d&limit=%d>; rel=\"prev\", <%s/v2/list?page=%d&limit=%d>; rel=\"next\"",
+ a.RootURL, page-1, limit, a.RootURL, page+1, limit,
+ )
+}
+
+func (a *API) getListImage(image database.Image) ListImage {
+ return ListImage{
+ Image: database.Image{
+ ID: image.ID,
+ Author: image.Author,
+ Width: image.Width,
+ Height: image.Height,
+ URL: image.URL,
+ },
+ DownloadURL: fmt.Sprintf("%s/id/%s/%d/%d", a.RootURL, image.ID, image.Width, image.Height),
+ }
+}
diff --git a/internal/api/params.go b/internal/api/params.go
new file mode 100644
index 0000000..87e5a54
--- /dev/null
+++ b/internal/api/params.go
@@ -0,0 +1,55 @@
+package api
+
+import (
+ "fmt"
+
+ "github.com/DMarby/picsum-photos/internal/database"
+ "github.com/DMarby/picsum-photos/internal/params"
+)
+
+// Errors
+var (
+ ErrInvalidBlurAmount = fmt.Errorf("Invalid blur amount")
+)
+
+const (
+ minBlurAmount = 1
+ maxBlurAmount = 10
+ maxImageSize = 5000 // The max allowed image width/height that can be requested
+)
+
+func validateImageParams(p *params.Params) error {
+ if p.Width > maxImageSize {
+ return params.ErrInvalidSize
+ }
+
+ if p.Height > maxImageSize {
+ return params.ErrInvalidSize
+ }
+
+ if p.Blur && p.BlurAmount < minBlurAmount {
+ return ErrInvalidBlurAmount
+ }
+
+ if p.Blur && p.BlurAmount > maxBlurAmount {
+ return ErrInvalidBlurAmount
+ }
+
+ return nil
+}
+
+func getImageDimensions(p *params.Params, databaseImage *database.Image) (width, height int) {
+ // Default to the image width/height if 0 is passed
+ width = p.Width
+ height = p.Height
+
+ if width == 0 {
+ width = databaseImage.Width
+ }
+
+ if height == 0 {
+ height = databaseImage.Height
+ }
+
+ return
+}
diff --git a/internal/cache/cache.go b/internal/cache/cache.go
new file mode 100644
index 0000000..17b7a78
--- /dev/null
+++ b/internal/cache/cache.go
@@ -0,0 +1,71 @@
+package cache
+
+import (
+ "context"
+ "errors"
+
+ "github.com/DMarby/picsum-photos/internal/tracing"
+ "golang.org/x/sync/singleflight"
+)
+
+// Provider is an interface for getting and setting cached objects
+type Provider interface {
+ Get(ctx context.Context, key string) (data []byte, err error)
+ Set(ctx context.Context, key string, data []byte) (err error)
+ Shutdown()
+}
+
+// LoaderFunc is a function for loading data into a cache
+type LoaderFunc func(ctx context.Context, key string) (data []byte, err error)
+
+// Auto is a cache that automatically attempts to load objects if they don't exist
+type Auto struct {
+ Tracer *tracing.Tracer
+ Provider Provider
+ Loader LoaderFunc
+ lookupGroup singleflight.Group
+}
+
+// Get returns an object from the cache if it exists, otherwise it loads it into the cache and returns it
+func (a *Auto) Get(ctx context.Context, key string) (data []byte, err error) {
+ ctx, span := a.Tracer.Start(ctx, "cache.Auto.Get")
+ defer span.End()
+
+ // Attempt to get the data from the cache
+ data, err = a.Provider.Get(ctx, key)
+ // Exit early if the error is nil as we got data from the cache
+ // Or if there's an error indicating that something went wrong
+ if err != ErrNotFound {
+ return
+ }
+
+ // Use singleflight to avoid concurrent requests
+ var v interface{}
+ v, err, _ = a.lookupGroup.Do(key, func() (interface{}, error) {
+ // Get the data
+ data, err := a.Loader(ctx, key)
+ if err != nil {
+ return nil, err
+ }
+
+ // Store the data in the cache
+ err = a.Provider.Set(ctx, key, data)
+ if err != nil {
+ return nil, err
+ }
+
+ return data, nil
+ })
+
+ if err != nil {
+ return
+ }
+
+ data, _ = v.([]byte)
+ return
+}
+
+// Errors
+var (
+ ErrNotFound = errors.New("not found in cache")
+)
diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go
new file mode 100644
index 0000000..f095d03
--- /dev/null
+++ b/internal/cache/cache_test.go
@@ -0,0 +1,66 @@
+package cache_test
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/DMarby/picsum-photos/internal/cache"
+ "github.com/DMarby/picsum-photos/internal/cache/mock"
+ "github.com/DMarby/picsum-photos/internal/logger"
+ "github.com/DMarby/picsum-photos/internal/tracing/test"
+ "go.uber.org/zap"
+)
+
+var mockLoaderFunc cache.LoaderFunc = func(ctx context.Context, key string) (data []byte, err error) {
+ if key == "notfounderr" {
+ return nil, fmt.Errorf("notfounderr")
+ }
+
+ return []byte("notfound"), nil
+}
+
+func TestAuto(t *testing.T) {
+ log := logger.New(zap.ErrorLevel)
+ defer log.Sync()
+
+ tracer := test.Tracer(log)
+
+ auto := &cache.Auto{
+ Tracer: tracer,
+ Provider: &mock.Provider{},
+ Loader: mockLoaderFunc,
+ }
+
+ tests := []struct {
+ Key string
+ ExpectedError error
+ }{
+ {"foo", nil},
+ {"notfound", nil},
+ {"notfounderr", fmt.Errorf("notfounderr")},
+ {"seterror", fmt.Errorf("seterror")},
+ }
+
+ for _, test := range tests {
+ data, err := auto.Get(context.Background(), test.Key)
+ if err != nil {
+ if test.ExpectedError == nil {
+ t.Errorf("%s: %s", test.Key, err)
+ continue
+ }
+
+ if test.ExpectedError.Error() != err.Error() {
+ t.Errorf("%s: wrong error: %s", test.Key, err)
+ continue
+ }
+
+ continue
+ }
+
+ if string(data) != test.Key {
+ t.Errorf("%s: wrong data", test.Key)
+ }
+ }
+
+}
diff --git a/internal/cache/memory/memory.go b/internal/cache/memory/memory.go
new file mode 100644
index 0000000..d400789
--- /dev/null
+++ b/internal/cache/memory/memory.go
@@ -0,0 +1,46 @@
+package memory
+
+import (
+ "context"
+ "sync"
+
+ "github.com/DMarby/picsum-photos/internal/cache"
+)
+
+// Provider implements a simple in-memory cache
+type Provider struct {
+ cache map[string][]byte
+ mutex sync.RWMutex
+}
+
+// New returns a new Provider instance
+func New() *Provider {
+ return &Provider{
+ cache: make(map[string][]byte),
+ }
+}
+
+// Get returns an object from the cache if it exists
+func (p *Provider) Get(ctx context.Context, key string) (data []byte, err error) {
+ p.mutex.RLock()
+ data, exists := p.cache[key]
+ p.mutex.RUnlock()
+
+ if !exists {
+ return nil, cache.ErrNotFound
+ }
+
+ return data, nil
+}
+
+// Set adds an object to the cache
+func (p *Provider) Set(ctx context.Context, key string, data []byte) (err error) {
+ p.mutex.Lock()
+ p.cache[key] = data
+ p.mutex.Unlock()
+
+ return nil
+}
+
+// Shutdown shuts down the cache
+func (p *Provider) Shutdown() {}
diff --git a/internal/cache/memory/memory_test.go b/internal/cache/memory/memory_test.go
new file mode 100644
index 0000000..fe2ecdb
--- /dev/null
+++ b/internal/cache/memory/memory_test.go
@@ -0,0 +1,42 @@
+package memory_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/DMarby/picsum-photos/internal/cache"
+ "github.com/DMarby/picsum-photos/internal/cache/memory"
+)
+
+func TestMemory(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ provider := memory.New()
+
+ t.Run("get item", func(t *testing.T) {
+ // Add item to the cache
+ provider.Set(ctx, "foo", []byte("bar"))
+
+ // Get item from the cache
+ data, err := provider.Get(ctx, "foo")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if string(data) != "bar" {
+ t.Fatal("wrong data")
+ }
+ })
+
+ t.Run("get nonexistant item", func(t *testing.T) {
+ _, err := provider.Get(ctx, "notfound")
+ if err == nil {
+ t.Fatal("no error")
+ }
+
+ if err != cache.ErrNotFound {
+ t.Fatalf("wrong error %s", err)
+ }
+ })
+}
diff --git a/internal/cache/mock/mock.go b/internal/cache/mock/mock.go
new file mode 100644
index 0000000..7d3bb83
--- /dev/null
+++ b/internal/cache/mock/mock.go
@@ -0,0 +1,36 @@
+package mock
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/DMarby/picsum-photos/internal/cache"
+)
+
+// Provider is a mock cache
+type Provider struct{}
+
+// Get returns an object from the cache if it exists
+func (p *Provider) Get(ctx context.Context, key string) (data []byte, err error) {
+ if key == "notfound" || key == "notfounderr" || key == "seterror" {
+ return nil, cache.ErrNotFound
+ }
+
+ if key == "error" {
+ return nil, fmt.Errorf("error")
+ }
+
+ return []byte("foo"), nil
+}
+
+// Set adds an object to the cache
+func (p *Provider) Set(ctx context.Context, key string, data []byte) (err error) {
+ if key == "seterror" {
+ return fmt.Errorf("seterror")
+ }
+
+ return nil
+}
+
+// Shutdown shuts down the cache
+func (p *Provider) Shutdown() {}
diff --git a/internal/cmd/main.go b/internal/cmd/main.go
new file mode 100644
index 0000000..94cf353
--- /dev/null
+++ b/internal/cmd/main.go
@@ -0,0 +1,13 @@
+package cmd
+
+import (
+ "time"
+)
+
+// Http timeouts
+const (
+ ReadTimeout = 30 * time.Second
+ WriteTimeout = 90 * time.Second
+ IdleTimeout = 120 * time.Second
+ HandlerTimeout = 60 * time.Second
+)
diff --git a/internal/database/database.go b/internal/database/database.go
new file mode 100644
index 0000000..0784952
--- /dev/null
+++ b/internal/database/database.go
@@ -0,0 +1,29 @@
+package database
+
+import (
+ "context"
+ "errors"
+)
+
+// Image contains metadata about an image
+type Image struct {
+ ID string `json:"id"`
+ Author string `json:"author"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ URL string `json:"url"`
+}
+
+// Provider is an interface for listing and retrieving images
+type Provider interface {
+ Get(ctx context.Context, id string) (i *Image, err error)
+ GetRandom(ctx context.Context) (i *Image, err error)
+ GetRandomWithSeed(ctx context.Context, seed int64) (i *Image, err error)
+ ListAll(ctx context.Context) ([]Image, error)
+ List(ctx context.Context, offset, limit int) ([]Image, error)
+}
+
+// Errors
+var (
+ ErrNotFound = errors.New("Image does not exist")
+)
diff --git a/internal/database/file/file.go b/internal/database/file/file.go
new file mode 100644
index 0000000..117a5d3
--- /dev/null
+++ b/internal/database/file/file.go
@@ -0,0 +1,110 @@
+package file
+
+import (
+ "context"
+ "encoding/json"
+ "math/rand"
+ "os"
+ "sort"
+ "strconv"
+ "sync"
+ "time"
+
+ "github.com/DMarby/picsum-photos/internal/database"
+)
+
+// Provider implements a file-based image storage
+type Provider struct {
+ images []database.Image
+ sortedImages []database.Image
+
+ random *rand.Rand
+ mu sync.Mutex
+}
+
+// New returns a new Provider instance
+func New(path string) (*Provider, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+
+ var images []database.Image
+ err = json.Unmarshal(data, &images)
+ if err != nil {
+ return nil, err
+ }
+
+ sortedImages := make([]database.Image, len(images))
+ copy(sortedImages, images)
+ sort.Slice(sortedImages, func(i, j int) bool {
+ ii, _ := strconv.Atoi(sortedImages[i].ID)
+ jj, _ := strconv.Atoi(sortedImages[j].ID)
+ return ii < jj
+ })
+
+ source := rand.NewSource(time.Now().UnixNano())
+ random := rand.New(source)
+
+ return &Provider{
+ images: images,
+ sortedImages: sortedImages,
+ random: random,
+ }, nil
+}
+
+func (p *Provider) getImage(id string) (*database.Image, error) {
+ for _, image := range p.images {
+ if image.ID == id {
+ return &image, nil
+ }
+ }
+
+ return nil, database.ErrNotFound
+}
+
+// Get returns the image data for an image id
+func (p *Provider) Get(ctx context.Context, id string) (i *database.Image, err error) {
+ image, err := p.getImage(id)
+ if err != nil {
+ return nil, err
+ }
+
+ return image, nil
+}
+
+// GetRandom returns a random image ID
+func (p *Provider) GetRandom(ctx context.Context) (i *database.Image, err error) {
+ p.mu.Lock()
+ image := &p.images[p.random.Intn(len(p.images))]
+ p.mu.Unlock()
+ return image, nil
+}
+
+// GetRandomWithSeed returns a random image ID based on the given seed
+func (p *Provider) GetRandomWithSeed(ctx context.Context, seed int64) (i *database.Image, err error) {
+ source := rand.NewSource(seed)
+ random := rand.New(source)
+
+ return &p.images[random.Intn(len(p.images))], nil
+}
+
+// ListAll returns a list of all the images
+func (p *Provider) ListAll(ctx context.Context) ([]database.Image, error) {
+ return p.sortedImages, nil
+}
+
+// List returns a list of all the images with an offset/limit
+func (p *Provider) List(ctx context.Context, offset, limit int) ([]database.Image, error) {
+ images := len(p.sortedImages)
+ if offset > images {
+ offset = images
+ }
+
+ limit = offset + limit
+ if limit > images {
+ limit = images
+ }
+
+ return p.sortedImages[offset:limit], nil
+}
diff --git a/internal/database/file/file_test.go b/internal/database/file/file_test.go
new file mode 100644
index 0000000..11e6118
--- /dev/null
+++ b/internal/database/file/file_test.go
@@ -0,0 +1,120 @@
+package file_test
+
+import (
+ "context"
+ "reflect"
+
+ "github.com/DMarby/picsum-photos/internal/database"
+ "github.com/DMarby/picsum-photos/internal/database/file"
+
+ "testing"
+)
+
+var image = database.Image{
+ ID: "1",
+ Author: "John Doe",
+ URL: "https://picsum.photos",
+ Width: 300,
+ Height: 400,
+}
+
+var secondImage = database.Image{
+ ID: "2",
+ Author: "John Doe",
+ URL: "https://picsum.photos",
+ Width: 300,
+ Height: 400,
+}
+
+func TestFile(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ provider, err := file.New("../../../test/fixtures/file/metadata_multiple.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ t.Run("Get an image by id", func(t *testing.T) {
+ buf, err := provider.Get(ctx, "1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if !reflect.DeepEqual(buf, &image) {
+ t.Error("image data doesn't match")
+ }
+ })
+
+ t.Run("Returns error on a nonexistant image", func(t *testing.T) {
+ _, err := provider.Get(ctx, "nonexistant")
+ if err == nil || err.Error() != database.ErrNotFound.Error() {
+ t.FailNow()
+ }
+ })
+
+ t.Run("Returns a random image", func(t *testing.T) {
+ image, err := provider.GetRandom(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if image.ID != "1" && image.ID != "2" {
+ t.Error("wrong image")
+ }
+ })
+
+ t.Run("Returns a random based on the seed", func(t *testing.T) {
+ image, err := provider.GetRandomWithSeed(ctx, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if image.ID != "1" {
+ t.Error("wrong image")
+ }
+ })
+
+ t.Run("Returns a list of all the images", func(t *testing.T) {
+ images, err := provider.ListAll(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if !reflect.DeepEqual(images, []database.Image{image, secondImage}) {
+ t.Error("image data doesn't match")
+ }
+ })
+
+ t.Run("Returns a list of images", func(t *testing.T) {
+ images, err := provider.List(ctx, 1, 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if !reflect.DeepEqual(images, []database.Image{secondImage}) {
+ t.Error("image data doesn't match")
+ }
+ })
+
+ t.Run("Handles offset and limit larger then db", func(t *testing.T) {
+ _, err := provider.List(ctx, 10, 30)
+ if err != nil {
+ t.Fatal(err)
+ }
+ })
+}
+
+func TestMissingMetadata(t *testing.T) {
+ _, err := file.New("")
+ if err == nil {
+ t.FailNow()
+ }
+}
+
+func TestInvalidJson(t *testing.T) {
+ _, err := file.New("../../../test/fixtures/file/invalid_metadata.json")
+ if err == nil {
+ t.FailNow()
+ }
+}
diff --git a/internal/database/mock/mock.go b/internal/database/mock/mock.go
new file mode 100644
index 0000000..60dcf6d
--- /dev/null
+++ b/internal/database/mock/mock.go
@@ -0,0 +1,37 @@
+package mock
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/DMarby/picsum-photos/internal/database"
+)
+
+// Provider implements a mock image storage
+type Provider struct {
+}
+
+// Get returns the image data for an image id
+func (p *Provider) Get(ctx context.Context, id string) (i *database.Image, err error) {
+ return nil, fmt.Errorf("get error")
+}
+
+// GetRandom returns a random image
+func (p *Provider) GetRandom(ctx context.Context) (i *database.Image, err error) {
+ return nil, fmt.Errorf("random error")
+}
+
+// GetRandomWithSeed returns a random image based on the given seed
+func (p *Provider) GetRandomWithSeed(ctx context.Context, seed int64) (i *database.Image, err error) {
+ return nil, fmt.Errorf("random error")
+}
+
+// ListAll returns a list of all the images
+func (p *Provider) ListAll(ctx context.Context) ([]database.Image, error) {
+ return nil, fmt.Errorf("list error")
+}
+
+// List returns a list of all the images with an offset/limit
+func (p *Provider) List(ctx context.Context, offset, limit int) ([]database.Image, error) {
+ return nil, fmt.Errorf("list error")
+}
diff --git a/internal/handler/handler.go b/internal/handler/handler.go
new file mode 100644
index 0000000..6050e15
--- /dev/null
+++ b/internal/handler/handler.go
@@ -0,0 +1,63 @@
+package handler
+
+import (
+ "encoding/json"
+ "net/http"
+)
+
+// Error is the message and http status code to return
+type Error struct {
+ Message string
+ Code int
+}
+
+// InternalServerError is a convenience function for returning an internal server error
+func InternalServerError() *Error {
+ return &Error{
+ Message: "Something went wrong",
+ Code: http.StatusInternalServerError,
+ }
+}
+
+// BadRequest is a convenience function for returning a bad request error
+func BadRequest(message string) *Error {
+ return &Error{
+ Message: message,
+ Code: http.StatusBadRequest,
+ }
+}
+
+// ServiceUnavailable is a convenience function for returning a service unavailable error
+func ServiceUnavailable() *Error {
+ return &Error{
+ Message: "Service temporarily unavailable",
+ Code: http.StatusServiceUnavailable,
+ }
+}
+
+const jsonMediaType = "application/json"
+
+// Handler wraps a http handler and deals with responding to errors
+type Handler func(w http.ResponseWriter, r *http.Request) *Error
+
+func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ err := h(w, r)
+ if err != nil {
+ w.Header().Set("Cache-Control", "private, no-cache, no-store, must-revalidate")
+
+ if r.Header.Get("accept") == jsonMediaType {
+ var data = struct {
+ Error string `json:"error"`
+ }{err.Message}
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(err.Code)
+ if err := json.NewEncoder(w).Encode(data); err != nil {
+ http.Error(w, "Something went wrong", http.StatusInternalServerError)
+ return
+ }
+ } else {
+ http.Error(w, err.Message, err.Code)
+ }
+ }
+}
diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go
new file mode 100644
index 0000000..5afa3b1
--- /dev/null
+++ b/internal/handler/handler_test.go
@@ -0,0 +1,78 @@
+package handler_test
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "testing"
+
+ "github.com/DMarby/picsum-photos/internal/handler"
+)
+
+func TestHandler(t *testing.T) {
+ tests := []struct {
+ Name string
+ AcceptHeader string
+ ExpectedContentType string
+ ExpectedStatus int
+ ExpectedResponse []byte
+ Handler handler.Handler
+ }{
+ {"internal server error", "text/html", "text/plain; charset=utf-8", http.StatusInternalServerError, []byte("Something went wrong\n"), errorHandler},
+ {"internal server error json", "application/json", "application/json", http.StatusInternalServerError, []byte("{\"error\":\"Something went wrong\"}\n"), errorHandler},
+ {"bad request", "text/html", "text/plain; charset=utf-8", http.StatusBadRequest, []byte("Bad request test\n"), badRequestHandler},
+ {"bad request json", "application/json", "application/json", http.StatusBadRequest, []byte("{\"error\":\"Bad request test\"}\n"), badRequestHandler},
+ }
+
+ for _, test := range tests {
+ ts := httptest.NewServer(handler.Handler(test.Handler))
+ defer ts.Close()
+
+ req, err := http.NewRequest("GET", ts.URL, nil)
+ if err != nil {
+ t.Errorf("%s: %s", test.Name, err)
+ continue
+ }
+
+ req.Header.Set("Accept", test.AcceptHeader)
+
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Errorf("%s: %s", test.Name, err)
+ continue
+ }
+
+ defer res.Body.Close()
+
+ if res.StatusCode != test.ExpectedStatus {
+ t.Errorf("%s: wrong response code, %#v", test.Name, res.StatusCode)
+ continue
+ }
+
+ contentType := res.Header.Get("Content-Type")
+ if contentType != test.ExpectedContentType {
+ t.Errorf("%s: wrong content type, %#v", test.Name, contentType)
+ continue
+ }
+
+ body, err := io.ReadAll(res.Body)
+ if err != nil {
+ t.Errorf("%s: %s", test.Name, err)
+ continue
+ }
+
+ if !reflect.DeepEqual(body, test.ExpectedResponse) {
+ t.Errorf("%s: wrong response %s", test.Name, body)
+ }
+ }
+
+}
+
+func errorHandler(rw http.ResponseWriter, req *http.Request) *handler.Error {
+ return handler.InternalServerError()
+}
+
+func badRequestHandler(rw http.ResponseWriter, req *http.Request) *handler.Error {
+ return handler.BadRequest("Bad request test")
+}
diff --git a/internal/handler/health.go b/internal/handler/health.go
new file mode 100644
index 0000000..a771530
--- /dev/null
+++ b/internal/handler/health.go
@@ -0,0 +1,31 @@
+package handler
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/DMarby/picsum-photos/internal/health"
+)
+
+// Health is a handler for health check status
+func Health(healthChecker *health.Checker) Handler {
+ return Handler(newHandler(healthChecker))
+}
+
+func newHandler(healthChecker *health.Checker) func(w http.ResponseWriter, r *http.Request) *Error {
+ return func(w http.ResponseWriter, r *http.Request) *Error {
+ status := healthChecker.Status()
+
+ if !status.Healthy {
+ w.WriteHeader(http.StatusInternalServerError)
+ }
+
+ w.Header().Set("Cache-Control", "private, no-cache, no-store, must-revalidate")
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(status); err != nil {
+ return InternalServerError()
+ }
+
+ return nil
+ }
+}
diff --git a/internal/handler/logger.go b/internal/handler/logger.go
new file mode 100644
index 0000000..54386ea
--- /dev/null
+++ b/internal/handler/logger.go
@@ -0,0 +1,69 @@
+package handler
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+
+ "github.com/DMarby/picsum-photos/internal/logger"
+ "github.com/DMarby/picsum-photos/internal/tracing"
+ "github.com/felixge/httpsnoop"
+)
+
+// Logger is a handler that logs requests using Zap
+func Logger(log *logger.Logger, h http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ respMetrics := httpsnoop.CaptureMetricsFn(w, func(ww http.ResponseWriter) {
+ h.ServeHTTP(ww, r)
+ })
+
+ ctx := r.Context()
+ traceID, spanID := tracing.TraceInfo(ctx)
+
+ logFields := []interface{}{
+ "http-method", r.Method,
+ "remote-addr", r.RemoteAddr,
+ "user-agent", r.UserAgent(),
+ "uri", r.URL.String(),
+ "status-code", respMetrics.Code,
+ "elapsed", fmt.Sprintf("%.9fs", respMetrics.Duration.Seconds()),
+ }
+
+ if traceID != "" {
+ logFields = append(logFields, "trace-id", traceID, "span-id", spanID)
+ }
+
+ // Add context error information if present
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ logFields = append(logFields, "context-error", ctxErr.Error())
+ }
+
+ switch {
+ case respMetrics.Code == http.StatusServiceUnavailable && ctx.Err() == context.Canceled:
+ // Client disconnected - not an error, just informational
+ log.Infow("Request cancelled by client", logFields...)
+ case respMetrics.Code == http.StatusServiceUnavailable && ctx.Err() == context.DeadlineExceeded:
+ // Handler timeout - this is an error
+ log.Errorw("Request timeout", logFields...)
+ case respMetrics.Code >= 500:
+ // Other 5xx errors
+ log.Errorw("Request completed", logFields...)
+ default:
+ log.Debugw("Request completed", logFields...)
+ }
+ })
+}
+
+// LogFields logs the given keys and values for a request
+func LogFields(r *http.Request, keysAndValues ...interface{}) []interface{} {
+ ctx := r.Context()
+ traceID, spanID := tracing.TraceInfo(ctx)
+
+ if traceID != "" {
+ return append([]interface{}{
+ "trace-id", traceID,
+ "span-id", spanID,
+ }, keysAndValues...)
+ }
+ return keysAndValues
+}
diff --git a/internal/handler/metrics.go b/internal/handler/metrics.go
new file mode 100644
index 0000000..c71b6c8
--- /dev/null
+++ b/internal/handler/metrics.go
@@ -0,0 +1,96 @@
+package handler
+
+import (
+ "context"
+ "expvar"
+ "net/http"
+ "strconv"
+
+ "github.com/felixge/httpsnoop"
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/common/expfmt"
+ "tailscale.com/tsweb/varz"
+)
+
+var (
+ httpRequestsInFlight = expvar.NewInt("gauge_http_requests_in_flight")
+
+ registry = prometheus.NewRegistry()
+ httpRequestDurationSeconds = prometheus.NewHistogramVec(prometheus.HistogramOpts{
+ Subsystem: "http",
+ Name: "request_duration_seconds",
+ Buckets: durationBuckets,
+ }, []string{"path", "code"})
+)
+
+var durationBuckets = []float64{
+ .01, // 10 ms
+ .025, // 25 ms
+ .05, // 50 ms
+ .1, // 100 ms
+ .25, // 250 ms
+ .5, // 500 ms
+ 1, // 1 s
+ 2.5, // 2.5 s
+ 3, // 3 s
+ 4, // 4 s
+ 5, // 5 s
+ 10, // 10 s
+ 30, // 30 s
+ 45, // 45 s
+}
+
+func init() {
+ registry.MustRegister(httpRequestDurationSeconds)
+}
+
+func VarzHandler(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
+ expvar.Do(func(kv expvar.KeyValue) {
+ varz.WritePrometheusExpvar(w, kv)
+ })
+
+ mfs, _ := registry.Gather()
+ enc := expfmt.NewEncoder(w, expfmt.FmtText)
+
+ for _, mf := range mfs {
+ enc.Encode(mf)
+ }
+
+ if closer, ok := enc.(expfmt.Closer); ok {
+ closer.Close()
+ }
+}
+
+// Metrics is a handler that collects performance metrics
+func Metrics(h http.Handler, routeMatcher RouteMatcher) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ route := routeMatcher.Match(r)
+
+ httpRequestsInFlight.Add(1)
+ defer httpRequestsInFlight.Add(-1)
+
+ respMetrics := httpsnoop.CaptureMetricsFn(w, func(ww http.ResponseWriter) {
+ h.ServeHTTP(ww, r)
+ })
+
+ // Exclude metrics for certain statuscodes to reduce cardinality
+ switch respMetrics.Code {
+ // Only set by mux's strict slash redirect
+ case http.StatusMovedPermanently:
+ return
+ // Produced by http.ServeFile when serving the static assets for the website
+ case http.StatusPartialContent, http.StatusNotModified, http.StatusRequestedRangeNotSatisfiable:
+ return
+ }
+
+ // Count client disconnects as 499s in metrics, so that we can distuingish them
+ // These are not real errors - the client simply disconnected
+ if respMetrics.Code == http.StatusServiceUnavailable && r.Context().Err() == context.Canceled {
+ respMetrics.Code = 499
+ }
+
+ histogram := httpRequestDurationSeconds.WithLabelValues(route, strconv.Itoa(respMetrics.Code))
+ histogram.Observe(respMetrics.Duration.Seconds())
+ })
+}
diff --git a/internal/handler/recovery.go b/internal/handler/recovery.go
new file mode 100644
index 0000000..57daae9
--- /dev/null
+++ b/internal/handler/recovery.go
@@ -0,0 +1,29 @@
+package handler
+
+import (
+ "net/http"
+ "runtime/debug"
+
+ "github.com/DMarby/picsum-photos/internal/logger"
+ "github.com/DMarby/picsum-photos/internal/tracing"
+)
+
+// Recovery is a handler for handling panics
+func Recovery(log *logger.Logger, next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ defer func() {
+ if err := recover(); err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ ctx := r.Context()
+ traceID, spanID := tracing.TraceInfo(ctx)
+ logFields := []interface{}{"stacktrace", string(debug.Stack())}
+ if traceID != "" {
+ logFields = append(logFields, "trace-id", traceID, "span-id", spanID)
+ }
+ log.Errorw("panic handling request", logFields...)
+ }
+ }()
+
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/internal/handler/recovery_test.go b/internal/handler/recovery_test.go
new file mode 100644
index 0000000..af71bd6
--- /dev/null
+++ b/internal/handler/recovery_test.go
@@ -0,0 +1,33 @@
+package handler_test
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/DMarby/picsum-photos/internal/handler"
+ "github.com/DMarby/picsum-photos/internal/logger"
+ "go.uber.org/zap"
+)
+
+func TestRecovery(t *testing.T) {
+ log := logger.New(zap.FatalLevel)
+ defer log.Sync()
+
+ ts := httptest.NewServer(handler.Recovery(log, http.HandlerFunc(panicHandler)))
+ defer ts.Close()
+
+ res, err := http.Get(ts.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusInternalServerError {
+ t.Errorf("wrong status code %#v", res.StatusCode)
+ }
+}
+
+func panicHandler(rw http.ResponseWriter, req *http.Request) {
+ panic("panicking handler")
+}
diff --git a/internal/handler/route_matcher.go b/internal/handler/route_matcher.go
new file mode 100644
index 0000000..66c6595
--- /dev/null
+++ b/internal/handler/route_matcher.go
@@ -0,0 +1,34 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gorilla/mux"
+)
+
+// RouteMatcher matches routes
+type RouteMatcher interface {
+ Match(r *http.Request) string
+}
+
+// MuxRouteMatcher maxes routes for a mux router
+type MuxRouteMatcher struct {
+ Router *mux.Router
+}
+
+// Match returns the mux route name of a given request, falling back to the path template if not set
+func (m *MuxRouteMatcher) Match(r *http.Request) string {
+ var routeMatch mux.RouteMatch
+ // The Route can be nil even on a Match, if a NotFoundHandler is specified
+ if m.Router.Match(r, &routeMatch) && routeMatch.Route != nil {
+ if routeName := routeMatch.Route.GetName(); routeName != "" {
+ return routeName
+ }
+
+ if tmpl, err := routeMatch.Route.GetPathTemplate(); err == nil {
+ return tmpl
+ }
+ }
+
+ return "unknown"
+}
diff --git a/internal/handler/tracing.go b/internal/handler/tracing.go
new file mode 100644
index 0000000..2be7000
--- /dev/null
+++ b/internal/handler/tracing.go
@@ -0,0 +1,26 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/DMarby/picsum-photos/internal/tracing"
+ "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
+ "go.opentelemetry.io/otel/propagation"
+)
+
+// Tracer is a handler that adds tracing for handlers
+func Tracer(tracer *tracing.Tracer, h http.Handler, routeMatcher RouteMatcher) http.Handler {
+ traceHandler := otelhttp.NewHandler(
+ h,
+ "http",
+ otelhttp.WithTracerProvider(tracer),
+ otelhttp.WithPropagators(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{})),
+ otelhttp.WithSpanNameFormatter(func(operation string, r *http.Request) string {
+ return routeMatcher.Match(r)
+ }),
+ )
+
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ traceHandler.ServeHTTP(w, r)
+ })
+}
diff --git a/internal/health/health.go b/internal/health/health.go
new file mode 100644
index 0000000..2f61dee
--- /dev/null
+++ b/internal/health/health.go
@@ -0,0 +1,162 @@
+package health
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ "github.com/DMarby/picsum-photos/internal/cache"
+ "github.com/DMarby/picsum-photos/internal/database"
+ "github.com/DMarby/picsum-photos/internal/logger"
+ "github.com/DMarby/picsum-photos/internal/storage"
+)
+
+const checkInterval = 10 * time.Second
+const checkTimeout = 8 * time.Second
+
+// Checker is a periodic health checker
+type Checker struct {
+ Ctx context.Context
+ Storage storage.Provider
+ Database database.Provider
+ Cache cache.Provider
+ status Status
+ mutex sync.RWMutex
+ Log *logger.Logger
+}
+
+// Status contains the healtcheck status
+type Status struct {
+ Healthy bool `json:"healthy"`
+ Cache string `json:"cache,omitempty"`
+ Database string `json:"database,omitempty"`
+ Storage string `json:"storage,omitempty"`
+}
+
+// Run starts the health checker
+func (c *Checker) Run() {
+ ticker := time.NewTicker(checkInterval)
+ go func() {
+ for {
+ select {
+ case <-ticker.C:
+ c.runCheck()
+ case <-c.Ctx.Done():
+ ticker.Stop()
+ return
+ }
+ }
+ }()
+
+ c.runCheck()
+}
+
+// Status returns the status of the health checks
+func (c *Checker) Status() Status {
+ c.mutex.RLock()
+ defer c.mutex.RUnlock()
+
+ return c.status
+}
+
+func (c *Checker) runCheck() {
+ ctx, cancel := context.WithTimeout(context.Background(), checkTimeout)
+ defer cancel()
+
+ channel := make(chan Status, 1)
+ go func() {
+ c.check(ctx, channel)
+ }()
+
+ select {
+ case <-ctx.Done():
+ c.mutex.Lock()
+
+ c.status = Status{
+ Healthy: false,
+ }
+ if c.Database != nil {
+ c.status.Database = "unknown"
+ }
+ if c.Cache != nil {
+ c.status.Cache = "unknown"
+ }
+ if c.Storage != nil {
+ c.status.Storage = "unknown"
+ }
+
+ c.mutex.Unlock()
+ c.Log.Errorw("healthcheck timed out")
+ case status, ok := <-channel:
+ if !ok {
+ return
+ }
+
+ c.mutex.Lock()
+ c.status = status
+ c.mutex.Unlock()
+ if !status.Healthy {
+ c.Log.Errorw("healthcheck error",
+ "status", status,
+ )
+ }
+ }
+}
+
+func (c *Checker) check(ctx context.Context, channel chan Status) {
+ defer close(channel)
+
+ if ctx.Err() != nil {
+ return
+ }
+
+ status := Status{
+ Healthy: true,
+ }
+ if c.Database != nil {
+ status.Database = "unknown"
+ }
+ if c.Cache != nil {
+ status.Cache = "unknown"
+ }
+ if c.Storage != nil {
+ status.Storage = "unknown"
+ }
+
+ if c.Database != nil {
+ if _, err := c.Database.GetRandom(ctx); err != nil {
+ status.Healthy = false
+ status.Database = "unhealthy"
+ } else {
+ status.Database = "healthy"
+ }
+ }
+
+ if ctx.Err() != nil {
+ return
+ }
+
+ if c.Cache != nil {
+ if _, err := c.Cache.Get(ctx, "healthcheck"); err != cache.ErrNotFound {
+ status.Healthy = false
+ status.Cache = "unhealthy"
+ } else {
+ status.Cache = "healthy"
+ }
+ }
+
+ if ctx.Err() != nil {
+ return
+ }
+
+ if c.Storage != nil {
+ if _, err := c.Storage.Get(ctx, "healthcheck"); err != storage.ErrNotFound {
+ status.Healthy = false
+ status.Storage = "unhealthy"
+ } else {
+ status.Storage = "healthy"
+ }
+ }
+
+ channel <- status
+}
diff --git a/internal/health/health_test.go b/internal/health/health_test.go
new file mode 100644
index 0000000..74a1fb1
--- /dev/null
+++ b/internal/health/health_test.go
@@ -0,0 +1,98 @@
+package health_test
+
+import (
+ "context"
+ "reflect"
+ "testing"
+
+ "github.com/DMarby/picsum-photos/internal/health"
+ "github.com/DMarby/picsum-photos/internal/logger"
+ "go.uber.org/zap"
+
+ fileDatabase "github.com/DMarby/picsum-photos/internal/database/file"
+ mockDatabase "github.com/DMarby/picsum-photos/internal/database/mock"
+
+ fileStorage "github.com/DMarby/picsum-photos/internal/storage/file"
+ mockStorage "github.com/DMarby/picsum-photos/internal/storage/mock"
+
+ memoryCache "github.com/DMarby/picsum-photos/internal/cache/memory"
+ mockCache "github.com/DMarby/picsum-photos/internal/cache/mock"
+)
+
+func TestHealth(t *testing.T) {
+ log := logger.New(zap.ErrorLevel)
+ defer log.Sync()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ storage, _ := fileStorage.New("../../test/fixtures/file")
+ db, _ := fileDatabase.New("../../test/fixtures/file/metadata.json")
+ cache := memoryCache.New()
+
+ checker := &health.Checker{Ctx: ctx, Storage: storage, Cache: cache, Log: log}
+ mockStorageChecker := &health.Checker{Ctx: ctx, Storage: &mockStorage.Provider{}, Cache: cache, Log: log}
+ mockCacheChecker := &health.Checker{Ctx: ctx, Storage: storage, Cache: &mockCache.Provider{}, Log: log}
+
+ dbOnlyChecker := &health.Checker{Ctx: ctx, Database: db, Log: log}
+ mockDbOnlyChecker := &health.Checker{Ctx: ctx, Database: &mockDatabase.Provider{}, Log: log}
+
+ tests := []struct {
+ Name string
+ ExpectedStatus health.Status
+ Checker *health.Checker
+ }{
+ {
+ Name: "runs checks and returns correct status",
+ ExpectedStatus: health.Status{
+ Healthy: true,
+ Cache: "healthy",
+ Storage: "healthy",
+ },
+ Checker: checker,
+ },
+ {
+ Name: "runs checks and returns correct status with broken storage",
+ ExpectedStatus: health.Status{
+ Healthy: false,
+ Cache: "healthy",
+ Storage: "unhealthy",
+ },
+ Checker: mockStorageChecker,
+ },
+ {
+ Name: "runs checks and returns correct status with broken cache",
+ ExpectedStatus: health.Status{
+ Healthy: false,
+ Cache: "unhealthy",
+ Storage: "healthy",
+ },
+ Checker: mockCacheChecker,
+ },
+ {
+ Name: "runs checks and returns correct status with only a database",
+ ExpectedStatus: health.Status{
+ Healthy: true,
+ Database: "healthy",
+ },
+ Checker: dbOnlyChecker,
+ },
+ {
+ Name: "runs checks and returns correct status with only a broken database",
+ ExpectedStatus: health.Status{
+ Healthy: false,
+ Database: "unhealthy",
+ },
+ Checker: mockDbOnlyChecker,
+ },
+ }
+
+ for _, test := range tests {
+ test.Checker.Run()
+ status := test.Checker.Status()
+
+ if !reflect.DeepEqual(status, test.ExpectedStatus) {
+ t.Errorf("%s: wrong status %+v", test.Name, status)
+ }
+ }
+}
diff --git a/internal/hmac/hmac.go b/internal/hmac/hmac.go
new file mode 100644
index 0000000..22fa15d
--- /dev/null
+++ b/internal/hmac/hmac.go
@@ -0,0 +1,34 @@
+package hmac
+
+import (
+ cryptoHMAC "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+)
+
+// HMAC is a utility for creating and verifying HMACs
+type HMAC struct {
+ Key []byte
+}
+
+// Create creates a HMAC based on the parameter values, encoded as urlsafe base64
+func (h *HMAC) Create(message string) (string, error) {
+ mac := cryptoHMAC.New(sha256.New, h.Key)
+
+ _, err := mac.Write([]byte(message))
+ if err != nil {
+ return "", err
+ }
+
+ return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
+}
+
+// Validate validates that the parameter values matches a given HMAC
+func (h *HMAC) Validate(message, mac string) (bool, error) {
+ expectedMAC, err := h.Create(message)
+ if err != nil {
+ return false, err
+ }
+
+ return cryptoHMAC.Equal([]byte(mac), []byte(expectedMAC)), nil
+}
diff --git a/internal/hmac/hmac_test.go b/internal/hmac/hmac_test.go
new file mode 100644
index 0000000..08635f6
--- /dev/null
+++ b/internal/hmac/hmac_test.go
@@ -0,0 +1,39 @@
+package hmac_test
+
+import (
+ "testing"
+
+ "github.com/DMarby/picsum-photos/internal/hmac"
+)
+
+var key = []byte("foobar")
+var message = "test"
+
+func TestHMAC(t *testing.T) {
+ h := &hmac.HMAC{
+ Key: key,
+ }
+
+ mac, err := h.Create(message)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ matches, err := h.Validate(message, mac)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if !matches {
+ t.Error("hmac does not match")
+ }
+
+ matches, err = h.Validate("doesnotmatch", mac)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if matches {
+ t.Error("hmac matches when it should not")
+ }
+}
diff --git a/internal/image/cache.go b/internal/image/cache.go
new file mode 100644
index 0000000..53f819b
--- /dev/null
+++ b/internal/image/cache.go
@@ -0,0 +1,26 @@
+package image
+
+import (
+ "context"
+
+ "github.com/DMarby/picsum-photos/internal/cache"
+ "github.com/DMarby/picsum-photos/internal/storage"
+ "github.com/DMarby/picsum-photos/internal/tracing"
+)
+
+// Cache is an image cache
+type Cache = cache.Auto
+
+// NewCache instantiates a new cache
+func NewCache(tracer *tracing.Tracer, cacheProvider cache.Provider, storageProvider storage.Provider) *Cache {
+ return &Cache{
+ Tracer: tracer,
+ Provider: cacheProvider,
+ Loader: func(ctx context.Context, key string) (data []byte, err error) {
+ ctx, span := tracer.Start(ctx, "image.Cache.Loader")
+ defer span.End()
+
+ return storageProvider.Get(ctx, key)
+ },
+ }
+}
diff --git a/internal/image/image.go b/internal/image/image.go
new file mode 100644
index 0000000..7716641
--- /dev/null
+++ b/internal/image/image.go
@@ -0,0 +1,8 @@
+package image
+
+import "context"
+
+// Processor is an image processor
+type Processor interface {
+ ProcessImage(ctx context.Context, task *Task) (processedImage []byte, err error)
+}
diff --git a/internal/image/mock/mock.go b/internal/image/mock/mock.go
new file mode 100644
index 0000000..93e3391
--- /dev/null
+++ b/internal/image/mock/mock.go
@@ -0,0 +1,17 @@
+package mock
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/DMarby/picsum-photos/internal/image"
+)
+
+// Processor implements a mock image processor
+type Processor struct {
+}
+
+// ProcessImage returns an error instead of process an image
+func (p *Processor) ProcessImage(ctx context.Context, task *image.Task) (processedImage []byte, err error) {
+ return nil, fmt.Errorf("processing error")
+}
diff --git a/internal/image/task.go b/internal/image/task.go
new file mode 100644
index 0000000..ec33774
--- /dev/null
+++ b/internal/image/task.go
@@ -0,0 +1,47 @@
+package image
+
+// Task is an image processing task
+type Task struct {
+ ImageID string
+ Width int
+ Height int
+ ApplyBlur bool
+ BlurAmount int
+ ApplyGrayscale bool
+ UserComment string
+ OutputFormat OutputFormat
+}
+
+// OutputFormat is the image format to output to
+type OutputFormat int
+
+const (
+ // JPEG represents the JPEG format
+ JPEG OutputFormat = iota
+ // WebP represents the WebP format
+ WebP
+)
+
+// NewTask creates a new image processing task
+func NewTask(imageID string, width int, height int, userComment string, format OutputFormat) *Task {
+ return &Task{
+ ImageID: imageID,
+ Width: width,
+ Height: height,
+ UserComment: userComment,
+ OutputFormat: format,
+ }
+}
+
+// Blur applies gaussian blur to the image
+func (t *Task) Blur(amount int) *Task {
+ t.ApplyBlur = true
+ t.BlurAmount = amount
+ return t
+}
+
+// Grayscale turns the image into grayscale
+func (t *Task) Grayscale() *Task {
+ t.ApplyGrayscale = true
+ return t
+}
diff --git a/internal/image/vips/image.go b/internal/image/vips/image.go
new file mode 100644
index 0000000..28e8dcd
--- /dev/null
+++ b/internal/image/vips/image.go
@@ -0,0 +1,73 @@
+package vips
+
+import "github.com/DMarby/picsum-photos/internal/vips"
+
+// resizedImage is a resized image
+type resizedImage struct {
+ vipsImage vips.Image
+}
+
+// resizeImage loads an image from a byte buffer, resizes it and returns an Image object for further use
+// Note that it does not use the processor worker queue, use ProcessImage for that
+func resizeImage(buffer []byte, width int, height int) (*resizedImage, error) {
+ image, err := vips.ResizeImage(buffer, width, height)
+
+ if err != nil {
+ return nil, err
+ }
+
+ return &resizedImage{
+ vipsImage: image,
+ }, nil
+}
+
+// grayscale turns an image into grayscale
+func (i *resizedImage) grayscale() (*resizedImage, error) {
+ image, err := vips.Grayscale(i.vipsImage)
+ if err != nil {
+ return nil, err
+ }
+
+ return &resizedImage{
+ vipsImage: image,
+ }, nil
+}
+
+// blur applies gaussian blur to an image
+func (i *resizedImage) blur(blur int) (*resizedImage, error) {
+ image, err := vips.Blur(i.vipsImage, blur)
+ if err != nil {
+ return nil, err
+ }
+
+ return &resizedImage{
+ vipsImage: image,
+ }, nil
+}
+
+// setUserComment sets the exif usercomment
+func (i *resizedImage) setUserComment(comment string) {
+ vips.SetUserComment(i.vipsImage, comment)
+}
+
+// saveToJpegBuffer returns the image as a JPEG byte buffer
+func (i *resizedImage) saveToJpegBuffer() ([]byte, error) {
+ imageBuffer, err := vips.SaveToJpegBuffer(i.vipsImage)
+
+ if err != nil {
+ return nil, err
+ }
+
+ return imageBuffer, nil
+}
+
+// saveToWebPBuffer returns the image as a WebP byte buffer
+func (i *resizedImage) saveToWebPBuffer() ([]byte, error) {
+ imageBuffer, err := vips.SaveToWebPBuffer(i.vipsImage)
+
+ if err != nil {
+ return nil, err
+ }
+
+ return imageBuffer, nil
+}
diff --git a/internal/image/vips/vips.go b/internal/image/vips/vips.go
new file mode 100644
index 0000000..013b9a9
--- /dev/null
+++ b/internal/image/vips/vips.go
@@ -0,0 +1,152 @@
+package vips
+
+import (
+ "context"
+ "expvar"
+ "fmt"
+ "math"
+
+ "github.com/DMarby/picsum-photos/internal/image"
+ "github.com/DMarby/picsum-photos/internal/logger"
+ "github.com/DMarby/picsum-photos/internal/queue"
+ "github.com/DMarby/picsum-photos/internal/tracing"
+ "github.com/DMarby/picsum-photos/internal/vips"
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/trace"
+)
+
+// Processor is an image processor that uses vips to process images
+type Processor struct {
+ queue *queue.Queue
+ tracer *tracing.Tracer
+}
+
+var (
+ processedImages = expvar.NewMap("counter_labelmap_dimensions_image_processor_processed_images")
+)
+
+// New initializes a new processor instance
+func New(ctx context.Context, log *logger.Logger, tracer *tracing.Tracer, workers int, cache *image.Cache) (*Processor, error) {
+ err := vips.Initialize(log)
+ if err != nil {
+ return nil, err
+ }
+
+ workerQueue := queue.New(ctx, workers, taskProcessor(cache, tracer))
+ instance := &Processor{
+ queue: workerQueue,
+ tracer: tracer,
+ }
+
+ // Publish queue size metric (only if not already registered)
+ if expvar.Get("gauge_image_processor_queue_size") == nil {
+ expvar.Publish("gauge_image_processor_queue_size", expvar.Func(func() any {
+ return workerQueue.Len()
+ }))
+ }
+
+ go workerQueue.Run()
+ log.Infof("starting vips worker queue with %d workers", workers)
+
+ return instance, err
+}
+
+// ProcessImage loads an image from a byte buffer, processes it, and returns a buffer containing the processed image
+func (p *Processor) ProcessImage(ctx context.Context, task *image.Task) (processedImage []byte, err error) {
+ ctx, span := p.tracer.Start(
+ ctx,
+ "image.ProcessImage",
+ trace.WithAttributes(attribute.Int("width", task.Width)),
+ trace.WithAttributes(attribute.Int("height", task.Height)),
+ trace.WithAttributes(attribute.Int("format", int(task.OutputFormat))),
+ )
+ defer span.End()
+
+ result, err := p.queue.Process(ctx, task)
+ if err != nil {
+ return nil, err
+ }
+
+ image, ok := result.([]byte)
+ if !ok {
+ return nil, fmt.Errorf("error getting result")
+ }
+
+ processedImages.Add(fmt.Sprintf("%0.f", math.Max(math.Round(float64(task.Width)/500)*500, math.Round(float64(task.Height)/500)*500)), 1)
+
+ return image, nil
+}
+
+func taskProcessor(cache *image.Cache, tracer *tracing.Tracer) func(ctx context.Context, data interface{}) (interface{}, error) {
+ return func(ctx context.Context, data interface{}) (interface{}, error) {
+ task, ok := data.(*image.Task)
+ if !ok {
+ return nil, fmt.Errorf("invalid data")
+ }
+
+ // Use a pre-processed source image closer to the desired size then the original
+ // We use 2x the requested size to maintain quality when downscaling
+ imageKey := task.ImageID
+ width := math.Ceil(float64(task.Width*2)/500) * 500
+ height := math.Ceil(float64(task.Height*2)/500) * 500
+ size := math.Max(width, height)
+ if size <= 4500 { // Files larger then 4500 doesn't have a suffix
+ imageKey = fmt.Sprintf("%s_%0.f", task.ImageID, size)
+ }
+
+ imageBuffer, err := cache.Get(ctx, imageKey)
+ if err != nil {
+ return nil, fmt.Errorf("error getting image from cache: %s", err)
+ }
+
+ _, span := tracer.Start(ctx, "image.resizeImage")
+ processedImage, err := resizeImage(imageBuffer, task.Width, task.Height)
+ span.End()
+ if err != nil {
+ return nil, err
+ }
+
+ if task.ApplyBlur {
+ _, span := tracer.Start(ctx, "image.blur")
+ processedImage, err = processedImage.blur(task.BlurAmount)
+ span.End()
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ if task.ApplyGrayscale {
+ _, span := tracer.Start(ctx, "image.grayscale")
+ processedImage, err = processedImage.grayscale()
+ span.End()
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ processedImage.setUserComment(task.UserComment)
+
+ var buffer []byte
+ switch task.OutputFormat {
+ case image.JPEG:
+ _, span := tracer.Start(ctx, "image.saveToJpegBuffer")
+ buffer, err = processedImage.saveToJpegBuffer()
+ span.End()
+ case image.WebP:
+ _, span := tracer.Start(ctx, "image.saveToWebPBuffer")
+ buffer, err = processedImage.saveToWebPBuffer()
+ span.End()
+ }
+
+ if err != nil {
+ return nil, err
+ }
+
+ return buffer, nil
+ }
+}
+
+// Shutdown shuts down the image processor and deinitialises vips
+func (p *Processor) Shutdown() {
+ vips.Shutdown()
+}
diff --git a/internal/image/vips/vips_test.go b/internal/image/vips/vips_test.go
new file mode 100644
index 0000000..1c4a317
--- /dev/null
+++ b/internal/image/vips/vips_test.go
@@ -0,0 +1,139 @@
+package vips_test
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "reflect"
+ "runtime"
+
+ "github.com/DMarby/picsum-photos/internal/cache/memory"
+ "github.com/DMarby/picsum-photos/internal/image"
+ "github.com/DMarby/picsum-photos/internal/image/vips"
+ "github.com/DMarby/picsum-photos/internal/logger"
+ "github.com/DMarby/picsum-photos/internal/storage/file"
+ "github.com/DMarby/picsum-photos/internal/tracing/test"
+ "go.uber.org/zap"
+
+ "testing"
+)
+
+var (
+ jpegFixture = fmt.Sprintf("../../../test/fixtures/image/complete_result_%s.jpg", runtime.GOOS)
+ webpFixture = fmt.Sprintf("../../../test/fixtures/image/complete_result_%s.webp", runtime.GOOS)
+)
+
+func TestVips(t *testing.T) {
+ cancel, processor, buf, err := setup()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cancel()
+ defer processor.Shutdown()
+
+ t.Run("Processor", func(t *testing.T) {
+ t.Run("process image", func(t *testing.T) {
+ _, err := processor.ProcessImage(context.Background(), image.NewTask("1", 500, 500, "testing", image.JPEG))
+ if err != nil {
+ t.Error(err)
+ }
+ })
+
+ t.Run("process image handles errors", func(t *testing.T) {
+ _, err := processor.ProcessImage(context.Background(), image.NewTask("foo", 500, 500, "testing", image.JPEG))
+ if err == nil || err.Error() != "error getting image from cache: Image does not exist" {
+ t.Error()
+ }
+ })
+
+ t.Run("full test jpeg", func(t *testing.T) {
+ resultFixture, _ := os.ReadFile(jpegFixture)
+ testResult := fullTest(processor, buf, image.JPEG)
+ if !reflect.DeepEqual(testResult, resultFixture) {
+ t.Error("image data doesn't match")
+ }
+ })
+
+ t.Run("full test webp", func(t *testing.T) {
+ resultFixture, _ := os.ReadFile(webpFixture)
+ testResult := fullTest(processor, buf, image.WebP)
+ if !reflect.DeepEqual(testResult, resultFixture) {
+ t.Error("image data doesn't match")
+ }
+ })
+ })
+}
+
+func BenchmarkVips(b *testing.B) {
+ cancel, processor, buf, err := setup()
+ if err != nil {
+ b.Fatal(err)
+ }
+ defer cancel()
+ defer processor.Shutdown()
+
+ b.Run("full test jpeg", func(b *testing.B) {
+ fullTest(processor, buf, image.JPEG)
+ })
+
+ b.Run("full test webp", func(b *testing.B) {
+ fullTest(processor, buf, image.WebP)
+ })
+}
+
+// Utility function for regenerating the fixtures
+func TestFixtures(t *testing.T) {
+ if os.Getenv("GENERATE_FIXTURES") != "1" {
+ t.SkipNow()
+ }
+
+ cancel, processor, buf, err := setup()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ defer cancel()
+ defer processor.Shutdown()
+
+ jpeg := fullTest(processor, buf, image.JPEG)
+ os.WriteFile(jpegFixture, jpeg, 0644)
+
+ webp := fullTest(processor, buf, image.WebP)
+ os.WriteFile(webpFixture, webp, 0644)
+}
+
+func setup() (context.CancelFunc, *vips.Processor, []byte, error) {
+ log := logger.New(zap.ErrorLevel)
+ defer log.Sync()
+
+ tracer := test.Tracer(log)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ storage, err := file.New("../../../test/fixtures/file")
+ if err != nil {
+ cancel()
+ return nil, nil, nil, err
+ }
+
+ cache := image.NewCache(tracer, memory.New(), storage)
+
+ processor, err := vips.New(ctx, log, tracer, 3, cache)
+ if err != nil {
+ cancel()
+ return nil, nil, nil, err
+ }
+
+ buf, err := os.ReadFile("../../../test/fixtures/fixture.jpg")
+ if err != nil {
+ cancel()
+ return nil, nil, nil, err
+ }
+
+ return cancel, processor, buf, nil
+}
+
+func fullTest(processor *vips.Processor, buf []byte, format image.OutputFormat) []byte {
+ task := image.NewTask("1", 500, 500, "testing", format).Grayscale().Blur(5)
+ imageBuffer, _ := processor.ProcessImage(context.Background(), task)
+ return imageBuffer
+}
diff --git a/internal/imageapi/api.go b/internal/imageapi/api.go
new file mode 100644
index 0000000..94adc0a
--- /dev/null
+++ b/internal/imageapi/api.go
@@ -0,0 +1,107 @@
+package imageapi
+
+import (
+ "expvar"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/DMarby/picsum-photos/internal/handler"
+ "github.com/DMarby/picsum-photos/internal/hmac"
+ "github.com/DMarby/picsum-photos/internal/tracing"
+ "github.com/hashicorp/golang-lru/v2/expirable"
+ "github.com/rs/cors"
+
+ "github.com/DMarby/picsum-photos/internal/image"
+ "github.com/DMarby/picsum-photos/internal/logger"
+ "github.com/gorilla/mux"
+)
+
+const (
+ imageCacheTTL = 10 * time.Minute
+ imageCacheCapacity = 150_000
+)
+
+// API is a http api
+type API struct {
+ ImageProcessor image.Processor
+ Log *logger.Logger
+ Tracer *tracing.Tracer
+ HandlerTimeout time.Duration
+ HMAC *hmac.HMAC
+ imageCache *expirable.LRU[string, []byte] // caches processed images
+ inflight sync.Map // map[string]chan struct{} - coalesces concurrent requests
+}
+
+// NewAPI creates a new API instance with initialized caches
+func NewAPI(imageProcessor image.Processor, log *logger.Logger, tracer *tracing.Tracer, handlerTimeout time.Duration, hmac *hmac.HMAC) *API {
+ cache := expirable.NewLRU[string, []byte](imageCacheCapacity, nil, imageCacheTTL)
+
+ // Publish cache size gauge metric (only if not already registered)
+ if expvar.Get("gauge_imageapi_cache_size") == nil {
+ expvar.Publish("gauge_imageapi_cache_size", expvar.Func(func() any {
+ return cache.Len()
+ }))
+ }
+
+ return &API{
+ ImageProcessor: imageProcessor,
+ Log: log,
+ Tracer: tracer,
+ HandlerTimeout: handlerTimeout,
+ HMAC: hmac,
+ imageCache: cache,
+ }
+}
+
+// Utility methods for logging
+func (a *API) logError(r *http.Request, message string, err error) {
+ a.Log.Errorw(message, handler.LogFields(r, "error", err)...)
+}
+
+// Router returns a http router
+func (a *API) Router() http.Handler {
+ router := mux.NewRouter()
+
+ router.NotFoundHandler = handler.Handler(a.notFoundHandler)
+
+ // Redirect trailing slashes
+ router.StrictSlash(true)
+
+ // Image by ID routes
+ router.Handle("/id/{id}/{width:[0-9]+}/{height:[0-9]+}{extension:\\..*}", handler.Handler(a.imageHandler)).Methods("GET").Name("imageapi.image")
+
+ // Query parameters:
+ // ?grayscale - Grayscale the image
+ // ?blur={amount} - Blur the image by {amount}
+
+ // ?hmac - HMAC signature of the path and URL parameters
+
+ // Set up handlers
+ cors := cors.New(cors.Options{
+ AllowedMethods: []string{"GET"},
+ AllowedOrigins: []string{"*"},
+ ExposedHeaders: []string{"Content-Type", "Picsum-ID"},
+ })
+
+ httpHandler := cors.Handler(router)
+ httpHandler = handler.Recovery(a.Log, httpHandler)
+ httpHandler = http.TimeoutHandler(httpHandler, a.HandlerTimeout, "Something went wrong. Timed out.")
+ httpHandler = handler.Logger(a.Log, httpHandler)
+
+ routeMatcher := &handler.MuxRouteMatcher{Router: router}
+ httpHandler = handler.Tracer(a.Tracer, httpHandler, routeMatcher)
+ httpHandler = handler.Metrics(httpHandler, routeMatcher)
+
+ return httpHandler
+}
+
+// Handle not found errors
+var notFoundError = &handler.Error{
+ Message: "page not found",
+ Code: http.StatusNotFound,
+}
+
+func (a *API) notFoundHandler(w http.ResponseWriter, r *http.Request) *handler.Error {
+ return notFoundError
+}
diff --git a/internal/imageapi/api_test.go b/internal/imageapi/api_test.go
new file mode 100644
index 0000000..c325b14
--- /dev/null
+++ b/internal/imageapi/api_test.go
@@ -0,0 +1,261 @@
+package imageapi_test
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "reflect"
+ "runtime"
+ "time"
+
+ "github.com/DMarby/picsum-photos/internal/hmac"
+ "github.com/DMarby/picsum-photos/internal/image"
+ api "github.com/DMarby/picsum-photos/internal/imageapi"
+ "github.com/DMarby/picsum-photos/internal/logger"
+ "github.com/DMarby/picsum-photos/internal/params"
+ "github.com/DMarby/picsum-photos/internal/tracing"
+ "github.com/DMarby/picsum-photos/internal/tracing/test"
+ "go.uber.org/zap"
+
+ mockProcessor "github.com/DMarby/picsum-photos/internal/image/mock"
+ vipsProcessor "github.com/DMarby/picsum-photos/internal/image/vips"
+
+ fileStorage "github.com/DMarby/picsum-photos/internal/storage/file"
+ mockStorage "github.com/DMarby/picsum-photos/internal/storage/mock"
+
+ memoryCache "github.com/DMarby/picsum-photos/internal/cache/memory"
+
+ "testing"
+)
+
+func TestAPI(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ log, tracer, imageProcessor, hmac := setup(t, ctx)
+
+ mockStorageImageProcessor, _ := vipsProcessor.New(ctx, log, tracer, 3, image.NewCache(tracer, memoryCache.New(), &mockStorage.Provider{}))
+
+ router := api.NewAPI(imageProcessor, log, tracer, time.Minute, hmac).Router()
+ mockStorageRouter := api.NewAPI(mockStorageImageProcessor, log, tracer, time.Minute, hmac).Router()
+ mockProcessorRouter := api.NewAPI(&mockProcessor.Processor{}, log, tracer, time.Minute, hmac).Router()
+
+ tests := []struct {
+ Name string
+ URL string
+ Router http.Handler
+ ExpectedStatus int
+ ExpectedResponse []byte
+ ExpectedHeaders map[string]string
+ HMAC bool
+ }{
+ // Errors
+ {"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},
+ // Storage errors
+ {"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},
+ // 404
+ {"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},
+ // Processor errors
+ {"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},
+ }
+
+ for _, test := range tests {
+ w := httptest.NewRecorder()
+
+ if test.HMAC {
+ url, err := params.HMAC(hmac, test.URL, url.Values{})
+ if err != nil {
+ t.Errorf("%s: hmac error %s", test.Name, err)
+ continue
+ }
+
+ test.URL = url
+ }
+
+ req, _ := http.NewRequest("GET", test.URL, nil)
+ test.Router.ServeHTTP(w, req)
+ if w.Code != test.ExpectedStatus {
+ t.Errorf("%s: wrong response code, %#v", test.Name, w.Code)
+ continue
+ }
+
+ if test.ExpectedHeaders != nil {
+ for expectedHeader, expectedValue := range test.ExpectedHeaders {
+ headerValue := w.Header().Get(expectedHeader)
+ if headerValue != expectedValue {
+ t.Errorf("%s: wrong header value for %s, %#v", test.Name, expectedHeader, headerValue)
+ }
+ }
+ }
+
+ if !reflect.DeepEqual(w.Body.Bytes(), test.ExpectedResponse) {
+ t.Errorf("%s: wrong response %#v", test.Name, w.Body.String())
+ }
+ }
+
+ imageTests := []struct {
+ Name string
+ URL string
+ ExpectedResponse []byte
+ ExpectedContentDisposition string
+ ExpectedContentType string
+ }{
+ // Images
+
+ // JPEG
+ {"/id/:id/:width/:height.jpg", "/id/1/200/120.jpg", readFixture("width_height", "jpg"), "inline; filename=\"1-200x120.jpg\"", "image/jpeg"},
+ {"/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"},
+ {"/id/:id/:width/:height.jpg?grayscale", "/id/1/200/200.jpg?grayscale", readFixture("grayscale", "jpg"), "inline; filename=\"1-200x200-grayscale.jpg\"", "image/jpeg"},
+ {"/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"},
+
+ // WebP
+ {"/id/:id/:width/:height.webp", "/id/1/200/120.webp", readFixture("width_height", "webp"), "inline; filename=\"1-200x120.webp\"", "image/webp"},
+ {"/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"},
+ {"/id/:id/:width/:height.webp?grayscale", "/id/1/200/200.webp?grayscale", readFixture("grayscale", "webp"), "inline; filename=\"1-200x200-grayscale.webp\"", "image/webp"},
+ {"/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"},
+ }
+
+ for _, test := range imageTests {
+ w := httptest.NewRecorder()
+
+ u, err := url.Parse(test.URL)
+ if err != nil {
+ t.Errorf("%s: url error %s", test.Name, err)
+ continue
+ }
+
+ url, err := params.HMAC(hmac, u.Path, u.Query())
+ if err != nil {
+ t.Errorf("%s: hmac error %s", test.Name, err)
+ continue
+ }
+
+ req, _ := http.NewRequest("GET", url, nil)
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Errorf("%s: wrong response code, %#v", test.Name, w.Code)
+ continue
+ }
+
+ if contentType := w.Header().Get("Content-Type"); contentType != test.ExpectedContentType {
+ t.Errorf("%s: wrong content type, %#v", test.Name, contentType)
+ }
+
+ if cacheControl := w.Header().Get("Cache-Control"); cacheControl != "public, max-age=2592000, stale-while-revalidate=60, stale-if-error=43200, immutable" {
+ t.Errorf("%s: wrong cache header, %#v", test.Name, cacheControl)
+ }
+
+ if contentDisposition := w.Header().Get("Content-Disposition"); contentDisposition != test.ExpectedContentDisposition {
+ t.Errorf("%s: wrong content disposition header, %#v", test.Name, contentDisposition)
+ }
+
+ if imageID := w.Header().Get("Picsum-ID"); imageID != "1" {
+ t.Errorf("%s: wrong image id header, %#v", test.Name, imageID)
+ }
+
+ if !reflect.DeepEqual(w.Body.Bytes(), test.ExpectedResponse) {
+ t.Errorf("%s: wrong response/image data", test.Name)
+ }
+ }
+
+ redirectTests := []struct {
+ Name string
+ URL string
+ ExpectedURL string
+ }{
+ // Trailing slashes
+ {"/id/:id/:width/:height/", "/id/1/200/120.jpg/", "/id/1/200/120.jpg"},
+ }
+
+ for _, test := range redirectTests {
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", test.URL, nil)
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusFound && w.Code != http.StatusMovedPermanently {
+ t.Errorf("%s: wrong response code, %#v", test.Name, w.Code)
+ continue
+ }
+
+ location := w.Header().Get("Location")
+ if location != test.ExpectedURL {
+ t.Errorf("%s: wrong redirect %s", test.Name, location)
+ }
+ }
+}
+
+func readFixture(fixtureName string, extension string) []byte {
+ return readFile(fixturePath(fixtureName, extension))
+}
+
+// Utility function for regenerating the fixtures
+func TestFixtures(t *testing.T) {
+ if os.Getenv("GENERATE_FIXTURES") != "1" {
+ t.SkipNow()
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ log, tracer, imageProcessor, hmac := setup(t, ctx)
+
+ router := api.NewAPI(imageProcessor, log, tracer, time.Minute, hmac).Router()
+
+ // JPEG
+ createFixture(router, hmac, "/id/1/200/120.jpg", "width_height", "jpg")
+ createFixture(router, hmac, "/id/1/200/200.jpg?blur=5", "blur", "jpg")
+ createFixture(router, hmac, "/id/1/200/200.jpg?grayscale", "grayscale", "jpg")
+ createFixture(router, hmac, "/id/1/200/200.jpg?blur=5&grayscale", "all", "jpg")
+ createFixture(router, hmac, "/id/1/300/400.jpg", "max_allowed", "jpg")
+
+ // WebP
+ createFixture(router, hmac, "/id/1/200/120.webp", "width_height", "webp")
+ createFixture(router, hmac, "/id/1/200/200.webp?blur=5", "blur", "webp")
+ createFixture(router, hmac, "/id/1/200/200.webp?grayscale", "grayscale", "webp")
+ createFixture(router, hmac, "/id/1/200/200.webp?blur=5&grayscale", "all", "webp")
+ createFixture(router, hmac, "/id/1/300/400.webp", "max_allowed", "webp")
+}
+
+func setup(t *testing.T, ctx context.Context) (*logger.Logger, *tracing.Tracer, image.Processor, *hmac.HMAC) {
+ t.Helper()
+
+ log := logger.New(zap.FatalLevel)
+ tracer := test.Tracer(log)
+
+ storage, _ := fileStorage.New("../../test/fixtures/file")
+ cache := memoryCache.New()
+ imageCache := image.NewCache(tracer, cache, storage)
+ imageProcessor, _ := vipsProcessor.New(ctx, log, tracer, 3, imageCache)
+
+ hmac := &hmac.HMAC{
+ Key: []byte("test"),
+ }
+
+ t.Cleanup(func() {
+ log.Sync()
+ })
+
+ return log, tracer, imageProcessor, hmac
+}
+
+func createFixture(router http.Handler, hmac *hmac.HMAC, URL string, fixtureName string, extension string) {
+ w := httptest.NewRecorder()
+
+ u, _ := url.Parse(URL)
+ url, _ := params.HMAC(hmac, u.Path, u.Query())
+
+ req, _ := http.NewRequest("GET", url, nil)
+ router.ServeHTTP(w, req)
+ os.WriteFile(fixturePath(fixtureName, extension), w.Body.Bytes(), 0644)
+}
+
+func fixturePath(fixtureName string, extension string) string {
+ return fmt.Sprintf("../../test/fixtures/api/%s_%s.%s", fixtureName, runtime.GOOS, extension)
+}
+
+func readFile(path string) []byte {
+ fixture, _ := os.ReadFile(path)
+ return fixture
+}
diff --git a/internal/imageapi/image.go b/internal/imageapi/image.go
new file mode 100644
index 0000000..f1cfd49
--- /dev/null
+++ b/internal/imageapi/image.go
@@ -0,0 +1,183 @@
+package imageapi
+
+import (
+ "errors"
+ "expvar"
+ "fmt"
+ "net/http"
+ "strconv"
+
+ "github.com/DMarby/picsum-photos/internal/handler"
+ "github.com/DMarby/picsum-photos/internal/image"
+ "github.com/DMarby/picsum-photos/internal/params"
+ "github.com/DMarby/picsum-photos/internal/queue"
+ "github.com/gorilla/mux"
+)
+
+// Metrics for cache and request coalescing
+var (
+ cacheHits = expvar.NewInt("counter_imageapi_cache_hits")
+ cacheMisses = expvar.NewInt("counter_imageapi_cache_misses")
+ requestsCoalesced = expvar.NewInt("counter_imageapi_requests_coalesced")
+ requestsProcessed = expvar.NewInt("counter_imageapi_requests_processed")
+ queueFullErrors = expvar.NewInt("counter_imageapi_queue_full_errors")
+)
+
+func (a *API) imageHandler(w http.ResponseWriter, r *http.Request) *handler.Error {
+ // Validate the path and query parameters
+ valid, err := params.ValidateHMAC(a.HMAC, r)
+ if err != nil {
+ return handler.InternalServerError()
+ }
+
+ if !valid {
+ return handler.BadRequest("Invalid parameters")
+ }
+
+ // Get the path and query parameters
+ p, err := params.GetParams(r)
+ if err != nil {
+ return handler.BadRequest(err.Error())
+ }
+
+ // Get the image ID from the path param
+ vars := mux.Vars(r)
+ imageID := vars["id"]
+
+ // Build the cache key for request coalescing
+ cacheKey := buildCacheKey(imageID, p)
+
+ // Request coalescing with LRU cache pattern
+ // This prevents the "thundering herd" problem where many identical
+ // requests arrive simultaneously and all hit the image processor
+
+ // First, check the LRU cache for a cached result
+ if cachedImage, ok := a.imageCache.Get(cacheKey); ok {
+ cacheHits.Add(1)
+ return a.sendImage(w, imageID, p, cachedImage)
+ }
+ cacheMisses.Add(1)
+
+ // Cache miss - use request coalescing to prevent duplicate processing
+ // Create a channel to signal when processing is complete
+ done := make(chan struct{})
+
+ // Try to claim responsibility for this request
+ existing, loaded := a.inflight.LoadOrStore(cacheKey, done)
+ if loaded {
+ // Another goroutine is already processing this request, wait for it
+ requestsCoalesced.Add(1)
+ select {
+ case <-existing.(chan struct{}):
+ // Processing complete, result should now be in cache
+ if cachedImage, ok := a.imageCache.Get(cacheKey); ok {
+ return a.sendImage(w, imageID, p, cachedImage)
+ }
+ // Cache miss after waiting (possibly evicted or error occurred)
+ // Fall through to process the image ourselves
+ case <-r.Context().Done():
+ // Request was cancelled
+ return handler.InternalServerError()
+ }
+ }
+
+ // We're responsible for processing this request (or retry after cache miss)
+ requestsProcessed.Add(1)
+
+ // Build the image task
+ task := image.NewTask(imageID, p.Width, p.Height, fmt.Sprintf("Picsum ID: %s", imageID), getOutputFormat(p.Extension))
+ if p.Blur {
+ task.Blur(p.BlurAmount)
+ }
+
+ if p.Grayscale {
+ task.Grayscale()
+ }
+
+ // Process the image
+ processedImage, err := a.ImageProcessor.ProcessImage(r.Context(), task)
+
+ // Cleanup and signal completion
+ if !loaded {
+ a.inflight.Delete(cacheKey)
+ close(done)
+ }
+
+ if err != nil {
+ if errors.Is(err, queue.ErrQueueFull) {
+ queueFullErrors.Add(1)
+ a.logError(r, "error processing image: queue is full", err)
+ return handler.ServiceUnavailable()
+ }
+ a.logError(r, "error processing image", err)
+ return handler.InternalServerError()
+ }
+
+ // Store in LRU cache for future requests
+ a.imageCache.Add(cacheKey, processedImage)
+
+ return a.sendImage(w, imageID, p, processedImage)
+}
+
+// sendImage writes the processed image to the response with appropriate headers
+func (a *API) sendImage(w http.ResponseWriter, imageID string, p *params.Params, processedImage []byte) *handler.Error {
+ w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", buildFilename(imageID, p)))
+ w.Header().Set("Content-Type", getContentType(p.Extension))
+ w.Header().Set("Content-Length", strconv.Itoa(len(processedImage)))
+ w.Header().Set("Cache-Control", "public, max-age=2592000, stale-while-revalidate=60, stale-if-error=43200, immutable") // Cache for a month
+ w.Header().Set("Picsum-ID", imageID)
+ w.Header().Set("Timing-Allow-Origin", "*") // Allow all origins to see timing resources
+
+ w.Write(processedImage)
+
+ return nil
+}
+
+func getOutputFormat(extension string) image.OutputFormat {
+ switch extension {
+ case ".webp":
+ return image.WebP
+ default:
+ return image.JPEG
+ }
+}
+
+func getContentType(extension string) string {
+ switch extension {
+ case ".webp":
+ return "image/webp"
+ default:
+ return "image/jpeg"
+ }
+}
+
+// buildCacheKey creates a unique key for request coalescing based on image parameters
+func buildCacheKey(imageID string, p *params.Params) string {
+ key := fmt.Sprintf("%s-%dx%d%s", imageID, p.Width, p.Height, p.Extension)
+
+ if p.Blur {
+ key += fmt.Sprintf("-blur_%d", p.BlurAmount)
+ }
+
+ if p.Grayscale {
+ key += "-grayscale"
+ }
+
+ return key
+}
+
+func buildFilename(imageID string, p *params.Params) string {
+ filename := fmt.Sprintf("%s-%dx%d", imageID, p.Width, p.Height)
+
+ if p.Blur {
+ filename += fmt.Sprintf("-blur_%d", p.BlurAmount)
+ }
+
+ if p.Grayscale {
+ filename += "-grayscale"
+ }
+
+ filename += p.Extension
+
+ return filename
+}
diff --git a/internal/logger/logger.go b/internal/logger/logger.go
new file mode 100644
index 0000000..3ce7ed5
--- /dev/null
+++ b/internal/logger/logger.go
@@ -0,0 +1,70 @@
+package logger
+
+import (
+ stdlog "log"
+ "os"
+ "strings"
+
+ "go.uber.org/zap"
+ "go.uber.org/zap/zapcore"
+)
+
+// Logger is a logger
+type Logger struct {
+ *zap.SugaredLogger
+}
+
+// New creates a new logger
+func New(loglevel zapcore.Level) *Logger {
+ // Configure console output.
+ encoderConfig := zap.NewProductionEncoderConfig()
+ encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
+ consoleEncoder := zapcore.NewJSONEncoder(encoderConfig)
+
+ // Log errors to stderr
+ stderrLevel := zap.LevelEnablerFunc(func(lvl zapcore.Level) bool {
+ return lvl >= loglevel && lvl >= zapcore.ErrorLevel
+ })
+
+ stdoutLevel := zap.LevelEnablerFunc(func(lvl zapcore.Level) bool {
+ return lvl >= loglevel && lvl < zapcore.ErrorLevel
+ })
+ stdout := zapcore.Lock(os.Stdout)
+ stderr := zapcore.Lock(os.Stderr)
+
+ // Merge the outputs, encoders, and level-handling functions
+ core := zapcore.NewTee(
+ zapcore.NewCore(consoleEncoder, stderr, stderrLevel),
+ zapcore.NewCore(consoleEncoder, stdout, stdoutLevel),
+ )
+
+ // Construct our logger
+ log := zap.New(core, zap.AddCaller())
+
+ // Redirect stdlib log package to zap
+ _, _ = zap.RedirectStdLogAt(log, zapcore.ErrorLevel)
+
+ return &Logger{
+ log.Sugar(),
+ }
+}
+
+type httpErrorLog struct {
+ log *Logger
+}
+
+func (h *httpErrorLog) Write(p []byte) (int, error) {
+ m := string(p)
+
+ if strings.HasPrefix(m, "http: URL query contains semicolon") {
+ h.log.Debug(m)
+ } else {
+ h.log.Error(m)
+ }
+
+ return len(p), nil
+}
+
+func NewHTTPErrorLog(logger *Logger) *stdlog.Logger {
+ return stdlog.New(&httpErrorLog{logger}, "", 0)
+}
diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go
new file mode 100644
index 0000000..3327898
--- /dev/null
+++ b/internal/metrics/metrics.go
@@ -0,0 +1,43 @@
+package metrics
+
+import (
+ "context"
+ "net/http"
+ "net/http/pprof"
+
+ "github.com/DMarby/picsum-photos/internal/handler"
+ "github.com/DMarby/picsum-photos/internal/health"
+ "github.com/DMarby/picsum-photos/internal/logger"
+)
+
+// Serve starts an http server for metrics and healthchecks
+func Serve(ctx context.Context, log *logger.Logger, healthChecker *health.Checker, listenAddress string) {
+ router := http.NewServeMux()
+ router.HandleFunc("/metrics", handler.VarzHandler)
+ router.Handle("/health", handler.Health(healthChecker))
+
+ router.HandleFunc("/debug/pprof/", pprof.Index)
+ router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
+ router.HandleFunc("/debug/pprof/profile", pprof.Profile)
+ router.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
+ router.HandleFunc("/debug/pprof/trace", pprof.Trace)
+
+ server := &http.Server{
+ Addr: listenAddress,
+ Handler: router,
+ }
+
+ go func() {
+ if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ log.Infof("shutting down the metrics http server: %s", err)
+ }
+ }()
+
+ log.Infof("metrics http server listening on %s", listenAddress)
+
+ <-ctx.Done()
+
+ if err := server.Close(); err != nil {
+ log.Warnf("error shutting down metrics http server: %s", err)
+ }
+}
diff --git a/internal/params/hmac.go b/internal/params/hmac.go
new file mode 100644
index 0000000..cc33079
--- /dev/null
+++ b/internal/params/hmac.go
@@ -0,0 +1,32 @@
+package params
+
+import (
+ "net/http"
+ "net/url"
+
+ "github.com/DMarby/picsum-photos/internal/hmac"
+)
+
+// HMAC generates and appends an HMAC to a URL path + query params
+func HMAC(h *hmac.HMAC, path string, query url.Values) (string, error) {
+ hmac, err := h.Create(path + BuildQuery(query))
+ if err != nil {
+ return "", err
+ }
+
+ query.Set("hmac", hmac)
+ return path + BuildQuery(query), nil
+}
+
+// ValidateHMAC validates the URL path/query params, given an hmac in a query parameter named hmac
+func ValidateHMAC(h *hmac.HMAC, r *http.Request) (bool, error) {
+ // Get the query params in the request
+ query := r.URL.Query()
+
+ // Get the HMAC query param and remove it from the request query params
+ hmac := query.Get("hmac")
+ query.Del("hmac")
+
+ encodedQuery := BuildQuery(query)
+ return h.Validate(r.URL.Path+encodedQuery, hmac)
+}
diff --git a/internal/params/params.go b/internal/params/params.go
new file mode 100644
index 0000000..e74c3fb
--- /dev/null
+++ b/internal/params/params.go
@@ -0,0 +1,128 @@
+package params
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/gorilla/mux"
+)
+
+// Errors
+var (
+ ErrInvalidSize = fmt.Errorf("Invalid size")
+ ErrInvalidFileExtension = fmt.Errorf("Invalid file extension")
+)
+
+const defaultBlurAmount = 5
+
+// Params contains all the parameters for a request
+type Params struct {
+ Width int
+ Height int
+ Blur bool
+ BlurAmount int
+ Grayscale bool
+ Extension string
+}
+
+// GetParams parses and returns all the path and query parameters
+func GetParams(r *http.Request) (*Params, error) {
+ // Get and validate the width and height from the path parameters
+ width, height, err := getSize(r)
+ if err != nil {
+ return nil, err
+ }
+
+ // Get the optional file extension from the path parameters
+ extension, err := getFileExtension(r)
+ if err != nil {
+ return nil, err
+ }
+
+ // Get and validate the query parameters for grayscale and blur
+ grayscale, blur, blurAmount := getQueryParams(r)
+
+ params := &Params{
+ Width: width,
+ Height: height,
+ Blur: blur,
+ BlurAmount: blurAmount,
+ Grayscale: grayscale,
+ Extension: extension,
+ }
+
+ return params, nil
+}
+
+// getSize gets the image size from the size or the width/height path params, and validates it
+func getSize(r *http.Request) (width int, height int, err error) {
+ // Check for the size parameter first
+ if size, ok := intParam(r, "size"); ok {
+ width, height = size, size
+ } else {
+ // If size doesn't exist, check for width/height
+ width, ok = intParam(r, "width")
+ if !ok {
+ return -1, -1, ErrInvalidSize
+ }
+
+ height, ok = intParam(r, "height")
+ if !ok {
+ return -1, -1, ErrInvalidSize
+ }
+ }
+
+ return
+}
+
+// intParam tries to get a param and convert it to an Integer
+func intParam(r *http.Request, name string) (int, bool) {
+ vars := mux.Vars(r)
+
+ if val, ok := vars[name]; ok {
+ val, err := strconv.Atoi(val)
+ return val, err == nil
+ }
+
+ return -1, false
+}
+
+// getFileExtension gets the file extension (if present) from the path params, and validates it
+func getFileExtension(r *http.Request) (extension string, err error) {
+ vars := mux.Vars(r)
+
+ // We only allow the .jpg and .webp extensions, as we only serve jpg and webp images
+ // We normalize having no extension since it's an optional path param
+ val := strings.ToLower(vars["extension"])
+
+ if val == "" {
+ val = ".jpg"
+ }
+
+ if val != ".jpg" && val != ".webp" {
+ return "", ErrInvalidFileExtension
+ }
+
+ return val, nil
+}
+
+// getQueryParams returns whether the grayscale and blur queryparams are present
+func getQueryParams(r *http.Request) (grayscale bool, blur bool, blurAmount int) {
+ if _, ok := r.URL.Query()["grayscale"]; ok {
+ grayscale = true
+ }
+
+ if _, ok := r.URL.Query()["blur"]; ok {
+ blur = true
+ blurAmount = defaultBlurAmount
+
+ if val, err := strconv.Atoi(r.URL.Query().Get("blur")); err == nil {
+ blurAmount = val
+ return
+ }
+ }
+
+ return
+}
diff --git a/internal/params/query.go b/internal/params/query.go
new file mode 100644
index 0000000..0b0eddc
--- /dev/null
+++ b/internal/params/query.go
@@ -0,0 +1,46 @@
+package params
+
+import (
+ "fmt"
+ "net/url"
+ "sort"
+ "strings"
+)
+
+// Utilities for building a URL with query params
+
+// BuildQuery builds a query parameter string for the given values
+// It differs from the stdlib url.Values.Encode in that it encodes query parameters with an empty value as "?key" instead of "?key="
+func BuildQuery(v url.Values) string {
+ var buf strings.Builder
+
+ keys := make([]string, 0, len(v))
+ for k := range v {
+ keys = append(keys, k)
+ }
+
+ sort.Strings(keys)
+
+ for _, key := range keys {
+ value := v.Get(key)
+
+ if value != "" {
+ addQueryParam(&buf, fmt.Sprintf("%s=%s", url.QueryEscape(key), url.QueryEscape(value)))
+ } else {
+ addQueryParam(&buf, fmt.Sprintf("%s", url.QueryEscape(key)))
+ }
+ }
+
+ return buf.String()
+}
+
+// addQueryParam adds a query parameter to a byte buffer
+func addQueryParam(buf *strings.Builder, param string) {
+ if buf.Len() > 0 {
+ buf.WriteByte('&')
+ } else {
+ buf.WriteByte('?')
+ }
+
+ buf.WriteString(param)
+}
diff --git a/internal/queue/queue.go b/internal/queue/queue.go
new file mode 100644
index 0000000..184071c
--- /dev/null
+++ b/internal/queue/queue.go
@@ -0,0 +1,120 @@
+package queue
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "runtime"
+)
+
+// ErrQueueFull is returned when the queue buffer is full
+var ErrQueueFull = errors.New("queue is full")
+
+// Queue is a worker queue with a fixed amount of workers
+type Queue struct {
+ workers int
+ queue chan job
+ handler func(context.Context, interface{}) (interface{}, error)
+ ctx context.Context
+}
+
+type job struct {
+ data interface{}
+ result chan jobResult
+ context context.Context
+}
+
+type jobResult struct {
+ result interface{}
+ err error
+}
+
+// New creates a new Queue with the specified amount of workers
+func New(ctx context.Context, workers int, handler func(context.Context, interface{}) (interface{}, error)) *Queue {
+ queue := &Queue{
+ workers: workers,
+ queue: make(chan job, workers*64),
+ handler: handler,
+ ctx: ctx,
+ }
+
+ return queue
+}
+
+// Run starts the queue and blocks until it's shut down
+func (q *Queue) Run() {
+ for i := 0; i < q.workers; i++ {
+ go q.worker()
+ }
+
+ <-q.ctx.Done()
+ close(q.queue)
+}
+
+func (q *Queue) worker() {
+ // Lock the thread to ensure that we get our own thread, and that tasks aren't moved between threads
+ // We won't unlock since it's uncertain how libvips would react
+ runtime.LockOSThread()
+
+ for {
+ select {
+ case job, open := <-q.queue:
+ if !open {
+ return
+ }
+
+ // Check if the job context was cancelled before processing
+ if job.context.Err() != nil {
+ job.result <- jobResult{result: nil, err: job.context.Err()}
+ continue
+ }
+
+ result, err := q.handler(job.context, job.data)
+ job.result <- jobResult{result: result, err: err}
+
+ case <-q.ctx.Done():
+ return
+ }
+ }
+}
+
+// Len returns the current number of jobs waiting in the queue buffer
+func (q *Queue) Len() int {
+ return len(q.queue)
+}
+
+// Process adds a job to the queue, waits for it to process, and returns the result
+func (q *Queue) Process(ctx context.Context, data interface{}) (interface{}, error) {
+ if q.ctx.Err() != nil {
+ return nil, fmt.Errorf("queue has been shutdown")
+ }
+
+ resultChan := make(chan jobResult, 1)
+
+ select {
+ case q.queue <- job{
+ data: data,
+ result: resultChan,
+ context: ctx,
+ }:
+ case <-q.ctx.Done():
+ return nil, fmt.Errorf("queue has been shutdown")
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ default:
+ return nil, ErrQueueFull
+ }
+
+ select {
+ case result := <-resultChan:
+ if result.err != nil {
+ return nil, result.err
+ }
+
+ return result.result, nil
+ case <-ctx.Done():
+ // Context cancelled - but worker may still be processing
+ // At least we can return early and not waste this goroutine
+ return nil, ctx.Err()
+ }
+}
diff --git a/internal/queue/queue_test.go b/internal/queue/queue_test.go
new file mode 100644
index 0000000..4d68f2e
--- /dev/null
+++ b/internal/queue/queue_test.go
@@ -0,0 +1,77 @@
+package queue_test
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ queue "github.com/DMarby/picsum-photos/internal/queue"
+)
+
+func setupQueue(f func(ctx context.Context, data interface{}) (interface{}, error)) (*queue.Queue, context.CancelFunc) {
+ ctx, cancel := context.WithCancel(context.Background())
+ workerQueue := queue.New(ctx, 3, f)
+ go workerQueue.Run()
+ return workerQueue, cancel
+}
+
+func TestProcess(t *testing.T) {
+ workerQueue, cancel := setupQueue(func(ctx context.Context, data interface{}) (interface{}, error) {
+ stringData, _ := data.(string)
+ return stringData, nil
+ })
+
+ defer cancel()
+
+ data, err := workerQueue.Process(context.Background(), "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if data != "test" {
+ t.Fatal(err)
+ }
+}
+
+func TestShutdown(t *testing.T) {
+ workerQueue, cancel := setupQueue(func(ctx context.Context, data interface{}) (interface{}, error) {
+ return "", nil
+ })
+
+ cancel()
+
+ _, err := workerQueue.Process(context.Background(), "test")
+ if err == nil || err.Error() != "queue has been shutdown" {
+ t.FailNow()
+ }
+}
+
+func TestTaskWithError(t *testing.T) {
+ errorQueue, cancel := setupQueue(func(ctx context.Context, data interface{}) (interface{}, error) {
+ return nil, fmt.Errorf("custom error")
+ })
+
+ defer cancel()
+ _, err := errorQueue.Process(context.Background(), "test")
+
+ if err == nil || err.Error() != "custom error" {
+ t.Fatal("Invalid error")
+ }
+}
+
+func TestTaskWithCancelledContext(t *testing.T) {
+ errorQueue, cancel := setupQueue(func(ctx context.Context, data interface{}) (interface{}, error) {
+ return nil, fmt.Errorf("custom error")
+ })
+
+ defer cancel()
+
+ ctx, ctxCancel := context.WithCancel(context.Background())
+ ctxCancel()
+
+ _, err := errorQueue.Process(ctx, "test")
+
+ if err == nil || err.Error() != "context canceled" {
+ t.Fatal("Invalid error")
+ }
+}
diff --git a/internal/storage/file/file.go b/internal/storage/file/file.go
new file mode 100644
index 0000000..80719eb
--- /dev/null
+++ b/internal/storage/file/file.go
@@ -0,0 +1,41 @@
+package file
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/DMarby/picsum-photos/internal/storage"
+)
+
+// Provider implements a file-based image storage
+type Provider struct {
+ path string
+}
+
+// New returns a new Provider instance
+func New(path string) (*Provider, error) {
+ if _, err := os.Stat(path); err != nil {
+ return nil, err
+ }
+
+ return &Provider{
+ path,
+ }, nil
+}
+
+// Get returns the image data for an image id
+func (p *Provider) Get(ctx context.Context, id string) ([]byte, error) {
+ imageData, err := os.ReadFile(filepath.Join(p.path, fmt.Sprintf("%s.jpg", id)))
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, storage.ErrNotFound
+ }
+
+ return nil, err
+ }
+
+ return imageData, nil
+}
diff --git a/internal/storage/file/file_test.go b/internal/storage/file/file_test.go
new file mode 100644
index 0000000..a2b204a
--- /dev/null
+++ b/internal/storage/file/file_test.go
@@ -0,0 +1,45 @@
+package file_test
+
+import (
+ "context"
+ "os"
+ "reflect"
+
+ "github.com/DMarby/picsum-photos/internal/storage"
+ "github.com/DMarby/picsum-photos/internal/storage/file"
+
+ "testing"
+)
+
+func TestFile(t *testing.T) {
+ provider, err := file.New("../../../test/fixtures/file")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ t.Run("Get an image by id", func(t *testing.T) {
+ buf, err := provider.Get(context.Background(), "1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ resultFixture, _ := os.ReadFile("../../../test/fixtures/file/1.jpg")
+ if !reflect.DeepEqual(buf, resultFixture) {
+ t.Error("image data doesn't match")
+ }
+ })
+
+ t.Run("Returns error on a nonexistant path", func(t *testing.T) {
+ _, err := file.New("")
+ if err == nil {
+ t.FailNow()
+ }
+ })
+
+ t.Run("Returns error on a nonexistant image", func(t *testing.T) {
+ _, err := provider.Get(context.Background(), "nonexistant")
+ if err == nil || err != storage.ErrNotFound {
+ t.FailNow()
+ }
+ })
+}
diff --git a/internal/storage/mock/mock.go b/internal/storage/mock/mock.go
new file mode 100644
index 0000000..ef4e6e2
--- /dev/null
+++ b/internal/storage/mock/mock.go
@@ -0,0 +1,14 @@
+package mock
+
+import (
+ "context"
+)
+
+// Provider implements a mock image storage
+type Provider struct {
+}
+
+// Get returns the image data for an image id
+func (p *Provider) Get(ctx context.Context, id string) ([]byte, error) {
+ return []byte("foo"), nil
+}
diff --git a/internal/storage/storage.go b/internal/storage/storage.go
new file mode 100644
index 0000000..0f539bf
--- /dev/null
+++ b/internal/storage/storage.go
@@ -0,0 +1,16 @@
+package storage
+
+import (
+ "context"
+ "errors"
+)
+
+// Provider is an interface for retrieving images
+type Provider interface {
+ Get(ctx context.Context, id string) ([]byte, error)
+}
+
+// Errors
+var (
+ ErrNotFound = errors.New("Image does not exist")
+)
diff --git a/internal/tracing/test/tracing.go b/internal/tracing/test/tracing.go
new file mode 100644
index 0000000..0129802
--- /dev/null
+++ b/internal/tracing/test/tracing.go
@@ -0,0 +1,22 @@
+package test
+
+import (
+ "context"
+
+ "github.com/DMarby/picsum-photos/internal/logger"
+ "github.com/DMarby/picsum-photos/internal/tracing"
+ "go.opentelemetry.io/otel/trace"
+)
+
+func Tracer(log *logger.Logger) *tracing.Tracer {
+ tp := trace.NewNoopTracerProvider()
+ return &tracing.Tracer{
+ ServiceName: "test",
+ Log: log,
+ TracerProvider: tp,
+ ShutdownFunc: func(context.Context) error {
+ return nil
+ },
+ TracerInstance: tp.Tracer("test"),
+ }
+}
diff --git a/internal/tracing/tracing.go b/internal/tracing/tracing.go
new file mode 100644
index 0000000..5cfe5e6
--- /dev/null
+++ b/internal/tracing/tracing.go
@@ -0,0 +1,74 @@
+package tracing
+
+import (
+ "context"
+ "fmt"
+ "log"
+
+ "github.com/DMarby/picsum-photos/internal/logger"
+ "github.com/go-logr/stdr"
+ "go.uber.org/zap"
+
+ "go.opentelemetry.io/otel"
+ "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
+ "go.opentelemetry.io/otel/sdk/resource"
+ sdktrace "go.opentelemetry.io/otel/sdk/trace"
+ semconv "go.opentelemetry.io/otel/semconv/v1.4.0"
+ "go.opentelemetry.io/otel/trace"
+)
+
+const tracerIdentifier = "github.com/DMarby/picsum-photos/internal/tracing"
+
+type Tracer struct {
+ ServiceName string
+ Log *logger.Logger
+
+ trace.TracerProvider
+
+ ShutdownFunc func(context.Context) error
+ TracerInstance trace.Tracer
+}
+
+func New(ctx context.Context, log *logger.Logger, serviceName string) (*Tracer, error) {
+ exporter, err := otlptracegrpc.New(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create opentelemetry grpc exporter: %w", err)
+ }
+
+ tp := sdktrace.NewTracerProvider(
+ sdktrace.WithBatcher(exporter),
+ sdktrace.WithResource(resource.NewWithAttributes(semconv.SchemaURL, semconv.ServiceNameKey.String(serviceName))),
+ )
+
+ // Override the global otel logging
+ otel.SetLogger(stdr.New(zap.NewStdLog(log.Desugar())))
+ otel.SetErrorHandler(otel.ErrorHandlerFunc(func(err error) {
+ log.Error(err)
+ }))
+
+ return &Tracer{
+ serviceName,
+ log,
+ tp,
+ tp.Shutdown,
+ tp.Tracer(tracerIdentifier),
+ }, nil
+}
+
+func (t *Tracer) Start(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
+ return t.TracerInstance.Start(ctx, spanName, opts...)
+}
+
+func (t *Tracer) Shutdown(ctx context.Context) {
+ if err := t.ShutdownFunc(ctx); err != nil {
+ log.Fatal("failed to shutdown tracer: %w", err)
+ }
+}
+
+func TraceInfo(ctx context.Context) (string, string) {
+ spanCtx := trace.SpanContextFromContext(ctx)
+ if !spanCtx.IsValid() {
+ return "", ""
+ }
+ return spanCtx.TraceID().String(), spanCtx.SpanID().String()
+}
diff --git a/internal/vips/vips-bridge.c b/internal/vips/vips-bridge.c
new file mode 100644
index 0000000..370abef
--- /dev/null
+++ b/internal/vips/vips-bridge.c
@@ -0,0 +1,71 @@
+#include "vips-bridge.h"
+
+void setup_logging() {
+ g_log_set_handler("VIPS", G_LOG_LEVEL_WARNING, log_handler, NULL);
+}
+
+void log_handler(char const* log_domain, GLogLevelFlags log_level, char const* message, void* ignore) {
+ log_callback((char*)message);
+}
+
+int save_image_to_jpeg_buffer(VipsImage *image, void **buf, size_t *len) {
+ // Guard against empty/partial images without data (segfaults in libvips 8.18+)
+ if (image == NULL || (image->dtype == VIPS_IMAGE_PARTIAL && image->generate_fn == NULL)) {
+ vips_error("jpegsave_buffer", "vips_image_pio_input: no image data\n");
+ return -1;
+ }
+ return vips_jpegsave_buffer(image, buf, len, "interlace", TRUE, "optimize_coding", TRUE, NULL);
+}
+
+int save_image_to_webp_buffer(VipsImage *image, void **buf, size_t *len) {
+ // Guard against empty/partial images without data (segfaults in libvips 8.18+)
+ if (image == NULL || (image->dtype == VIPS_IMAGE_PARTIAL && image->generate_fn == NULL)) {
+ vips_error("webpsave_buffer", "vips_image_pio_input: no image data\n");
+ return -1;
+ }
+ return vips_webpsave_buffer(image, buf, len, NULL);
+}
+
+int resize_image(void *buf, size_t len, VipsImage **out, int width, int height, VipsInteresting interesting) {
+ return vips_thumbnail_buffer(buf, len, out, width, "height", height, "crop", interesting, NULL);
+}
+
+int change_colorspace(VipsImage *in, VipsImage **out, VipsInterpretation colorspace) {
+ // Guard against empty/partial images without data (segfaults in libvips 8.18+)
+ if (in == NULL || (in->dtype == VIPS_IMAGE_PARTIAL && in->generate_fn == NULL)) {
+ vips_error("vips_image_pio_input", "no image data");
+ return -1;
+ }
+ return vips_call("colourspace", in, out, colorspace, NULL);
+}
+
+int blur_image(VipsImage *in, VipsImage **out, double blur) {
+ // Guard against empty/partial images without data (segfaults in libvips 8.18+)
+ if (in == NULL || (in->dtype == VIPS_IMAGE_PARTIAL && in->generate_fn == NULL)) {
+ vips_error("vips_image_pio_input", "no image data");
+ return -1;
+ }
+ return vips_call("gaussblur", in, out, blur, NULL);
+}
+
+static void * remove_metadata(VipsImage *image, const char *field, GValue *value, void *my_data) {
+ if (vips_isprefix("exif-", field)) {
+ vips_image_remove(image, field);
+ }
+
+ return (NULL);
+}
+
+void set_user_comment(VipsImage *image, char const* comment) {
+ // Strip all the metadata
+ vips_image_remove(image, VIPS_META_EXIF_NAME);
+ vips_image_remove(image, VIPS_META_XMP_NAME);
+ vips_image_remove(image, VIPS_META_IPTC_NAME);
+ vips_image_remove(image, VIPS_META_ICC_NAME);
+ vips_image_remove(image, VIPS_META_ORIENTATION);
+ vips_image_remove(image, "jpeg-thumbnail-data");
+ vips_image_map(image, remove_metadata, NULL);
+
+ // Set the user comment
+ vips_image_set_string(image, "exif-ifd2-UserComment", comment);
+}
diff --git a/internal/vips/vips-bridge.h b/internal/vips/vips-bridge.h
new file mode 100644
index 0000000..a475977
--- /dev/null
+++ b/internal/vips/vips-bridge.h
@@ -0,0 +1,21 @@
+#include
+#include
+#include
+#include
+
+// Require libvips 8 at compile time
+#if (VIPS_MAJOR_VERSION != 8 || VIPS_MINOR_VERSION < 6)
+ #error "unsupported libvips version"
+#endif
+
+
+void setup_logging();
+void log_handler(char const* log_domain, GLogLevelFlags log_level, char const* message, void* ignore);
+extern void log_callback(char* message);
+
+int save_image_to_jpeg_buffer(VipsImage *image, void **buf, size_t *len);
+int save_image_to_webp_buffer(VipsImage *image, void **buf, size_t *len);
+int resize_image(void *buf, size_t len, VipsImage **out, int width, int height, VipsInteresting interesting);
+int change_colorspace(VipsImage *in, VipsImage **out, VipsInterpretation colorspace);
+int blur_image(VipsImage *in, VipsImage **out, double blur);
+void set_user_comment(VipsImage *image, char const* comment);
diff --git a/internal/vips/vips.go b/internal/vips/vips.go
new file mode 100644
index 0000000..0f4521f
--- /dev/null
+++ b/internal/vips/vips.go
@@ -0,0 +1,206 @@
+package vips
+
+/*
+#cgo pkg-config: vips
+#include
+#include "vips-bridge.h"
+*/
+import "C"
+
+import (
+ "fmt"
+ "runtime"
+ "sync"
+ "unsafe"
+
+ "github.com/DMarby/picsum-photos/internal/logger"
+)
+
+// Image is a representation of the *C.VipsImage type
+type Image *C.VipsImage
+
+var (
+ once sync.Once
+ log *logger.Logger
+ errMutex sync.Mutex
+)
+
+// Initialize libvips if it's not already started
+func Initialize(logger *logger.Logger) error {
+ var err error
+
+ once.Do(func() {
+ // vips_init needs to run on the main thread
+ runtime.LockOSThread()
+ defer runtime.UnlockOSThread()
+
+ if C.VIPS_MAJOR_VERSION != 8 || C.VIPS_MINOR_VERSION < 6 {
+ err = fmt.Errorf("unsupported libvips version")
+ return
+ }
+
+ cName := C.CString("picsum-photos")
+ defer C.free(unsafe.Pointer(cName))
+
+ errorCode := C.vips_init(cName)
+ if errorCode != 0 {
+ err = fmt.Errorf("unable to initialize vips: %v", catchVipsError())
+ return
+ }
+
+ // Catch vips logging/warnings
+ log = logger
+ C.setup_logging()
+
+ // Set concurrency to 1 so that each job only uses one thread
+ C.vips_concurrency_set(1)
+
+ // Disable the cache
+ C.vips_cache_set_max_mem(0)
+ C.vips_cache_set_max(0)
+
+ // // Disable SIMD vector instructions due to g_object_unref segfault
+ // C.vips_vector_set_enabled(C.int(0))
+ })
+
+ return err
+}
+
+// log_callback catches logs from libvips
+//
+//export log_callback
+func log_callback(message *C.char) {
+ log.Debug(C.GoString(message))
+}
+
+// Shutdown libvips
+func Shutdown() {
+ C.vips_shutdown()
+}
+
+// PrintDebugInfo prints libvips debug info to stdout
+func PrintDebugInfo() {
+ C.vips_object_print_all()
+}
+
+// catchVipsError returns the vips error buffer as an error
+func catchVipsError() error {
+ errMutex.Lock()
+ defer errMutex.Unlock()
+ defer C.vips_error_clear()
+
+ s := C.GoString(C.vips_error_buffer())
+ return fmt.Errorf("%s", s)
+}
+
+// ResizeImage loads an image from a buffer and resizes it.
+func ResizeImage(buffer []byte, width int, height int) (Image, error) {
+ if len(buffer) == 0 {
+ return nil, fmt.Errorf("empty buffer")
+ }
+
+ imageBuffer := unsafe.Pointer(&buffer[0])
+ imageBufferSize := C.size_t(len(buffer))
+
+ var image *C.VipsImage
+
+ errCode := C.resize_image(imageBuffer, imageBufferSize, &image, C.int(width), C.int(height), C.VIPS_INTERESTING_CENTRE)
+
+ // Prevent buffer from being garbage collected until after resize_image has been called
+ runtime.KeepAlive(buffer)
+
+ if errCode != 0 {
+ return nil, fmt.Errorf("error processing image from buffer %s", catchVipsError())
+ }
+
+ return image, nil
+}
+
+// SaveToJpegBuffer saves an image as JPEG to a buffer
+func SaveToJpegBuffer(image Image) ([]byte, error) {
+ defer UnrefImage(image)
+
+ var bufferPointer unsafe.Pointer
+ bufferLength := C.size_t(0)
+
+ errCode := C.save_image_to_jpeg_buffer(image, &bufferPointer, &bufferLength)
+
+ if errCode != 0 {
+ return nil, fmt.Errorf("error saving to jpeg buffer %s", catchVipsError())
+ }
+
+ buffer := C.GoBytes(bufferPointer, C.int(bufferLength))
+
+ C.g_free(C.gpointer(bufferPointer))
+
+ return buffer, nil
+}
+
+// SaveToWebPBuffer saves an image as WebP to a buffer
+func SaveToWebPBuffer(image Image) ([]byte, error) {
+ defer UnrefImage(image)
+
+ var bufferPointer unsafe.Pointer
+ bufferLength := C.size_t(0)
+
+ errCode := C.save_image_to_webp_buffer(image, &bufferPointer, &bufferLength)
+
+ if errCode != 0 {
+ return nil, fmt.Errorf("error saving to webp buffer %s", catchVipsError())
+ }
+
+ buffer := C.GoBytes(bufferPointer, C.int(bufferLength))
+
+ C.g_free(C.gpointer(bufferPointer))
+
+ return buffer, nil
+}
+
+// Grayscale converts an image to grayscale
+func Grayscale(image Image) (Image, error) {
+ defer UnrefImage(image)
+
+ var result *C.VipsImage
+
+ errCode := C.change_colorspace(image, &result, C.VIPS_INTERPRETATION_B_W)
+
+ if errCode != 0 {
+ return nil, fmt.Errorf("error changing image colorspace %s", catchVipsError())
+ }
+
+ return result, nil
+}
+
+// Blur applies gaussian blur to an image
+func Blur(image Image, blur int) (Image, error) {
+ defer UnrefImage(image)
+
+ var result *C.VipsImage
+
+ errCode := C.blur_image(image, &result, C.double(blur))
+
+ if errCode != 0 {
+ return nil, fmt.Errorf("error applying blur to image %s", catchVipsError())
+ }
+
+ return result, nil
+}
+
+// SetUserComment sets the UserComment field in the exif metadata for an image
+func SetUserComment(image Image, comment string) {
+ cComment := C.CString(comment)
+ defer C.free(unsafe.Pointer(cComment))
+ C.set_user_comment(image, cComment)
+}
+
+// UnrefImage unrefs an image object
+func UnrefImage(image Image) {
+ if image != nil {
+ C.g_object_unref(C.gpointer(image))
+ }
+}
+
+// NewEmptyImage returns an empty image object
+func NewEmptyImage() Image {
+ return C.vips_image_new()
+}
diff --git a/internal/vips/vips_test.go b/internal/vips/vips_test.go
new file mode 100644
index 0000000..3c152e7
--- /dev/null
+++ b/internal/vips/vips_test.go
@@ -0,0 +1,238 @@
+package vips_test
+
+import (
+ "fmt"
+ "os"
+ "reflect"
+ "runtime"
+ "strings"
+
+ "github.com/DMarby/picsum-photos/internal/logger"
+ "github.com/DMarby/picsum-photos/internal/vips"
+ "go.uber.org/zap"
+
+ "testing"
+)
+
+func TestVips(t *testing.T) {
+ imageBuffer := setup(t)
+ defer vips.Shutdown()
+
+ t.Run("SaveToJpegBuffer", func(t *testing.T) {
+ t.Run("saves an image to buffer", func(t *testing.T) {
+ _, err := vips.SaveToJpegBuffer(resizeImage(t, imageBuffer))
+ if err != nil {
+ t.Error(err)
+ }
+ })
+
+ t.Run("errors on an invalid image", func(t *testing.T) {
+ _, err := vips.SaveToJpegBuffer(vips.NewEmptyImage())
+ if err == nil || !strings.Contains(err.Error(), "error saving to jpeg buffer") || !strings.Contains(err.Error(), "vips_image_pio_input: no image data") {
+ t.Error(err)
+ }
+ })
+ })
+
+ t.Run("SaveToWebPBuffer", func(t *testing.T) {
+ t.Run("saves an image to buffer", func(t *testing.T) {
+ _, err := vips.SaveToWebPBuffer(resizeImage(t, imageBuffer))
+ if err != nil {
+ t.Error(err)
+ }
+ })
+
+ t.Run("errors on an invalid image", func(t *testing.T) {
+ _, err := vips.SaveToWebPBuffer(vips.NewEmptyImage())
+ if err == nil || !strings.Contains(err.Error(), "error saving to webp buffer") || !strings.Contains(err.Error(), "vips_image_pio_input: no image data") {
+ t.Error(err)
+ }
+ })
+ })
+
+ t.Run("ResizeImage", func(t *testing.T) {
+ t.Run("loads and resizes an image as jpeg", func(t *testing.T) {
+ image, err := vips.ResizeImage(imageBuffer, 500, 500)
+ if err != nil {
+ t.Error(err)
+ }
+
+ buf, _ := vips.SaveToJpegBuffer(image)
+ resultFixture := readFixture("resize", "jpg")
+ if !reflect.DeepEqual(buf, resultFixture) {
+ t.Error("image data doesn't match")
+ }
+ })
+
+ t.Run("loads and resizes an image as webp", func(t *testing.T) {
+ image, err := vips.ResizeImage(imageBuffer, 500, 500)
+ if err != nil {
+ t.Error(err)
+ }
+
+ buf, _ := vips.SaveToWebPBuffer(image)
+ resultFixture := readFixture("resize", "webp")
+ if !reflect.DeepEqual(buf, resultFixture) {
+ t.Error("image data doesn't match")
+ }
+ })
+
+ t.Run("errors when given an empty buffer", func(t *testing.T) {
+ var buf []byte
+ _, err := vips.ResizeImage(buf, 500, 500)
+ if err == nil || err.Error() != "empty buffer" {
+ t.Error(err)
+ }
+ })
+
+ t.Run("errors when given an invalid image", func(t *testing.T) {
+ _, err := vips.ResizeImage(make([]byte, 5), 500, 500)
+ if err == nil || err.Error() != "error processing image from buffer VipsForeignLoad: buffer is not in a known format\n" {
+ t.Error(err)
+ }
+ })
+ })
+
+ t.Run("Grayscale", func(t *testing.T) {
+ t.Run("converts an image to grayscale as jpeg", func(t *testing.T) {
+ image, err := vips.Grayscale(resizeImage(t, imageBuffer))
+ if err != nil {
+ t.Error(err)
+ }
+
+ buf, _ := vips.SaveToJpegBuffer(image)
+ resultFixture := readFixture("grayscale", "jpg")
+ if !reflect.DeepEqual(buf, resultFixture) {
+ t.Error("image data doesn't match")
+ }
+ })
+
+ t.Run("converts an image to grayscale as webp", func(t *testing.T) {
+ image, err := vips.Grayscale(resizeImage(t, imageBuffer))
+ if err != nil {
+ t.Error(err)
+ }
+
+ buf, _ := vips.SaveToWebPBuffer(image)
+ resultFixture := readFixture("grayscale", "webp")
+ if !reflect.DeepEqual(buf, resultFixture) {
+ t.Error("image data doesn't match")
+ }
+ })
+
+ t.Run("errors when given an invalid image", func(t *testing.T) {
+ _, err := vips.Grayscale(vips.NewEmptyImage())
+ if err == nil || err.Error() != "error changing image colorspace vips_image_pio_input: no image data\n" {
+ t.Error(err)
+ }
+ })
+ })
+
+ t.Run("Blur", func(t *testing.T) {
+ t.Run("blurs an image as jpeg", func(t *testing.T) {
+ image, err := vips.Blur(resizeImage(t, imageBuffer), 5)
+ if err != nil {
+ t.Error(err)
+ }
+
+ buf, _ := vips.SaveToJpegBuffer(image)
+ resultFixture := readFixture("blur", "jpg")
+ if !reflect.DeepEqual(buf, resultFixture) {
+ t.Error("image data doesn't match")
+ }
+ })
+
+ t.Run("blurs an image as webp", func(t *testing.T) {
+ image, err := vips.Blur(resizeImage(t, imageBuffer), 5)
+ if err != nil {
+ t.Error(err)
+ }
+
+ buf, _ := vips.SaveToWebPBuffer(image)
+ resultFixture := readFixture("blur", "webp")
+ if !reflect.DeepEqual(buf, resultFixture) {
+ t.Error("image data doesn't match")
+ }
+ })
+
+ t.Run("errors when given an invalid image", func(t *testing.T) {
+ _, err := vips.Blur(vips.NewEmptyImage(), 5)
+ if err == nil || err.Error() != "error applying blur to image vips_image_pio_input: no image data\n" {
+ t.Error(err)
+ }
+ })
+ })
+}
+
+// Utility function for regenerating the fixtures
+func TestFixtures(t *testing.T) {
+ if os.Getenv("GENERATE_FIXTURES") != "1" {
+ t.SkipNow()
+ }
+
+ imageBuffer := setup(t)
+ defer vips.Shutdown()
+
+ // Resize
+ image, _ := vips.ResizeImage(imageBuffer, 500, 500)
+ resizeJpeg, _ := vips.SaveToJpegBuffer(image)
+ os.WriteFile(fixturePath("resize", "jpg"), resizeJpeg, 0644)
+
+ image, _ = vips.ResizeImage(imageBuffer, 500, 500)
+ resizeWebP, _ := vips.SaveToWebPBuffer(image)
+ os.WriteFile(fixturePath("resize", "webp"), resizeWebP, 0644)
+
+ // Grayscale
+ image, _ = vips.Grayscale(resizeImage(t, imageBuffer))
+ grayscaleJpeg, _ := vips.SaveToJpegBuffer(image)
+ os.WriteFile(fixturePath("grayscale", "jpg"), grayscaleJpeg, 0644)
+
+ image, _ = vips.Grayscale(resizeImage(t, imageBuffer))
+ grayscaleWebP, _ := vips.SaveToWebPBuffer(image)
+ os.WriteFile(fixturePath("grayscale", "webp"), grayscaleWebP, 0644)
+
+ // Blur
+ image, _ = vips.Blur(resizeImage(t, imageBuffer), 5)
+ blurJpeg, _ := vips.SaveToJpegBuffer(image)
+ os.WriteFile(fixturePath("blur", "jpg"), blurJpeg, 0644)
+
+ image, _ = vips.Blur(resizeImage(t, imageBuffer), 5)
+ blurWebP, _ := vips.SaveToWebPBuffer(image)
+ os.WriteFile(fixturePath("blur", "webp"), blurWebP, 0644)
+}
+
+func setup(t *testing.T) []byte {
+ log := logger.New(zap.FatalLevel)
+ defer log.Sync()
+
+ err := vips.Initialize(log)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ imageBuffer, err := os.ReadFile("../../test/fixtures/fixture.jpg")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ return imageBuffer
+}
+
+func resizeImage(t *testing.T, imageBuffer []byte) vips.Image {
+ resizedImage, err := vips.ResizeImage(imageBuffer, 500, 500)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ vips.SetUserComment(resizedImage, "Test")
+
+ return resizedImage
+}
+
+func readFixture(fixtureName string, extension string) []byte {
+ fixture, _ := os.ReadFile(fixturePath(fixtureName, extension))
+ return fixture
+}
+func fixturePath(fixtureName string, extension string) string {
+ return fmt.Sprintf("../../test/fixtures/vips/%s_result_%s.%s", fixtureName, runtime.GOOS, extension)
+}
diff --git a/internal/web/embed.go b/internal/web/embed.go
new file mode 100644
index 0000000..a1360ea
--- /dev/null
+++ b/internal/web/embed.go
@@ -0,0 +1,7 @@
+package web
+
+import "embed"
+
+//go:generate tailwindcss -c tailwind.config.js -i style.css -o embed/assets/css/style.css --minify
+//go:embed embed
+var Static embed.FS
diff --git a/internal/web/embed/assets/images/fastly.svg b/internal/web/embed/assets/images/fastly.svg
new file mode 100644
index 0000000..9e02c15
--- /dev/null
+++ b/internal/web/embed/assets/images/fastly.svg
@@ -0,0 +1,33 @@
+
+
+
diff --git a/internal/web/embed/assets/images/favicon/favicon-16x16.png b/internal/web/embed/assets/images/favicon/favicon-16x16.png
new file mode 100644
index 0000000..c734225
Binary files /dev/null and b/internal/web/embed/assets/images/favicon/favicon-16x16.png differ
diff --git a/internal/web/embed/assets/images/favicon/favicon-32x32.png b/internal/web/embed/assets/images/favicon/favicon-32x32.png
new file mode 100644
index 0000000..21ed4c5
Binary files /dev/null and b/internal/web/embed/assets/images/favicon/favicon-32x32.png differ
diff --git a/internal/web/embed/assets/images/icon.svg b/internal/web/embed/assets/images/icon.svg
new file mode 100644
index 0000000..f896692
--- /dev/null
+++ b/internal/web/embed/assets/images/icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/internal/web/embed/assets/js/images.js b/internal/web/embed/assets/js/images.js
new file mode 100644
index 0000000..7c6282d
--- /dev/null
+++ b/internal/web/embed/assets/js/images.js
@@ -0,0 +1,112 @@
+window.addEventListener('DOMContentLoaded', function () {
+ document.getElementById('prev').addEventListener('click', handleNavigationButton)
+ document.getElementById('next').addEventListener('click', handleNavigationButton)
+
+ loadPageFromhHash()
+})
+
+window.addEventListener('hashchange', loadPageFromhHash)
+
+function loadPageFromhHash() {
+ var page = 1
+
+ if (window.location.hash) {
+ page = window.location.hash.substring(1)
+ }
+
+ loadPage(page)
+}
+
+function handleNavigationButton (event) {
+ event.preventDefault()
+
+ var page = event.target.getAttribute('data-page')
+ if (!page) {
+ return
+ }
+
+ // Set the hash, this will change the page by triggering the 'hashchange' event
+ window.location.hash = page
+ window.scrollTo({ top: 0 })
+}
+
+function loadPage (page) {
+ var xhr = new XMLHttpRequest()
+ xhr.open('GET', '/v2/list?page=' + page, true)
+ xhr.onreadystatechange = function () {
+ if (xhr.readyState === 4 && xhr.status === 200) {
+ var images = JSON.parse(xhr.responseText)
+
+ var container = document.querySelector('.image-list')
+ container.innerHTML = ''
+
+ for (var image of images) {
+ var template = document.querySelector('#image-template')
+ var clone = document.importNode(template.content, true)
+
+ // Image
+ clone.querySelector('img').src = '/id/' + image.id + '/367/267'
+ clone.querySelector('.download-url').href = image.download_url
+
+ // Author
+ clone.querySelector('.author').innerHTML = image.author
+ clone.querySelector('.author-url').href = image.url
+
+ // Image id indicator
+ clone.querySelector('.image-id').innerHTML = '#' + image.id
+ clone.querySelector('.image-id').href = image.download_url
+
+ container.appendChild(clone)
+ }
+
+ var linkHeaders = parseLinkHeader(xhr.getResponseHeader('Link'))
+
+ updateButton('prev', linkHeaders.prev)
+ updateButton('next', linkHeaders.next)
+ }
+ }
+
+ xhr.send()
+}
+
+function updateButton (id, page_url) {
+ var button = document.getElementById(id)
+
+ if (page_url) {
+ var url = new URL(page_url)
+ var urlParams = new URLSearchParams(url.search)
+ button.setAttribute('data-page', urlParams.get('page'))
+ button.classList.add('hover:text-white', 'hover:bg-gray-500')
+ button.classList.remove('cursor-not-allowed', 'opacity-50')
+ } else {
+ button.removeAttribute('data-page')
+ button.classList.add('cursor-not-allowed', 'opacity-50')
+ button.classList.remove('hover:text-white', 'hover:bg-gray-500')
+ }
+}
+
+// From https://gist.github.com/deiu/9335803
+function parseLinkHeader (header) {
+ var linkexp = /<[^>]*>\s*(\s*;\s*[^\(\)<>@,;:"\/\[\]\?={} \t]+=(([^\(\)<>@,;:"\/\[\]\?={} \t]+)|("[^"]*")))*(,|$)/g
+ var paramexp = /[^\(\)<>@,;:"\/\[\]\?={} \t]+=(([^\(\)<>@,;:"\/\[\]\?={} \t]+)|("[^"]*"))/g
+
+ var matches = header.match(linkexp)
+ var rels = {}
+
+ for (var i = 0; i < matches.length; i++) {
+ var split = matches[i].split('>')
+ var href = split[0].substring(1)
+ var ps = split[1]
+
+ var s = ps.match(paramexp)
+ for (var j = 0; j < s.length; j++) {
+ var p = s[j]
+ var paramsplit = p.split('=')
+ var name = paramsplit[0]
+ var rel = paramsplit[1].replace(/["']/g, '')
+ rels[rel] = href
+ }
+ }
+
+ return rels
+}
diff --git a/internal/web/embed/favicon.ico b/internal/web/embed/favicon.ico
new file mode 100644
index 0000000..375a7a7
Binary files /dev/null and b/internal/web/embed/favicon.ico differ
diff --git a/internal/web/embed/images.html b/internal/web/embed/images.html
new file mode 100644
index 0000000..d447777
--- /dev/null
+++ b/internal/web/embed/images.html
@@ -0,0 +1,239 @@
+
+
+
+
+
+ Lorem Picsum - Images
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
Lorem Picsum
+
The Lorem Ipsum for photos.
+
+
+

+
+
+
+
+
+
+
+
+
Image Gallery
+
Here you can view all the images Lorem Picsum provides.
+
Get a specific image by adding /id/{image} to the start of the url.
+
https://picsum.photos/id/1/200/300
+
More detailed instructions can be found on the main page.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+
+
diff --git a/internal/web/embed/index.html b/internal/web/embed/index.html
new file mode 100644
index 0000000..acc57c4
--- /dev/null
+++ b/internal/web/embed/index.html
@@ -0,0 +1,326 @@
+
+
+
+
+
+ Lorem Picsum
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
Lorem Picsum
+
The Lorem Ipsum for photos.
+
+
+

+
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+
+
+
+
+

+
+
+
+
+
+
+
+
+

+
+
+
+
+
+
+
+
+

+
+
+
+
+
+
+
+
Advanced Usage
+
You may combine any of the options above.
+
For example, to get a specific image that is grayscale and blurred.
+
https://picsum.photos/id/870/200/300?grayscale&blur=2
+
To request multiple images of the same size in your browser, add the random query param to prevent the images from being cached:
+
<img src="https://picsum.photos/200/300?random=1">
+<img src="https://picsum.photos/200/300?random=2">
+
If you need a file ending, you can add .jpg to the end of the url.
+
https://picsum.photos/200/300.jpg
+
To get an image in the WebP format, you can add .webp to the end of the url.
+
https://picsum.photos/200/300.webp
+
+
+

+
+
+
+
+
+
+
+
List Images
+
Get a list of images by using the /v2/list endpoint.
+
https://picsum.photos/v2/list
+
The API will return 30 items per page by default.
+
To request another page, use the ?page parameter.
+
To change the amount of items per page, use the ?limit parameter.
+
https://picsum.photos/v2/list?page=2&limit=100
+
The Link header includes pagination information about the next/previous pages
+
+
+
[
+ {
+ "id": "0",
+ "author": "Alejandro Escamilla",
+ "width": 5616,
+ "height": 3744,
+ "url": "https://unsplash.com/...",
+ "download_url": "https://picsum.photos/..."
+ }
+]
+
+
+
+
+
+
+
+
+
{
+ "id": "0",
+ "author": "Alejandro Escamilla",
+ "width": 5616,
+ "height": 3744,
+ "url": "https://unsplash.com/...",
+ "download_url": "https://picsum.photos/..."
+}
+
+
+
+
+
+
+
+
+
+
diff --git a/internal/web/embed/robots.txt b/internal/web/embed/robots.txt
new file mode 100644
index 0000000..02b550a
--- /dev/null
+++ b/internal/web/embed/robots.txt
@@ -0,0 +1,7 @@
+User-agent: *
+Allow: /$
+Allow: /?*
+Allow: /images
+Allow: /images?*
+Allow: /id/237/250
+Disallow: /
diff --git a/internal/web/style.css b/internal/web/style.css
new file mode 100644
index 0000000..3ad712e
--- /dev/null
+++ b/internal/web/style.css
@@ -0,0 +1,194 @@
+@tailwind base;
+
+html {
+ line-height: 1.15;
+ font-family: sans-serif;
+}
+
+@tailwind components;
+@tailwind utilities;
+
+/* Typography */
+body {
+ font-family: 'Open Sans', sans-serif;
+}
+
+p {
+ font-size: 16px;
+ line-height: 1.6em;
+}
+
+h2 {
+ font-family: 'Roboto', sans-serif;
+ font-weight: 600;
+ margin-bottom: 0.5em;
+}
+
+a {
+ color: #386BF3;
+ font-weight: 400;
+}
+
+a:hover {
+ color: hsla(0, 100%, 100%, .6);
+}
+
+pre,
+code {
+ font-family: SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
+}
+
+pre {
+ font-size: 14px;
+ background-color: hsl(210, 14.3%, 93.3%);
+ border-radius: 4px;
+ padding: 1em;
+ margin-top: 1em;
+ margin-bottom: 2em;
+ width: 100%;
+}
+
+
+code {
+ padding: 4px 6px;
+ font-size: 90%;
+ color: hsl(0, 0%, 23.7%);
+ background-color: hsl(0, 0%, 94.9%);
+ border-radius: 4px;
+}
+
+pre code {
+ padding: 0;
+ font-size: inherit;
+ color: inherit;
+ white-space: pre-wrap;
+ background-color: transparent;
+ border-radius: 0;
+}
+
+.break-all {
+ word-break: break-all;
+}
+
+/* Header */
+header {
+ padding-top: 100px;
+ padding-bottom: 100px;
+}
+
+header .brand-icon {
+ position: relative;
+ width: 42px;
+ margin-right: 10px;
+ top: 7px;
+ display: inline;
+ vertical-align: unset;
+}
+
+header h1 {
+ font-family: 'Work Sans', sans-serif;
+ font-weight: 600;
+ font-size: 42px;
+ display: inline-block;
+ margin: 0;
+}
+
+header h2 {
+ margin-top: .5em;
+ font-family: 'Open Sans', sans-serif;
+ font-weight: 300;
+}
+
+header a {
+ color: #fff;
+}
+
+header a:hover {
+ opacity: 0.6;
+}
+
+/* Main content */
+.content-section-light,
+.content-section-dark {
+ padding: 100px 0;
+}
+
+.content-section-dark {
+ background: #F4F7FC;
+}
+
+.content-section-dark + .content-section-light {
+ padding-top: 150px;
+}
+
+.content-section-light a:hover,
+.content-section-dark a:hover,
+.content-section-images a:hover {
+ color: hsla(0, 0%, 60%, 1);
+}
+
+.content-section-dark pre {
+ background: white;
+}
+
+/* Images */
+img.resize {
+ max-width: 100%;
+ height: auto;
+ border-radius: 8px;
+ 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);
+}
+
+/* Custom Pre Code Box */
+
+pre.code-box {
+ 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);
+ background: white;
+ padding: 18px;
+}
+
+/* Image gallery */
+.content-section-images {
+ padding: 50px 0;
+}
+
+.content-section-images a {
+ color: hsla(0, 0%, 0%, 1);
+}
+
+/* Footer */
+footer {
+ text-align: center;
+ background: hsl(0, 0%, 0%);
+ padding: 100px 0;
+ margin-top: 100px;
+}
+
+footer p {
+ color: hsla(0, 100%, 100%, 0.6);
+ font-size: 14px;
+}
+
+footer a {
+ color: hsla(0, 100%, 100%, 0.8);
+}
+
+.sponsor {
+ display: inline;
+ width: 230px;
+ height: auto;
+}
+
+.sponsor:hover {
+ opacity: 0.6;
+}
+
+.fastly {
+ margin-top: -20px;
+}
+
+@media (max-width: 1000px) {
+ header {
+ text-align: center;
+ }
+}
diff --git a/internal/web/tailwind.config.js b/internal/web/tailwind.config.js
new file mode 100644
index 0000000..c5c8a70
--- /dev/null
+++ b/internal/web/tailwind.config.js
@@ -0,0 +1,150 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ content: {
+ relative: true,
+ files: [
+ './embed/**/*.{html,js}',
+ ],
+ },
+ theme: {
+ screens: {
+ sm: '576px',
+ md: '768px',
+ lg: '992px',
+ xl: '1200px',
+ },
+ colors: {
+ transparent: 'transparent',
+ current: 'currentColor',
+
+ black: '#000',
+ white: '#fff',
+
+ gray: {
+ 100: '#f7fafc',
+ 200: '#edf2f7',
+ 300: '#e2e8f0',
+ 400: '#cbd5e0',
+ 500: '#a0aec0',
+ 600: '#718096',
+ 700: '#4a5568',
+ 800: '#2d3748',
+ 900: '#1a202c',
+ },
+ red: {
+ 100: '#fff5f5',
+ 200: '#fed7d7',
+ 300: '#feb2b2',
+ 400: '#fc8181',
+ 500: '#f56565',
+ 600: '#e53e3e',
+ 700: '#c53030',
+ 800: '#9b2c2c',
+ 900: '#742a2a',
+ },
+ orange: {
+ 100: '#fffaf0',
+ 200: '#feebc8',
+ 300: '#fbd38d',
+ 400: '#f6ad55',
+ 500: '#ed8936',
+ 600: '#dd6b20',
+ 700: '#c05621',
+ 800: '#9c4221',
+ 900: '#7b341e',
+ },
+ yellow: {
+ 100: '#fffff0',
+ 200: '#fefcbf',
+ 300: '#faf089',
+ 400: '#f6e05e',
+ 500: '#ecc94b',
+ 600: '#d69e2e',
+ 700: '#b7791f',
+ 800: '#975a16',
+ 900: '#744210',
+ },
+ green: {
+ 100: '#f0fff4',
+ 200: '#c6f6d5',
+ 300: '#9ae6b4',
+ 400: '#68d391',
+ 500: '#48bb78',
+ 600: '#38a169',
+ 700: '#2f855a',
+ 800: '#276749',
+ 900: '#22543d',
+ },
+ teal: {
+ 100: '#e6fffa',
+ 200: '#b2f5ea',
+ 300: '#81e6d9',
+ 400: '#4fd1c5',
+ 500: '#38b2ac',
+ 600: '#319795',
+ 700: '#2c7a7b',
+ 800: '#285e61',
+ 900: '#234e52',
+ },
+ blue: {
+ 100: '#ebf8ff',
+ 200: '#bee3f8',
+ 300: '#90cdf4',
+ 400: '#63b3ed',
+ 500: '#4299e1',
+ 600: '#3182ce',
+ 700: '#2b6cb0',
+ 800: '#2c5282',
+ 900: '#2a4365',
+ },
+ indigo: {
+ 100: '#ebf4ff',
+ 200: '#c3dafe',
+ 300: '#a3bffa',
+ 400: '#7f9cf5',
+ 500: '#667eea',
+ 600: '#5a67d8',
+ 700: '#4c51bf',
+ 800: '#434190',
+ 900: '#3c366b',
+ },
+ purple: {
+ 100: '#faf5ff',
+ 200: '#e9d8fd',
+ 300: '#d6bcfa',
+ 400: '#b794f4',
+ 500: '#9f7aea',
+ 600: '#805ad5',
+ 700: '#6b46c1',
+ 800: '#553c9a',
+ 900: '#44337a',
+ },
+ pink: {
+ 100: '#fff5f7',
+ 200: '#fed7e2',
+ 300: '#fbb6ce',
+ 400: '#f687b3',
+ 500: '#ed64a6',
+ 600: '#d53f8c',
+ 700: '#b83280',
+ 800: '#97266d',
+ 900: '#702459',
+ },
+ },
+ fontSize: {
+ xs: '0.75rem',
+ sm: '0.875rem',
+ base: '1rem',
+ lg: '1.125rem',
+ xl: '1.25rem',
+ '2xl': '1.5rem',
+ '3xl': '1.875rem',
+ '4xl': '2.25rem',
+ '5xl': '3rem',
+ '6xl': '4rem',
+ },
+
+ extend: {},
+ },
+ plugins: [],
+}
diff --git a/package.json b/package.json
deleted file mode 100644
index d530836..0000000
--- a/package.json
+++ /dev/null
@@ -1,24 +0,0 @@
-{
- "name": "unsplash-it",
- "version": "0.0.1",
- "main": "index.js",
- "scripts": {
- "test": "echo \"Error: no test specified\" && exit 1",
- "start": "node index.js"
- },
- "repository": {
- "type": "git",
- "url": "https://github.com/DMarby/unsplash-it.git"
- },
- "author": "David Marby (http://dmarby.se)",
- "dependencies": {
- "async": "^0.9.0",
- "compression": "^1.5.0",
- "cors": "^2.4.2",
- "express": "^4.8.2",
- "moment": "^2.9.0",
- "sharp": "^0.10.1",
- "socket.io": "^1.0.6",
- "vnstat-dumpdb": "^1.0.2"
- }
-}
diff --git a/public/belugacdn.png b/public/belugacdn.png
deleted file mode 100644
index 396a738..0000000
Binary files a/public/belugacdn.png and /dev/null differ
diff --git a/public/digitalocean.png b/public/digitalocean.png
deleted file mode 100644
index a3cf197..0000000
Binary files a/public/digitalocean.png and /dev/null differ
diff --git a/public/favicon-114.png b/public/favicon-114.png
deleted file mode 100755
index 2f1f108..0000000
Binary files a/public/favicon-114.png and /dev/null differ
diff --git a/public/favicon-120.png b/public/favicon-120.png
deleted file mode 100755
index 089b23f..0000000
Binary files a/public/favicon-120.png and /dev/null differ
diff --git a/public/favicon-144.png b/public/favicon-144.png
deleted file mode 100755
index 9e2b5f7..0000000
Binary files a/public/favicon-144.png and /dev/null differ
diff --git a/public/favicon-150.png b/public/favicon-150.png
deleted file mode 100755
index 55c4bf5..0000000
Binary files a/public/favicon-150.png and /dev/null differ
diff --git a/public/favicon-152.png b/public/favicon-152.png
deleted file mode 100755
index 3b96196..0000000
Binary files a/public/favicon-152.png and /dev/null differ
diff --git a/public/favicon-16.png b/public/favicon-16.png
deleted file mode 100755
index 26e8310..0000000
Binary files a/public/favicon-16.png and /dev/null differ
diff --git a/public/favicon-160.png b/public/favicon-160.png
deleted file mode 100755
index 90bebfe..0000000
Binary files a/public/favicon-160.png and /dev/null differ
diff --git a/public/favicon-196.png b/public/favicon-196.png
deleted file mode 100755
index dd52cf7..0000000
Binary files a/public/favicon-196.png and /dev/null differ
diff --git a/public/favicon-310.png b/public/favicon-310.png
deleted file mode 100755
index 1ccfc80..0000000
Binary files a/public/favicon-310.png and /dev/null differ
diff --git a/public/favicon-32.png b/public/favicon-32.png
deleted file mode 100755
index b64a97a..0000000
Binary files a/public/favicon-32.png and /dev/null differ
diff --git a/public/favicon-57.png b/public/favicon-57.png
deleted file mode 100755
index 54789a8..0000000
Binary files a/public/favicon-57.png and /dev/null differ
diff --git a/public/favicon-64.png b/public/favicon-64.png
deleted file mode 100755
index 51f359d..0000000
Binary files a/public/favicon-64.png and /dev/null differ
diff --git a/public/favicon-70.png b/public/favicon-70.png
deleted file mode 100755
index 4e015f4..0000000
Binary files a/public/favicon-70.png and /dev/null differ
diff --git a/public/favicon-72.png b/public/favicon-72.png
deleted file mode 100755
index 0c834ab..0000000
Binary files a/public/favicon-72.png and /dev/null differ
diff --git a/public/favicon-76.png b/public/favicon-76.png
deleted file mode 100755
index e971f7d..0000000
Binary files a/public/favicon-76.png and /dev/null differ
diff --git a/public/favicon-96.png b/public/favicon-96.png
deleted file mode 100755
index ddb8716..0000000
Binary files a/public/favicon-96.png and /dev/null differ
diff --git a/public/favicon.ico b/public/favicon.ico
deleted file mode 100755
index 894b68b..0000000
Binary files a/public/favicon.ico and /dev/null differ
diff --git a/public/images.html b/public/images.html
deleted file mode 100644
index 0327a59..0000000
--- a/public/images.html
+++ /dev/null
@@ -1,145 +0,0 @@
-
-
-
- Unsplash It - Images
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Image Gallery
-
Here you can view all images Unsplash It uses
-
Get a specific image by appending ?image to the end of the url
-
https://unsplash.it/200/300?image=0
-
-
-

-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
deleted file mode 100644
index ae89716..0000000
--- a/public/index.html
+++ /dev/null
@@ -1,228 +0,0 @@
-
-
-
- Unsplash It
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Basic Usage
-
Just put your image size (width & height) after our URL and you'll get a placeholder.
-
https://unsplash.it/200/300
-
To get a square image, just put the size you want.
-
https://unsplash.it/200
-
-
-

-
-
-
-
-
-
-
-
-
-
-
Random image
-
Get a random image by appending ?random to the end of the url.
-
https://unsplash.it/200/300/?random
-
-
-

-
-
-
-
-
-
-
-
-
-
-
Grayscale
-
Use the /g/ path to greyscale the image.
-
https://unsplash.it/g/200/300
-
-
-

-
-
-
-
-
-
-
-
-
-
-
List images
-
Get a list of images by using the /list endpoint.
-
https://unsplash.it/list
-
-
-
[
- {
- "format": "jpeg",
- "width": 5616,
- "height": 3744,
- "filename": "51492538696.jpg",
- "id": 0,
- "author": "Alejandro Escamilla",
- "author_url": "https://alejandroescamilla.com/",
- "post_url": "https://unsplash.com/post/51492538696/download-by-alejandro-escamilla"
- }
-]
-
-
-
-
-
-
-
-
-
-
-
Specific Image
-
Get a specific image by appending ?image to the end of the url.
-
https://unsplash.it/200/300?image=0
-
You can find a list of all images here.
-
-
-

-
-
-
-
-
-
-
-
-
-
-
Blurred image
-
Get a blurred image by appending ?blur to the end of the url.
-
https://unsplash.it/200/300/?blur
-
-
-

-
-
-
-
-
-
-
-
-
-
-
Crop Gravity
-
Select the cropping gravity by adding ?gravity to the end of the url.
-
Valid options are: north, east, south, west, center
-
https://unsplash.it/200/300/?gravity=east
-
-
-

-
-
-
-
-
-
-
-
-
diff --git a/public/main.js b/public/main.js
deleted file mode 100644
index b4d10a4..0000000
--- a/public/main.js
+++ /dev/null
@@ -1,13 +0,0 @@
-$(document).ready(function() {
- var setupRandomImage = function (element) {
- var $element = $(element);
- var width = $element.width();
- var height = $element.outerHeight();
- var src = '/' + width + '/' + height + '?random&element=' + $element.prop('tagName');
-
- $element.css('background-image', 'url('+src+')');
- }
-
- setupRandomImage('.intro-header');
- setupRandomImage('footer');
-});
\ No newline at end of file
diff --git a/public/style.css b/public/style.css
deleted file mode 100644
index 58ad397..0000000
--- a/public/style.css
+++ /dev/null
@@ -1,202 +0,0 @@
-* {
- text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; box-sizing: border-box;
-}
-
-body {
- font-family: "Open Sans","Helvetica Neue",Helvetica,Arial,ยง-serif !important;
-}
-
-h1,
-h2,
-h3,
-h4,
-h5,
-h6,
-a {
- font-family: "Lato","Helvetica Neue",Helvetica,Arial,sans-serif !important;
-}
-
-p {
- font-weight: 300 !important;
-
-}
-
-.intro-header {
- text-align: center;
- color: #f8f8f8;
- background: #000;
- background-size: cover;
- font-family: Lato;
-}
-
-.overlay {
- padding-top: 100px;
- padding-bottom: 100px;
- background: rgba(0,0,0,0.6);
-}
-
-.intro-message h1 {
- font-family: Lato;
- font-weight: 300;
-}
-
-.intro-message a:hover {
- text-decoration: none;
- color: rgba(255,255,255,0.6) !important;
-}
-
-.intro-message h3 {
- font-family: Lato;
- color: hsla(0, 100%, 100%, 0.6);
-}
-
-.intro-message h3 a {
- color: rgba(255,255,255,0.8);
-}
-
-.content-section {
- padding: 50px 0;
- text-align: center;
-}
-
-.content-section h2, .content-section h3 {
- font-weight: 700 !important;
-}
-
-.content-section a {
- color: #000 !important;
-}
-
-.content-section a:hover {
- color: #999898 !important;
- text-decoration: none;
-}
-
-.content-section h3 {
- margin-top: 0px;
-}
-
-.content-section-right {
- padding: 50px 0;
- background: hsl(211, 20%, 97%);
-}
-
-.content-section-left {
- padding: 50px 0;
- border-top: 1px solid hsl(211, 20%, 91%);
- border-bottom: 1px solid hsl(211, 20%, 91%);
-}
-
-.content-section-left a, .content-section-right a {
- color: #000;
- font-weight: 700;
-}
-
-.content-section-left a:hover, .content-section-right a:hover {
- color: #999898;
- text-decoration: none;
-}
-
-pre {
- background-color: hsl(211, 20%, 96%) !important;
- border: 1px solid hsl(211, 20%, 91%) !important;
- border-radius: 4px !important;
- box-shadow: inset 0 1px 2px hsl(211, 20%, 94%) !important;
-}
-
-footer {
- text-align: center;
- background: #000;
- background-size: cover;
-}
-
-footer p {
- font-family: Lato;
- font-weight: 400 !important;
- color: rgba(255,255,255,0.6) !important;
-}
-
-footer a, footer a:hover {
- font-weight: 700 !important;
- color: rgba(255,255,255,0.8) !important;
-}
-
-footer a:hover {
- text-decoration: none;
- color: rgba(255,255,255,0.6) !important;
-}
-
-.footer-overlay {
- padding: 50px 0;
- background: rgba(0,0,0,0.6);
-}
-
-.digitalocean, .belugacdn {
- height: auto;
- width: 200px;
- margin-bottom: 10px;
-}
-
-.plus {
- padding-left: 20px;
- padding-right: 15px;
- color: rgba(255,255,255,1.0);
-}
-
-.digitalocean:hover, .belugacdn:hover {
- opacity: 0.7;
-}
-
-/* Gallery */
-
-.intro-message a {
- color: #fff !important;
-}
-
-.template {
- display: none;
-}
-
-
-.image {
- margin-top: -10px;
-}
-
-.image img {
- display: block;
- min-height: 197px;
- border-radius: 3px;
-}
-
-.imageContainer {
- margin-top: 50px;
-}
-
-.attribution {
- position: relative;
- top: -40;
- background-color: #000;
- padding: 10px 10px 10px 10px;
- border-radius: 3px;
- background: rgba(0, 0, 0, 0.8);
- float: right !important;
-}
-
-@media (min-width: 768px) and (max-width: 995px) {
- .attribution {
- right: 16.66666667%;
- }
-}
-
-.attribution a {
- color: #fff !important;
-}
-
-.attribution a:hover {
- text-decoration: none;
- color: #999898 !important;
-}
-
-.attribution .id {
- float: right;
-}
\ No newline at end of file
diff --git a/server.js b/server.js
deleted file mode 100644
index 0fd33fd..0000000
--- a/server.js
+++ /dev/null
@@ -1,170 +0,0 @@
-module.exports = function (callback) {
- var fs = require('fs')
- var path = require('path')
- var express = require('express')
- var cors = require('cors')
- var compress = require('compression')
- var sharp = require('sharp')
- var config = require('./config')()
- var packageinfo = require('./package.json')
- var imageProcessor = require('./imageProcessor')(sharp, path, config, fs)
-
- var app = express()
-
- sharp.cache(0)
-
- try {
- var images = require(config.image_store_path)
- } catch (e) {
- var images = []
- }
-
- process.addListener('uncaughtException', function (err) {
- console.log('Uncaught exception: ')
- console.trace(err)
- })
-
- app.use(compress());
-
- app.use(cors());
-
- app.use(express.static(path.join(__dirname, 'public'), {
- extensions: ['html'],
- maxAge: '1h'
- }));
-
- app.get('/info', function (req, res, next) {
- res.jsonp({ name: packageinfo.name, version: packageinfo.version, author: packageinfo.author })
- })
-
- app.get('/list', function (req, res, next) {
- var newImages = []
-
- for (var i in images) {
- var item = images[i]
- var image = {
- format: item.format,
- width: item.width,
- height: item.height,
- filename: path.basename(item.filename),
- id: item.id,
- author: item.author,
- author_url: item.author_url,
- post_url: item.post_url
- }
-
- newImages.push(image)
- }
-
- res.jsonp(newImages)
- })
-
- app.get('/:size', function (req, res, next) {
- serveImage(req, res, true, false)
- })
-
- app.get('/g/:size', function (req, res, next) {
- serveImage(req, res, true, true)
- })
-
- app.get('/:width/:height', function (req, res, next) {
- serveImage(req, res, false, false)
- })
-
- app.get('/g/:width/:height', function (req, res, next) {
- serveImage(req, res, false, true)
- })
-
- app.all('*', function (req, res, next) {
- res.status(404)
- res.send({ error: 'Resource not found' })
- })
-
- var serveImage = function(req, res, square, gray) {
- checkParameters(req.params, req.query, square, function (error, code, message) {
- if (error) {
- return displayError(res, code, message)
- }
-
- imageProcessor.getWidthAndHeight(req.params, square, function (width, height) {
- var filePath
- var blur = false
-
- if (req.query.image) {
- var matchingImage = findMatchingImage(req.query.image)
-
- if (matchingImage) {
- filePath = matchingImage.filename
-
- if (parseInt(width) == 0) {
- width = matchingImage.width
- }
-
- if (parseInt(height) == 0) {
- height = matchingImage.height
- }
- } else {
- return displayError(res, 404, 'Invalid image id')
- }
- } else {
- filePath = images[Math.floor(Math.random() * images.length)].filename
- }
-
- var isRandom = (req.query.random || req.query.random === '')
-
- imageProcessor.getProcessedImage(parseInt(width), parseInt(height), req.query.gravity, gray, !(!req.query.blur && req.query.blur !== ''), filePath, (!req.query.image && !req.query.random && req.query.random !== ''), function (error, imagePath) {
- if (error) {
- console.log('filePath: ' + filePath)
- console.log('imagePath: ' + imagePath)
- console.log('error: ' + error)
- return displayError(res, 500, 'Something went wrong')
- }
-
- res.sendFile(imagePath, {
- maxAge: isRandom ? 0 : '24h' // Lets set cache to 24h for now
- })
- process.send(imagePath)
- })
- })
- })
- }
-
- var checkParameters = function (params, queryparams, square, callback) {
- if ((square && !params.size) || (square && isNaN(parseInt(params.size))) || (!square && !params.width) || (!square && !params.height) || (!square && isNaN(parseInt(params.width))) || (!square && isNaN(parseInt(params.height))) || (queryparams.gravity && sharp.gravity[queryparams.gravity] != 0 && !sharp.gravity[queryparams.gravity])) {
- return callback(true, 400, 'Invalid arguments')
- }
-
- if (params.size > config.max_width || params.size > config.max_height || params.height > config.max_height || params.width > config.max_width) {
- if (queryparams.image) {
- var matchingImage = findMatchingImage(queryparams.image)
-
- if (matchingImage && params.height == matchingImage.height && params.width == matchingImage.width) {
- return callback(false)
- }
- }
-
- return callback(true, 413, 'Specified dimensions too large')
- }
-
- callback(false)
- }
-
- var findMatchingImage = function (id) {
- var matchingImages = images.filter(function (image) {
- return image.id == id
- })
-
- if (!matchingImages.length) {
- return false
- }
-
- return matchingImages[0]
- }
-
- var displayError = function (res, code, message) {
- res.status(code)
- res.send({ error: message })
- }
-
- callback(app)
-}
\ No newline at end of file
diff --git a/test/fixtures/api/all_darwin.jpg b/test/fixtures/api/all_darwin.jpg
new file mode 100644
index 0000000..8464fe9
Binary files /dev/null and b/test/fixtures/api/all_darwin.jpg differ
diff --git a/test/fixtures/api/all_darwin.webp b/test/fixtures/api/all_darwin.webp
new file mode 100644
index 0000000..5e4e47c
Binary files /dev/null and b/test/fixtures/api/all_darwin.webp differ
diff --git a/test/fixtures/api/all_linux.jpg b/test/fixtures/api/all_linux.jpg
new file mode 100644
index 0000000..e4b6b15
Binary files /dev/null and b/test/fixtures/api/all_linux.jpg differ
diff --git a/test/fixtures/api/all_linux.webp b/test/fixtures/api/all_linux.webp
new file mode 100644
index 0000000..34aa39f
Binary files /dev/null and b/test/fixtures/api/all_linux.webp differ
diff --git a/test/fixtures/api/blur_darwin.jpg b/test/fixtures/api/blur_darwin.jpg
new file mode 100644
index 0000000..cfc0b62
Binary files /dev/null and b/test/fixtures/api/blur_darwin.jpg differ
diff --git a/test/fixtures/api/blur_darwin.webp b/test/fixtures/api/blur_darwin.webp
new file mode 100644
index 0000000..927e2b4
Binary files /dev/null and b/test/fixtures/api/blur_darwin.webp differ
diff --git a/test/fixtures/api/blur_linux.jpg b/test/fixtures/api/blur_linux.jpg
new file mode 100644
index 0000000..d097bd6
Binary files /dev/null and b/test/fixtures/api/blur_linux.jpg differ
diff --git a/test/fixtures/api/blur_linux.webp b/test/fixtures/api/blur_linux.webp
new file mode 100644
index 0000000..5e526dc
Binary files /dev/null and b/test/fixtures/api/blur_linux.webp differ
diff --git a/test/fixtures/api/grayscale_darwin.jpg b/test/fixtures/api/grayscale_darwin.jpg
new file mode 100644
index 0000000..53d9532
Binary files /dev/null and b/test/fixtures/api/grayscale_darwin.jpg differ
diff --git a/test/fixtures/api/grayscale_darwin.webp b/test/fixtures/api/grayscale_darwin.webp
new file mode 100644
index 0000000..a02dba8
Binary files /dev/null and b/test/fixtures/api/grayscale_darwin.webp differ
diff --git a/test/fixtures/api/grayscale_linux.jpg b/test/fixtures/api/grayscale_linux.jpg
new file mode 100644
index 0000000..436d2c3
Binary files /dev/null and b/test/fixtures/api/grayscale_linux.jpg differ
diff --git a/test/fixtures/api/grayscale_linux.webp b/test/fixtures/api/grayscale_linux.webp
new file mode 100644
index 0000000..036d48e
Binary files /dev/null and b/test/fixtures/api/grayscale_linux.webp differ
diff --git a/test/fixtures/api/max_allowed_darwin.jpg b/test/fixtures/api/max_allowed_darwin.jpg
new file mode 100644
index 0000000..6755b0b
Binary files /dev/null and b/test/fixtures/api/max_allowed_darwin.jpg differ
diff --git a/test/fixtures/api/max_allowed_darwin.webp b/test/fixtures/api/max_allowed_darwin.webp
new file mode 100644
index 0000000..02fe814
Binary files /dev/null and b/test/fixtures/api/max_allowed_darwin.webp differ
diff --git a/test/fixtures/api/max_allowed_linux.jpg b/test/fixtures/api/max_allowed_linux.jpg
new file mode 100644
index 0000000..5b08efb
Binary files /dev/null and b/test/fixtures/api/max_allowed_linux.jpg differ
diff --git a/test/fixtures/api/max_allowed_linux.webp b/test/fixtures/api/max_allowed_linux.webp
new file mode 100644
index 0000000..0960c17
Binary files /dev/null and b/test/fixtures/api/max_allowed_linux.webp differ
diff --git a/test/fixtures/api/width_height_darwin.jpg b/test/fixtures/api/width_height_darwin.jpg
new file mode 100644
index 0000000..7b169ed
Binary files /dev/null and b/test/fixtures/api/width_height_darwin.jpg differ
diff --git a/test/fixtures/api/width_height_darwin.webp b/test/fixtures/api/width_height_darwin.webp
new file mode 100644
index 0000000..5805134
Binary files /dev/null and b/test/fixtures/api/width_height_darwin.webp differ
diff --git a/test/fixtures/api/width_height_linux.jpg b/test/fixtures/api/width_height_linux.jpg
new file mode 100644
index 0000000..425041b
Binary files /dev/null and b/test/fixtures/api/width_height_linux.jpg differ
diff --git a/test/fixtures/api/width_height_linux.webp b/test/fixtures/api/width_height_linux.webp
new file mode 100644
index 0000000..46c8a47
Binary files /dev/null and b/test/fixtures/api/width_height_linux.webp differ
diff --git a/test/fixtures/file/1.jpg b/test/fixtures/file/1.jpg
new file mode 100644
index 0000000..ea2c383
Binary files /dev/null and b/test/fixtures/file/1.jpg differ
diff --git a/test/fixtures/file/1_1000.jpg b/test/fixtures/file/1_1000.jpg
new file mode 100644
index 0000000..9db1da8
Binary files /dev/null and b/test/fixtures/file/1_1000.jpg differ
diff --git a/test/fixtures/file/1_500.jpg b/test/fixtures/file/1_500.jpg
new file mode 100644
index 0000000..b7cfe94
Binary files /dev/null and b/test/fixtures/file/1_500.jpg differ
diff --git a/test/fixtures/file/invalid_metadata.json b/test/fixtures/file/invalid_metadata.json
new file mode 100644
index 0000000..739bdb7
--- /dev/null
+++ b/test/fixtures/file/invalid_metadata.json
@@ -0,0 +1,9 @@
+[
+ {
+ "id": "1",
+ "author": "John Doe",
+ "url": "https://picsum.photos",
+ "width": 100,
+ "height": 200
+ },
+]
diff --git a/test/fixtures/file/metadata.json b/test/fixtures/file/metadata.json
new file mode 100644
index 0000000..0aa1c45
--- /dev/null
+++ b/test/fixtures/file/metadata.json
@@ -0,0 +1,9 @@
+[
+ {
+ "id": "1",
+ "author": "John Doe",
+ "url": "https://picsum.photos",
+ "width": 300,
+ "height": 400
+ }
+]
diff --git a/test/fixtures/file/metadata_multiple.json b/test/fixtures/file/metadata_multiple.json
new file mode 100644
index 0000000..769fc3d
--- /dev/null
+++ b/test/fixtures/file/metadata_multiple.json
@@ -0,0 +1,16 @@
+[
+ {
+ "id": "1",
+ "author": "John Doe",
+ "url": "https://picsum.photos",
+ "width": 300,
+ "height": 400
+ },
+ {
+ "id": "2",
+ "author": "John Doe",
+ "url": "https://picsum.photos",
+ "width": 300,
+ "height": 400
+ }
+]
diff --git a/test/fixtures/fixture.jpg b/test/fixtures/fixture.jpg
new file mode 100644
index 0000000..ea2c383
Binary files /dev/null and b/test/fixtures/fixture.jpg differ
diff --git a/test/fixtures/image/complete_result_darwin.jpg b/test/fixtures/image/complete_result_darwin.jpg
new file mode 100644
index 0000000..9d69255
Binary files /dev/null and b/test/fixtures/image/complete_result_darwin.jpg differ
diff --git a/test/fixtures/image/complete_result_darwin.webp b/test/fixtures/image/complete_result_darwin.webp
new file mode 100644
index 0000000..dfc2bee
Binary files /dev/null and b/test/fixtures/image/complete_result_darwin.webp differ
diff --git a/test/fixtures/image/complete_result_linux.jpg b/test/fixtures/image/complete_result_linux.jpg
new file mode 100644
index 0000000..fdbab63
Binary files /dev/null and b/test/fixtures/image/complete_result_linux.jpg differ
diff --git a/test/fixtures/image/complete_result_linux.webp b/test/fixtures/image/complete_result_linux.webp
new file mode 100644
index 0000000..2ee375b
Binary files /dev/null and b/test/fixtures/image/complete_result_linux.webp differ
diff --git a/test/fixtures/vips/blur_result_darwin.jpg b/test/fixtures/vips/blur_result_darwin.jpg
new file mode 100644
index 0000000..c1ffbfd
Binary files /dev/null and b/test/fixtures/vips/blur_result_darwin.jpg differ
diff --git a/test/fixtures/vips/blur_result_darwin.webp b/test/fixtures/vips/blur_result_darwin.webp
new file mode 100644
index 0000000..11e50d3
Binary files /dev/null and b/test/fixtures/vips/blur_result_darwin.webp differ
diff --git a/test/fixtures/vips/blur_result_linux.jpg b/test/fixtures/vips/blur_result_linux.jpg
new file mode 100644
index 0000000..7764d3e
Binary files /dev/null and b/test/fixtures/vips/blur_result_linux.jpg differ
diff --git a/test/fixtures/vips/blur_result_linux.webp b/test/fixtures/vips/blur_result_linux.webp
new file mode 100644
index 0000000..1787533
Binary files /dev/null and b/test/fixtures/vips/blur_result_linux.webp differ
diff --git a/test/fixtures/vips/grayscale_result_darwin.jpg b/test/fixtures/vips/grayscale_result_darwin.jpg
new file mode 100644
index 0000000..027f13e
Binary files /dev/null and b/test/fixtures/vips/grayscale_result_darwin.jpg differ
diff --git a/test/fixtures/vips/grayscale_result_darwin.webp b/test/fixtures/vips/grayscale_result_darwin.webp
new file mode 100644
index 0000000..6154cd8
Binary files /dev/null and b/test/fixtures/vips/grayscale_result_darwin.webp differ
diff --git a/test/fixtures/vips/grayscale_result_linux.jpg b/test/fixtures/vips/grayscale_result_linux.jpg
new file mode 100644
index 0000000..f6666a6
Binary files /dev/null and b/test/fixtures/vips/grayscale_result_linux.jpg differ
diff --git a/test/fixtures/vips/grayscale_result_linux.webp b/test/fixtures/vips/grayscale_result_linux.webp
new file mode 100644
index 0000000..75016ce
Binary files /dev/null and b/test/fixtures/vips/grayscale_result_linux.webp differ
diff --git a/test/fixtures/vips/resize_result_darwin.jpg b/test/fixtures/vips/resize_result_darwin.jpg
new file mode 100644
index 0000000..103ca99
Binary files /dev/null and b/test/fixtures/vips/resize_result_darwin.jpg differ
diff --git a/test/fixtures/vips/resize_result_darwin.webp b/test/fixtures/vips/resize_result_darwin.webp
new file mode 100644
index 0000000..e11bc72
Binary files /dev/null and b/test/fixtures/vips/resize_result_darwin.webp differ
diff --git a/test/fixtures/vips/resize_result_linux.jpg b/test/fixtures/vips/resize_result_linux.jpg
new file mode 100644
index 0000000..2de822a
Binary files /dev/null and b/test/fixtures/vips/resize_result_linux.jpg differ
diff --git a/test/fixtures/vips/resize_result_linux.webp b/test/fixtures/vips/resize_result_linux.webp
new file mode 100644
index 0000000..4b43a43
Binary files /dev/null and b/test/fixtures/vips/resize_result_linux.webp differ
diff --git a/tools/go.mod b/tools/go.mod
new file mode 100644
index 0000000..478855e
--- /dev/null
+++ b/tools/go.mod
@@ -0,0 +1,30 @@
+module github.com/DMarby/picsum-photos/tools
+
+go 1.25.5
+
+require (
+ github.com/air-verse/air v1.64.4
+ tailscale.com v1.94.1
+)
+
+require (
+ dario.cat/mergo v1.0.2 // indirect
+ github.com/andybalholm/brotli v1.2.0 // indirect
+ github.com/bep/godartsass/v2 v2.5.0 // indirect
+ github.com/bep/golibsass v1.2.0 // indirect
+ github.com/fatih/color v1.18.0 // indirect
+ github.com/fsnotify/fsnotify v1.9.0 // indirect
+ github.com/gobwas/glob v0.2.3 // indirect
+ github.com/gohugoio/hugo v0.149.1 // indirect
+ github.com/joho/godotenv v1.5.1 // indirect
+ github.com/mattn/go-colorable v0.1.14 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/pelletier/go-toml v1.9.5 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.4 // indirect
+ github.com/spf13/afero v1.14.0 // indirect
+ github.com/spf13/cast v1.9.2 // indirect
+ github.com/tdewolff/parse/v2 v2.8.3 // indirect
+ golang.org/x/sys v0.40.0 // indirect
+ golang.org/x/text v0.32.0 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
+)
diff --git a/tools/go.sum b/tools/go.sum
new file mode 100644
index 0000000..02339b6
--- /dev/null
+++ b/tools/go.sum
@@ -0,0 +1,211 @@
+dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
+dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
+github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU=
+github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg=
+github.com/air-verse/air v1.64.4 h1:P0alz5Jia5NucZew1HYZy69lzPWZHFjLTCKo0VhxME8=
+github.com/air-verse/air v1.64.4/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
+github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
+github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
+github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
+github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
+github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M=
+github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
+github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
+github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
+github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps=
+github.com/bep/clocks v0.5.0/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU=
+github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
+github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
+github.com/bep/gitmap v1.9.0 h1:2pyb1ex+cdwF6c4tsrhEgEKfyNfxE34d5K+s2sa9byc=
+github.com/bep/gitmap v1.9.0/go.mod h1:Juq6e1qqCRvc1W7nzgadPGI9IGV13ZncEebg5atj4Vo=
+github.com/bep/goat v0.5.0 h1:S8jLXHCVy/EHIoCY+btKkmcxcXFd34a0Q63/0D4TKeA=
+github.com/bep/goat v0.5.0/go.mod h1:Md9x7gRxiWKs85yHlVTvHQw9rg86Bm+Y4SuYE8CTH7c=
+github.com/bep/godartsass/v2 v2.5.0 h1:tKRvwVdyjCIr48qgtLa4gHEdtRkPF8H1OeEhJAEv7xg=
+github.com/bep/godartsass/v2 v2.5.0/go.mod h1:rjsi1YSXAl/UbsGL85RLDEjRKdIKUlMQHr6ChUNYOFU=
+github.com/bep/golibsass v1.2.0 h1:nyZUkKP/0psr8nT6GR2cnmt99xS93Ji82ZD9AgOK6VI=
+github.com/bep/golibsass v1.2.0/go.mod h1:DL87K8Un/+pWUS75ggYv41bliGiolxzDKWJAq3eJ1MA=
+github.com/bep/goportabletext v0.1.0 h1:8dqym2So1cEqVZiBa4ZnMM1R9l/DnC1h4ONg4J5kujw=
+github.com/bep/goportabletext v0.1.0/go.mod h1:6lzSTsSue75bbcyvVc0zqd1CdApuT+xkZQ6Re5DzZFg=
+github.com/bep/gowebp v0.4.0 h1:QihuVnvIKbRoeBNQkN0JPMM8ClLmD6V2jMftTFwSK3Q=
+github.com/bep/gowebp v0.4.0/go.mod h1:95gtYkAA8iIn1t3HkAPurRCVGV/6NhgaHJ1urz0iIwc=
+github.com/bep/helpers v0.6.0 h1:qtqMCK8XPFNM9hp5Ztu9piPjxNNkk8PIyUVjg6v8Bsw=
+github.com/bep/helpers v0.6.0/go.mod h1:IOZlgx5PM/R/2wgyCatfsgg5qQ6rNZJNDpWGXqDR044=
+github.com/bep/imagemeta v0.12.0 h1:ARf+igs5B7pf079LrqRnwzQ/wEB8Q9v4NSDRZO1/F5k=
+github.com/bep/imagemeta v0.12.0/go.mod h1:23AF6O+4fUi9avjiydpKLStUNtJr5hJB4rarG18JpN8=
+github.com/bep/lazycache v0.8.0 h1:lE5frnRjxaOFbkPZ1YL6nijzOPPz6zeXasJq8WpG4L8=
+github.com/bep/lazycache v0.8.0/go.mod h1:BQ5WZepss7Ko91CGdWz8GQZi/fFnCcyWupv8gyTeKwk=
+github.com/bep/logg v0.4.0 h1:luAo5mO4ZkhA5M1iDVDqDqnBBnlHjmtZF6VAyTp+nCQ=
+github.com/bep/logg v0.4.0/go.mod h1:Ccp9yP3wbR1mm++Kpxet91hAZBEQgmWgFgnXX3GkIV0=
+github.com/bep/overlayfs v0.10.0 h1:wS3eQ6bRsLX+4AAmwGjvoFSAQoeheamxofFiJ2SthSE=
+github.com/bep/overlayfs v0.10.0/go.mod h1:ouu4nu6fFJaL0sPzNICzxYsBeWwrjiTdFZdK4lI3tro=
+github.com/bep/tmc v0.5.1 h1:CsQnSC6MsomH64gw0cT5f+EwQDcvZz4AazKunFwTpuI=
+github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
+github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc=
+github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
+github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
+github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/evanw/esbuild v0.25.9 h1:aU7GVC4lxJGC1AyaPwySWjSIaNLAdVEEuq3chD0Khxs=
+github.com/evanw/esbuild v0.25.9/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
+github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
+github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
+github.com/frankban/quicktest v1.7.2/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03DbaAcR7Ks/o=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
+github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
+github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
+github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
+github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
+github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
+github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
+github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
+github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
+github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
+github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
+github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
+github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e h1:QArsSubW7eDh8APMXkByjQWvuljwPGAGQpJEFn0F0wY=
+github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e/go.mod h1:3Ltoo9Banwq0gOtcOwxuHG6omk+AwsQPADyw2vQYOJQ=
+github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg=
+github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec=
+github.com/gohugoio/httpcache v0.7.0 h1:ukPnn04Rgvx48JIinZvZetBfHaWE7I01JR2Q2RrQ3Vs=
+github.com/gohugoio/httpcache v0.7.0/go.mod h1:fMlPrdY/vVJhAriLZnrF5QpN3BNAcoBClgAyQd+lGFI=
+github.com/gohugoio/hugo v0.149.1 h1:uWOc8Ve4h4e48FyYhBquRoHCJviyxA5yGrFJLT48yio=
+github.com/gohugoio/hugo v0.149.1/go.mod h1:HS6BP6e8FGxungP4CHC3zeLDvhBLnTJIjHJZWTZjs7o=
+github.com/gohugoio/hugo-goldmark-extensions/extras v0.5.0 h1:dco+7YiOryRoPOMXwwaf+kktZSCtlFtreNdiJbETvYE=
+github.com/gohugoio/hugo-goldmark-extensions/extras v0.5.0/go.mod h1:CRrxQTKeM3imw+UoS4EHKyrqB7Zp6sAJiqHit+aMGTE=
+github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.1 h1:nUzXfRTszLliZuN0JTKeunXTRaiFX6ksaWP0puLLYAY=
+github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.1/go.mod h1:Wy8ThAA8p2/w1DY05vEzq6EIeI2mzDjvHsu7ULBVwog=
+github.com/gohugoio/locales v0.14.0 h1:Q0gpsZwfv7ATHMbcTNepFd59H7GoykzWJIxi113XGDc=
+github.com/gohugoio/locales v0.14.0/go.mod h1:ip8cCAv/cnmVLzzXtiTpPwgJ4xhKZranqNqtoIu0b/4=
+github.com/gohugoio/localescompressed v1.0.1 h1:KTYMi8fCWYLswFyJAeOtuk/EkXR/KPTHHNN9OS+RTxo=
+github.com/gohugoio/localescompressed v1.0.1/go.mod h1:jBF6q8D7a0vaEmcWPNcAjUZLJaIVNiwvM3WlmTvooB0=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
+github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
+github.com/hairyhenderson/go-codeowners v0.7.0 h1:s0W4wF8bdsBEjTWzwzSlsatSthWtTAF2xLgo4a4RwAo=
+github.com/hairyhenderson/go-codeowners v0.7.0/go.mod h1:wUlNgQ3QjqC4z8DnM5nnCYVq/icpqXJyJOukKx5U8/Q=
+github.com/hashicorp/golang-lru v0.6.0 h1:uL2shRDx7RTrOrTCUZEGP/wJUFiUI8QT6E7z5o8jga4=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU=
+github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
+github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U=
+github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
+github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1rSnAZns+1msaCXetrMFE=
+github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc=
+github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0=
+github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
+github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
+github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE=
+github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
+github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
+github.com/muesli/smartcrop v0.3.0 h1:JTlSkmxWg/oQ1TcLDoypuirdE8Y/jzNirQeLkxpA6Oc=
+github.com/muesli/smartcrop v0.3.0/go.mod h1:i2fCI/UorTfgEpPPLWiFBv4pye+YAG78RwcQLUkocpI=
+github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0=
+github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48=
+github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
+github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
+github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
+github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
+github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
+github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
+github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
+github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
+github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8=
+github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
+github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
+github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
+github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
+github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
+github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
+github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
+github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
+github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tdewolff/minify/v2 v2.24.2 h1:vnY3nTulEAbCAAlxTxPPDkzG24rsq31SOzp63yT+7mo=
+github.com/tdewolff/minify/v2 v2.24.2/go.mod h1:1JrCtoZXaDbqioQZfk3Jdmr0GPJKiU7c1Apmb+7tCeE=
+github.com/tdewolff/parse/v2 v2.8.3 h1:5VbvtJ83cfb289A1HzRA9sf02iT8YyUwN84ezjkdY1I=
+github.com/tdewolff/parse/v2 v2.8.3/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
+github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
+github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
+github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
+github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
+github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
+github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
+github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
+github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
+github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
+github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
+github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
+golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
+golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
+golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
+golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
+golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
+golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
+golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
+golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
+golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
+golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
+golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
+golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
+rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
+tailscale.com v1.94.1 h1:0dAst/ozTuFkgmxZULc3oNwR9+qPIt5ucvzH7kaM0Jw=
+tailscale.com v1.94.1/go.mod h1:gLnVrEOP32GWvroaAHHGhjSGMPJ1i4DvqNwEg+Yuov4=
diff --git a/tools/tools.go b/tools/tools.go
new file mode 100644
index 0000000..8ed61be
--- /dev/null
+++ b/tools/tools.go
@@ -0,0 +1,7 @@
+//go:build tools
+package tools
+
+import (
+ _ "github.com/air-verse/air"
+ _ "tailscale.com/cmd/nardump"
+)