Compare commits
42 Commits
fix/issue-
...
golangci-v
| Author | SHA1 | Date | |
|---|---|---|---|
| ce06170604 | |||
| 1a15b88971 | |||
| 61f42e6602 | |||
| 23506df609 | |||
| 5d0b5f864e | |||
| 6573b9d1ef | |||
| 275e145a6d | |||
| b6e9ac2a93 | |||
| 504afea4f8 | |||
| 2fb909283d | |||
| 6b4a1d7607 | |||
| e34743f070 | |||
| 7010d55d72 | |||
| a50364bfca | |||
| e85b5ff033 | |||
| 55a609dd77 | |||
| 9c29cb57df | |||
| 2e934c8894 | |||
| 2f15340f26 | |||
| 811c210b09 | |||
|
|
5ca64a37ce | ||
| 118bca1151 | |||
|
|
85729d9181 | ||
| a1c0ae0a44 | |||
| 429926fb71 | |||
| ce6db7627d | |||
| 454de2f170 | |||
| 133d9e5a4a | |||
| 73f1073d61 | |||
| d0fe5e7334 | |||
| c4fc1e1548 | |||
| 39fa0a5d05 | |||
| 2f53b49a88 | |||
| ce360880f7 | |||
| 9d71fabdd7 | |||
| 8a2b630864 | |||
| 0000188265 | |||
| f702e64139 | |||
|
|
c4368b1541 | ||
| 2fb36c5ccb | |||
|
|
40c4b53b01 | ||
| b800ef86d8 |
@@ -1,7 +1,8 @@
|
|||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
*.md
|
.DS_Store
|
||||||
Dockerfile
|
|
||||||
.dockerignore
|
|
||||||
.env*
|
.env*
|
||||||
.claude
|
.claude
|
||||||
|
node_modules
|
||||||
|
bin/
|
||||||
|
data/
|
||||||
|
|||||||
15
.editorconfig
Normal file
15
.editorconfig
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
|
[*.go]
|
||||||
|
indent_style = tab
|
||||||
9
.gitea/workflows/check.yml
Normal file
9
.gitea/workflows/check.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
name: check
|
||||||
|
on: [push]
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# actions/checkout v4.2.2, 2026-02-22
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
|
- run: script/cibuild
|
||||||
33
.gitignore
vendored
33
.gitignore
vendored
@@ -1,20 +1,35 @@
|
|||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editors
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
*.bak
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.sublime-*
|
||||||
|
|
||||||
|
# Environment / secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
vendor/
|
vendor/
|
||||||
|
node_modules/
|
||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
/bin/
|
/bin/
|
||||||
/pixad
|
/pixad
|
||||||
/cmd/pixad/pixad
|
/cmd/pixad/pixad
|
||||||
|
|
||||||
# IDE
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
# Data
|
# Data
|
||||||
/data/
|
/data/
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
|
# Local dev configs
|
||||||
|
config.yaml
|
||||||
|
config.dev.yml
|
||||||
|
|||||||
133
.golangci.yml
133
.golangci.yml
@@ -1,117 +1,34 @@
|
|||||||
version: "2"
|
version: "2"
|
||||||
|
|
||||||
|
# Config schema uses the golangci-lint v2 layout (settings live under
|
||||||
|
# linters.settings, not top-level linters-settings) so that the
|
||||||
|
# thresholds below are actually applied by golangci-lint >= v2.
|
||||||
|
|
||||||
run:
|
run:
|
||||||
go: "1.24"
|
timeout: 5m
|
||||||
tests: false
|
modules-download-mode: readonly
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
enable:
|
default: all
|
||||||
# Additional linters requested
|
disable:
|
||||||
- testifylint # Checks usage of github.com/stretchr/testify
|
# Genuinely incompatible with project patterns
|
||||||
- usetesting # usetesting is an analyzer that detects using os.Setenv instead of t.Setenv since Go 1.17
|
- exhaustruct # Requires all struct fields
|
||||||
# - tagliatelle # Disabled: we need snake_case for external API compatibility
|
- depguard # Dependency allow/block lists
|
||||||
- nlreturn # nlreturn checks for a new line before return and branch statements
|
- godot # Requires comments to end with periods
|
||||||
- nilnil # Checks that there is no simultaneous return of nil error and an invalid value
|
- wsl # Deprecated, replaced by wsl_v5
|
||||||
- nestif # Reports deeply nested if statements
|
- wrapcheck # Too verbose for internal packages
|
||||||
- mnd # An analyzer to detect magic numbers
|
- varnamelen # Short names like db, id are idiomatic Go
|
||||||
- lll # Reports long lines
|
settings:
|
||||||
- intrange # intrange is a linter to find places where for loops could make use of an integer range
|
lll:
|
||||||
- gochecknoglobals # Check that no global variables exist
|
line-length: 88
|
||||||
|
funlen:
|
||||||
# Default/existing linters that are commonly useful
|
lines: 80
|
||||||
- govet
|
statements: 50
|
||||||
- errcheck
|
cyclop:
|
||||||
- staticcheck
|
max-complexity: 15
|
||||||
- unused
|
dupl:
|
||||||
- ineffassign
|
threshold: 100
|
||||||
- misspell
|
|
||||||
- revive
|
|
||||||
- gosec
|
|
||||||
- unconvert
|
|
||||||
- unparam
|
|
||||||
|
|
||||||
linters-settings:
|
|
||||||
lll:
|
|
||||||
line-length: 120
|
|
||||||
|
|
||||||
nestif:
|
|
||||||
min-complexity: 4
|
|
||||||
|
|
||||||
nlreturn:
|
|
||||||
block-size: 2
|
|
||||||
|
|
||||||
revive:
|
|
||||||
rules:
|
|
||||||
- name: var-naming
|
|
||||||
arguments:
|
|
||||||
- []
|
|
||||||
- []
|
|
||||||
- "upperCaseConst=true"
|
|
||||||
|
|
||||||
tagliatelle:
|
|
||||||
case:
|
|
||||||
rules:
|
|
||||||
json: snake
|
|
||||||
yaml: snake
|
|
||||||
xml: snake
|
|
||||||
bson: snake
|
|
||||||
|
|
||||||
testifylint:
|
|
||||||
enable-all: true
|
|
||||||
|
|
||||||
usetesting: {}
|
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
max-same-issues: 0
|
max-same-issues: 0
|
||||||
exclude-rules:
|
|
||||||
# Exclude unused parameter warnings for cobra command signatures
|
|
||||||
- text: "parameter '(args|cmd)' seems to be unused"
|
|
||||||
linters:
|
|
||||||
- revive
|
|
||||||
|
|
||||||
# Allow ALL_CAPS constant names
|
|
||||||
- text: "don't use ALL_CAPS in Go names"
|
|
||||||
linters:
|
|
||||||
- revive
|
|
||||||
|
|
||||||
# Allow snake_case JSON tags for external API compatibility
|
|
||||||
- path: "internal/types/ris.go"
|
|
||||||
linters:
|
|
||||||
- tagliatelle
|
|
||||||
|
|
||||||
# Allow snake_case JSON tags for database models
|
|
||||||
- path: "internal/database/models.go"
|
|
||||||
linters:
|
|
||||||
- tagliatelle
|
|
||||||
|
|
||||||
# Allow generic package name for types that define data structures
|
|
||||||
- path: "internal/types/"
|
|
||||||
text: "avoid meaningless package names"
|
|
||||||
linters:
|
|
||||||
- revive
|
|
||||||
|
|
||||||
# Allow globals in the globals package (by design)
|
|
||||||
- path: "internal/globals/"
|
|
||||||
linters:
|
|
||||||
- gochecknoglobals
|
|
||||||
|
|
||||||
# Allow globals in main (Version/Buildarch set by ldflags)
|
|
||||||
- path: "cmd/"
|
|
||||||
linters:
|
|
||||||
- gochecknoglobals
|
|
||||||
|
|
||||||
# Allow blank imports for driver registration
|
|
||||||
- text: "blank-imports"
|
|
||||||
linters:
|
|
||||||
- revive
|
|
||||||
|
|
||||||
# Allow unused fx.Lifecycle parameters (required by fx signature)
|
|
||||||
- text: "parameter 'lc' seems to be unused"
|
|
||||||
linters:
|
|
||||||
- revive
|
|
||||||
|
|
||||||
# Allow unused context parameters in fx hooks
|
|
||||||
- text: "parameter 'ctx' seems to be unused"
|
|
||||||
linters:
|
|
||||||
- revive
|
|
||||||
|
|||||||
31
Dockerfile
31
Dockerfile
@@ -1,5 +1,28 @@
|
|||||||
|
# Lint stage
|
||||||
|
# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07
|
||||||
|
FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60 AS lint
|
||||||
|
|
||||||
|
RUN apk add --no-cache make build-base vips-dev libheif-dev pkgconfig
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Copy go mod files first for better layer caching
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Run formatting check and linter
|
||||||
|
RUN make fmt-check
|
||||||
|
RUN make lint
|
||||||
|
|
||||||
# Build stage
|
# Build stage
|
||||||
FROM golang:1.24-alpine AS builder
|
# golang:1.25.4-alpine, 2026-02-25
|
||||||
|
FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder
|
||||||
|
|
||||||
|
# Depend on lint stage passing
|
||||||
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
@@ -19,11 +42,15 @@ RUN GOTOOLCHAIN=auto go mod download
|
|||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
RUN make test
|
||||||
|
|
||||||
# Build with CGO enabled
|
# Build with CGO enabled
|
||||||
RUN CGO_ENABLED=1 GOTOOLCHAIN=auto go build -ldflags "-X main.Version=${VERSION}" -o /pixad ./cmd/pixad
|
RUN CGO_ENABLED=1 GOTOOLCHAIN=auto go build -ldflags "-X main.Version=${VERSION}" -o /pixad ./cmd/pixad
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage
|
||||||
FROM alpine:3.21
|
# alpine:3.21, 2026-02-25
|
||||||
|
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
||||||
|
|
||||||
# Install runtime dependencies only
|
# Install runtime dependencies only
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
|
|||||||
674
LICENSE
Normal file
674
LICENSE
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
54
Makefile
54
Makefile
@@ -1,45 +1,61 @@
|
|||||||
.PHONY: check lint test fmt build clean docker docker-test devserver devserver-stop
|
.PHONY: bootstrap setup check lint test fmt fmt-check build clean docker docker-versioned docker-test devserver devserver-stop hooks
|
||||||
|
|
||||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
LDFLAGS := -X main.Version=$(VERSION)
|
LDFLAGS := -X main.Version=$(VERSION)
|
||||||
|
|
||||||
|
# Use nix-shell to provide CGO dependencies unless they are already available
|
||||||
|
# (e.g. inside a Docker build or an existing nix-shell).
|
||||||
|
HAS_PKGCONFIG := $(shell command -v pkg-config 2>/dev/null)
|
||||||
|
ifdef HAS_PKGCONFIG
|
||||||
|
NIX_RUN_PREFIX =
|
||||||
|
NIX_RUN_SUFFIX =
|
||||||
|
else
|
||||||
|
NIX_RUN_PREFIX = nix-shell -p pkg-config vips libheif golangci-lint git --run '
|
||||||
|
NIX_RUN_SUFFIX = '
|
||||||
|
endif
|
||||||
|
|
||||||
# Default target: run all checks
|
# Default target: run all checks
|
||||||
check: fmt-check lint test
|
check:
|
||||||
|
@script/check
|
||||||
|
|
||||||
|
bootstrap:
|
||||||
|
@script/bootstrap
|
||||||
|
|
||||||
|
setup:
|
||||||
|
@script/setup
|
||||||
|
|
||||||
# Check formatting without modifying files
|
# Check formatting without modifying files
|
||||||
fmt-check:
|
fmt-check:
|
||||||
@echo "Checking formatting..."
|
@script/fmt-check
|
||||||
@test -z "$$(gofmt -l . | grep -v '^vendor/')" || (echo "Files need formatting:"; gofmt -l . | grep -v '^vendor/'; exit 1)
|
|
||||||
|
|
||||||
# Format code
|
# Format code
|
||||||
fmt:
|
fmt:
|
||||||
@echo "Formatting code..."
|
@script/fmt
|
||||||
gofmt -w $$(find . -name '*.go' -not -path './vendor/*')
|
|
||||||
|
|
||||||
# Run linter
|
# Run linter
|
||||||
lint:
|
lint:
|
||||||
@echo "Running linter..."
|
@script/lint
|
||||||
golangci-lint run
|
|
||||||
|
|
||||||
# Run tests
|
# Run tests (30-second timeout)
|
||||||
test:
|
test:
|
||||||
@echo "Running tests..."
|
@script/test
|
||||||
go test -v ./...
|
|
||||||
|
|
||||||
# Build the binary
|
# Build the binary
|
||||||
build: ./bin/pixad
|
build:
|
||||||
|
|
||||||
./bin/pixad: ./internal/*/*.go ./cmd/pixad/*.go ./internal/static/* ./internal/templates/*
|
|
||||||
@echo "Building pixad..."
|
@echo "Building pixad..."
|
||||||
go build -ldflags "$(LDFLAGS)" -o $@ ./cmd/pixad
|
$(NIX_RUN_PREFIX)CGO_ENABLED=1 go build -ldflags "$(LDFLAGS)" -o ./bin/pixad ./cmd/pixad$(NIX_RUN_SUFFIX)
|
||||||
|
|
||||||
# Clean build artifacts
|
# Clean build artifacts
|
||||||
clean:
|
clean:
|
||||||
rm -rf bin/
|
rm -rf bin/
|
||||||
rm -rf ./data
|
rm -rf ./data
|
||||||
|
|
||||||
# Build Docker image
|
# Build Docker image (tagged via script/projectname)
|
||||||
docker:
|
docker:
|
||||||
|
@script/docker
|
||||||
|
|
||||||
|
# Build Docker image tagged pixad:$(VERSION) and pixad:latest
|
||||||
|
docker-versioned:
|
||||||
docker build --build-arg VERSION=$(VERSION) -t pixad:$(VERSION) -t pixad:latest .
|
docker build --build-arg VERSION=$(VERSION) -t pixad:$(VERSION) -t pixad:latest .
|
||||||
|
|
||||||
# Run tests in Docker (needed for CGO/libvips)
|
# Run tests in Docker (needed for CGO/libvips)
|
||||||
@@ -48,7 +64,7 @@ docker-test:
|
|||||||
docker run --rm pixad-builder sh -c "CGO_ENABLED=1 GOTOOLCHAIN=auto go test -v ./..."
|
docker run --rm pixad-builder sh -c "CGO_ENABLED=1 GOTOOLCHAIN=auto go test -v ./..."
|
||||||
|
|
||||||
# Run local dev server in Docker
|
# Run local dev server in Docker
|
||||||
devserver: docker devserver-stop
|
devserver: docker-versioned devserver-stop
|
||||||
docker run -d --name pixad-dev -p 8080:8080 \
|
docker run -d --name pixad-dev -p 8080:8080 \
|
||||||
-v $(CURDIR)/config.dev.yml:/etc/pixa/config.yml:ro \
|
-v $(CURDIR)/config.dev.yml:/etc/pixa/config.yml:ro \
|
||||||
pixad:latest
|
pixad:latest
|
||||||
@@ -58,3 +74,7 @@ devserver: docker devserver-stop
|
|||||||
devserver-stop:
|
devserver-stop:
|
||||||
-docker stop pixad-dev 2>/dev/null
|
-docker stop pixad-dev 2>/dev/null
|
||||||
-docker rm pixad-dev 2>/dev/null
|
-docker rm pixad-dev 2>/dev/null
|
||||||
|
|
||||||
|
# Install pre-commit hook
|
||||||
|
hooks:
|
||||||
|
@script/install-precommit
|
||||||
|
|||||||
398
README.md
398
README.md
@@ -1,324 +1,166 @@
|
|||||||
# pixa caching image reverse proxy server
|
# pixa
|
||||||
|
|
||||||
This is a web service written in go that is designed to proxy images from
|
pixa is a GPL-3.0-licensed Go web server by
|
||||||
source URLs, optionally resizing or transforming them, and serving the
|
[@sneak](https://sneak.berlin) that proxies images from upstream
|
||||||
results. Both the source images as well as the transformed images are
|
sources, optionally resizing or transforming them, and serves the
|
||||||
cached. The images served to the client are cached a configurable interval
|
results. Both source and transformed images are cached to disk so that
|
||||||
so that subsequent requests to the same path on the pixa server are served
|
subsequent requests are served without origin fetches or additional
|
||||||
from disk without origin server requests or additional processing.
|
processing.
|
||||||
|
|
||||||
# storage
|
## Getting Started
|
||||||
|
|
||||||
* unaltered source file straight from upstream:
|
```bash
|
||||||
* `<statedir>/cache/src-content/<ab>/<cd>/<abcdef0123... sha256 of source content>`
|
# clone and build
|
||||||
* source path metadata
|
git clone https://git.eeqj.de/sneak/pixa.git
|
||||||
* `<statedir>/cache/src-metadata/<hostname>/<sha256 of path component>.json`
|
cd pixa
|
||||||
* fetch time
|
make build
|
||||||
* all original resp headers
|
|
||||||
* original request
|
|
||||||
* sha256 hash
|
|
||||||
|
|
||||||
Note that multiple source paths may reference the same content blob. We
|
# run with a config file
|
||||||
won't do refcounting here, we'll use the state database for that.
|
./bin/pixad --config config.example.yml
|
||||||
|
|
||||||
* database:
|
# or build and run via Docker
|
||||||
* `<statedir>/state.sqlite3`
|
make docker
|
||||||
|
docker run -p 8080:8080 pixad:latest
|
||||||
|
```
|
||||||
|
|
||||||
* output documents:
|
## Rationale
|
||||||
* `<statedir>/cache/dst-content/<ab>/<cd>/<abcd... sha256 of output content>`
|
|
||||||
|
|
||||||
While the database is the long-term authority on what we have in the output
|
Image-heavy web applications need a fast, caching reverse proxy that
|
||||||
cache, we must aggressively cache in-process the mapping between requests
|
can resize and transcode images on the fly. pixa fills that role as a
|
||||||
and output content hashes so as to serve as a maximally efficient caching
|
single, self-contained binary with no external runtime dependencies
|
||||||
proxy for extremely popular/hot request paths. The goal is the ability to
|
beyond libvips. It supports HMAC-SHA256 signed URLs with expiration to
|
||||||
easily support 1-5k r/s.
|
prevent abuse, and allowlisted source hosts for open access.
|
||||||
|
|
||||||
# Routes
|
## Design
|
||||||
|
|
||||||
/img/<size>/<orig host>/<orig path>?signature=<sig>&format=<format>
|
### Storage
|
||||||
|
|
||||||
Images are only fetched from origins using TLS. Origin certificates must be
|
- **Source content**:
|
||||||
valid at time of fetch.
|
`<statedir>/cache/src-content/<ab>/<cd>/<sha256 of source content>`
|
||||||
|
- **Source metadata**:
|
||||||
|
`<statedir>/cache/src-metadata/<hostname>/<sha256 of path>.json`
|
||||||
|
(fetch time, original headers, request, content hash)
|
||||||
|
- **Database**: `<statedir>/state.sqlite3` (SQLite)
|
||||||
|
- **Output documents**:
|
||||||
|
`<statedir>/cache/dst-content/<ab>/<cd>/<sha256 of output content>`
|
||||||
|
|
||||||
<format> is one of 'orig', 'png', 'jpeg', 'webp'
|
Multiple source paths may reference the same content blob; the
|
||||||
|
database tracks references rather than using filesystem refcounting.
|
||||||
|
In-process caching of request-to-output mappings targets 1-5k r/s.
|
||||||
|
|
||||||
<size> is one of 'orig' or '<x resolution>x<y resolution>'
|
### Routes
|
||||||
|
|
||||||
# Source Hosts
|
```
|
||||||
|
/v1/image/<host>/<path>/<size>.<format>?sig=<signature>&exp=<expiration>
|
||||||
|
```
|
||||||
|
|
||||||
Source hosts may be whitelisted in the pixa configuration. If not in the
|
Images are only fetched from origins using TLS with valid certificates.
|
||||||
explicit whitelist, a signature using a shared secret must be appended.
|
|
||||||
|
|
||||||
## Signature Specification
|
- `<format>`: one of `orig`, `png`, `jpeg`, `webp`
|
||||||
|
- `<size>`: `orig` or `<width>x<height>` (e.g. `800x600`)
|
||||||
|
|
||||||
Signatures use HMAC-SHA256 and include an expiration timestamp to prevent replay attacks.
|
### Source Hosts
|
||||||
|
|
||||||
### Signed Data Format
|
Source hosts may be allowlisted in the configuration. Non-allowlisted
|
||||||
|
hosts require an HMAC-SHA256 signature.
|
||||||
|
|
||||||
The signature is computed over a colon-separated string:
|
#### Signature Specification
|
||||||
|
|
||||||
|
Signatures use HMAC-SHA256 and include an expiration timestamp to
|
||||||
|
prevent replay attacks. Signatures are **exact match only**: every
|
||||||
|
component (host, path, query, dimensions, format, expiration) must
|
||||||
|
match exactly what was signed. No suffix matching, wildcard matching,
|
||||||
|
or partial matching is supported.
|
||||||
|
|
||||||
|
**Signed data format** (colon-separated):
|
||||||
|
|
||||||
```
|
```
|
||||||
HMAC-SHA256(secret, "host:path:query:width:height:format:expiration")
|
HMAC-SHA256(secret, "host:path:query:width:height:format:expiration")
|
||||||
```
|
```
|
||||||
|
|
||||||
Where:
|
Where:
|
||||||
- `host` - Source origin hostname (e.g., `cdn.example.com`)
|
|
||||||
- `path` - Source path (e.g., `/photos/cat.jpg`)
|
|
||||||
- `query` - Source query string, empty string if none
|
|
||||||
- `width` - Requested width in pixels, `0` for original
|
|
||||||
- `height` - Requested height in pixels, `0` for original
|
|
||||||
- `format` - Output format (jpeg, png, webp, avif, gif, orig)
|
|
||||||
- `expiration` - Unix timestamp when signature expires
|
|
||||||
|
|
||||||
### URL Format with Signature
|
- `host` — source origin hostname (e.g. `cdn.example.com`)
|
||||||
|
- `path` — source path (e.g. `/photos/cat.jpg`)
|
||||||
|
- `query` — source query string, empty string if none
|
||||||
|
- `width` — requested width in pixels, `0` for original
|
||||||
|
- `height` — requested height in pixels, `0` for original
|
||||||
|
- `format` — output format (jpeg, png, webp, avif, gif, orig)
|
||||||
|
- `expiration` — Unix timestamp when signature expires
|
||||||
|
|
||||||
```
|
**Example:** resize
|
||||||
/v1/image/<host>/<path>/<size>.<format>?sig=<signature>&exp=<expiration>
|
`https://cdn.example.com/photos/cat.jpg` to 800x600 WebP with
|
||||||
```
|
expiration 1704067200:
|
||||||
|
|
||||||
### Example
|
|
||||||
|
|
||||||
For a request to resize `https://cdn.example.com/photos/cat.jpg` to 800x600 WebP
|
|
||||||
with expiration at Unix timestamp 1704067200:
|
|
||||||
|
|
||||||
1. Build the signature input:
|
|
||||||
```
|
|
||||||
cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200
|
|
||||||
```
|
|
||||||
|
|
||||||
|
1. Build input:
|
||||||
|
`cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200`
|
||||||
2. Compute HMAC-SHA256 with your secret key
|
2. Compute HMAC-SHA256 with your secret key
|
||||||
|
|
||||||
3. Base64URL-encode the result
|
3. Base64URL-encode the result
|
||||||
|
4. URL:
|
||||||
|
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=<base64url>&exp=1704067200`
|
||||||
|
|
||||||
4. Final URL:
|
**Allowlist patterns:**
|
||||||
```
|
|
||||||
/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=<base64url>&exp=1704067200
|
|
||||||
```
|
|
||||||
|
|
||||||
### Whitelist Patterns
|
- **Exact match**: `cdn.example.com` — matches only that host
|
||||||
|
- **Suffix match**: `.example.com` — matches `cdn.example.com`,
|
||||||
|
`images.example.com`, and `example.com`
|
||||||
|
|
||||||
The whitelist supports two pattern types:
|
### Configuration
|
||||||
- **Exact match**: `cdn.example.com` - matches only that host
|
|
||||||
- **Suffix match**: `.example.com` - matches `cdn.example.com`, `images.example.com`, and `example.com`
|
|
||||||
|
|
||||||
# configuration
|
Configured via YAML file (`--config`). Key settings:
|
||||||
|
|
||||||
* access-control-allow-origin config
|
- `access_control_allow_origin` — CORS origin
|
||||||
* source host whitelist
|
- `allowlist_hosts` — list of allowed upstream hosts
|
||||||
* upstream fetch timeout
|
- `upstream_fetch_timeout` — timeout for origin requests
|
||||||
* upstream max response size
|
- `upstream_max_response_size` — max origin response size
|
||||||
* downstream timeout
|
- `downstream_timeout` — client response timeout
|
||||||
* downstream max request size
|
- `signing_key` — HMAC secret for URL signatures
|
||||||
* downstream max response size
|
|
||||||
* internal processing timeout
|
|
||||||
* referer blacklist
|
|
||||||
|
|
||||||
# Design Review & Recommendations
|
See `config.example.yml` for all options with defaults.
|
||||||
|
|
||||||
## Security Concerns
|
### Architecture
|
||||||
|
|
||||||
### Critical
|
- **Dependency injection**: Uber fx
|
||||||
- **HMAC signature scheme is undefined** - The "FIXME" for signature
|
- **HTTP router**: go-chi
|
||||||
construction is a blocker. Recommend HMAC-SHA256 over the full path:
|
- **Image processing**: govips (CGO wrapper for libvips)
|
||||||
`HMAC-SHA256(secret, "/<size>/<host>/<path>?format=<format>")`
|
- **Database**: SQLite via modernc.org/sqlite
|
||||||
- **No signature expiration** - Signatures should include a timestamp to
|
- **Static assets**: embedded via `//go:embed`
|
||||||
prevent indefinite replay. Add `&expires=<unix_ts>` and include it in the
|
- **Metrics**: Prometheus
|
||||||
HMAC input
|
- **Logging**: stdlib slog
|
||||||
- **Path traversal risk** - Ensure `<orig path>` cannot contain `..`
|
|
||||||
sequences or be used to access unintended resources on origin
|
|
||||||
- **SSRF potential** - Even with TLS requirement, internal/private IPs
|
|
||||||
(10.x, 172.16.x, 192.168.x, 127.x, ::1, link-local) must be blocked to
|
|
||||||
prevent server-side request forgery
|
|
||||||
- **Open redirect via Host header** - Validate that requests cannot be
|
|
||||||
manipulated to cache content under incorrect keys
|
|
||||||
|
|
||||||
### Important
|
## Entrypoints
|
||||||
- **No authentication for cache purge** - If cache invalidation is needed, it requires auth
|
|
||||||
- **Response header sanitization** - Strip sensitive headers from upstream before forwarding (X-Powered-By, Server, etc.)
|
|
||||||
- **Content-Type validation** - Verify upstream Content-Type matches expected image types before processing
|
|
||||||
- **Maximum image dimensions** - Limit output dimensions to prevent resource exhaustion (e.g., max 4096x4096)
|
|
||||||
|
|
||||||
## URL Route Improvements
|
This repository adheres to the
|
||||||
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
|
standard: normalized scripts in `script/` are the entrypoints for the
|
||||||
|
development workflow, and the Makefile targets are thin shims that call
|
||||||
|
them. We provide:
|
||||||
|
|
||||||
Current: `/img/<size>/<orig host>/<orig path>?signature=<sig>&format=<format>`
|
- `script/bootstrap` — install all dependencies (idempotent)
|
||||||
|
- `script/setup` — make a fresh clone ready for development
|
||||||
|
(bootstrap, then install-precommit)
|
||||||
|
- `script/projectname` — output the project name ("pixa")
|
||||||
|
- `script/test` — run the test suite
|
||||||
|
- `script/lint` — run golangci-lint
|
||||||
|
- `script/fmt` — format all code (writes)
|
||||||
|
- `script/fmt-check` — check formatting (read-only)
|
||||||
|
- `script/check` — run test, lint, and fmt-check
|
||||||
|
- `script/docker` — build the Docker image tagged via `script/projectname`
|
||||||
|
- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile
|
||||||
|
runs the checks, so a green build implies a green repo)
|
||||||
|
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then
|
||||||
|
`script/check`)
|
||||||
|
- `script/install-precommit` — install the git pre-commit hook that
|
||||||
|
runs `script/precommit`
|
||||||
|
|
||||||
### Recommended Scheme
|
## TODO
|
||||||
```
|
|
||||||
/v1/image/<host>/<path>/<width>x<height>.<format>?sig=<sig>&exp=<expires>
|
|
||||||
```
|
|
||||||
|
|
||||||
The size+format segment (e.g., `800x600.webp`) is appended to the source path and stripped when constructing the upstream request. This pattern is unambiguous (regex: `(\d+x\d+|orig)\.(webp|jpg|jpeg|png|avif)$`) and won't collide with real paths.
|
See [TODO.md](TODO.md) for the full prioritized task list.
|
||||||
|
|
||||||
**Size options:**
|
## License
|
||||||
- `800x600.<format>` - resize to 800x600
|
|
||||||
- `0x0.<format>` - original size, format conversion only
|
|
||||||
- `orig.<format>` - original size, format conversion only (human-friendly alias)
|
|
||||||
|
|
||||||
**Benefits:**
|
GPL-3.0. See [LICENSE](LICENSE).
|
||||||
- API versioning (`/v1/`) allows breaking changes later
|
|
||||||
- Human-readable URLs that can be manually constructed for whitelisted domains
|
|
||||||
- Format as extension is intuitive and CDN-friendly
|
|
||||||
|
|
||||||
### Examples
|
## Author
|
||||||
|
|
||||||
**Basic resize and convert:**
|
[@sneak](https://sneak.berlin)
|
||||||
```
|
|
||||||
/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=abc123&exp=1704067200
|
|
||||||
```
|
|
||||||
Fetches `https://cdn.example.com/photos/cat.jpg`, resizes to 800x600, converts to webp.
|
|
||||||
|
|
||||||
**Source URL with query parameters:**
|
|
||||||
```
|
|
||||||
/v1/image/cdn.example.com/photos/cat.jpg%3Farg1=val1%26arg2=val2/800x600.webp?sig=abc123&exp=1704067200
|
|
||||||
```
|
|
||||||
Fetches `https://cdn.example.com/photos/cat.jpg?arg1=val1&arg2=val2`, resizes to 800x600, converts to webp.
|
|
||||||
|
|
||||||
Note: The source query string must be URL-encoded (`?` → `%3F`, `&` → `%26`) to avoid ambiguity with pixa's own query parameters.
|
|
||||||
|
|
||||||
**Original size, format conversion only:**
|
|
||||||
```
|
|
||||||
/v1/image/cdn.example.com/photos/cat.jpg/orig.webp?sig=abc123&exp=1704067200
|
|
||||||
/v1/image/cdn.example.com/photos/cat.jpg/0x0.webp?sig=abc123&exp=1704067200
|
|
||||||
```
|
|
||||||
Both fetch the original image and convert to webp without resizing.
|
|
||||||
|
|
||||||
## Additional Formats
|
|
||||||
|
|
||||||
### Output Formats to Support
|
|
||||||
- `avif` - Superior compression, growing browser support
|
|
||||||
- `gif` - For animated image passthrough (with frame limit)
|
|
||||||
- `svg` - Passthrough only, no resizing (vector)
|
|
||||||
|
|
||||||
### Input Format Whitelist (MIME types to accept)
|
|
||||||
- `image/jpeg`
|
|
||||||
- `image/png`
|
|
||||||
- `image/webp`
|
|
||||||
- `image/gif`
|
|
||||||
- `image/avif`
|
|
||||||
- `image/svg+xml` (passthrough or rasterize)
|
|
||||||
- **Reject all others** - Especially `image/x-*`, `application/*`
|
|
||||||
|
|
||||||
### Input Validation
|
|
||||||
- Verify magic bytes match declared Content-Type
|
|
||||||
- Maximum input file size (e.g., 50MB)
|
|
||||||
- Maximum input dimensions (e.g., 16384x16384)
|
|
||||||
- Reject files with embedded scripts (SVG sanitization)
|
|
||||||
|
|
||||||
## Rate Limiting
|
|
||||||
|
|
||||||
### Per-IP Limits
|
|
||||||
- Requests per second (e.g., 10 req/s burst, 100 req/min sustained)
|
|
||||||
- Concurrent connections (e.g., 50 per IP)
|
|
||||||
|
|
||||||
### Global Limits
|
|
||||||
- Total concurrent upstream fetches (prevent origin overwhelm)
|
|
||||||
- Per-origin fetch rate limiting (be a good citizen)
|
|
||||||
- Cache miss rate limiting (prevent cache-busting attacks)
|
|
||||||
|
|
||||||
### Response
|
|
||||||
- Return `429 Too Many Requests` with `Retry-After` header
|
|
||||||
- Consider `X-RateLimit-*` headers for transparency
|
|
||||||
|
|
||||||
## Additional Features for 1.0
|
|
||||||
|
|
||||||
### Must Have
|
|
||||||
- **Health check endpoint** - `/health` or `/healthz` for load balancers
|
|
||||||
- **Metrics endpoint** - `/metrics` (Prometheus format) for observability
|
|
||||||
- **Graceful shutdown** - Drain connections on SIGTERM
|
|
||||||
- **Request ID/tracing** - `X-Request-ID` header propagation
|
|
||||||
- **Cache-Control headers** - Proper `Cache-Control`, `ETag`, `Last-Modified` on responses
|
|
||||||
- **Vary header** - `Vary: Accept` if doing content negotiation
|
|
||||||
|
|
||||||
### Should Have
|
|
||||||
- **Auto-format selection** - If `format=auto`, pick best format based on `Accept` header
|
|
||||||
- **Quality parameter** - `&q=85` for lossy format quality control
|
|
||||||
- **Fit modes** - `fit=cover|contain|fill|inside|outside` for resize behavior
|
|
||||||
- **Background color** - For transparent-to-JPEG conversion
|
|
||||||
- **Blur/sharpen** - Common post-resize operations
|
|
||||||
- **Watermarking** - Optional overlay support
|
|
||||||
|
|
||||||
### Nice to Have
|
|
||||||
- **Cache warming API** - Pre-populate cache for known images
|
|
||||||
- **Cache stats API** - Hit/miss rates, storage usage
|
|
||||||
- **Admin UI** - Simple dashboard for monitoring
|
|
||||||
|
|
||||||
## Configuration Additions
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
server:
|
|
||||||
listen: ":8080"
|
|
||||||
read_timeout: 30s
|
|
||||||
write_timeout: 60s
|
|
||||||
max_header_bytes: 8192
|
|
||||||
|
|
||||||
cache:
|
|
||||||
directory: "/var/cache/pixa"
|
|
||||||
max_size_gb: 100
|
|
||||||
ttl: 168h # 7 days
|
|
||||||
negative_ttl: 5m # Cache 404s briefly
|
|
||||||
|
|
||||||
upstream:
|
|
||||||
timeout: 30s
|
|
||||||
max_response_size: 52428800 # 50MB
|
|
||||||
max_concurrent: 100
|
|
||||||
user_agent: "Pixa/1.0"
|
|
||||||
|
|
||||||
processing:
|
|
||||||
max_input_pixels: 268435456 # 16384x16384
|
|
||||||
max_output_dimension: 4096
|
|
||||||
default_quality: 85
|
|
||||||
strip_metadata: true # Remove EXIF etc.
|
|
||||||
|
|
||||||
security:
|
|
||||||
hmac_secret: "${PIXA_HMAC_SECRET}" # From env
|
|
||||||
signature_ttl: 3600 # 1 hour
|
|
||||||
blocked_networks:
|
|
||||||
- "10.0.0.0/8"
|
|
||||||
- "172.16.0.0/12"
|
|
||||||
- "192.168.0.0/16"
|
|
||||||
- "127.0.0.0/8"
|
|
||||||
- "::1/128"
|
|
||||||
- "fc00::/7"
|
|
||||||
|
|
||||||
rate_limit:
|
|
||||||
per_ip_rps: 10
|
|
||||||
per_ip_burst: 50
|
|
||||||
per_origin_rps: 100
|
|
||||||
|
|
||||||
cors:
|
|
||||||
allowed_origins: ["*"] # Or specific list
|
|
||||||
allowed_methods: ["GET", "HEAD", "OPTIONS"]
|
|
||||||
max_age: 86400
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
### HTTP Status Codes
|
|
||||||
- `400` - Bad request (invalid parameters, malformed URL)
|
|
||||||
- `403` - Forbidden (invalid/expired signature, blocked origin)
|
|
||||||
- `404` - Origin returned 404 (cache negative response briefly)
|
|
||||||
- `413` - Payload too large (origin image exceeds limits)
|
|
||||||
- `415` - Unsupported media type (origin returned non-image)
|
|
||||||
- `422` - Unprocessable (valid image but cannot transform as requested)
|
|
||||||
- `429` - Rate limited
|
|
||||||
- `500` - Internal error
|
|
||||||
- `502` - Bad gateway (origin connection failed)
|
|
||||||
- `503` - Service unavailable (overloaded)
|
|
||||||
- `504` - Gateway timeout (origin timeout)
|
|
||||||
|
|
||||||
### Error Response Format
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "invalid_signature",
|
|
||||||
"message": "Signature has expired",
|
|
||||||
"request_id": "abc123"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick Wins
|
|
||||||
|
|
||||||
1. **Conditional requests** - Support `If-None-Match` / `If-Modified-Since` to return `304 Not Modified`
|
|
||||||
2. **HEAD support** - Allow clients to check image metadata without downloading
|
|
||||||
3. **Canonical URLs** - Redirect non-canonical requests to prevent cache fragmentation
|
|
||||||
4. **Debug header** - `X-Pixa-Cache: HIT|MISS|STALE` for debugging
|
|
||||||
5. **Robots.txt** - Serve a robots.txt to prevent search engine crawling of proxy URLs
|
|
||||||
|
|||||||
408
REPO_POLICIES.md
Normal file
408
REPO_POLICIES.md
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
---
|
||||||
|
title: Repository Policies
|
||||||
|
last_modified: 2026-07-06
|
||||||
|
---
|
||||||
|
|
||||||
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
|
style conventions are in separate documents:
|
||||||
|
|
||||||
|
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
|
||||||
|
(general, bash, Docker)
|
||||||
|
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
|
||||||
|
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
|
||||||
|
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
|
||||||
|
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- Cross-project documentation (such as this file) must include
|
||||||
|
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
|
||||||
|
with the authoritative source as policies evolve.
|
||||||
|
|
||||||
|
- **ALL external references must be pinned by cryptographic hash.** This
|
||||||
|
includes Docker base images, Go modules, npm packages, GitHub Actions, and
|
||||||
|
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
|
||||||
|
`:3.21`, etc.) are server-mutable and therefore remote code execution
|
||||||
|
vulnerabilities. The ONLY acceptable way to reference an external dependency
|
||||||
|
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
|
||||||
|
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
|
||||||
|
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
|
||||||
|
etc. Instead, download a specific release archive from GitHub, verify its hash
|
||||||
|
(hardcoded in the Dockerfile or script), and only then install. Unverified
|
||||||
|
install scripts are arbitrary remote code execution. This is the single most
|
||||||
|
important rule in this document. Double-check every external reference in
|
||||||
|
every file before committing. There are zero exceptions to this rule.
|
||||||
|
|
||||||
|
- Every repo with software must have a root `Makefile` with these targets:
|
||||||
|
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
|
||||||
|
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
|
||||||
|
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
|
||||||
|
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
|
||||||
|
|
||||||
|
- Repos follow the
|
||||||
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
|
pattern: the implementation of each Makefile target lives in an executable
|
||||||
|
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
|
||||||
|
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
|
||||||
|
`script/docker`), and the Makefile targets are thin shims that call them. The
|
||||||
|
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
|
||||||
|
minimal containers (e.g. alpine images have no bash); locate the repo root
|
||||||
|
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
|
||||||
|
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
|
||||||
|
for development after a fresh clone: runs `bootstrap`, then
|
||||||
|
`install-precommit`, plus any repo-specific initialization), `test`, and
|
||||||
|
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
|
||||||
|
assumes nothing is present: base tools come from nix, apt, brew, or apk
|
||||||
|
(detected in that order; apt runs noninteractive). For node it uses the
|
||||||
|
installed node if present; otherwise it installs a PINNED node version via
|
||||||
|
nvm, first installing nvm itself if missing — from a hash-verified GitHub
|
||||||
|
release archive (never `curl | sh`), with bash installed as an explicit
|
||||||
|
prerequisite since nvm requires bash. yarn is then pinned via
|
||||||
|
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
||||||
|
always exact versions. `script/cibuild` runs the CI build: it changes to the
|
||||||
|
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
|
||||||
|
scripts are our own extensions to the standard: `script/check` runs
|
||||||
|
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
|
||||||
|
what the git pre-commit hook runs, and it calls `script/check`;
|
||||||
|
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
|
||||||
|
target shims to it); and `script/projectname` (literally that filename) simply
|
||||||
|
outputs the project's name. Scripts that need the name call
|
||||||
|
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
|
||||||
|
so those scripts stay byte-identical across all repos. Repo-type-specific
|
||||||
|
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
|
||||||
|
`script/precommit`, not in the hook itself. Model scripts are at
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
|
||||||
|
must document the provided scripts in an **Entrypoints** section (see the
|
||||||
|
README requirements below).
|
||||||
|
|
||||||
|
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
||||||
|
instead of invoking the underlying tools directly. The Makefile is the single
|
||||||
|
source of truth for how these operations are run.
|
||||||
|
|
||||||
|
- The Makefile is authoritative documentation for how the repo is used. Beyond
|
||||||
|
the required targets above, it should have targets for every common operation:
|
||||||
|
running a local development server (`make run`, `make dev`), re-initializing
|
||||||
|
or migrating the database (`make db-reset`, `make migrate`), building
|
||||||
|
artifacts (`make build`), generating code, seeding data, or anything else a
|
||||||
|
developer would do regularly. If someone checks out the repo and types
|
||||||
|
`make<tab>`, they should see every meaningful operation available. A new
|
||||||
|
contributor should be able to understand the entire development workflow by
|
||||||
|
reading the Makefile.
|
||||||
|
|
||||||
|
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
|
||||||
|
as a build step so the build fails if the branch is not green. For non-server
|
||||||
|
repos, the Dockerfile should bring up a development environment and run
|
||||||
|
`make check`. For server repos, `make check` should run as an early build
|
||||||
|
stage before the final image is assembled. Dockerfiles install development
|
||||||
|
prerequisites by running `script/bootstrap` rather than duplicating installs
|
||||||
|
inline; COPY `script/` and the dependency manifests (`package.json` +
|
||||||
|
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
|
||||||
|
layer stays cached until dependencies change.
|
||||||
|
|
||||||
|
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
|
||||||
|
repos use a multistage build where linting runs in an independent stage based
|
||||||
|
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
|
||||||
|
`make fmt-check` and `make lint` before the full build begins. The build stage
|
||||||
|
then declares an explicit dependency on the lint stage via
|
||||||
|
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
|
||||||
|
linting before proceeding to compilation and tests. This ensures lint failures
|
||||||
|
surface in seconds rather than minutes, without blocking on dependency
|
||||||
|
download or compilation in the build stage.
|
||||||
|
|
||||||
|
The standard pattern for a Go repo Dockerfile is:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Lint stage — fast feedback on formatting and lint issues
|
||||||
|
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
||||||
|
FROM golangci/golangci-lint@sha256:... AS lint
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN make fmt-check
|
||||||
|
RUN make lint
|
||||||
|
|
||||||
|
# Build stage
|
||||||
|
# golang:1.x-alpine, YYYY-MM-DD
|
||||||
|
FROM golang@sha256:... AS builder
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Force BuildKit to run the lint stage before proceeding
|
||||||
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN make test
|
||||||
|
|
||||||
|
ARG VERSION=dev
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath \
|
||||||
|
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||||
|
-o /app ./cmd/app/
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM alpine@sha256:...
|
||||||
|
COPY --from=builder /app /usr/local/bin/app
|
||||||
|
ENTRYPOINT ["app"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
- The lint stage uses the `golangci/golangci-lint` image directly (it
|
||||||
|
includes both Go and the linter), so there is no need to install the
|
||||||
|
linter separately.
|
||||||
|
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
|
||||||
|
a stage dependency. BuildKit runs stages in parallel by default; without
|
||||||
|
this line, the build stage would not wait for lint to finish and a lint
|
||||||
|
failure might not fail the overall build.
|
||||||
|
- If the project uses `//go:embed` directives that reference build artifacts
|
||||||
|
(e.g. a web frontend compiled in a separate stage), the lint stage must
|
||||||
|
create placeholder files so the embed directives resolve. Example:
|
||||||
|
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
||||||
|
The lint stage should not depend on the actual build output — it exists to
|
||||||
|
fail fast.
|
||||||
|
- If the project requires CGO or system libraries for linting (e.g.
|
||||||
|
`vips-dev`), install them in the lint stage with `apk add`.
|
||||||
|
- The build stage runs `make test` after compilation setup. Tests run in the
|
||||||
|
build stage, not the lint stage, because they may require compiled
|
||||||
|
artifacts or heavier dependencies.
|
||||||
|
|
||||||
|
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
||||||
|
runs `script/cibuild` (which runs `docker build .`) on push. Since the
|
||||||
|
Dockerfile already runs `make check`, a successful build implies all checks
|
||||||
|
pass.
|
||||||
|
|
||||||
|
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||||
|
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||||
|
two exceptions: four-space indents (except Go), and `proseWrap: always` for
|
||||||
|
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
|
||||||
|
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
|
||||||
|
|
||||||
|
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
|
||||||
|
testing is not possible in the repo, `script/precommit` may skip `script/test`
|
||||||
|
and run only `script/lint` and `script/fmt-check`. The hook is installed by
|
||||||
|
`script/install-precommit`; the Makefile must provide a `make hooks` target
|
||||||
|
that shims to it.
|
||||||
|
|
||||||
|
- All repos with software must have tests that run via the platform-standard
|
||||||
|
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
|
||||||
|
tests exist yet, add the most minimal test possible — e.g. importing the
|
||||||
|
module under test to verify it compiles/parses. There is no excuse for
|
||||||
|
`make test` to be a no-op.
|
||||||
|
|
||||||
|
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
|
||||||
|
Makefile.
|
||||||
|
|
||||||
|
- **`make test` should use the conditional verbose rerun pattern.** Run tests
|
||||||
|
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
|
||||||
|
show full output. This keeps CI logs and `docker build` output clean on
|
||||||
|
success (just package/suite summaries) while providing full diagnostic detail
|
||||||
|
on failure (every test case, every assertion). The general shell pattern:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
test:
|
||||||
|
@<test-command> || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
<test-command-with-v>; exit 1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Go example:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
test:
|
||||||
|
@go test -timeout 30s -race -cover ./... || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
go test -timeout 30s -race -v ./...; exit 1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Python example:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
test:
|
||||||
|
@python -m pytest || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
python -m pytest -v; exit 1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
The `exit 1` ensures the target always fails after a rerun — the first run
|
||||||
|
already proved the tests are broken, so the build must not pass even if a
|
||||||
|
flaky test happens to succeed on the second attempt. The rerun exists solely
|
||||||
|
for diagnostic output.
|
||||||
|
|
||||||
|
- Docker builds must complete in under 5 minutes.
|
||||||
|
|
||||||
|
- `make check` must not modify any files in the repo. Tests may use temporary
|
||||||
|
directories.
|
||||||
|
|
||||||
|
- `main` must always pass `make check`, no exceptions.
|
||||||
|
|
||||||
|
- Never commit secrets. `.env` files, credentials, API keys, and private keys
|
||||||
|
must be in `.gitignore`. No exceptions.
|
||||||
|
|
||||||
|
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||||
|
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
||||||
|
Fetch the standard `.gitignore` from
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||||
|
a new repo.
|
||||||
|
|
||||||
|
- **No build artifacts in version control.** Code-derived data (compiled
|
||||||
|
bundles, minified output, generated assets) must never be committed to the
|
||||||
|
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
|
||||||
|
should generate these at build time. Notable exception: Go protobuf generated
|
||||||
|
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
|
||||||
|
downloads code but does not execute code generation.
|
||||||
|
|
||||||
|
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
||||||
|
|
||||||
|
- Never force-push to `main`.
|
||||||
|
|
||||||
|
- Make all changes on a feature branch. You can do whatever you want on a
|
||||||
|
feature branch.
|
||||||
|
|
||||||
|
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
||||||
|
manually by the user. Fetch from
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
|
||||||
|
|
||||||
|
- When pinning images or packages by hash, add a comment above the reference
|
||||||
|
with the version and date (YYYY-MM-DD).
|
||||||
|
|
||||||
|
- Use `yarn`, not `npm`.
|
||||||
|
|
||||||
|
- Write all dates as YYYY-MM-DD (ISO 8601).
|
||||||
|
|
||||||
|
- Simple projects should be configured with environment variables.
|
||||||
|
|
||||||
|
- Dockerized web services listen on port 8080 by default, overridable with
|
||||||
|
`PORT`.
|
||||||
|
|
||||||
|
- **HTTP/web services must be hardened for production internet exposure before
|
||||||
|
tagging 1.0.** This means full compliance with security best practices
|
||||||
|
including, without limitation, all of the following:
|
||||||
|
- **Security headers** on every response:
|
||||||
|
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
|
||||||
|
and `includeSubDomains`.
|
||||||
|
- `Content-Security-Policy` (CSP) with a restrictive default policy
|
||||||
|
(`default-src 'self'` as a baseline, tightened per-resource as
|
||||||
|
needed). Never use `unsafe-inline` or `unsafe-eval` unless
|
||||||
|
unavoidable, and document the reason.
|
||||||
|
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
|
||||||
|
Prefer the `frame-ancestors` CSP directive as the primary control.
|
||||||
|
- `X-Content-Type-Options: nosniff`.
|
||||||
|
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
|
||||||
|
- `Permissions-Policy` restricting access to browser features the
|
||||||
|
application does not use (camera, microphone, geolocation, etc.).
|
||||||
|
- **Request and response limits:**
|
||||||
|
- Maximum request body size enforced on all endpoints (e.g. Go
|
||||||
|
`http.MaxBytesReader`). Choose a sane default per-route; never accept
|
||||||
|
unbounded input.
|
||||||
|
- Maximum response body size where applicable (e.g. paginated APIs).
|
||||||
|
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
|
||||||
|
against slowloris attacks.
|
||||||
|
- `WriteTimeout` on the `http.Server`.
|
||||||
|
- `IdleTimeout` on the `http.Server`.
|
||||||
|
- Per-handler execution time limits via `context.WithTimeout` or
|
||||||
|
chi/stdlib `middleware.Timeout`.
|
||||||
|
- **Authentication and session security:**
|
||||||
|
- Rate limiting on password-based authentication endpoints. API keys are
|
||||||
|
high-entropy and not susceptible to brute force, so they are exempt.
|
||||||
|
- CSRF tokens on all state-mutating HTML forms. API endpoints
|
||||||
|
authenticated via `Authorization` header (Bearer token, API key) are
|
||||||
|
exempt because the browser does not attach these automatically.
|
||||||
|
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
|
||||||
|
MD5, or SHA.
|
||||||
|
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
|
||||||
|
`Strict`) attributes.
|
||||||
|
- **Reverse proxy awareness:**
|
||||||
|
- True client IP detection when behind a reverse proxy
|
||||||
|
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
|
||||||
|
forwarded headers only from a configured set of trusted proxy
|
||||||
|
addresses — never trust `X-Forwarded-For` unconditionally.
|
||||||
|
- **CORS:**
|
||||||
|
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
|
||||||
|
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
|
||||||
|
only for public, unauthenticated read-only APIs.
|
||||||
|
- **Error handling:**
|
||||||
|
- Internal errors must never leak stack traces, SQL queries, file paths,
|
||||||
|
or other implementation details to the client. Return generic error
|
||||||
|
messages in production; detailed errors only when `DEBUG` is enabled.
|
||||||
|
- **TLS:**
|
||||||
|
- Services never terminate TLS directly. They are always deployed behind
|
||||||
|
a TLS-terminating reverse proxy. The service itself listens on plain
|
||||||
|
HTTP. However, HSTS headers and `Secure` cookie flags must still be
|
||||||
|
set by the application so that the browser enforces HTTPS end-to-end.
|
||||||
|
|
||||||
|
This list is non-exhaustive. Apply defense-in-depth: if a standard security
|
||||||
|
hardening measure exists for HTTP services and is not listed here, it is
|
||||||
|
still expected. When in doubt, harden.
|
||||||
|
|
||||||
|
- `README.md` is the primary documentation. Required sections:
|
||||||
|
- **Description**: First line must include the project name, purpose,
|
||||||
|
category (web server, SPA, CLI tool, etc.), license, and author. Example:
|
||||||
|
"µPaaS is an MIT-licensed Go web application by @sneak that receives
|
||||||
|
git-frontend webhooks and deploys applications via Docker in realtime."
|
||||||
|
- **Getting Started**: Copy-pasteable install/usage code block.
|
||||||
|
- **Entrypoints**: Opens by stating that the repo adheres to the
|
||||||
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
|
standard (with that link), then documents each provided `script/`
|
||||||
|
entrypoint and its purpose.
|
||||||
|
- **Rationale**: Why does this exist?
|
||||||
|
- **Design**: How is the program structured?
|
||||||
|
- **TODO**: Update meticulously, even between commits. When planning, put
|
||||||
|
the todo list in the README so a new agent can pick up where the last one
|
||||||
|
left off.
|
||||||
|
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
|
||||||
|
`LICENSE` file in the repo root and a License section in the README.
|
||||||
|
- **Author**: [@sneak](https://sneak.berlin).
|
||||||
|
|
||||||
|
- First commit of a new repo should contain only `README.md`.
|
||||||
|
|
||||||
|
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
|
||||||
|
committing.
|
||||||
|
|
||||||
|
- Use SemVer.
|
||||||
|
|
||||||
|
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
||||||
|
the binary.
|
||||||
|
- `000_migration.sql` — contains ONLY the creation of the migrations
|
||||||
|
tracking table itself. Nothing else.
|
||||||
|
- `001_schema.sql` — the full application schema.
|
||||||
|
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
|
||||||
|
There is no installed base to migrate. Edit `001_schema.sql` directly.
|
||||||
|
- **Post-1.0.0:** add new numbered migration files for each schema change.
|
||||||
|
Never edit existing migrations after release.
|
||||||
|
|
||||||
|
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||||
|
settings.
|
||||||
|
|
||||||
|
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||||
|
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
|
||||||
|
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
|
||||||
|
language-specific config). Everything else goes in a subdirectory. Canonical
|
||||||
|
subdirectory names:
|
||||||
|
- `bin/` — executable scripts and tools
|
||||||
|
- `cmd/` — Go command entrypoints
|
||||||
|
- `configs/` — configuration templates and examples
|
||||||
|
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
||||||
|
- `docs/` — documentation and markdown (README.md stays in root)
|
||||||
|
- `internal/` — Go internal packages
|
||||||
|
- `internal/db/migrations/` — database migrations
|
||||||
|
- `pkg/` — Go library packages
|
||||||
|
- `share/` — systemd units, data files
|
||||||
|
- `static/` — static assets (images, fonts, etc.)
|
||||||
|
- `web/` — web frontend source
|
||||||
|
|
||||||
|
- When setting up a new repo, files from the `prompts` repo may be used as
|
||||||
|
templates. Fetch them from
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
|
||||||
|
|
||||||
|
- New repos must contain at minimum:
|
||||||
|
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
||||||
|
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
|
||||||
|
- `Makefile`
|
||||||
|
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
|
||||||
|
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
|
||||||
|
`install-precommit`)
|
||||||
|
- `Dockerfile`, `.dockerignore`
|
||||||
|
- `.gitea/workflows/check.yml`
|
||||||
|
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||||
|
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
||||||
|
- Python: `pyproject.toml`
|
||||||
167
TODO.md
167
TODO.md
@@ -1,65 +1,120 @@
|
|||||||
# Pixa 1.0 TODO
|
# Workflow
|
||||||
|
|
||||||
Remaining tasks sorted by priority for a working 1.0 release.
|
* branch (from `main`)
|
||||||
|
* do the work in Next Step
|
||||||
|
* move Next Step to the top of Completed Steps
|
||||||
|
* move the top item of Future Steps into Next Step
|
||||||
|
* commit (`TODO.md` changes in the same commit as the work)
|
||||||
|
* merge to `main` if the branch is not protected, otherwise open a PR
|
||||||
|
* push
|
||||||
|
|
||||||
## P0: Critical for 1.0
|
# Status
|
||||||
|
|
||||||
### Image Processing
|
pre-1.0. No git tags exist. Recent work extracted the internal/magic,
|
||||||
- [x] Add WebP encoding support (currently returns error)
|
internal/allowlist, internal/httpfetcher, and internal/signature
|
||||||
- [ ] Add AVIF encoding support (currently returns error)
|
packages. The gosec findings from the 2026-07-06 survey are resolved:
|
||||||
|
the last two open findings (G124, session cookie attributes in
|
||||||
|
internal/session) are fixed as of this change, so `make check` is green
|
||||||
|
on main.
|
||||||
|
|
||||||
### Manual Testing (verify auth/encrypted URLs work)
|
# Next Step
|
||||||
- [ ] Manual test: visit `/`, see login form
|
|
||||||
- [ ] Manual test: enter wrong key, see error
|
|
||||||
- [ ] Manual test: enter correct signing key, see generator form
|
|
||||||
- [ ] Manual test: generate encrypted URL, verify it works
|
|
||||||
- [ ] Manual test: wait for expiration or use short TTL, verify expired URL returns 410
|
|
||||||
- [ ] Manual test: logout, verify redirected to login
|
|
||||||
|
|
||||||
### Cache Management
|
P0: implement cache size management and eviction so the disk cannot
|
||||||
- [ ] Implement cache size management/eviction (prevent disk from filling up)
|
fill up
|
||||||
|
|
||||||
### Configuration
|
# Completed Steps
|
||||||
- [ ] Validate configuration on startup (fail fast on bad config)
|
|
||||||
|
|
||||||
## P1: Important for Production
|
- 2026-08-07 update golangci-lint to v2.12.2 with the canonical
|
||||||
|
`.golangci.yml` (v2 schema, `default: all` minus six disabled
|
||||||
|
linters, `lll` 88, tests included): bumped the pinned
|
||||||
|
`golangci/golangci-lint:v2.12.2-alpine` image in `Dockerfile` and the
|
||||||
|
release-archive sha256 pins in `script/bootstrap`; fixed all 747
|
||||||
|
findings the stricter config surfaced (notably `paralleltest`,
|
||||||
|
`wsl_v5`, `goconst`, `lll`, `noinlineerr`, `err113`, `errcheck`,
|
||||||
|
`testpackage` — white-box test files renamed to
|
||||||
|
`*_internal_test.go`); three `//nolint:tagliatelle` directives keep
|
||||||
|
the snake_case JSON wire/disk formats unchanged; `make check` green
|
||||||
|
- 2026-08-07 validate configuration on startup, fail fast on bad
|
||||||
|
config (closes #52): a config value that is set but unparseable or
|
||||||
|
invalid aborts startup naming the key and value (defaults apply only
|
||||||
|
to omitted keys), unknown config keys abort startup, a malformed
|
||||||
|
config file aborts instead of being skipped, and `state_dir` is
|
||||||
|
verified creatable and writable before the listener binds
|
||||||
|
- 2026-08-07 manual test pass of the auth and encrypted URL flows
|
||||||
|
against a locally built and running `pixad` (built from `main` at
|
||||||
|
`6573b9d`, port 18099, local throwaway config); all six checks
|
||||||
|
passed, plus all nine tests in `scripts/manual-test.sh` (closes #49):
|
||||||
|
- [x] visit `/` and see the login form: HTTP 200, `Pixa - Login`
|
||||||
|
page with `name="key"` password form
|
||||||
|
- [x] wrong key shows an error: POST `/` with `key=wrong-key`
|
||||||
|
returned HTTP 200 login page containing "Invalid signing key"
|
||||||
|
- [x] correct signing key shows the generator form: POST `/`
|
||||||
|
returned HTTP 303 to `/` with
|
||||||
|
`Set-Cookie: pixa_session=...; HttpOnly; Secure; SameSite=Strict`;
|
||||||
|
GET `/` with that cookie rendered `Pixa - URL Generator` with the
|
||||||
|
`/generate` form and logout link
|
||||||
|
- [x] a generated encrypted URL serves the image: POST `/generate`
|
||||||
|
(ttl=3600) produced a `/v1/e/<token>/img.jpeg` URL that returned
|
||||||
|
HTTP 200, `Content-Type: image/jpeg`, an 800x600 baseline JPEG of
|
||||||
|
61706 bytes
|
||||||
|
- [x] an expired URL (short TTL) returns 410: a ttl=1 URL fetched
|
||||||
|
after 3 s returned HTTP 410 Gone with
|
||||||
|
`{"error":"URL has expired","status":410,...}`
|
||||||
|
- [x] logout redirects back to login: GET `/logout` returned HTTP
|
||||||
|
303 to `/` with `Set-Cookie: pixa_session=; Max-Age=0`;
|
||||||
|
subsequent GET `/` rendered the login form again
|
||||||
|
- 2026-08-07 fix the two remaining gosec findings (G124 in
|
||||||
|
internal/session): session cookies now always carry
|
||||||
|
Secure/HttpOnly/SameSite=Strict on both the set and clear paths;
|
||||||
|
`make check` green (closes #47)
|
||||||
|
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
||||||
|
Makefile shims, README Entrypoints section
|
||||||
|
- 2026-04-07 extract magic byte detection into internal/magic (#42)
|
||||||
|
- 2026-03-25 extract allowlist package from internal/imgcache (#41)
|
||||||
|
- 2026-03-25 move schema_migrations table creation into 000.sql (#36)
|
||||||
|
- 2026-03-20 enforce and document exact-match-only signature
|
||||||
|
verification (#40)
|
||||||
|
- 2026-03-20 bound imageprocessor.Process input read to prevent
|
||||||
|
unbounded memory use (#37); consolidate appname into an
|
||||||
|
internal/globals constant (#34)
|
||||||
|
- 2026-03-18 parse version prefix from migration filenames (#33)
|
||||||
|
- 2026-03-15 QA audit fixes for 1.0/MVP readiness (#25)
|
||||||
|
- 2026-03-02 split Dockerfile with pre-built golangci-lint stage for
|
||||||
|
faster CI (#23)
|
||||||
|
- 2026-02-25 repo policy compliance: CI workflow, hash-pinned images,
|
||||||
|
golangci-lint and gosec fixes of that date (#14); arm64 Docker build
|
||||||
|
fix (#16)
|
||||||
|
- 2026-01-08 WebP and AVIF encoding support via govips (both former P0
|
||||||
|
image processing items, now done)
|
||||||
|
|
||||||
### Security
|
# Future Steps
|
||||||
- [ ] Implement blocked networks configuration (extend SSRF protection)
|
|
||||||
- [ ] Add rate limiting global concurrent fetches (prevent resource exhaustion)
|
|
||||||
|
|
||||||
### Image Processing
|
- P1: implement blocked networks configuration to extend SSRF
|
||||||
- [ ] Implement EXIF/metadata stripping (privacy)
|
protection
|
||||||
|
- P1: rate limit global concurrent upstream fetches to prevent
|
||||||
## P2: Nice to Have
|
resource exhaustion
|
||||||
|
- P1: strip EXIF and other metadata from processed images (privacy)
|
||||||
### Security
|
- P2: security
|
||||||
- [ ] Implement referer blacklist
|
- referer blacklist
|
||||||
- [ ] Add rate limiting per-IP
|
- per-IP rate limiting
|
||||||
- [ ] Add rate limiting per-origin
|
- per-origin rate limiting
|
||||||
|
- P2: HTTP response handling
|
||||||
### HTTP Response Handling
|
- Last-Modified headers
|
||||||
- [ ] Implement Last-Modified headers
|
- Vary header for content negotiation
|
||||||
- [ ] Implement Vary header for content negotiation
|
- X-Request-ID propagation
|
||||||
- [ ] Implement X-Request-ID propagation
|
- P2: auto format selection (format=auto based on Accept header)
|
||||||
|
- P2: configuration
|
||||||
### Additional Endpoints
|
- add all configuration options from README
|
||||||
- [ ] Implement auto-format selection (format=auto based on Accept header)
|
- environment variable overrides
|
||||||
|
- YAML config file support
|
||||||
### Configuration
|
- P2: operational
|
||||||
- [ ] Add all configuration options from README
|
- optional Sentry error reporting
|
||||||
- [ ] Implement environment variable overrides
|
- comprehensive request logging
|
||||||
- [ ] Implement YAML config file support
|
- Prometheus performance metrics
|
||||||
|
- integration tests for the image proxy flow
|
||||||
### Operational
|
- load tests to verify the 1k to 5k req/s target
|
||||||
- [ ] Implement Sentry error reporting (optional)
|
- P2: documentation
|
||||||
- [ ] Add comprehensive request logging
|
- configuration options
|
||||||
- [ ] Add performance metrics (Prometheus)
|
- API endpoints
|
||||||
- [ ] Write integration tests for image proxy flow
|
- deployment guide
|
||||||
- [ ] Write load tests to verify 1-5k req/s target
|
- example nginx or caddy reverse proxy config
|
||||||
|
|
||||||
### Documentation
|
|
||||||
- [ ] Document configuration options
|
|
||||||
- [ ] Document API endpoints
|
|
||||||
- [ ] Document deployment guide
|
|
||||||
- [ ] Add example nginx/caddy reverse proxy config
|
|
||||||
|
|||||||
@@ -17,10 +17,7 @@ import (
|
|||||||
"sneak.berlin/go/pixa/internal/server"
|
"sneak.berlin/go/pixa/internal/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var Version string //nolint:gochecknoglobals // set by ldflags
|
||||||
Appname = "pixad" //nolint:gochecknoglobals // set by ldflags
|
|
||||||
Version string //nolint:gochecknoglobals // set by ldflags
|
|
||||||
)
|
|
||||||
|
|
||||||
var configPath string //nolint:gochecknoglobals // cobra flag
|
var configPath string //nolint:gochecknoglobals // cobra flag
|
||||||
|
|
||||||
@@ -33,14 +30,14 @@ func main() {
|
|||||||
|
|
||||||
rootCmd.Flags().StringVarP(&configPath, "config", "c", "", "path to config file")
|
rootCmd.Flags().StringVarP(&configPath, "config", "c", "", "path to config file")
|
||||||
|
|
||||||
if err := rootCmd.Execute(); err != nil {
|
err := rootCmd.Execute()
|
||||||
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(_ *cobra.Command, _ []string) {
|
func run(_ *cobra.Command, _ []string) {
|
||||||
globals.Appname = Appname
|
|
||||||
globals.Version = Version
|
globals.Version = Version
|
||||||
|
|
||||||
// Set config path in environment if specified via flag
|
// Set config path in environment if specified via flag
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
# Development config for local Docker testing
|
|
||||||
signing_key: "dev-signing-key-minimum-32-chars!"
|
|
||||||
debug: true
|
|
||||||
allow_http: true
|
|
||||||
whitelist_hosts:
|
|
||||||
- localhost
|
|
||||||
- s3.sneak.cloud
|
|
||||||
- static.sneak.cloud
|
|
||||||
- sneak.berlin
|
|
||||||
- github.com
|
|
||||||
- user-images.githubusercontent.com
|
|
||||||
@@ -1,13 +1,37 @@
|
|||||||
# Pixa configuration
|
# Pixa Example Configuration
|
||||||
#
|
|
||||||
# REQUIRED: Set signing_key before starting the server.
|
|
||||||
# Generate with: openssl rand -base64 32
|
|
||||||
|
|
||||||
|
# Server settings
|
||||||
|
port: 8080
|
||||||
|
debug: false
|
||||||
|
maintenance_mode: false
|
||||||
|
|
||||||
|
# Data directory for SQLite database and cache files
|
||||||
|
state_dir: ./data
|
||||||
|
|
||||||
|
# Image proxy settings
|
||||||
|
# HMAC signing key for URL signatures (required, at least 32 characters)
|
||||||
|
# Generate with: openssl rand -base64 32
|
||||||
signing_key: "CHANGE_ME_generate_with_openssl_rand_base64_32"
|
signing_key: "CHANGE_ME_generate_with_openssl_rand_base64_32"
|
||||||
|
|
||||||
whitelist_hosts:
|
# Hosts that don't require signatures
|
||||||
|
# Use "." prefix for wildcard subdomain matching (e.g., ".example.com" matches "cdn.example.com")
|
||||||
|
allowlist_hosts:
|
||||||
- s3.sneak.cloud
|
- s3.sneak.cloud
|
||||||
- static.sneak.cloud
|
- static.sneak.cloud
|
||||||
- sneak.berlin
|
- sneak.berlin
|
||||||
- github.com
|
- github.com
|
||||||
- user-images.githubusercontent.com
|
- user-images.githubusercontent.com
|
||||||
|
|
||||||
|
# Allow HTTP upstream (only for testing, always use HTTPS in production)
|
||||||
|
allow_http: false
|
||||||
|
|
||||||
|
# Maximum concurrent connections per upstream host (default: 20)
|
||||||
|
upstream_connections_per_host: 20
|
||||||
|
|
||||||
|
# Sentry error reporting (optional)
|
||||||
|
sentry_dsn: ""
|
||||||
|
|
||||||
|
# Metrics endpoint authentication (optional)
|
||||||
|
# metrics:
|
||||||
|
# username: "admin"
|
||||||
|
# password: "secret"
|
||||||
|
|||||||
10
config.yaml
10
config.yaml
@@ -1,10 +0,0 @@
|
|||||||
debug: true
|
|
||||||
port: 8080
|
|
||||||
state_dir: ./data
|
|
||||||
signing_key: "test-signing-key-for-development-only"
|
|
||||||
whitelist_hosts:
|
|
||||||
- "*.example.com"
|
|
||||||
- "images.unsplash.com"
|
|
||||||
- "picsum.photos"
|
|
||||||
- "s3.sneak.cloud"
|
|
||||||
allow_http: false
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# Pixa Example Configuration
|
|
||||||
|
|
||||||
# Server settings
|
|
||||||
port: 8080
|
|
||||||
debug: false
|
|
||||||
maintenance_mode: false
|
|
||||||
|
|
||||||
# Data directory for SQLite database and cache files
|
|
||||||
state_dir: ./data
|
|
||||||
|
|
||||||
# Image proxy settings
|
|
||||||
# HMAC signing key for URL signatures (leave empty to require whitelist for all requests)
|
|
||||||
signing_key: "change-me-to-a-secure-random-string"
|
|
||||||
|
|
||||||
# Hosts that don't require signatures
|
|
||||||
# Use "." prefix for wildcard subdomain matching (e.g., ".example.com" matches "cdn.example.com")
|
|
||||||
whitelist_hosts:
|
|
||||||
- static.sneak.cloud
|
|
||||||
- sneak.berlin
|
|
||||||
- s3.sneak.cloud
|
|
||||||
|
|
||||||
# Allow HTTP upstream (only for testing, always use HTTPS in production)
|
|
||||||
allow_http: false
|
|
||||||
|
|
||||||
# Maximum concurrent connections per upstream host (default: 20)
|
|
||||||
upstream_connections_per_host: 20
|
|
||||||
|
|
||||||
# Sentry error reporting (optional)
|
|
||||||
sentry_dsn: ""
|
|
||||||
|
|
||||||
# Metrics endpoint authentication (optional)
|
|
||||||
# metrics:
|
|
||||||
# username: "admin"
|
|
||||||
# password: "secret"
|
|
||||||
@@ -1,25 +1,27 @@
|
|||||||
package imgcache
|
// Package allowlist provides host-based URL allow-listing for the image proxy.
|
||||||
|
package allowlist
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HostWhitelist implements the Whitelist interface for checking allowed source hosts.
|
// HostAllowList checks whether source hosts are permitted.
|
||||||
type HostWhitelist struct {
|
type HostAllowList struct {
|
||||||
// exactHosts contains hosts that must match exactly (e.g., "cdn.example.com")
|
// exactHosts contains hosts that must match exactly (e.g., "cdn.example.com")
|
||||||
exactHosts map[string]struct{}
|
exactHosts map[string]struct{}
|
||||||
// suffixHosts contains domain suffixes to match (e.g., ".example.com" matches "cdn.example.com")
|
// suffixHosts contains domain suffixes to match
|
||||||
|
// (e.g., ".example.com" matches "cdn.example.com")
|
||||||
suffixHosts []string
|
suffixHosts []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHostWhitelist creates a whitelist from a list of host patterns.
|
// New creates a HostAllowList from a list of host patterns.
|
||||||
// Patterns starting with "." are treated as suffix matches.
|
// Patterns starting with "." are treated as suffix matches.
|
||||||
// Examples:
|
// Examples:
|
||||||
// - "cdn.example.com" - exact match only
|
// - "cdn.example.com" - exact match only
|
||||||
// - ".example.com" - matches cdn.example.com, images.example.com, etc.
|
// - ".example.com" - matches cdn.example.com, images.example.com, etc.
|
||||||
func NewHostWhitelist(patterns []string) *HostWhitelist {
|
func New(patterns []string) *HostAllowList {
|
||||||
w := &HostWhitelist{
|
w := &HostAllowList{
|
||||||
exactHosts: make(map[string]struct{}),
|
exactHosts: make(map[string]struct{}),
|
||||||
suffixHosts: make([]string, 0),
|
suffixHosts: make([]string, 0),
|
||||||
}
|
}
|
||||||
@@ -40,8 +42,8 @@ func NewHostWhitelist(patterns []string) *HostWhitelist {
|
|||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsWhitelisted checks if a URL's host is in the whitelist.
|
// IsAllowed checks if a URL's host is in the allow list.
|
||||||
func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool {
|
func (w *HostAllowList) IsAllowed(u *url.URL) bool {
|
||||||
if u == nil {
|
if u == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -71,12 +73,12 @@ func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsEmpty returns true if the whitelist has no entries.
|
// IsEmpty returns true if the allow list has no entries.
|
||||||
func (w *HostWhitelist) IsEmpty() bool {
|
func (w *HostAllowList) IsEmpty() bool {
|
||||||
return len(w.exactHosts) == 0 && len(w.suffixHosts) == 0
|
return len(w.exactHosts) == 0 && len(w.suffixHosts) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count returns the total number of whitelist entries.
|
// Count returns the total number of allow list entries.
|
||||||
func (w *HostWhitelist) Count() int {
|
func (w *HostAllowList) Count() int {
|
||||||
return len(w.exactHosts) + len(w.suffixHosts)
|
return len(w.exactHosts) + len(w.suffixHosts)
|
||||||
}
|
}
|
||||||
@@ -1,119 +1,148 @@
|
|||||||
package imgcache
|
package allowlist_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/allowlist"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHostWhitelist_IsWhitelisted(t *testing.T) {
|
const (
|
||||||
tests := []struct {
|
testExactHost = "cdn.example.com"
|
||||||
name string
|
testImageURL = "https://cdn.example.com/image.jpg"
|
||||||
patterns []string
|
testSuffix = ".example.com"
|
||||||
testURL string
|
)
|
||||||
want bool
|
|
||||||
}{
|
type isAllowedCase struct {
|
||||||
|
name string
|
||||||
|
patterns []string
|
||||||
|
testURL string
|
||||||
|
want bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func runIsAllowedCases(t *testing.T, tests []isAllowedCase) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
w := allowlist.New(tt.patterns)
|
||||||
|
|
||||||
|
var u *url.URL
|
||||||
|
|
||||||
|
if tt.testURL != "" {
|
||||||
|
parsed, err := url.Parse(tt.testURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse test URL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
u = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
got := w.IsAllowed(u)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("IsAllowed() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostAllowList_IsAllowed_ExactMatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runIsAllowedCases(t, []isAllowedCase{
|
||||||
{
|
{
|
||||||
name: "exact match",
|
name: "exact match",
|
||||||
patterns: []string{"cdn.example.com"},
|
patterns: []string{testExactHost},
|
||||||
testURL: "https://cdn.example.com/image.jpg",
|
testURL: testImageURL,
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "exact match case insensitive",
|
name: "exact match case insensitive",
|
||||||
patterns: []string{"CDN.Example.COM"},
|
patterns: []string{"CDN.Example.COM"},
|
||||||
testURL: "https://cdn.example.com/image.jpg",
|
testURL: testImageURL,
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "exact match not found",
|
name: "exact match not found",
|
||||||
patterns: []string{"cdn.example.com"},
|
patterns: []string{testExactHost},
|
||||||
testURL: "https://other.example.com/image.jpg",
|
testURL: "https://other.example.com/image.jpg",
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "suffix match",
|
|
||||||
patterns: []string{".example.com"},
|
|
||||||
testURL: "https://cdn.example.com/image.jpg",
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "suffix match deep subdomain",
|
|
||||||
patterns: []string{".example.com"},
|
|
||||||
testURL: "https://cdn.images.example.com/image.jpg",
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "suffix match apex domain",
|
|
||||||
patterns: []string{".example.com"},
|
|
||||||
testURL: "https://example.com/image.jpg",
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "suffix match not found",
|
|
||||||
patterns: []string{".example.com"},
|
|
||||||
testURL: "https://notexample.com/image.jpg",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "suffix match partial not allowed",
|
|
||||||
patterns: []string{".example.com"},
|
|
||||||
testURL: "https://fakeexample.com/image.jpg",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "multiple patterns",
|
name: "multiple patterns",
|
||||||
patterns: []string{"cdn.example.com", ".images.org", "static.test.net"},
|
patterns: []string{testExactHost, ".images.org", "static.test.net"},
|
||||||
testURL: "https://photos.images.org/image.jpg",
|
testURL: "https://photos.images.org/image.jpg",
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty whitelist",
|
name: "empty allow list",
|
||||||
patterns: []string{},
|
patterns: []string{},
|
||||||
testURL: "https://cdn.example.com/image.jpg",
|
testURL: testImageURL,
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "nil url",
|
name: "nil url",
|
||||||
patterns: []string{"cdn.example.com"},
|
patterns: []string{testExactHost},
|
||||||
testURL: "",
|
testURL: "",
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "url with port",
|
name: "url with port",
|
||||||
patterns: []string{"cdn.example.com"},
|
patterns: []string{testExactHost},
|
||||||
testURL: "https://cdn.example.com:443/image.jpg",
|
testURL: "https://cdn.example.com:443/image.jpg",
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "whitespace in patterns",
|
name: "whitespace in patterns",
|
||||||
patterns: []string{" cdn.example.com ", " .other.com "},
|
patterns: []string{" cdn.example.com ", " .other.com "},
|
||||||
testURL: "https://cdn.example.com/image.jpg",
|
testURL: testImageURL,
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
w := NewHostWhitelist(tt.patterns)
|
|
||||||
|
|
||||||
var u *url.URL
|
|
||||||
if tt.testURL != "" {
|
|
||||||
var err error
|
|
||||||
u, err = url.Parse(tt.testURL)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to parse test URL: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
got := w.IsWhitelisted(u)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("IsWhitelisted() = %v, want %v", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHostWhitelist_IsEmpty(t *testing.T) {
|
func TestHostAllowList_IsAllowed_SuffixMatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runIsAllowedCases(t, []isAllowedCase{
|
||||||
|
{
|
||||||
|
name: "suffix match",
|
||||||
|
patterns: []string{testSuffix},
|
||||||
|
testURL: testImageURL,
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "suffix match deep subdomain",
|
||||||
|
patterns: []string{testSuffix},
|
||||||
|
testURL: "https://cdn.images.example.com/image.jpg",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "suffix match apex domain",
|
||||||
|
patterns: []string{testSuffix},
|
||||||
|
testURL: "https://example.com/image.jpg",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "suffix match not found",
|
||||||
|
patterns: []string{testSuffix},
|
||||||
|
testURL: "https://notexample.com/image.jpg",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "suffix match partial not allowed",
|
||||||
|
patterns: []string{testSuffix},
|
||||||
|
testURL: "https://fakeexample.com/image.jpg",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostAllowList_IsEmpty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
patterns []string
|
patterns []string
|
||||||
@@ -143,7 +172,9 @@ func TestHostWhitelist_IsEmpty(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
w := NewHostWhitelist(tt.patterns)
|
t.Parallel()
|
||||||
|
|
||||||
|
w := allowlist.New(tt.patterns)
|
||||||
if got := w.IsEmpty(); got != tt.want {
|
if got := w.IsEmpty(); got != tt.want {
|
||||||
t.Errorf("IsEmpty() = %v, want %v", got, tt.want)
|
t.Errorf("IsEmpty() = %v, want %v", got, tt.want)
|
||||||
}
|
}
|
||||||
@@ -151,7 +182,9 @@ func TestHostWhitelist_IsEmpty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHostWhitelist_Count(t *testing.T) {
|
func TestHostAllowList_Count(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
patterns []string
|
patterns []string
|
||||||
@@ -181,7 +214,9 @@ func TestHostWhitelist_Count(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
w := NewHostWhitelist(tt.patterns)
|
t.Parallel()
|
||||||
|
|
||||||
|
w := allowlist.New(tt.patterns)
|
||||||
if got := w.Count(); got != tt.want {
|
if got := w.Count(); got != tt.want {
|
||||||
t.Errorf("Count() = %v, want %v", got, tt.want)
|
t.Errorf("Count() = %v, want %v", got, tt.want)
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,15 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/smartconfig"
|
"git.eeqj.de/sneak/smartconfig"
|
||||||
@@ -21,9 +26,54 @@ const (
|
|||||||
DefaultUpstreamConnectionsPerHost = 20
|
DefaultUpstreamConnectionsPerHost = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Configuration key names.
|
||||||
|
const (
|
||||||
|
keyDebug = "debug"
|
||||||
|
keyMaintenanceMode = "maintenance_mode"
|
||||||
|
keyPort = "port"
|
||||||
|
keyStateDir = "state_dir"
|
||||||
|
keySentryDSN = "sentry_dsn"
|
||||||
|
keyDBURL = "db_url"
|
||||||
|
keyMetrics = "metrics"
|
||||||
|
keyMetricsUsername = "metrics.username"
|
||||||
|
keyMetricsPassword = "metrics.password"
|
||||||
|
keySigningKey = "signing_key"
|
||||||
|
keyAllowlistHosts = "allowlist_hosts"
|
||||||
|
keyAllowHTTP = "allow_http"
|
||||||
|
keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Static validation errors. Each use site attaches the offending key
|
||||||
|
// and value by wrapping these with fmt.Errorf and %w.
|
||||||
|
var (
|
||||||
|
errValueRequired = errors.New("a value is required")
|
||||||
|
errValueEmpty = errors.New("value must not be empty")
|
||||||
|
errUnknownConfigKeys = errors.New("unknown config keys")
|
||||||
|
errNotAString = errors.New("not a string")
|
||||||
|
errNotAnInteger = errors.New("not an integer")
|
||||||
|
errNotABoolean = errors.New("not a boolean")
|
||||||
|
errNotAStringList = errors.New("not a list of strings")
|
||||||
|
errNotAMetricsMap = errors.New("not a map of metrics settings")
|
||||||
|
errEmptyListEntry = errors.New("list contains an empty entry")
|
||||||
|
errEmptyEntry = errors.New("contains an empty entry")
|
||||||
|
errNotAValidURL = errors.New("not a valid URL")
|
||||||
|
errPortOutOfRange = errors.New("outside the valid port range")
|
||||||
|
errTooFewConnections = errors.New("must be at least 1")
|
||||||
|
errValueTooShort = errors.New("value too short")
|
||||||
|
errMustBeSetTogether = errors.New("must be set together")
|
||||||
|
errValueNull = errors.New(
|
||||||
|
"value is null; omit the key entirely to use the default")
|
||||||
|
errValuesNull = errors.New(
|
||||||
|
"value is null; omit a key entirely to use its default")
|
||||||
|
errNotBareHostname = errors.New(
|
||||||
|
"must be a bare hostname without scheme, path, or whitespace")
|
||||||
|
errNoHostnameLabels = errors.New("contains no hostname labels")
|
||||||
|
)
|
||||||
|
|
||||||
// Params defines dependencies for Config.
|
// Params defines dependencies for Config.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
}
|
}
|
||||||
@@ -41,7 +91,7 @@ type Config struct {
|
|||||||
|
|
||||||
// Image proxy settings
|
// Image proxy settings
|
||||||
SigningKey string // HMAC signing key for URL signatures
|
SigningKey string // HMAC signing key for URL signatures
|
||||||
WhitelistHosts []string // Hosts that don't require signatures
|
AllowlistHosts []string // Hosts that don't require signatures
|
||||||
AllowHTTP bool // Allow non-TLS upstream (testing only)
|
AllowHTTP bool // Allow non-TLS upstream (testing only)
|
||||||
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host
|
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host
|
||||||
}
|
}
|
||||||
@@ -60,54 +110,281 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
|
|||||||
log.Info("no config file found, using defaults")
|
log.Info("no config file found, using defaults")
|
||||||
}
|
}
|
||||||
|
|
||||||
c := &Config{
|
c, err := newFromSmartConfig(sc)
|
||||||
Debug: getBool(sc, "debug", false),
|
if err != nil {
|
||||||
MaintenanceMode: getBool(sc, "maintenance_mode", false),
|
return nil, err
|
||||||
Port: getInt(sc, "port", DefaultPort),
|
|
||||||
StateDir: getString(sc, "state_dir", DefaultStateDir),
|
|
||||||
SentryDSN: getString(sc, "sentry_dsn", ""),
|
|
||||||
MetricsUsername: getString(sc, "metrics.username", ""),
|
|
||||||
MetricsPassword: getString(sc, "metrics.password", ""),
|
|
||||||
SigningKey: getString(sc, "signing_key", ""),
|
|
||||||
WhitelistHosts: getStringSlice(sc, "whitelist_hosts"),
|
|
||||||
AllowHTTP: getBool(sc, "allow_http", false),
|
|
||||||
UpstreamConnectionsPerHost: getInt(sc, "upstream_connections_per_host", DefaultUpstreamConnectionsPerHost),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build DBURL from StateDir if not explicitly set
|
err = c.ensureStateDirWritable()
|
||||||
c.DBURL = getString(sc, "db_url", "")
|
if err != nil {
|
||||||
if c.DBURL == "" {
|
return nil, err
|
||||||
c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_journal_mode=WAL", c.StateDir)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.Debug {
|
if c.Debug {
|
||||||
params.Logger.EnableDebugLogging()
|
params.Logger.EnableDebugLogging()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate required configuration
|
return c, nil
|
||||||
if err := c.validate(); err != nil {
|
}
|
||||||
|
|
||||||
|
// newFromSmartConfig constructs a Config from a loaded smartconfig
|
||||||
|
// instance and validates it. A nil sc means no config file was found,
|
||||||
|
// in which case every option takes its default value. A key that is
|
||||||
|
// present but unparseable or invalid is an error: defaults apply only
|
||||||
|
// to omitted keys, never to invalid explicit values.
|
||||||
|
func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
||||||
|
if sc != nil {
|
||||||
|
err := validateKnownKeys(sc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = validateAllowlistHostsValue(sc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loader := &strictLoader{sc: sc}
|
||||||
|
|
||||||
|
c := &Config{
|
||||||
|
Debug: loader.boolVal(keyDebug, false),
|
||||||
|
MaintenanceMode: loader.boolVal(keyMaintenanceMode, false),
|
||||||
|
Port: loader.intVal(keyPort, DefaultPort),
|
||||||
|
StateDir: loader.stringVal(keyStateDir, DefaultStateDir),
|
||||||
|
SentryDSN: loader.stringVal(keySentryDSN, ""),
|
||||||
|
MetricsUsername: loader.stringVal(keyMetricsUsername, ""),
|
||||||
|
MetricsPassword: loader.stringVal(keyMetricsPassword, ""),
|
||||||
|
SigningKey: loader.stringVal(keySigningKey, ""),
|
||||||
|
AllowlistHosts: getStringSlice(sc),
|
||||||
|
AllowHTTP: loader.boolVal(keyAllowHTTP, false),
|
||||||
|
UpstreamConnectionsPerHost: loader.intVal(
|
||||||
|
keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build DBURL from StateDir if not explicitly set. The derived URL
|
||||||
|
// is a default: it applies only when db_url is omitted, never to an
|
||||||
|
// explicitly empty value.
|
||||||
|
c.DBURL = loader.stringVal(keyDBURL, "")
|
||||||
|
if c.DBURL == "" && loader.err == nil {
|
||||||
|
if sc != nil {
|
||||||
|
if _, present := sc.Get(keyDBURL); present {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"config key %q: %w; omit the key to derive it from state_dir",
|
||||||
|
keyDBURL, errValueEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_journal_mode=WAL", c.StateDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if loader.err != nil {
|
||||||
|
return nil, loader.err
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.validate()
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// validate checks that all required configuration values are set.
|
// validateKnownKeys rejects configuration files containing keys the
|
||||||
func (c *Config) validate() error {
|
// application does not understand, so typos fail at startup instead of
|
||||||
if c.SigningKey == "" {
|
// being silently ignored, and rejects keys that are explicitly set to
|
||||||
return fmt.Errorf("signing_key is required")
|
// null: a null is a SET value, never an omission, so it must not
|
||||||
|
// silently take the default. The env section is permitted because
|
||||||
|
// smartconfig consumes it for environment variable injection.
|
||||||
|
func validateKnownKeys(sc *smartconfig.Config) error {
|
||||||
|
var unknown, nullKeys []string
|
||||||
|
|
||||||
|
for key, value := range sc.Data() {
|
||||||
|
if !isKnownConfigKey(key) {
|
||||||
|
unknown = append(unknown, key)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if value == nil {
|
||||||
|
nullKeys = append(nullKeys, key)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if key == keyMetrics {
|
||||||
|
metricsMap, ok := value.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("config key %q: value %v is %w",
|
||||||
|
keyMetrics, value, errNotAMetricsMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
for subkey, subvalue := range metricsMap {
|
||||||
|
if subkey != "username" && subkey != "password" {
|
||||||
|
unknown = append(unknown, keyMetrics+"."+subkey)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if subvalue == nil {
|
||||||
|
nullKeys = append(nullKeys, keyMetrics+"."+subkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minimum key length for security (32 bytes = 256 bits)
|
if len(unknown) > 0 {
|
||||||
const minKeyLength = 32
|
sort.Strings(unknown)
|
||||||
if len(c.SigningKey) < minKeyLength {
|
|
||||||
return fmt.Errorf("signing_key must be at least %d characters", minKeyLength)
|
return fmt.Errorf("%w: %s", errUnknownConfigKeys, strings.Join(unknown, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(nullKeys) > 0 {
|
||||||
|
sort.Strings(nullKeys)
|
||||||
|
|
||||||
|
if len(nullKeys) == 1 {
|
||||||
|
return errNullConfigValue(nullKeys[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("config keys %s: %w",
|
||||||
|
strings.Join(nullKeys, ", "), errValuesNull)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadConfigFile loads configuration from PIXA_CONFIG_PATH env var or standard locations.
|
// errNullConfigValue reports a config key that is explicitly set to
|
||||||
|
// null (including the bare "key:" form and the "~" alias). Silently
|
||||||
|
// applying the default would mask a truncated or typo'd config entry.
|
||||||
|
func errNullConfigValue(key string) error {
|
||||||
|
return fmt.Errorf("config key %q: %w", key, errValueNull)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isKnownConfigKey reports whether key is a permitted top-level
|
||||||
|
// configuration key.
|
||||||
|
func isKnownConfigKey(key string) bool {
|
||||||
|
switch key {
|
||||||
|
case keyDebug, keyMaintenanceMode, keyPort, keyStateDir, keySentryDSN,
|
||||||
|
keyDBURL, keyMetrics, keySigningKey, keyAllowlistHosts, keyAllowHTTP,
|
||||||
|
keyUpstreamConnectionsPerHost, "env":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureStateDirWritable verifies at startup that StateDir can be
|
||||||
|
// created and written to, so a misconfigured path aborts startup
|
||||||
|
// instead of failing later at first use.
|
||||||
|
func (c *Config) ensureStateDirWritable() error {
|
||||||
|
const stateDirPerms = 0o750
|
||||||
|
|
||||||
|
err := os.MkdirAll(c.StateDir, stateDirPerms)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("config key %q: cannot create directory %q: %w",
|
||||||
|
keyStateDir, c.StateDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
probe, err := os.CreateTemp(c.StateDir, ".startup-write-probe-*")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("config key %q: directory %q is not writable: %w",
|
||||||
|
keyStateDir, c.StateDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
probePath := probe.Name()
|
||||||
|
|
||||||
|
err = probe.Close()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("config key %q: cannot close probe file %q: %w",
|
||||||
|
keyStateDir, probePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Remove(probePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("config key %q: cannot remove probe file %q: %w",
|
||||||
|
keyStateDir, probePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate checks that all required configuration values are set and
|
||||||
|
// that every value is within its valid range.
|
||||||
|
func (c *Config) validate() error {
|
||||||
|
// The signing key value is never echoed in error messages.
|
||||||
|
if c.SigningKey == "" {
|
||||||
|
return fmt.Errorf("config key %q: %w", keySigningKey, errValueRequired)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimum key length for security (32 bytes = 256 bits)
|
||||||
|
const minKeyLength = 32
|
||||||
|
if len(c.SigningKey) < minKeyLength {
|
||||||
|
return fmt.Errorf("config key %q: %w: must be at least %d characters, got %d",
|
||||||
|
keySigningKey, errValueTooShort, minKeyLength, len(c.SigningKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxPort = 65535
|
||||||
|
if c.Port < 1 || c.Port > maxPort {
|
||||||
|
return fmt.Errorf("config key %q: value %d is %w 1-%d",
|
||||||
|
keyPort, c.Port, errPortOutOfRange, maxPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.UpstreamConnectionsPerHost < 1 {
|
||||||
|
return fmt.Errorf("config key %q: value %d %w",
|
||||||
|
keyUpstreamConnectionsPerHost, c.UpstreamConnectionsPerHost,
|
||||||
|
errTooFewConnections)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.StateDir == "" {
|
||||||
|
return fmt.Errorf("config key %q: %w", keyStateDir, errValueEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, host := range c.AllowlistHosts {
|
||||||
|
err := validateAllowlistHost(host)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.SentryDSN != "" {
|
||||||
|
parsed, err := url.Parse(c.SentryDSN)
|
||||||
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||||
|
return fmt.Errorf("config key %q: value %q is %w",
|
||||||
|
keySentryDSN, c.SentryDSN, errNotAValidURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c.MetricsUsername == "") != (c.MetricsPassword == "") {
|
||||||
|
return fmt.Errorf("config keys %q and %q %w",
|
||||||
|
keyMetricsUsername, keyMetricsPassword, errMustBeSetTogether)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateAllowlistHost checks that an allowlist_hosts entry is a bare
|
||||||
|
// hostname, optionally with a leading dot for suffix matching. URLs,
|
||||||
|
// paths, and whitespace indicate a misconfigured entry. An entry with
|
||||||
|
// no hostname labels (such as ".") is rejected: the allowlist matcher
|
||||||
|
// treats a leading dot as a suffix pattern, so a bare "." would match
|
||||||
|
// any upstream host written in FQDN trailing-dot form and effectively
|
||||||
|
// disable URL signing.
|
||||||
|
func validateAllowlistHost(host string) error {
|
||||||
|
if strings.Contains(host, "://") || strings.ContainsAny(host, "/ \t") {
|
||||||
|
return fmt.Errorf("config key %q: entry %q %w",
|
||||||
|
keyAllowlistHosts, host, errNotBareHostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Trim(host, ".") == "" {
|
||||||
|
return fmt.Errorf("config key %q: entry %q %w",
|
||||||
|
keyAllowlistHosts, host, errNoHostnameLabels)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadConfigFile loads configuration from the PIXA_CONFIG_PATH env var
|
||||||
|
// or standard locations.
|
||||||
func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, error) {
|
func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, error) {
|
||||||
// Check for explicit config path from environment
|
// Check for explicit config path from environment
|
||||||
if envPath := os.Getenv("PIXA_CONFIG_PATH"); envPath != "" {
|
if envPath := os.Getenv("PIXA_CONFIG_PATH"); envPath != "" {
|
||||||
@@ -132,12 +409,15 @@ func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, path := range configPaths {
|
for _, path := range configPaths {
|
||||||
if _, statErr := os.Stat(path); statErr == nil {
|
cleanPath := filepath.Clean(path)
|
||||||
|
|
||||||
|
_, statErr := os.Stat(cleanPath)
|
||||||
|
if statErr == nil {
|
||||||
|
// A config file that exists but does not parse is a fatal
|
||||||
|
// startup error, never something to skip over.
|
||||||
sc, err := smartconfig.NewFromConfigPath(path)
|
sc, err := smartconfig.NewFromConfigPath(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("failed to parse config file", "path", path, "error", err)
|
return nil, fmt.Errorf("failed to parse config file %s: %w", path, err)
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("loaded config file", "path", path)
|
log.Info("loaded config file", "path", path)
|
||||||
@@ -149,57 +429,221 @@ func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, erro
|
|||||||
return nil, nil //nolint:nilnil // nil config is valid (use defaults)
|
return nil, nil //nolint:nilnil // nil config is valid (use defaults)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getString(sc *smartconfig.Config, key, defaultVal string) string {
|
// strictLoader accumulates the first error encountered while reading
|
||||||
if sc == nil {
|
// typed values out of a smartconfig instance, so Config construction
|
||||||
return defaultVal
|
// can stay a single struct literal.
|
||||||
|
type strictLoader struct {
|
||||||
|
sc *smartconfig.Config
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *strictLoader) stringVal(key, defaultVal string) string {
|
||||||
|
if l.err != nil {
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
val, err := sc.GetString(key)
|
val, err := getString(l.sc, key, defaultVal)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return defaultVal
|
l.err = err
|
||||||
}
|
}
|
||||||
|
|
||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
|
|
||||||
func getInt(sc *smartconfig.Config, key string, defaultVal int) int {
|
func (l *strictLoader) intVal(key string, defaultVal int) int {
|
||||||
if sc == nil {
|
if l.err != nil {
|
||||||
return defaultVal
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
val, err := sc.GetInt(key)
|
val, err := getInt(l.sc, key, defaultVal)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return defaultVal
|
l.err = err
|
||||||
}
|
}
|
||||||
|
|
||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
|
|
||||||
func getBool(sc *smartconfig.Config, key string, defaultVal bool) bool {
|
func (l *strictLoader) boolVal(key string, defaultVal bool) bool {
|
||||||
if sc == nil {
|
if l.err != nil {
|
||||||
return defaultVal
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
val, err := sc.GetBool(key)
|
val, err := getBool(l.sc, key, defaultVal)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return defaultVal
|
l.err = err
|
||||||
}
|
}
|
||||||
|
|
||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
|
|
||||||
func getStringSlice(sc *smartconfig.Config, key string) []string {
|
// getString returns the string value for key, or defaultVal if the key
|
||||||
|
// is omitted. A present value that is not a string, or is explicitly
|
||||||
|
// null, is an error.
|
||||||
|
func getString(sc *smartconfig.Config, key, defaultVal string) (string, error) {
|
||||||
|
if sc == nil {
|
||||||
|
return defaultVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, ok := sc.Get(key)
|
||||||
|
if !ok {
|
||||||
|
return defaultVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw == nil {
|
||||||
|
return "", errNullConfigValue(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
str, ok := raw.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("config key %q: value %v (%T) is %w",
|
||||||
|
key, raw, raw, errNotAString)
|
||||||
|
}
|
||||||
|
|
||||||
|
return str, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getInt returns the integer value for key, or defaultVal if the key is
|
||||||
|
// omitted. A present value that is not a whole number, or is explicitly
|
||||||
|
// null, is an error; fractional values are never truncated.
|
||||||
|
func getInt(sc *smartconfig.Config, key string, defaultVal int) (int, error) {
|
||||||
|
if sc == nil {
|
||||||
|
return defaultVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, ok := sc.Get(key)
|
||||||
|
if !ok {
|
||||||
|
return defaultVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw == nil {
|
||||||
|
return 0, errNullConfigValue(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch val := raw.(type) {
|
||||||
|
case int:
|
||||||
|
return val, nil
|
||||||
|
case int64:
|
||||||
|
return int(val), nil
|
||||||
|
case float64:
|
||||||
|
if val != math.Trunc(val) {
|
||||||
|
return 0, fmt.Errorf("config key %q: value %v is %w",
|
||||||
|
key, val, errNotAnInteger)
|
||||||
|
}
|
||||||
|
|
||||||
|
return int(val), nil
|
||||||
|
case string:
|
||||||
|
parsed, err := strconv.Atoi(strings.TrimSpace(val))
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("config key %q: value %q is %w",
|
||||||
|
key, val, errNotAnInteger)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed, nil
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("config key %q: value %v (%T) is %w",
|
||||||
|
key, raw, raw, errNotAnInteger)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getBool returns the boolean value for key, or defaultVal if the key
|
||||||
|
// is omitted. A present value that is not a boolean (or a ParseBool-able
|
||||||
|
// string), or is explicitly null, is an error; numbers are not accepted
|
||||||
|
// as booleans.
|
||||||
|
func getBool(sc *smartconfig.Config, key string, defaultVal bool) (bool, error) {
|
||||||
|
if sc == nil {
|
||||||
|
return defaultVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, ok := sc.Get(key)
|
||||||
|
if !ok {
|
||||||
|
return defaultVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw == nil {
|
||||||
|
return false, errNullConfigValue(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch val := raw.(type) {
|
||||||
|
case bool:
|
||||||
|
return val, nil
|
||||||
|
case string:
|
||||||
|
parsed, err := strconv.ParseBool(strings.TrimSpace(val))
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("config key %q: value %q is %w",
|
||||||
|
key, val, errNotABoolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed, nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("config key %q: value %v (%T) is %w",
|
||||||
|
key, raw, raw, errNotABoolean)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateAllowlistHostsValue checks the raw shape of the
|
||||||
|
// allowlist_hosts value before the lenient extraction in getStringSlice
|
||||||
|
// runs: an explicitly null value, a value that is not a list of strings
|
||||||
|
// (or a comma-separated string), a non-string entry, or an empty entry
|
||||||
|
// is an error, never silently skipped.
|
||||||
|
func validateAllowlistHostsValue(sc *smartconfig.Config) error {
|
||||||
|
raw, ok := sc.Get(keyAllowlistHosts)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw == nil {
|
||||||
|
return errNullConfigValue(keyAllowlistHosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch val := raw.(type) {
|
||||||
|
case []any:
|
||||||
|
for _, item := range val {
|
||||||
|
str, ok := item.(string)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("config key %q: list entry %v (%T) is %w",
|
||||||
|
keyAllowlistHosts, item, item, errNotAString)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(str) == "" {
|
||||||
|
return fmt.Errorf("config key %q: %w",
|
||||||
|
keyAllowlistHosts, errEmptyListEntry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
if strings.TrimSpace(val) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for part := range strings.SplitSeq(val, ",") {
|
||||||
|
if strings.TrimSpace(part) == "" {
|
||||||
|
return fmt.Errorf("config key %q: value %q %w",
|
||||||
|
keyAllowlistHosts, val, errEmptyEntry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("config key %q: value %v (%T) is %w",
|
||||||
|
keyAllowlistHosts, raw, raw, errNotAStringList)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getStringSlice returns the allowlist_hosts list of strings, or nil if
|
||||||
|
// the key is omitted. It accepts a YAML list of strings or a
|
||||||
|
// comma-separated string (backwards compatibility). Malformed entries
|
||||||
|
// are rejected beforehand by validateAllowlistHostsValue.
|
||||||
|
func getStringSlice(sc *smartconfig.Config) []string {
|
||||||
if sc == nil {
|
if sc == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
val, ok := sc.Get(key)
|
val, ok := sc.Get(keyAllowlistHosts)
|
||||||
if !ok || val == nil {
|
if !ok || val == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle YAML list format
|
// Handle YAML list format
|
||||||
if slice, ok := val.([]interface{}); ok {
|
if slice, ok := val.([]any); ok {
|
||||||
result := make([]string, 0, len(slice))
|
result := make([]string, 0, len(slice))
|
||||||
for _, item := range slice {
|
for _, item := range slice {
|
||||||
if str, ok := item.(string); ok {
|
if str, ok := item.(string); ok {
|
||||||
|
|||||||
98
internal/config/config_internal_test.go
Normal file
98
internal/config/config_internal_test.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/smartconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeTestConfig writes yamlContent to a temp config file and returns
|
||||||
|
// the file path.
|
||||||
|
func writeTestConfig(t *testing.T, yamlContent string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(configPath, []byte(yamlContent), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return configPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAllowlistHosts loads the config at configPath and asserts that
|
||||||
|
// getStringSlice returns the three expected hosts.
|
||||||
|
func checkAllowlistHosts(t *testing.T, configPath string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
sc, err := loadTestConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hosts := getStringSlice(sc)
|
||||||
|
|
||||||
|
if len(hosts) != 3 {
|
||||||
|
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := []string{"static.sneak.cloud", "sneak.berlin", testHostS3}
|
||||||
|
for i, want := range expected {
|
||||||
|
if i >= len(hosts) {
|
||||||
|
t.Errorf("missing host at index %d: want %q", i, want)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if hosts[i] != want {
|
||||||
|
t.Errorf("host[%d] = %q, want %q", i, hosts[i], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetStringSlice_YAMLList(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := `
|
||||||
|
allowlist_hosts:
|
||||||
|
- static.sneak.cloud
|
||||||
|
- sneak.berlin
|
||||||
|
- s3.sneak.cloud
|
||||||
|
`
|
||||||
|
|
||||||
|
checkAllowlistHosts(t, writeTestConfig(t, yamlContent))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetStringSlice_CommaSeparated(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Backwards compatibility with comma-separated string values.
|
||||||
|
yamlContent := `allowlist_hosts: "static.sneak.cloud, sneak.berlin, s3.sneak.cloud"`
|
||||||
|
|
||||||
|
checkAllowlistHosts(t, writeTestConfig(t, yamlContent))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetStringSlice_Empty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
configPath := writeTestConfig(t, `port: 8080`)
|
||||||
|
|
||||||
|
sc, err := loadTestConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hosts := getStringSlice(sc)
|
||||||
|
if len(hosts) != 0 {
|
||||||
|
t.Errorf("expected nil or empty slice, got %v", hosts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadTestConfig is a helper to load a config file for testing.
|
||||||
|
func loadTestConfig(path string) (*smartconfig.Config, error) {
|
||||||
|
return smartconfig.NewFromConfigPath(path)
|
||||||
|
}
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/smartconfig"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGetStringSlice_YAMLList(t *testing.T) {
|
|
||||||
// Create a temp config file with YAML list format
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
configPath := filepath.Join(tmpDir, "config.yml")
|
|
||||||
|
|
||||||
yamlContent := `
|
|
||||||
whitelist_hosts:
|
|
||||||
- static.sneak.cloud
|
|
||||||
- sneak.berlin
|
|
||||||
- s3.sneak.cloud
|
|
||||||
`
|
|
||||||
err := os.WriteFile(configPath, []byte(yamlContent), 0644)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to write test config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load config using smartconfig
|
|
||||||
sc, err := loadTestConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to load config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that getStringSlice correctly parses YAML list
|
|
||||||
hosts := getStringSlice(sc, "whitelist_hosts")
|
|
||||||
|
|
||||||
if len(hosts) != 3 {
|
|
||||||
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
|
|
||||||
}
|
|
||||||
|
|
||||||
expected := []string{"static.sneak.cloud", "sneak.berlin", "s3.sneak.cloud"}
|
|
||||||
for i, want := range expected {
|
|
||||||
if i >= len(hosts) {
|
|
||||||
t.Errorf("missing host at index %d: want %q", i, want)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if hosts[i] != want {
|
|
||||||
t.Errorf("host[%d] = %q, want %q", i, hosts[i], want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetStringSlice_CommaSeparated(t *testing.T) {
|
|
||||||
// Test backwards compatibility with comma-separated string
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
configPath := filepath.Join(tmpDir, "config.yml")
|
|
||||||
|
|
||||||
yamlContent := `whitelist_hosts: "static.sneak.cloud, sneak.berlin, s3.sneak.cloud"`
|
|
||||||
|
|
||||||
err := os.WriteFile(configPath, []byte(yamlContent), 0644)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to write test config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sc, err := loadTestConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to load config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hosts := getStringSlice(sc, "whitelist_hosts")
|
|
||||||
|
|
||||||
if len(hosts) != 3 {
|
|
||||||
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
|
|
||||||
}
|
|
||||||
|
|
||||||
expected := []string{"static.sneak.cloud", "sneak.berlin", "s3.sneak.cloud"}
|
|
||||||
for i, want := range expected {
|
|
||||||
if i >= len(hosts) {
|
|
||||||
t.Errorf("missing host at index %d: want %q", i, want)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if hosts[i] != want {
|
|
||||||
t.Errorf("host[%d] = %q, want %q", i, hosts[i], want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetStringSlice_Empty(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
configPath := filepath.Join(tmpDir, "config.yml")
|
|
||||||
|
|
||||||
yamlContent := `port: 8080`
|
|
||||||
|
|
||||||
err := os.WriteFile(configPath, []byte(yamlContent), 0644)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to write test config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sc, err := loadTestConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to load config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hosts := getStringSlice(sc, "whitelist_hosts")
|
|
||||||
|
|
||||||
if hosts != nil && len(hosts) != 0 {
|
|
||||||
t.Errorf("expected nil or empty slice, got %v", hosts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadTestConfig is a helper to load a config file for testing
|
|
||||||
func loadTestConfig(path string) (*smartconfig.Config, error) {
|
|
||||||
return smartconfig.NewFromConfigPath(path)
|
|
||||||
}
|
|
||||||
596
internal/config/config_validation_internal_test.go
Normal file
596
internal/config/config_validation_internal_test.go
Normal file
@@ -0,0 +1,596 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/smartconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validTestSigningKey is a 32-character signing key that satisfies the
|
||||||
|
// minimum length requirement in validate().
|
||||||
|
const validTestSigningKey = "0123456789abcdef0123456789abcdef"
|
||||||
|
|
||||||
|
// signingKeyLine is a valid signing_key config line used as the base of
|
||||||
|
// test config files.
|
||||||
|
const signingKeyLine = "signing_key: " + validTestSigningKey + "\n"
|
||||||
|
|
||||||
|
// testHostS3 is an allowlist host entry used across the config tests.
|
||||||
|
const testHostS3 = "s3.sneak.cloud"
|
||||||
|
|
||||||
|
// nullValueText is the substring that error messages about explicitly
|
||||||
|
// null config values must contain.
|
||||||
|
const nullValueText = "null"
|
||||||
|
|
||||||
|
// abortCase describes a config file that must abort startup with an
|
||||||
|
// error mentioning every string in wantErrSubstrings.
|
||||||
|
type abortCase struct {
|
||||||
|
name string
|
||||||
|
yaml string
|
||||||
|
// wantErrSubstrings must all appear in the error message.
|
||||||
|
wantErrSubstrings []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// configFromYAML writes yamlContent to a temporary config file, loads it
|
||||||
|
// via smartconfig, and constructs a Config from it using the same code
|
||||||
|
// path the server uses at startup.
|
||||||
|
func configFromYAML(t *testing.T, yamlContent string) (*Config, error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(configPath, []byte(yamlContent), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sc, err := smartconfig.NewFromConfigPath(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newFromSmartConfig(sc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOmittedValuesUseDefaults(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minimal config should be valid, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Port != DefaultPort {
|
||||||
|
t.Errorf("Port = %d, want default %d", c.Port, DefaultPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.StateDir != DefaultStateDir {
|
||||||
|
t.Errorf("StateDir = %q, want default %q", c.StateDir, DefaultStateDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.UpstreamConnectionsPerHost != DefaultUpstreamConnectionsPerHost {
|
||||||
|
t.Errorf("UpstreamConnectionsPerHost = %d, want default %d",
|
||||||
|
c.UpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Debug {
|
||||||
|
t.Error("Debug = true, want default false")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.MaintenanceMode {
|
||||||
|
t.Error("MaintenanceMode = true, want default false")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.AllowHTTP {
|
||||||
|
t.Error("AllowHTTP = true, want default false")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.AllowlistHosts) != 0 {
|
||||||
|
t.Errorf("AllowlistHosts = %v, want empty", c.AllowlistHosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantDBURL := "file:" + DefaultStateDir + "/state.sqlite3?_journal_mode=WAL"
|
||||||
|
if c.DBURL != wantDBURL {
|
||||||
|
t.Errorf("DBURL = %q, want derived default %q", c.DBURL, wantDBURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExplicitValidValuesAreUsed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := `
|
||||||
|
port: 9090
|
||||||
|
debug: true
|
||||||
|
maintenance_mode: true
|
||||||
|
state_dir: /tmp/pixa-test-state
|
||||||
|
db_url: "file:/tmp/pixa-test-state/other.sqlite3"
|
||||||
|
signing_key: ` + validTestSigningKey + `
|
||||||
|
allowlist_hosts:
|
||||||
|
- s3.sneak.cloud
|
||||||
|
- .example.com
|
||||||
|
allow_http: true
|
||||||
|
upstream_connections_per_host: 5
|
||||||
|
sentry_dsn: "https://abc123@sentry.example.com/42"
|
||||||
|
metrics:
|
||||||
|
username: metricsuser
|
||||||
|
password: metricspass
|
||||||
|
`
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("valid config should load, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Port != 9090 {
|
||||||
|
t.Errorf("Port = %d, want 9090", c.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.Debug || !c.MaintenanceMode || !c.AllowHTTP {
|
||||||
|
t.Errorf("bool fields = debug %v maintenance %v allow_http %v, want all true",
|
||||||
|
c.Debug, c.MaintenanceMode, c.AllowHTTP)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.StateDir != "/tmp/pixa-test-state" {
|
||||||
|
t.Errorf("StateDir = %q, want /tmp/pixa-test-state", c.StateDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.DBURL != "file:/tmp/pixa-test-state/other.sqlite3" {
|
||||||
|
t.Errorf("DBURL = %q, want explicit value", c.DBURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.AllowlistHosts) != 2 || c.AllowlistHosts[0] != testHostS3 ||
|
||||||
|
c.AllowlistHosts[1] != ".example.com" {
|
||||||
|
t.Errorf("AllowlistHosts = %v, want [s3.sneak.cloud .example.com]",
|
||||||
|
c.AllowlistHosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.UpstreamConnectionsPerHost != 5 {
|
||||||
|
t.Errorf("UpstreamConnectionsPerHost = %d, want 5", c.UpstreamConnectionsPerHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.SentryDSN != "https://abc123@sentry.example.com/42" {
|
||||||
|
t.Errorf("SentryDSN = %q, want explicit value", c.SentryDSN)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.MetricsUsername != "metricsuser" || c.MetricsPassword != "metricspass" {
|
||||||
|
t.Errorf("metrics = %q/%q, want metricsuser/metricspass",
|
||||||
|
c.MetricsUsername, c.MetricsPassword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommaSeparatedAllowlistStillSupported(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := signingKeyLine +
|
||||||
|
`allowlist_hosts: "s3.sneak.cloud, sneak.berlin"
|
||||||
|
`
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("comma-separated allowlist should load, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.AllowlistHosts) != 2 || c.AllowlistHosts[0] != testHostS3 ||
|
||||||
|
c.AllowlistHosts[1] != "sneak.berlin" {
|
||||||
|
t.Errorf("AllowlistHosts = %v, want [s3.sneak.cloud sneak.berlin]",
|
||||||
|
c.AllowlistHosts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runAbortCases asserts that each case's config aborts startup with an
|
||||||
|
// error message mentioning every expected substring.
|
||||||
|
func runAbortCases(t *testing.T, cases []abortCase) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, tc.yaml)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("config with %s must abort startup, got config: %+v", tc.name, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
for _, want := range tc.wantErrSubstrings {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("error %q does not mention %q", err.Error(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalidScalarValueCases are configs where a scalar key is explicitly
|
||||||
|
// set to an unparseable or out-of-range value; each must abort startup
|
||||||
|
// naming the offending key, never silently fall back to the default.
|
||||||
|
func invalidScalarValueCases() []abortCase {
|
||||||
|
return []abortCase{
|
||||||
|
{
|
||||||
|
name: "port not a number",
|
||||||
|
yaml: signingKeyLine + "port: banana\n",
|
||||||
|
wantErrSubstrings: []string{keyPort, "banana"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "port zero",
|
||||||
|
yaml: signingKeyLine + "port: 0\n",
|
||||||
|
wantErrSubstrings: []string{keyPort, "0"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "port above 65535",
|
||||||
|
yaml: signingKeyLine + "port: 99999\n",
|
||||||
|
wantErrSubstrings: []string{keyPort, "99999"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "port fractional",
|
||||||
|
yaml: signingKeyLine + "port: 8080.5\n",
|
||||||
|
wantErrSubstrings: []string{keyPort, "8080.5"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "debug not a bool",
|
||||||
|
yaml: signingKeyLine + "debug: notabool\n",
|
||||||
|
wantErrSubstrings: []string{keyDebug, "notabool"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "maintenance_mode not a bool",
|
||||||
|
yaml: signingKeyLine + "maintenance_mode: sometimes\n",
|
||||||
|
wantErrSubstrings: []string{keyMaintenanceMode, "sometimes"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allow_http numeric",
|
||||||
|
yaml: signingKeyLine + "allow_http: 2\n",
|
||||||
|
wantErrSubstrings: []string{keyAllowHTTP, "2"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upstream_connections_per_host zero",
|
||||||
|
yaml: signingKeyLine + "upstream_connections_per_host: 0\n",
|
||||||
|
wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, "0"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upstream_connections_per_host negative",
|
||||||
|
yaml: signingKeyLine + "upstream_connections_per_host: -3\n",
|
||||||
|
wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, "-3"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upstream_connections_per_host not a number",
|
||||||
|
yaml: signingKeyLine + "upstream_connections_per_host: many\n",
|
||||||
|
wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, "many"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalidHostAndCredentialCases are configs where allowlist_hosts,
|
||||||
|
// signing_key, state_dir, sentry_dsn, or metrics is explicitly set to
|
||||||
|
// an invalid value; each must abort startup naming the offending key.
|
||||||
|
func invalidHostAndCredentialCases() []abortCase {
|
||||||
|
return []abortCase{
|
||||||
|
{
|
||||||
|
name: "allowlist host with scheme",
|
||||||
|
yaml: signingKeyLine + "allowlist_hosts:\n - https://example.com\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
keyAllowlistHosts, "https://example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allowlist host with path",
|
||||||
|
yaml: signingKeyLine + "allowlist_hosts:\n - example.com/images\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
keyAllowlistHosts, "example.com/images",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allowlist host with whitespace",
|
||||||
|
yaml: signingKeyLine + "allowlist_hosts:\n - \"exa mple.com\"\n",
|
||||||
|
wantErrSubstrings: []string{keyAllowlistHosts, "exa mple.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allowlist entry not a string",
|
||||||
|
yaml: signingKeyLine + "allowlist_hosts:\n - 123\n",
|
||||||
|
wantErrSubstrings: []string{keyAllowlistHosts, "123"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allowlist not a list",
|
||||||
|
yaml: signingKeyLine + "allowlist_hosts:\n key: value\n",
|
||||||
|
wantErrSubstrings: []string{keyAllowlistHosts},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "signing_key too short",
|
||||||
|
yaml: "signing_key: short\n",
|
||||||
|
wantErrSubstrings: []string{keySigningKey},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "signing_key missing",
|
||||||
|
yaml: "port: 8080\n",
|
||||||
|
wantErrSubstrings: []string{keySigningKey},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "state_dir explicitly empty",
|
||||||
|
yaml: signingKeyLine + "state_dir: \"\"\n",
|
||||||
|
wantErrSubstrings: []string{keyStateDir},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sentry_dsn not a URL",
|
||||||
|
yaml: signingKeyLine + "sentry_dsn: \"not a url\"\n",
|
||||||
|
wantErrSubstrings: []string{keySentryDSN, "not a url"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "metrics username without password",
|
||||||
|
yaml: signingKeyLine + "metrics:\n username: bob\n",
|
||||||
|
wantErrSubstrings: []string{keyMetrics},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "metrics password without username",
|
||||||
|
yaml: signingKeyLine + "metrics:\n password: hunter2\n",
|
||||||
|
wantErrSubstrings: []string{keyMetrics},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetButInvalidValueAbortsStartup verifies the no-silent-fallback
|
||||||
|
// rule: a key that is explicitly set to an unparseable or out-of-range
|
||||||
|
// value must produce a startup error naming the offending key, never
|
||||||
|
// silently fall back to the default.
|
||||||
|
func TestSetButInvalidValueAbortsStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runAbortCases(t, append(
|
||||||
|
invalidScalarValueCases(), invalidHostAndCredentialCases()...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// explicitNullValueCases are configs where a key is explicitly set to
|
||||||
|
// null (including the bare "key:" form and the "~" alias); each must
|
||||||
|
// abort startup naming the key.
|
||||||
|
func explicitNullValueCases() []abortCase {
|
||||||
|
return []abortCase{
|
||||||
|
{
|
||||||
|
name: "port explicit null",
|
||||||
|
yaml: signingKeyLine + "port: null\n",
|
||||||
|
wantErrSubstrings: []string{keyPort, nullValueText},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "port bare key no value",
|
||||||
|
yaml: signingKeyLine + "port:\n",
|
||||||
|
wantErrSubstrings: []string{keyPort, nullValueText},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "debug tilde null",
|
||||||
|
yaml: signingKeyLine + "debug: ~\n",
|
||||||
|
wantErrSubstrings: []string{keyDebug, nullValueText},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "maintenance_mode null",
|
||||||
|
yaml: signingKeyLine + "maintenance_mode: null\n",
|
||||||
|
wantErrSubstrings: []string{keyMaintenanceMode, nullValueText},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allow_http null",
|
||||||
|
yaml: signingKeyLine + "allow_http: null\n",
|
||||||
|
wantErrSubstrings: []string{keyAllowHTTP, nullValueText},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "state_dir null",
|
||||||
|
yaml: signingKeyLine + "state_dir: null\n",
|
||||||
|
wantErrSubstrings: []string{keyStateDir, nullValueText},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "db_url null",
|
||||||
|
yaml: signingKeyLine + "db_url: null\n",
|
||||||
|
wantErrSubstrings: []string{keyDBURL, nullValueText},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sentry_dsn null",
|
||||||
|
yaml: signingKeyLine + "sentry_dsn: null\n",
|
||||||
|
wantErrSubstrings: []string{keySentryDSN, nullValueText},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upstream_connections_per_host null",
|
||||||
|
yaml: signingKeyLine + "upstream_connections_per_host: null\n",
|
||||||
|
wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, nullValueText},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allowlist_hosts null",
|
||||||
|
yaml: signingKeyLine + "allowlist_hosts: null\n",
|
||||||
|
wantErrSubstrings: []string{keyAllowlistHosts, nullValueText},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "signing_key null",
|
||||||
|
yaml: "signing_key: null\n",
|
||||||
|
wantErrSubstrings: []string{keySigningKey, nullValueText},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "metrics null",
|
||||||
|
yaml: signingKeyLine + "metrics: null\n",
|
||||||
|
wantErrSubstrings: []string{keyMetrics, nullValueText},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "metrics subkeys null",
|
||||||
|
yaml: signingKeyLine + "metrics:\n username: null\n password: null\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
keyMetricsUsername, keyMetricsPassword, nullValueText,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExplicitNullValueAbortsStartup verifies that a key explicitly
|
||||||
|
// set to null (including the bare "key:" form and the "~" alias) aborts
|
||||||
|
// startup naming the key. An explicit null is a SET value: it must
|
||||||
|
// never silently fall back to the default the way an omitted key does.
|
||||||
|
func TestExplicitNullValueAbortsStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runAbortCases(t, explicitNullValueCases())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExplicitlyEmptyDBURLAbortsStartup verifies that db_url set to an
|
||||||
|
// empty string aborts startup: the derived file:...state.sqlite3 URL is
|
||||||
|
// a default, and defaults apply only to omitted keys. This matches
|
||||||
|
// state_dir, where an explicitly empty value already aborts.
|
||||||
|
func TestExplicitlyEmptyDBURLAbortsStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := signingKeyLine + "db_url: \"\"\n"
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("explicitly empty db_url must abort startup, got config: %+v", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), keyDBURL) {
|
||||||
|
t.Errorf("error %q does not name the offending key db_url", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAllowlistHostsRejectsDotOnlyEntries verifies that entries with no
|
||||||
|
// hostname labels are rejected. The allowlist matcher treats a leading
|
||||||
|
// dot as a suffix pattern, so a bare "." entry would match any upstream
|
||||||
|
// host written in FQDN trailing-dot form (e.g. evil.com.) and
|
||||||
|
// effectively disable URL signing with a single character.
|
||||||
|
func TestAllowlistHostsRejectsDotOnlyEntries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, entry := range []string{".", ".."} {
|
||||||
|
t.Run(entry, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := signingKeyLine +
|
||||||
|
"allowlist_hosts:\n - \"" + entry + "\"\n"
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("allowlist entry %q must abort startup, got config: %+v",
|
||||||
|
entry, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), keyAllowlistHosts) {
|
||||||
|
t.Errorf("error %q does not name the offending key allowlist_hosts",
|
||||||
|
err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownTopLevelKeyAbortsStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := signingKeyLine + `whitelist_hosts:
|
||||||
|
- example.com
|
||||||
|
`
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("config with unknown key must abort startup, got config: %+v", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "whitelist_hosts") {
|
||||||
|
t.Errorf("error %q does not name the unknown key whitelist_hosts", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownMetricsSubkeyAbortsStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := signingKeyLine + `metrics:
|
||||||
|
username: bob
|
||||||
|
password: hunter2
|
||||||
|
port: 9100
|
||||||
|
`
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("config with unknown metrics subkey must abort startup, got config: %+v", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "metrics.port") {
|
||||||
|
t.Errorf("error %q does not name the unknown key metrics.port", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvSectionIsPermitted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
yamlContent := signingKeyLine + `env:
|
||||||
|
PIXA_TEST_ENV_INJECTION: injected
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := configFromYAML(t, yamlContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"env section must be permitted (smartconfig consumes it), got error: %v",
|
||||||
|
err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMalformedConfigFileAbortsStartup(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(configPath, []byte("port: [unclosed\n"), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write malformed config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadConfigFile falls through to the relative config.yml candidate;
|
||||||
|
// the appname is chosen so no /etc or $HOME candidate can exist.
|
||||||
|
t.Setenv("PIXA_CONFIG_PATH", "")
|
||||||
|
t.Chdir(tmpDir)
|
||||||
|
|
||||||
|
log := slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
|
sc, err := loadConfigFile(log, "pixa-test-nonexistent-app")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("malformed config file must abort startup, got config: %v", sc)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureStateDirCreatesDirectory(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stateDir := filepath.Join(t.TempDir(), "nested", "state")
|
||||||
|
|
||||||
|
c := &Config{StateDir: stateDir}
|
||||||
|
|
||||||
|
err := c.ensureStateDirWritable()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("creatable state_dir must validate, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(stateDir)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
t.Fatalf("state_dir was not created: info=%v err=%v", info, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureStateDirFailsOnUncreatablePath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// A path below /dev/null can never be created, even when running
|
||||||
|
// as root (as in the Docker build).
|
||||||
|
c := &Config{StateDir: "/dev/null/pixa-state"}
|
||||||
|
|
||||||
|
err := c.ensureStateDirWritable()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("uncreatable state_dir must abort startup, got nil error")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), keyStateDir) {
|
||||||
|
t.Errorf("error %q does not name the offending key state_dir", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"embed"
|
"embed"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
@@ -21,13 +23,22 @@ import (
|
|||||||
//go:embed schema/*.sql
|
//go:embed schema/*.sql
|
||||||
var schemaFS embed.FS
|
var schemaFS embed.FS
|
||||||
|
|
||||||
|
// bootstrapVersion is the migration that creates the schema_migrations
|
||||||
|
// table itself. It is applied before the normal migration loop.
|
||||||
|
const bootstrapVersion = 0
|
||||||
|
|
||||||
// Params defines dependencies for Database.
|
// Params defines dependencies for Database.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// errInvalidMigrationFilename is returned when a migration filename does
|
||||||
|
// not match the "<version>[_<description>].sql" pattern.
|
||||||
|
var errInvalidMigrationFilename = errors.New("invalid migration filename")
|
||||||
|
|
||||||
// Database wraps the SQL database connection.
|
// Database wraps the SQL database connection.
|
||||||
type Database struct {
|
type Database struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
@@ -35,6 +46,44 @@ type Database struct {
|
|||||||
config *config.Config
|
config *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseMigrationVersion extracts the numeric version prefix from a migration
|
||||||
|
// filename. Filenames must follow the pattern "<version>.sql" or
|
||||||
|
// "<version>_<description>.sql", where version is a zero-padded numeric
|
||||||
|
// string (e.g. "001", "002"). Returns the version as an integer and an
|
||||||
|
// error if the filename does not match the expected pattern.
|
||||||
|
func ParseMigrationVersion(filename string) (int, error) {
|
||||||
|
name := strings.TrimSuffix(filename, filepath.Ext(filename))
|
||||||
|
if name == "" {
|
||||||
|
return 0, fmt.Errorf("%w %q: empty name", errInvalidMigrationFilename, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split on underscore to separate version from description.
|
||||||
|
// If there's no underscore, the entire stem is the version.
|
||||||
|
versionStr, _, _ := strings.Cut(name, "_")
|
||||||
|
if versionStr == "" {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"%w %q: empty version prefix", errInvalidMigrationFilename, filename,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the version is purely numeric.
|
||||||
|
for _, ch := range versionStr {
|
||||||
|
if ch < '0' || ch > '9' {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"%w %q: version %q contains non-numeric character %q",
|
||||||
|
errInvalidMigrationFilename, filename, versionStr, string(ch),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
version, err := strconv.Atoi(versionStr)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("%w %q: %w", errInvalidMigrationFilename, filename, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return version, nil
|
||||||
|
}
|
||||||
|
|
||||||
// New creates a new Database instance.
|
// New creates a new Database instance.
|
||||||
func New(lc fx.Lifecycle, params Params) (*Database, error) {
|
func New(lc fx.Lifecycle, params Params) (*Database, error) {
|
||||||
s := &Database{
|
s := &Database{
|
||||||
@@ -52,6 +101,7 @@ func New(lc fx.Lifecycle, params Params) (*Database, error) {
|
|||||||
},
|
},
|
||||||
OnStop: func(_ context.Context) error {
|
OnStop: func(_ context.Context) error {
|
||||||
s.log.Info("Database OnStop Hook")
|
s.log.Info("Database OnStop Hook")
|
||||||
|
|
||||||
if s.db != nil {
|
if s.db != nil {
|
||||||
return s.db.Close()
|
return s.db.Close()
|
||||||
}
|
}
|
||||||
@@ -63,6 +113,137 @@ func New(lc fx.Lifecycle, params Params) (*Database, error) {
|
|||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// collectMigrations reads the embedded schema directory and returns
|
||||||
|
// migration filenames sorted lexicographically.
|
||||||
|
func collectMigrations() ([]string, error) {
|
||||||
|
entries, err := schemaFS.ReadDir("schema")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read schema directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var migrations []string
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
|
||||||
|
migrations = append(migrations, entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(migrations)
|
||||||
|
|
||||||
|
return migrations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// bootstrapMigrationsTable ensures the schema_migrations table exists
|
||||||
|
// by applying 000.sql if the table is missing.
|
||||||
|
func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
||||||
|
var tableExists int
|
||||||
|
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
|
||||||
|
).Scan(&tableExists)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check for migrations table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tableExists > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := schemaFS.ReadFile("schema/000.sql")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read bootstrap migration 000.sql: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if log != nil {
|
||||||
|
log.Info("applying bootstrap migration", "version", bootstrapVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.ExecContext(ctx, string(content))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to apply bootstrap migration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyMigrations applies all pending migrations to db. An optional logger
|
||||||
|
// may be provided for informational output; pass nil for silent operation.
|
||||||
|
// This is exported so tests can apply the real schema without the full fx
|
||||||
|
// lifecycle.
|
||||||
|
func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
||||||
|
err := bootstrapMigrationsTable(ctx, db, log)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
migrations, err := collectMigrations()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, migration := range migrations {
|
||||||
|
version, parseErr := ParseMigrationVersion(migration)
|
||||||
|
if parseErr != nil {
|
||||||
|
return parseErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already applied.
|
||||||
|
var count int
|
||||||
|
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
|
||||||
|
version,
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check migration status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
if log != nil {
|
||||||
|
log.Debug("migration already applied", "version", version)
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and apply migration.
|
||||||
|
content, readErr := schemaFS.ReadFile(filepath.Join("schema", migration))
|
||||||
|
if readErr != nil {
|
||||||
|
return fmt.Errorf("failed to read migration %s: %w", migration, readErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if log != nil {
|
||||||
|
log.Info("applying migration", "version", version)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, execErr := db.ExecContext(ctx, string(content))
|
||||||
|
if execErr != nil {
|
||||||
|
return fmt.Errorf("failed to apply migration %s: %w", migration, execErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record migration as applied.
|
||||||
|
_, recErr := db.ExecContext(ctx,
|
||||||
|
"INSERT INTO schema_migrations (version) VALUES (?)",
|
||||||
|
version,
|
||||||
|
)
|
||||||
|
if recErr != nil {
|
||||||
|
return fmt.Errorf("failed to record migration %s: %w", migration, recErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if log != nil {
|
||||||
|
log.Info("migration applied successfully", "version", version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB returns the underlying sql.DB.
|
||||||
|
func (s *Database) DB() *sql.DB {
|
||||||
|
return s.db
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Database) connect(ctx context.Context) error {
|
func (s *Database) connect(ctx context.Context) error {
|
||||||
dbURL := s.config.DBURL
|
dbURL := s.config.DBURL
|
||||||
|
|
||||||
@@ -75,7 +256,8 @@ func (s *Database) connect(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.PingContext(ctx); err != nil {
|
err = db.PingContext(ctx)
|
||||||
|
if err != nil {
|
||||||
s.log.Error("failed to ping database", "error", err)
|
s.log.Error("failed to ping database", "error", err)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
@@ -84,159 +266,5 @@ func (s *Database) connect(ctx context.Context) error {
|
|||||||
s.db = db
|
s.db = db
|
||||||
s.log.Info("database connected")
|
s.log.Info("database connected")
|
||||||
|
|
||||||
return s.runMigrations(ctx)
|
return ApplyMigrations(ctx, s.db, s.log)
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Database) runMigrations(ctx context.Context) error {
|
|
||||||
// Create migrations tracking table
|
|
||||||
_, err := s.db.ExecContext(ctx, `
|
|
||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
||||||
version TEXT PRIMARY KEY,
|
|
||||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create migrations table: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get list of migration files
|
|
||||||
entries, err := schemaFS.ReadDir("schema")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read schema directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort migration files by name (001.sql, 002.sql, etc.)
|
|
||||||
var migrations []string
|
|
||||||
for _, entry := range entries {
|
|
||||||
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
|
|
||||||
migrations = append(migrations, entry.Name())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(migrations)
|
|
||||||
|
|
||||||
// Apply each migration that hasn't been applied yet
|
|
||||||
for _, migration := range migrations {
|
|
||||||
version := strings.TrimSuffix(migration, filepath.Ext(migration))
|
|
||||||
|
|
||||||
// Check if already applied
|
|
||||||
var count int
|
|
||||||
err := s.db.QueryRowContext(ctx,
|
|
||||||
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
|
|
||||||
version,
|
|
||||||
).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to check migration status: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if count > 0 {
|
|
||||||
s.log.Debug("migration already applied", "version", version)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read and apply migration
|
|
||||||
content, err := schemaFS.ReadFile(filepath.Join("schema", migration))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read migration %s: %w", migration, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.log.Info("applying migration", "version", version)
|
|
||||||
|
|
||||||
_, err = s.db.ExecContext(ctx, string(content))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to apply migration %s: %w", migration, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record migration as applied
|
|
||||||
_, err = s.db.ExecContext(ctx,
|
|
||||||
"INSERT INTO schema_migrations (version) VALUES (?)",
|
|
||||||
version,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to record migration %s: %w", migration, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.log.Info("migration applied successfully", "version", version)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DB returns the underlying sql.DB.
|
|
||||||
func (s *Database) DB() *sql.DB {
|
|
||||||
return s.db
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyMigrations applies all migrations to the given database.
|
|
||||||
// This is useful for testing where you want to use the real schema
|
|
||||||
// without the full fx lifecycle.
|
|
||||||
func ApplyMigrations(db *sql.DB) error {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Create migrations tracking table
|
|
||||||
_, err := db.ExecContext(ctx, `
|
|
||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
||||||
version TEXT PRIMARY KEY,
|
|
||||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create migrations table: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get list of migration files
|
|
||||||
entries, err := schemaFS.ReadDir("schema")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read schema directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort migration files by name (001.sql, 002.sql, etc.)
|
|
||||||
var migrations []string
|
|
||||||
for _, entry := range entries {
|
|
||||||
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
|
|
||||||
migrations = append(migrations, entry.Name())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(migrations)
|
|
||||||
|
|
||||||
// Apply each migration that hasn't been applied yet
|
|
||||||
for _, migration := range migrations {
|
|
||||||
version := strings.TrimSuffix(migration, filepath.Ext(migration))
|
|
||||||
|
|
||||||
// Check if already applied
|
|
||||||
var count int
|
|
||||||
err := db.QueryRowContext(ctx,
|
|
||||||
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
|
|
||||||
version,
|
|
||||||
).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to check migration status: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if count > 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read and apply migration
|
|
||||||
content, err := schemaFS.ReadFile(filepath.Join("schema", migration))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read migration %s: %w", migration, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.ExecContext(ctx, string(content))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to apply migration %s: %w", migration, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record migration as applied
|
|
||||||
_, err = db.ExecContext(ctx,
|
|
||||||
"INSERT INTO schema_migrations (version) VALUES (?)",
|
|
||||||
version,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to record migration %s: %w", migration, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
255
internal/database/database_internal_test.go
Normal file
255
internal/database/database_internal_test.go
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite" // SQLite driver registration
|
||||||
|
)
|
||||||
|
|
||||||
|
// openTestDB returns a fresh in-memory SQLite database.
|
||||||
|
func openTestDB(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", ":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open test db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMigrationVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
filename string
|
||||||
|
want int
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "version only",
|
||||||
|
filename: "001.sql",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "version with description",
|
||||||
|
filename: "001_initial_schema.sql",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multi-digit version",
|
||||||
|
filename: "042_add_indexes.sql",
|
||||||
|
want: 42,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "long version number",
|
||||||
|
filename: "00001_long_prefix.sql",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "description with multiple underscores",
|
||||||
|
filename: "003_add_user_auth_tables.sql",
|
||||||
|
want: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty filename",
|
||||||
|
filename: ".sql",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leading underscore",
|
||||||
|
filename: "_description.sql",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-numeric version",
|
||||||
|
filename: "abc_migration.sql",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed alphanumeric version",
|
||||||
|
filename: "001a_migration.sql",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got, err := ParseMigrationVersion(tt.filename)
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ParseMigrationVersion(%q) expected error, got %d", tt.filename, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParseMigrationVersion(%q) unexpected error: %v", tt.filename, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("ParseMigrationVersion(%q) = %d, want %d", tt.filename, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyMigrations_CreatesSchemaAndTables(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
err := ApplyMigrations(ctx, db, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyMigrations failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The schema_migrations table must exist and contain at least
|
||||||
|
// version 0 (the bootstrap) and 1 (the initial schema).
|
||||||
|
rows, err := db.QueryContext(
|
||||||
|
ctx, "SELECT version FROM schema_migrations ORDER BY version",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query schema_migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var versions []int
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var v int
|
||||||
|
|
||||||
|
scanErr := rows.Scan(&v)
|
||||||
|
if scanErr != nil {
|
||||||
|
t.Fatalf("failed to scan version: %v", scanErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
versions = append(versions, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("row iteration error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(versions) < 2 {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected at least 2 migrations recorded, got %d: %v",
|
||||||
|
len(versions), versions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if versions[0] != 0 {
|
||||||
|
t.Errorf("first recorded migration = %d, want %d", versions[0], 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if versions[1] != 1 {
|
||||||
|
t.Errorf("second recorded migration = %d, want %d", versions[1], 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the application tables created by 001.sql exist.
|
||||||
|
tables := []string{
|
||||||
|
"source_content", "source_metadata", "output_content",
|
||||||
|
"request_cache", "negative_cache", "cache_stats",
|
||||||
|
}
|
||||||
|
for _, table := range tables {
|
||||||
|
var count int
|
||||||
|
|
||||||
|
err := db.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?",
|
||||||
|
table,
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to check for table %s: %v", table, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("table %s does not exist after migrations", table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyMigrations_Idempotent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
err := ApplyMigrations(ctx, db, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first ApplyMigrations failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Running a second time must succeed without errors.
|
||||||
|
err = ApplyMigrations(ctx, db, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second ApplyMigrations failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no duplicate rows in schema_migrations.
|
||||||
|
var count int
|
||||||
|
|
||||||
|
err = db.QueryRowContext(
|
||||||
|
ctx, "SELECT COUNT(*) FROM schema_migrations WHERE version = 0",
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to count version 0 rows: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("expected exactly 1 row for version 0, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBootstrapMigrationsTable_FreshDatabase(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
err := bootstrapMigrationsTable(ctx, db, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bootstrapMigrationsTable failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// schema_migrations table must exist.
|
||||||
|
var tableCount int
|
||||||
|
|
||||||
|
err = db.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
|
||||||
|
).Scan(&tableCount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to check for table: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tableCount != 1 {
|
||||||
|
t.Fatalf("schema_migrations table not created")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version 0 must be recorded.
|
||||||
|
var recorded int
|
||||||
|
|
||||||
|
err = db.QueryRowContext(
|
||||||
|
ctx, "SELECT COUNT(*) FROM schema_migrations WHERE version = 0",
|
||||||
|
).Scan(&recorded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to check version: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if recorded != 1 {
|
||||||
|
t.Errorf("expected version 0 to be recorded, got count %d", recorded)
|
||||||
|
}
|
||||||
|
}
|
||||||
9
internal/database/schema/000.sql
Normal file
9
internal/database/schema/000.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
-- Migration 000: Schema migrations tracking table
|
||||||
|
-- Applied as a bootstrap step before the normal migration loop.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);
|
||||||
@@ -48,7 +48,8 @@ type Generator struct {
|
|||||||
key [seal.KeySize]byte
|
key [seal.KeySize]byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGenerator creates an encrypted URL generator with a key derived from the signing key.
|
// NewGenerator creates an encrypted URL generator with a key derived
|
||||||
|
// from the signing key.
|
||||||
func NewGenerator(signingKey string) (*Generator, error) {
|
func NewGenerator(signingKey string) (*Generator, error) {
|
||||||
key, err := seal.DeriveKey([]byte(signingKey), urlKeySalt)
|
key, err := seal.DeriveKey([]byte(signingKey), urlKeySalt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -77,7 +78,8 @@ func (g *Generator) Parse(token string) (*Payload, error) {
|
|||||||
// Decrypt
|
// Decrypt
|
||||||
data, err := seal.Decrypt(g.key, token)
|
data, err := seal.Decrypt(g.key, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, seal.ErrDecryptionFailed) || errors.Is(err, seal.ErrInvalidPayload) {
|
if errors.Is(err, seal.ErrDecryptionFailed) ||
|
||||||
|
errors.Is(err, seal.ErrInvalidPayload) {
|
||||||
return nil, ErrDecryptFailed
|
return nil, ErrDecryptFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +88,9 @@ func (g *Generator) Parse(token string) (*Payload, error) {
|
|||||||
|
|
||||||
// CBOR decode
|
// CBOR decode
|
||||||
var p Payload
|
var p Payload
|
||||||
if err := cbor.Unmarshal(data, &p); err != nil {
|
|
||||||
|
err = cbor.Unmarshal(data, &p)
|
||||||
|
if err != nil {
|
||||||
return nil, ErrInvalidFormat
|
return nil, ErrInvalidFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,33 @@
|
|||||||
package encurl
|
package encurl_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Shared test fixture strings.
|
||||||
|
const (
|
||||||
|
testSourceHost = "cdn.example.com"
|
||||||
|
testSourcePath = "/images/photo.jpg"
|
||||||
|
testSourceQuery = "v=2"
|
||||||
|
)
|
||||||
|
|
||||||
func TestGenerator_GenerateAndParse(t *testing.T) {
|
func TestGenerator_GenerateAndParse(t *testing.T) {
|
||||||
gen, err := NewGenerator("test-signing-key-12345")
|
t.Parallel()
|
||||||
|
|
||||||
|
gen, err := encurl.NewGenerator("test-signing-key-12345")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewGenerator() error = %v", err)
|
t.Fatalf("NewGenerator() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := &Payload{
|
payload := &encurl.Payload{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testSourceHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testSourcePath,
|
||||||
SourceQuery: "v=2",
|
SourceQuery: testSourceQuery,
|
||||||
Width: 800,
|
Width: 800,
|
||||||
Height: 600,
|
Height: 600,
|
||||||
Format: imgcache.FormatWebP,
|
Format: imgcache.FormatWebP,
|
||||||
@@ -43,38 +54,48 @@ func TestGenerator_GenerateAndParse(t *testing.T) {
|
|||||||
if parsed.SourceHost != payload.SourceHost {
|
if parsed.SourceHost != payload.SourceHost {
|
||||||
t.Errorf("SourceHost = %q, want %q", parsed.SourceHost, payload.SourceHost)
|
t.Errorf("SourceHost = %q, want %q", parsed.SourceHost, payload.SourceHost)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.SourcePath != payload.SourcePath {
|
if parsed.SourcePath != payload.SourcePath {
|
||||||
t.Errorf("SourcePath = %q, want %q", parsed.SourcePath, payload.SourcePath)
|
t.Errorf("SourcePath = %q, want %q", parsed.SourcePath, payload.SourcePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.SourceQuery != payload.SourceQuery {
|
if parsed.SourceQuery != payload.SourceQuery {
|
||||||
t.Errorf("SourceQuery = %q, want %q", parsed.SourceQuery, payload.SourceQuery)
|
t.Errorf("SourceQuery = %q, want %q", parsed.SourceQuery, payload.SourceQuery)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Width != payload.Width {
|
if parsed.Width != payload.Width {
|
||||||
t.Errorf("Width = %d, want %d", parsed.Width, payload.Width)
|
t.Errorf("Width = %d, want %d", parsed.Width, payload.Width)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Height != payload.Height {
|
if parsed.Height != payload.Height {
|
||||||
t.Errorf("Height = %d, want %d", parsed.Height, payload.Height)
|
t.Errorf("Height = %d, want %d", parsed.Height, payload.Height)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Format != payload.Format {
|
if parsed.Format != payload.Format {
|
||||||
t.Errorf("Format = %q, want %q", parsed.Format, payload.Format)
|
t.Errorf("Format = %q, want %q", parsed.Format, payload.Format)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Quality != payload.Quality {
|
if parsed.Quality != payload.Quality {
|
||||||
t.Errorf("Quality = %d, want %d", parsed.Quality, payload.Quality)
|
t.Errorf("Quality = %d, want %d", parsed.Quality, payload.Quality)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.FitMode != payload.FitMode {
|
if parsed.FitMode != payload.FitMode {
|
||||||
t.Errorf("FitMode = %q, want %q", parsed.FitMode, payload.FitMode)
|
t.Errorf("FitMode = %q, want %q", parsed.FitMode, payload.FitMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.ExpiresAt != payload.ExpiresAt {
|
if parsed.ExpiresAt != payload.ExpiresAt {
|
||||||
t.Errorf("ExpiresAt = %d, want %d", parsed.ExpiresAt, payload.ExpiresAt)
|
t.Errorf("ExpiresAt = %d, want %d", parsed.ExpiresAt, payload.ExpiresAt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerator_Parse_Expired(t *testing.T) {
|
func TestGenerator_Parse_Expired(t *testing.T) {
|
||||||
gen, _ := NewGenerator("test-signing-key-12345")
|
t.Parallel()
|
||||||
|
|
||||||
payload := &Payload{
|
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/images/photo.jpg",
|
payload := &encurl.Payload{
|
||||||
|
SourceHost: testSourceHost,
|
||||||
|
SourcePath: testSourcePath,
|
||||||
ExpiresAt: time.Now().Add(-time.Hour).Unix(), // Already expired
|
ExpiresAt: time.Now().Add(-time.Hour).Unix(), // Already expired
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,13 +109,15 @@ func TestGenerator_Parse_Expired(t *testing.T) {
|
|||||||
t.Error("Parse() should fail for expired token")
|
t.Error("Parse() should fail for expired token")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != ErrExpired {
|
if !errors.Is(err, encurl.ErrExpired) {
|
||||||
t.Errorf("Parse() error = %v, want %v", err, ErrExpired)
|
t.Errorf("Parse() error = %v, want %v", err, encurl.ErrExpired)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerator_Parse_InvalidToken(t *testing.T) {
|
func TestGenerator_Parse_InvalidToken(t *testing.T) {
|
||||||
gen, _ := NewGenerator("test-signing-key-12345")
|
t.Parallel()
|
||||||
|
|
||||||
|
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
||||||
|
|
||||||
_, err := gen.Parse("not-a-valid-token")
|
_, err := gen.Parse("not-a-valid-token")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -103,11 +126,13 @@ func TestGenerator_Parse_InvalidToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerator_Parse_TamperedToken(t *testing.T) {
|
func TestGenerator_Parse_TamperedToken(t *testing.T) {
|
||||||
gen, _ := NewGenerator("test-signing-key-12345")
|
t.Parallel()
|
||||||
|
|
||||||
payload := &Payload{
|
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/images/photo.jpg",
|
payload := &encurl.Payload{
|
||||||
|
SourceHost: testSourceHost,
|
||||||
|
SourcePath: testSourcePath,
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,12 +151,14 @@ func TestGenerator_Parse_TamperedToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerator_Parse_WrongKey(t *testing.T) {
|
func TestGenerator_Parse_WrongKey(t *testing.T) {
|
||||||
gen1, _ := NewGenerator("signing-key-1")
|
t.Parallel()
|
||||||
gen2, _ := NewGenerator("signing-key-2")
|
|
||||||
|
|
||||||
payload := &Payload{
|
gen1, _ := encurl.NewGenerator("signing-key-1")
|
||||||
SourceHost: "cdn.example.com",
|
gen2, _ := encurl.NewGenerator("signing-key-2")
|
||||||
SourcePath: "/images/photo.jpg",
|
|
||||||
|
payload := &encurl.Payload{
|
||||||
|
SourceHost: testSourceHost,
|
||||||
|
SourcePath: testSourcePath,
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,10 +171,12 @@ func TestGenerator_Parse_WrongKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPayload_ToImageRequest(t *testing.T) {
|
func TestPayload_ToImageRequest(t *testing.T) {
|
||||||
payload := &Payload{
|
t.Parallel()
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/images/photo.jpg",
|
payload := &encurl.Payload{
|
||||||
SourceQuery: "v=2",
|
SourceHost: testSourceHost,
|
||||||
|
SourcePath: testSourcePath,
|
||||||
|
SourceQuery: testSourceQuery,
|
||||||
Width: 800,
|
Width: 800,
|
||||||
Height: 600,
|
Height: 600,
|
||||||
Format: imgcache.FormatWebP,
|
Format: imgcache.FormatWebP,
|
||||||
@@ -161,55 +190,68 @@ func TestPayload_ToImageRequest(t *testing.T) {
|
|||||||
if req.SourceHost != payload.SourceHost {
|
if req.SourceHost != payload.SourceHost {
|
||||||
t.Errorf("SourceHost = %q, want %q", req.SourceHost, payload.SourceHost)
|
t.Errorf("SourceHost = %q, want %q", req.SourceHost, payload.SourceHost)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.SourcePath != payload.SourcePath {
|
if req.SourcePath != payload.SourcePath {
|
||||||
t.Errorf("SourcePath = %q, want %q", req.SourcePath, payload.SourcePath)
|
t.Errorf("SourcePath = %q, want %q", req.SourcePath, payload.SourcePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.SourceQuery != payload.SourceQuery {
|
if req.SourceQuery != payload.SourceQuery {
|
||||||
t.Errorf("SourceQuery = %q, want %q", req.SourceQuery, payload.SourceQuery)
|
t.Errorf("SourceQuery = %q, want %q", req.SourceQuery, payload.SourceQuery)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Size.Width != payload.Width {
|
if req.Size.Width != payload.Width {
|
||||||
t.Errorf("Width = %d, want %d", req.Size.Width, payload.Width)
|
t.Errorf("Width = %d, want %d", req.Size.Width, payload.Width)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Size.Height != payload.Height {
|
if req.Size.Height != payload.Height {
|
||||||
t.Errorf("Height = %d, want %d", req.Size.Height, payload.Height)
|
t.Errorf("Height = %d, want %d", req.Size.Height, payload.Height)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Format != payload.Format {
|
if req.Format != payload.Format {
|
||||||
t.Errorf("Format = %q, want %q", req.Format, payload.Format)
|
t.Errorf("Format = %q, want %q", req.Format, payload.Format)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Quality != payload.Quality {
|
if req.Quality != payload.Quality {
|
||||||
t.Errorf("Quality = %d, want %d", req.Quality, payload.Quality)
|
t.Errorf("Quality = %d, want %d", req.Quality, payload.Quality)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.FitMode != payload.FitMode {
|
if req.FitMode != payload.FitMode {
|
||||||
t.Errorf("FitMode = %q, want %q", req.FitMode, payload.FitMode)
|
t.Errorf("FitMode = %q, want %q", req.FitMode, payload.FitMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPayload_ToImageRequest_Defaults(t *testing.T) {
|
func TestPayload_ToImageRequest_Defaults(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// Payload with only required fields - should get defaults
|
// Payload with only required fields - should get defaults
|
||||||
payload := &Payload{
|
payload := &encurl.Payload{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testSourceHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testSourcePath,
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
||||||
}
|
}
|
||||||
|
|
||||||
req := payload.ToImageRequest()
|
req := payload.ToImageRequest()
|
||||||
|
|
||||||
if req.Format != DefaultFormat {
|
if req.Format != encurl.DefaultFormat {
|
||||||
t.Errorf("Format = %q, want default %q", req.Format, DefaultFormat)
|
t.Errorf("Format = %q, want default %q", req.Format, encurl.DefaultFormat)
|
||||||
}
|
}
|
||||||
if req.Quality != DefaultQuality {
|
|
||||||
t.Errorf("Quality = %d, want default %d", req.Quality, DefaultQuality)
|
if req.Quality != encurl.DefaultQuality {
|
||||||
|
t.Errorf("Quality = %d, want default %d", req.Quality, encurl.DefaultQuality)
|
||||||
}
|
}
|
||||||
if req.FitMode != DefaultFitMode {
|
|
||||||
t.Errorf("FitMode = %q, want default %q", req.FitMode, DefaultFitMode)
|
if req.FitMode != encurl.DefaultFitMode {
|
||||||
|
t.Errorf("FitMode = %q, want default %q", req.FitMode, encurl.DefaultFitMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFromImageRequest(t *testing.T) {
|
func TestFromImageRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
req := &imgcache.ImageRequest{
|
req := &imgcache.ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testSourceHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testSourcePath,
|
||||||
SourceQuery: "v=2",
|
SourceQuery: testSourceQuery,
|
||||||
Size: imgcache.Size{Width: 800, Height: 600},
|
Size: imgcache.Size{Width: 800, Height: 600},
|
||||||
Format: imgcache.FormatWebP,
|
Format: imgcache.FormatWebP,
|
||||||
Quality: 90,
|
Quality: 90,
|
||||||
@@ -217,52 +259,62 @@ func TestFromImageRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expiresAt := time.Now().Add(time.Hour)
|
expiresAt := time.Now().Add(time.Hour)
|
||||||
payload := FromImageRequest(req, expiresAt)
|
payload := encurl.FromImageRequest(req, expiresAt)
|
||||||
|
|
||||||
if payload.SourceHost != req.SourceHost {
|
if payload.SourceHost != req.SourceHost {
|
||||||
t.Errorf("SourceHost = %q, want %q", payload.SourceHost, req.SourceHost)
|
t.Errorf("SourceHost = %q, want %q", payload.SourceHost, req.SourceHost)
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.SourcePath != req.SourcePath {
|
if payload.SourcePath != req.SourcePath {
|
||||||
t.Errorf("SourcePath = %q, want %q", payload.SourcePath, req.SourcePath)
|
t.Errorf("SourcePath = %q, want %q", payload.SourcePath, req.SourcePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.Width != req.Size.Width {
|
if payload.Width != req.Size.Width {
|
||||||
t.Errorf("Width = %d, want %d", payload.Width, req.Size.Width)
|
t.Errorf("Width = %d, want %d", payload.Width, req.Size.Width)
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.ExpiresAt != expiresAt.Unix() {
|
if payload.ExpiresAt != expiresAt.Unix() {
|
||||||
t.Errorf("ExpiresAt = %d, want %d", payload.ExpiresAt, expiresAt.Unix())
|
t.Errorf("ExpiresAt = %d, want %d", payload.ExpiresAt, expiresAt.Unix())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFromImageRequest_OmitsDefaults(t *testing.T) {
|
func TestFromImageRequest_OmitsDefaults(t *testing.T) {
|
||||||
// Request with default values - payload should omit them for smaller encoding
|
t.Parallel()
|
||||||
|
|
||||||
|
// Request with default values - payload should omit them for
|
||||||
|
// smaller encoding
|
||||||
req := &imgcache.ImageRequest{
|
req := &imgcache.ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testSourceHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testSourcePath,
|
||||||
Format: DefaultFormat,
|
Format: encurl.DefaultFormat,
|
||||||
Quality: DefaultQuality,
|
Quality: encurl.DefaultQuality,
|
||||||
FitMode: DefaultFitMode,
|
FitMode: encurl.DefaultFitMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := FromImageRequest(req, time.Now().Add(time.Hour))
|
payload := encurl.FromImageRequest(req, time.Now().Add(time.Hour))
|
||||||
|
|
||||||
// These should be zero/empty because they match defaults
|
// These should be zero/empty because they match defaults
|
||||||
if payload.Format != "" {
|
if payload.Format != "" {
|
||||||
t.Errorf("Format should be empty for default, got %q", payload.Format)
|
t.Errorf("Format should be empty for default, got %q", payload.Format)
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.Quality != 0 {
|
if payload.Quality != 0 {
|
||||||
t.Errorf("Quality should be 0 for default, got %d", payload.Quality)
|
t.Errorf("Quality should be 0 for default, got %d", payload.Quality)
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.FitMode != "" {
|
if payload.FitMode != "" {
|
||||||
t.Errorf("FitMode should be empty for default, got %q", payload.FitMode)
|
t.Errorf("FitMode should be empty for default, got %q", payload.FitMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerator_TokenIsURLSafe(t *testing.T) {
|
func TestGenerator_TokenIsURLSafe(t *testing.T) {
|
||||||
gen, _ := NewGenerator("test-signing-key-12345")
|
t.Parallel()
|
||||||
|
|
||||||
payload := &Payload{
|
gen, _ := encurl.NewGenerator("test-signing-key-12345")
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/images/photo.jpg",
|
payload := &encurl.Payload{
|
||||||
|
SourceHost: testSourceHost,
|
||||||
|
SourcePath: testSourcePath,
|
||||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import (
|
|||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Build-time variables populated from main() via ldflags.
|
const appname = "pixad"
|
||||||
var (
|
|
||||||
Appname string //nolint:gochecknoglobals // set from main
|
// Version is populated from main() via ldflags.
|
||||||
Version string //nolint:gochecknoglobals // set from main
|
var Version string //nolint:gochecknoglobals // set from main
|
||||||
)
|
|
||||||
|
|
||||||
// Globals holds application-wide constants.
|
// Globals holds application-wide constants.
|
||||||
type Globals struct {
|
type Globals struct {
|
||||||
@@ -20,7 +19,7 @@ type Globals struct {
|
|||||||
// New creates a new Globals instance from build-time variables.
|
// New creates a new Globals instance from build-time variables.
|
||||||
func New(_ fx.Lifecycle) (*Globals, error) {
|
func New(_ fx.Lifecycle) (*Globals, error) {
|
||||||
return &Globals{
|
return &Globals{
|
||||||
Appname: Appname,
|
Appname: appname,
|
||||||
Version: Version,
|
Version: Version,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ func (s *Handlers) HandleRoot() http.HandlerFunc {
|
|||||||
|
|
||||||
// handleLoginPost handles login form submission.
|
// handleLoginPost handles login form submission.
|
||||||
func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := r.ParseForm(); err != nil {
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
s.renderLogin(w, "Invalid form data")
|
s.renderLogin(w, "Invalid form data")
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -52,7 +53,8 @@ func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create session
|
// Create session
|
||||||
if err := s.sessMgr.CreateSession(w); err != nil {
|
err = s.sessMgr.CreateSession(w)
|
||||||
|
if err != nil {
|
||||||
s.log.Error("failed to create session", "error", err)
|
s.log.Error("failed to create session", "error", err)
|
||||||
s.renderLogin(w, "Failed to create session")
|
s.renderLogin(w, "Failed to create session")
|
||||||
|
|
||||||
@@ -83,20 +85,14 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.ParseForm(); err != nil {
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
s.renderGenerator(w, &generatorData{Error: "Invalid form data"})
|
s.renderGenerator(w, &generatorData{Error: "Invalid form data"})
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse form values
|
|
||||||
sourceURL := r.FormValue("url")
|
sourceURL := r.FormValue("url")
|
||||||
widthStr := r.FormValue("width")
|
|
||||||
heightStr := r.FormValue("height")
|
|
||||||
format := r.FormValue("format")
|
|
||||||
qualityStr := r.FormValue("quality")
|
|
||||||
fit := r.FormValue("fit")
|
|
||||||
ttlStr := r.FormValue("ttl")
|
|
||||||
|
|
||||||
// Validate source URL
|
// Validate source URL
|
||||||
parsed, err := url.Parse(sourceURL)
|
parsed, err := url.Parse(sourceURL)
|
||||||
@@ -106,38 +102,7 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse dimensions
|
payload, expiresAt, ttl := buildGeneratePayload(parsed, r.Form)
|
||||||
width, _ := strconv.Atoi(widthStr)
|
|
||||||
height, _ := strconv.Atoi(heightStr)
|
|
||||||
quality, _ := strconv.Atoi(qualityStr)
|
|
||||||
ttl, _ := strconv.Atoi(ttlStr)
|
|
||||||
|
|
||||||
if quality <= 0 {
|
|
||||||
quality = 85
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create payload
|
|
||||||
// ttl=0 means never expires
|
|
||||||
var expiresAt time.Time
|
|
||||||
var expiresAtUnix int64
|
|
||||||
|
|
||||||
if ttl > 0 {
|
|
||||||
expiresAt = time.Now().Add(time.Duration(ttl) * time.Second)
|
|
||||||
expiresAtUnix = expiresAt.Unix()
|
|
||||||
}
|
|
||||||
// else expiresAtUnix stays 0 (never expires)
|
|
||||||
|
|
||||||
payload := &encurl.Payload{
|
|
||||||
SourceHost: parsed.Host,
|
|
||||||
SourcePath: parsed.Path,
|
|
||||||
SourceQuery: parsed.RawQuery,
|
|
||||||
Width: width,
|
|
||||||
Height: height,
|
|
||||||
Format: imgcache.ImageFormat(format),
|
|
||||||
Quality: quality,
|
|
||||||
FitMode: imgcache.FitMode(fit),
|
|
||||||
ExpiresAt: expiresAtUnix,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate encrypted token
|
// Generate encrypted token
|
||||||
token, err := s.encGen.Generate(payload)
|
token, err := s.encGen.Generate(payload)
|
||||||
@@ -148,20 +113,7 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build full URL (URL-encode the token for safety)
|
generatedURL := s.buildGeneratedURL(r, token, r.FormValue("format"))
|
||||||
scheme := "https"
|
|
||||||
if s.config.Debug {
|
|
||||||
scheme = "http"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine file extension for the trailing filename
|
|
||||||
ext := format
|
|
||||||
if ext == "" || ext == "orig" {
|
|
||||||
ext = "jpg" // Default extension
|
|
||||||
}
|
|
||||||
|
|
||||||
host := r.Host
|
|
||||||
generatedURL := scheme + "://" + host + "/v1/e/" + url.PathEscape(token) + "/img." + ext
|
|
||||||
|
|
||||||
// Format expiry for display
|
// Format expiry for display
|
||||||
expiresAtStr := "Never"
|
expiresAtStr := "Never"
|
||||||
@@ -173,16 +125,55 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
GeneratedURL: generatedURL,
|
GeneratedURL: generatedURL,
|
||||||
ExpiresAt: expiresAtStr,
|
ExpiresAt: expiresAtStr,
|
||||||
FormURL: sourceURL,
|
FormURL: sourceURL,
|
||||||
FormWidth: widthStr,
|
FormWidth: r.FormValue("width"),
|
||||||
FormHeight: heightStr,
|
FormHeight: r.FormValue("height"),
|
||||||
FormFormat: format,
|
FormFormat: r.FormValue("format"),
|
||||||
FormQuality: qualityStr,
|
FormQuality: r.FormValue("quality"),
|
||||||
FormFit: fit,
|
FormFit: r.FormValue("fit"),
|
||||||
FormTTL: ttlStr,
|
FormTTL: r.FormValue("ttl"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildGeneratePayload parses the numeric form fields and assembles the
|
||||||
|
// encrypted URL payload. ttl=0 means never expires (ExpiresAt stays 0).
|
||||||
|
func buildGeneratePayload(
|
||||||
|
parsed *url.URL, form url.Values,
|
||||||
|
) (*encurl.Payload, time.Time, int) {
|
||||||
|
width, _ := strconv.Atoi(form.Get("width"))
|
||||||
|
height, _ := strconv.Atoi(form.Get("height"))
|
||||||
|
quality, _ := strconv.Atoi(form.Get("quality"))
|
||||||
|
ttl, _ := strconv.Atoi(form.Get("ttl"))
|
||||||
|
|
||||||
|
if quality <= 0 {
|
||||||
|
quality = 85
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
expiresAt time.Time
|
||||||
|
expiresAtUnix int64
|
||||||
|
)
|
||||||
|
|
||||||
|
if ttl > 0 {
|
||||||
|
expiresAt = time.Now().Add(time.Duration(ttl) * time.Second)
|
||||||
|
expiresAtUnix = expiresAt.Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := &encurl.Payload{
|
||||||
|
SourceHost: parsed.Host,
|
||||||
|
SourcePath: parsed.Path,
|
||||||
|
SourceQuery: parsed.RawQuery,
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
Format: imgcache.ImageFormat(form.Get("format")),
|
||||||
|
Quality: quality,
|
||||||
|
FitMode: imgcache.FitMode(form.Get("fit")),
|
||||||
|
ExpiresAt: expiresAtUnix,
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload, expiresAt, ttl
|
||||||
|
}
|
||||||
|
|
||||||
// generatorData holds template data for the generator page.
|
// generatorData holds template data for the generator page.
|
||||||
type generatorData struct {
|
type generatorData struct {
|
||||||
GeneratedURL string
|
GeneratedURL string
|
||||||
@@ -206,7 +197,8 @@ func (s *Handlers) renderLogin(w http.ResponseWriter, errorMsg string) {
|
|||||||
Error: errorMsg,
|
Error: errorMsg,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := templates.Render(w, "login.html", data); err != nil {
|
err := templates.Render(w, "login.html", data)
|
||||||
|
if err != nil {
|
||||||
s.log.Error("failed to render login template", "error", err)
|
s.log.Error("failed to render login template", "error", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
@@ -219,13 +211,16 @@ func (s *Handlers) renderGenerator(w http.ResponseWriter, data *generatorData) {
|
|||||||
data = &generatorData{}
|
data = &generatorData{}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := templates.Render(w, "generator.html", data); err != nil {
|
err := templates.Render(w, "generator.html", data)
|
||||||
|
if err != nil {
|
||||||
s.log.Error("failed to render generator template", "error", err)
|
s.log.Error("failed to render generator template", "error", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Handlers) renderGeneratorWithForm(w http.ResponseWriter, errorMsg string, form url.Values) {
|
func (s *Handlers) renderGeneratorWithForm(
|
||||||
|
w http.ResponseWriter, errorMsg string, form url.Values,
|
||||||
|
) {
|
||||||
s.renderGenerator(w, &generatorData{
|
s.renderGenerator(w, &generatorData{
|
||||||
Error: errorMsg,
|
Error: errorMsg,
|
||||||
FormURL: form.Get("url"),
|
FormURL: form.Get("url"),
|
||||||
@@ -237,3 +232,19 @@ func (s *Handlers) renderGeneratorWithForm(w http.ResponseWriter, errorMsg strin
|
|||||||
FormTTL: form.Get("ttl"),
|
FormTTL: form.Get("ttl"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Handlers) buildGeneratedURL(r *http.Request, token, format string) string {
|
||||||
|
// Build full URL (URL-encode the token for safety)
|
||||||
|
scheme := "https"
|
||||||
|
if s.config.Debug {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine file extension for the trailing filename
|
||||||
|
ext := format
|
||||||
|
if ext == "" || ext == "orig" {
|
||||||
|
ext = "jpg" // Default extension
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheme + "://" + r.Host + "/v1/e/" + url.PathEscape(token) + "/img." + ext
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"sneak.berlin/go/pixa/internal/database"
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/healthcheck"
|
"sneak.berlin/go/pixa/internal/healthcheck"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
"sneak.berlin/go/pixa/internal/logger"
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
"sneak.berlin/go/pixa/internal/session"
|
"sneak.berlin/go/pixa/internal/session"
|
||||||
@@ -21,6 +22,7 @@ import (
|
|||||||
// Params defines dependencies for Handlers.
|
// Params defines dependencies for Handlers.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
Database *database.Database
|
Database *database.Database
|
||||||
@@ -72,8 +74,9 @@ func (s *Handlers) initImageService() error {
|
|||||||
s.imgCache = cache
|
s.imgCache = cache
|
||||||
|
|
||||||
// Create the fetcher config
|
// Create the fetcher config
|
||||||
fetcherCfg := imgcache.DefaultFetcherConfig()
|
fetcherCfg := httpfetcher.DefaultConfig()
|
||||||
fetcherCfg.AllowHTTP = s.config.AllowHTTP
|
fetcherCfg.AllowHTTP = s.config.AllowHTTP
|
||||||
|
|
||||||
if s.config.UpstreamConnectionsPerHost > 0 {
|
if s.config.UpstreamConnectionsPerHost > 0 {
|
||||||
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost
|
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost
|
||||||
}
|
}
|
||||||
@@ -83,7 +86,7 @@ func (s *Handlers) initImageService() error {
|
|||||||
Cache: cache,
|
Cache: cache,
|
||||||
FetcherConfig: fetcherCfg,
|
FetcherConfig: fetcherCfg,
|
||||||
SigningKey: s.config.SigningKey,
|
SigningKey: s.config.SigningKey,
|
||||||
Whitelist: s.config.WhitelistHosts,
|
Allowlist: s.config.AllowlistHosts,
|
||||||
Logger: s.log,
|
Logger: s.log,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -93,11 +96,13 @@ func (s *Handlers) initImageService() error {
|
|||||||
s.imgSvc = svc
|
s.imgSvc = svc
|
||||||
s.log.Info("image service initialized")
|
s.log.Info("image service initialized")
|
||||||
|
|
||||||
// Initialize session manager (signing key is validated at config load time)
|
// Initialize session manager (signing key is validated at config load
|
||||||
sessMgr, err := session.NewManager(s.config.SigningKey, !s.config.Debug)
|
// time). Session cookies are always Secure/HttpOnly/SameSite=Strict.
|
||||||
|
sessMgr, err := session.NewManager(s.config.SigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.sessMgr = sessMgr
|
s.sessMgr = sessMgr
|
||||||
|
|
||||||
// Initialize encrypted URL generator
|
// Initialize encrypted URL generator
|
||||||
@@ -105,6 +110,7 @@ func (s *Handlers) initImageService() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.encGen = encGen
|
s.encGen = encGen
|
||||||
|
|
||||||
s.log.Info("session manager and URL generator initialized")
|
s.log.Info("session manager and URL generator initialized")
|
||||||
@@ -112,9 +118,10 @@ func (s *Handlers) initImageService() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Handlers) respondJSON(w http.ResponseWriter, data interface{}, status int) {
|
func (s *Handlers) respondJSON(w http.ResponseWriter, data any, status int) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
|
|
||||||
if data != nil {
|
if data != nil {
|
||||||
err := json.NewEncoder(w).Encode(data)
|
err := json.NewEncoder(w).Encode(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -124,7 +131,7 @@ func (s *Handlers) respondJSON(w http.ResponseWriter, data interface{}, status i
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Handlers) respondError(w http.ResponseWriter, message string, status int) {
|
func (s *Handlers) respondError(w http.ResponseWriter, message string, status int) {
|
||||||
s.respondJSON(w, map[string]interface{}{
|
s.respondJSON(w, map[string]any{
|
||||||
"error": message,
|
"error": message,
|
||||||
"status": status,
|
"status": status,
|
||||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"sneak.berlin/go/pixa/internal/database"
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ func setupTestHandler(t *testing.T) *testFixtures {
|
|||||||
Cache: cache,
|
Cache: cache,
|
||||||
Fetcher: newMockFetcher(mockFS),
|
Fetcher: newMockFetcher(mockFS),
|
||||||
SigningKey: "test-signing-key-must-be-32-chars",
|
SigningKey: "test-signing-key-must-be-32-chars",
|
||||||
Whitelist: []string{goodHost},
|
Allowlist: []string{goodHost},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create service: %v", err)
|
t.Fatalf("failed to create service: %v", err)
|
||||||
@@ -82,7 +83,8 @@ func setupTestDB(t *testing.T) *sql.DB {
|
|||||||
t.Fatalf("failed to open test db: %v", err)
|
t.Fatalf("failed to open test db: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := database.ApplyMigrations(db); err != nil {
|
err = database.ApplyMigrations(context.Background(), db, nil)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("failed to apply migrations: %v", err)
|
t.Fatalf("failed to apply migrations: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,14 +95,16 @@ func generateTestJPEG(t *testing.T, width, height int, c color.Color) []byte {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
for y := 0; y < height; y++ {
|
for y := range height {
|
||||||
for x := 0; x < width; x++ {
|
for x := range width {
|
||||||
img.Set(x, y, c)
|
img.Set(x, y, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
|
|
||||||
|
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85})
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("failed to encode test JPEG: %v", err)
|
t.Fatalf("failed to encode test JPEG: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,16 +120,18 @@ func newMockFetcher(fs fs.FS) *mockFetcher {
|
|||||||
return &mockFetcher{fs: fs}
|
return &mockFetcher{fs: fs}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *mockFetcher) Fetch(ctx context.Context, url string) (*imgcache.FetchResult, error) {
|
func (f *mockFetcher) Fetch(
|
||||||
|
_ context.Context, url string,
|
||||||
|
) (*httpfetcher.FetchResult, error) {
|
||||||
// Remove https:// prefix
|
// Remove https:// prefix
|
||||||
path := url[8:] // Remove "https://"
|
path := url[8:] // Remove "https://"
|
||||||
|
|
||||||
data, err := fs.ReadFile(f.fs, path)
|
data, err := fs.ReadFile(f.fs, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, imgcache.ErrUpstreamError
|
return nil, httpfetcher.ErrUpstreamError
|
||||||
}
|
}
|
||||||
|
|
||||||
return &imgcache.FetchResult{
|
return &httpfetcher.FetchResult{
|
||||||
Content: io.NopCloser(bytes.NewReader(data)),
|
Content: io.NopCloser(bytes.NewReader(data)),
|
||||||
ContentLength: int64(len(data)),
|
ContentLength: int64(len(data)),
|
||||||
ContentType: "image/jpeg",
|
ContentType: "image/jpeg",
|
||||||
@@ -133,13 +139,16 @@ func (f *mockFetcher) Fetch(ctx context.Context, url string) (*imgcache.FetchRes
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleImage_HEAD_ReturnsHeadersOnly(t *testing.T) {
|
func TestHandleImage_HEAD_ReturnsHeadersOnly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fix := setupTestHandler(t)
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
// Create a chi router to properly handle wildcards
|
// Create a chi router to properly handle wildcards
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Head("/v1/image/*", fix.handler.HandleImage())
|
r.Head("/v1/image/*", fix.handler.HandleImage())
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodHead, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodHead,
|
||||||
|
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
@@ -166,13 +175,16 @@ func TestHandleImage_HEAD_ReturnsHeadersOnly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleImage_ConditionalRequest_IfNoneMatch_Returns304(t *testing.T) {
|
func TestHandleImage_ConditionalRequest_IfNoneMatch_Returns304(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fix := setupTestHandler(t)
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Get("/v1/image/*", fix.handler.HandleImage())
|
r.Get("/v1/image/*", fix.handler.HandleImage())
|
||||||
|
|
||||||
// First request to get the ETag
|
// First request to get the ETag
|
||||||
req1 := httptest.NewRequest(http.MethodGet, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
req1 := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
||||||
rec1 := httptest.NewRecorder()
|
rec1 := httptest.NewRecorder()
|
||||||
|
|
||||||
r.ServeHTTP(rec1, req1)
|
r.ServeHTTP(rec1, req1)
|
||||||
@@ -187,15 +199,18 @@ func TestHandleImage_ConditionalRequest_IfNoneMatch_Returns304(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Second request with If-None-Match header
|
// Second request with If-None-Match header
|
||||||
req2 := httptest.NewRequest(http.MethodGet, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
req2 := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
||||||
req2.Header.Set("If-None-Match", etag)
|
req2.Header.Set("If-None-Match", etag)
|
||||||
|
|
||||||
rec2 := httptest.NewRecorder()
|
rec2 := httptest.NewRecorder()
|
||||||
|
|
||||||
r.ServeHTTP(rec2, req2)
|
r.ServeHTTP(rec2, req2)
|
||||||
|
|
||||||
// Should return 304 Not Modified
|
// Should return 304 Not Modified
|
||||||
if rec2.Code != http.StatusNotModified {
|
if rec2.Code != http.StatusNotModified {
|
||||||
t.Errorf("Conditional request status = %d, want %d", rec2.Code, http.StatusNotModified)
|
t.Errorf("Conditional request status = %d, want %d",
|
||||||
|
rec2.Code, http.StatusNotModified)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Body should be empty for 304 response
|
// Body should be empty for 304 response
|
||||||
@@ -205,21 +220,26 @@ func TestHandleImage_ConditionalRequest_IfNoneMatch_Returns304(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleImage_ConditionalRequest_IfNoneMatch_DifferentETag(t *testing.T) {
|
func TestHandleImage_ConditionalRequest_IfNoneMatch_DifferentETag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fix := setupTestHandler(t)
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Get("/v1/image/*", fix.handler.HandleImage())
|
r.Get("/v1/image/*", fix.handler.HandleImage())
|
||||||
|
|
||||||
// Request with non-matching ETag
|
// Request with non-matching ETag
|
||||||
req := httptest.NewRequest(http.MethodGet, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
||||||
req.Header.Set("If-None-Match", `"different-etag"`)
|
req.Header.Set("If-None-Match", `"different-etag"`)
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
// Should return 200 OK with full response
|
// Should return 200 OK with full response
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Errorf("Request with non-matching ETag status = %d, want %d", rec.Code, http.StatusOK)
|
t.Errorf("Request with non-matching ETag status = %d, want %d",
|
||||||
|
rec.Code, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Body should not be empty
|
// Body should not be empty
|
||||||
@@ -229,12 +249,15 @@ func TestHandleImage_ConditionalRequest_IfNoneMatch_DifferentETag(t *testing.T)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleImage_ETagHeader(t *testing.T) {
|
func TestHandleImage_ETagHeader(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fix := setupTestHandler(t)
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Get("/v1/image/*", fix.handler.HandleImage())
|
r.Get("/v1/image/*", fix.handler.HandleImage())
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
|
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,64 +16,14 @@ import (
|
|||||||
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
||||||
func (s *Handlers) HandleImage() http.HandlerFunc {
|
func (s *Handlers) HandleImage() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
req, ok := s.parseImageRequest(w, r)
|
||||||
|
if !ok {
|
||||||
// Get the wildcard path from chi
|
|
||||||
pathParam := chi.URLParam(r, "*")
|
|
||||||
|
|
||||||
// Parse the URL path
|
|
||||||
parsed, err := imgcache.ParseImagePath(pathParam)
|
|
||||||
if err != nil {
|
|
||||||
s.log.Warn("failed to parse image URL",
|
|
||||||
"path", pathParam,
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
s.respondError(w, "invalid image URL: "+err.Error(), http.StatusBadRequest)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to ImageRequest
|
|
||||||
req := parsed.ToImageRequest()
|
|
||||||
|
|
||||||
// Parse signature params from query string
|
|
||||||
query := r.URL.Query()
|
|
||||||
req.Signature = query.Get("sig")
|
|
||||||
|
|
||||||
if expStr := query.Get("exp"); expStr != "" {
|
|
||||||
if exp, err := strconv.ParseInt(expStr, 10, 64); err == nil {
|
|
||||||
req.Expires = time.Unix(exp, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse optional quality and fit params
|
|
||||||
if qStr := query.Get("q"); qStr != "" {
|
|
||||||
if q, err := strconv.Atoi(qStr); err == nil && q > 0 && q <= 100 {
|
|
||||||
req.Quality = q
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if fit := query.Get("fit"); fit != "" {
|
|
||||||
req.FitMode = imgcache.FitMode(fit)
|
|
||||||
if err := imgcache.ValidateFitMode(req.FitMode); err != nil {
|
|
||||||
s.respondError(w, "invalid fit mode: "+fit, http.StatusBadRequest)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default quality if not set
|
|
||||||
if req.Quality == 0 {
|
|
||||||
req.Quality = 85
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default fit mode if not set
|
|
||||||
if req.FitMode == "" {
|
|
||||||
req.FitMode = imgcache.FitCover
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate signature if required
|
// Validate signature if required
|
||||||
if err := s.imgSvc.ValidateRequest(req); err != nil {
|
err := s.imgSvc.ValidateRequest(req)
|
||||||
|
if err != nil {
|
||||||
s.log.Warn("signature validation failed",
|
s.log.Warn("signature validation failed",
|
||||||
"host", req.SourceHost,
|
"host", req.SourceHost,
|
||||||
"path", req.SourcePath,
|
"path", req.SourcePath,
|
||||||
@@ -88,83 +39,17 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
|
|||||||
|
|
||||||
// Get the image (from cache or fetch/process)
|
// Get the image (from cache or fetch/process)
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
resp, err := s.imgSvc.Get(ctx, req)
|
|
||||||
|
resp, err := s.imgSvc.Get(r.Context(), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("failed to get image",
|
s.respondImageError(w, req, err)
|
||||||
"host", req.SourceHost,
|
|
||||||
"path", req.SourcePath,
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Check for specific error types
|
|
||||||
if errors.Is(err, imgcache.ErrSSRFBlocked) {
|
|
||||||
s.respondError(w, "forbidden", http.StatusForbidden)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if errors.Is(err, imgcache.ErrUpstreamError) {
|
|
||||||
s.respondError(w, "upstream error", http.StatusBadGateway)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.respondError(w, "internal error", http.StatusInternalServerError)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = resp.Content.Close() }()
|
defer func() { _ = resp.Content.Close() }()
|
||||||
|
|
||||||
// Set response headers
|
s.writeImageResponse(w, r, req, resp, cacheKey, startTime)
|
||||||
w.Header().Set("Content-Type", resp.ContentType)
|
|
||||||
if resp.ContentLength > 0 {
|
|
||||||
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache control headers
|
|
||||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
||||||
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
|
||||||
|
|
||||||
if resp.ETag != "" {
|
|
||||||
w.Header().Set("ETag", resp.ETag)
|
|
||||||
|
|
||||||
// Check for conditional request (If-None-Match)
|
|
||||||
if ifNoneMatch := r.Header.Get("If-None-Match"); ifNoneMatch != "" {
|
|
||||||
if ifNoneMatch == resp.ETag {
|
|
||||||
w.WriteHeader(http.StatusNotModified)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle HEAD request - return headers only
|
|
||||||
if r.Method == http.MethodHead {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stream the response
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
|
|
||||||
servedBytes, err := io.Copy(w, resp.Content)
|
|
||||||
if err != nil {
|
|
||||||
s.log.Error("failed to write response",
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log cache status and timing after serving
|
|
||||||
duration := time.Since(startTime)
|
|
||||||
s.log.Info("image served",
|
|
||||||
"cache_key", cacheKey,
|
|
||||||
"cache_status", resp.CacheStatus,
|
|
||||||
"duration_ms", duration.Milliseconds(),
|
|
||||||
"format", req.Format,
|
|
||||||
"served_bytes", servedBytes,
|
|
||||||
"fetched_bytes", resp.FetchedBytes,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,3 +64,156 @@ func (s *Handlers) HandleRobotsTxt() http.HandlerFunc {
|
|||||||
_, _ = w.Write(robotsTxt)
|
_, _ = w.Write(robotsTxt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseImageRequest parses the wildcard path and query parameters into
|
||||||
|
// an ImageRequest. On invalid input it writes an error response and
|
||||||
|
// returns false.
|
||||||
|
func (s *Handlers) parseImageRequest(
|
||||||
|
w http.ResponseWriter, r *http.Request,
|
||||||
|
) (*imgcache.ImageRequest, bool) {
|
||||||
|
// Get the wildcard path from chi
|
||||||
|
pathParam := chi.URLParam(r, "*")
|
||||||
|
|
||||||
|
// Parse the URL path
|
||||||
|
parsed, err := imgcache.ParseImagePath(pathParam)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("failed to parse image URL",
|
||||||
|
"path", pathParam,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
s.respondError(w, "invalid image URL: "+err.Error(), http.StatusBadRequest)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to ImageRequest
|
||||||
|
req := parsed.ToImageRequest()
|
||||||
|
|
||||||
|
// Parse signature params from query string
|
||||||
|
query := r.URL.Query()
|
||||||
|
req.Signature = query.Get("sig")
|
||||||
|
|
||||||
|
if expStr := query.Get("exp"); expStr != "" {
|
||||||
|
exp, parseErr := strconv.ParseInt(expStr, 10, 64)
|
||||||
|
if parseErr == nil {
|
||||||
|
req.Expires = time.Unix(exp, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse optional quality and fit params
|
||||||
|
if qStr := query.Get("q"); qStr != "" {
|
||||||
|
q, parseErr := strconv.Atoi(qStr)
|
||||||
|
if parseErr == nil && q > 0 && q <= 100 {
|
||||||
|
req.Quality = q
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fit := query.Get("fit"); fit != "" {
|
||||||
|
req.FitMode = imgcache.FitMode(fit)
|
||||||
|
|
||||||
|
fitErr := imgcache.ValidateFitMode(req.FitMode)
|
||||||
|
if fitErr != nil {
|
||||||
|
s.respondError(w, "invalid fit mode: "+fit, http.StatusBadRequest)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default quality if not set
|
||||||
|
if req.Quality == 0 {
|
||||||
|
req.Quality = 85
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default fit mode if not set
|
||||||
|
if req.FitMode == "" {
|
||||||
|
req.FitMode = imgcache.FitCover
|
||||||
|
}
|
||||||
|
|
||||||
|
return req, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// respondImageError maps image retrieval errors to HTTP responses.
|
||||||
|
func (s *Handlers) respondImageError(
|
||||||
|
w http.ResponseWriter, req *imgcache.ImageRequest, err error,
|
||||||
|
) {
|
||||||
|
s.log.Error("failed to get image",
|
||||||
|
"host", req.SourceHost,
|
||||||
|
"path", req.SourcePath,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check for specific error types
|
||||||
|
if errors.Is(err, httpfetcher.ErrSSRFBlocked) {
|
||||||
|
s.respondError(w, "forbidden", http.StatusForbidden)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, httpfetcher.ErrUpstreamError) {
|
||||||
|
s.respondError(w, "upstream error", http.StatusBadGateway)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.respondError(w, "internal error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeImageResponse writes headers and streams the image content,
|
||||||
|
// handling conditional and HEAD requests.
|
||||||
|
func (s *Handlers) writeImageResponse(
|
||||||
|
w http.ResponseWriter, r *http.Request,
|
||||||
|
req *imgcache.ImageRequest, resp *imgcache.ImageResponse,
|
||||||
|
cacheKey imgcache.VariantKey, startTime time.Time,
|
||||||
|
) {
|
||||||
|
// Set response headers
|
||||||
|
w.Header().Set("Content-Type", resp.ContentType)
|
||||||
|
|
||||||
|
if resp.ContentLength > 0 {
|
||||||
|
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache control headers
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
||||||
|
|
||||||
|
if resp.ETag != "" {
|
||||||
|
w.Header().Set("ETag", resp.ETag)
|
||||||
|
|
||||||
|
// Check for conditional request (If-None-Match)
|
||||||
|
if ifNoneMatch := r.Header.Get("If-None-Match"); ifNoneMatch != "" {
|
||||||
|
if ifNoneMatch == resp.ETag {
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle HEAD request - return headers only
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream the response
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
servedBytes, err := io.Copy(w, resp.Content)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("failed to write response",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log cache status and timing after serving
|
||||||
|
duration := time.Since(startTime)
|
||||||
|
s.log.Info("image served",
|
||||||
|
"cache_key", cacheKey,
|
||||||
|
"cache_status", resp.CacheStatus,
|
||||||
|
"duration_ms", duration.Milliseconds(),
|
||||||
|
"format", req.Format,
|
||||||
|
"served_bytes", servedBytes,
|
||||||
|
"fetched_bytes", resp.FetchedBytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleImageEnc handles requests to /v1/e/{token}/* for encrypted image URLs.
|
// HandleImageEnc handles requests to /v1/e/{token}/* for encrypted
|
||||||
// The trailing path (e.g., /img.jpg) is ignored but helps browsers identify the content type.
|
// image URLs. The trailing path (e.g., /img.jpg) is ignored but helps
|
||||||
|
// browsers identify the content type.
|
||||||
func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
@@ -56,7 +58,8 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
|||||||
"format", req.Format,
|
"format", req.Format,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fetch and process the image (no signature validation needed - encrypted URL is trusted)
|
// Fetch and process the image (no signature validation
|
||||||
|
// needed - encrypted URL is trusted)
|
||||||
resp, err := s.imgSvc.Get(ctx, req)
|
resp, err := s.imgSvc.Get(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.handleImageError(w, err)
|
s.handleImageError(w, err)
|
||||||
@@ -67,6 +70,7 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
|||||||
|
|
||||||
// Set response headers
|
// Set response headers
|
||||||
w.Header().Set("Content-Type", resp.ContentType)
|
w.Header().Set("Content-Type", resp.ContentType)
|
||||||
|
|
||||||
if resp.ContentLength > 0 {
|
if resp.ContentLength > 0 {
|
||||||
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
|
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
|
||||||
}
|
}
|
||||||
@@ -100,11 +104,11 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
|||||||
// handleImageError converts image service errors to HTTP responses.
|
// handleImageError converts image service errors to HTTP responses.
|
||||||
func (s *Handlers) handleImageError(w http.ResponseWriter, err error) {
|
func (s *Handlers) handleImageError(w http.ResponseWriter, err error) {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, imgcache.ErrSSRFBlocked):
|
case errors.Is(err, httpfetcher.ErrSSRFBlocked):
|
||||||
s.respondError(w, "forbidden", http.StatusForbidden)
|
s.respondError(w, "forbidden", http.StatusForbidden)
|
||||||
case errors.Is(err, imgcache.ErrUpstreamError):
|
case errors.Is(err, httpfetcher.ErrUpstreamError):
|
||||||
s.respondError(w, "upstream error", http.StatusBadGateway)
|
s.respondError(w, "upstream error", http.StatusBadGateway)
|
||||||
case errors.Is(err, imgcache.ErrUpstreamTimeout):
|
case errors.Is(err, httpfetcher.ErrUpstreamTimeout):
|
||||||
s.respondError(w, "upstream timeout", http.StatusGatewayTimeout)
|
s.respondError(w, "upstream timeout", http.StatusGatewayTimeout)
|
||||||
default:
|
default:
|
||||||
s.log.Error("image request failed", "error", err)
|
s.log.Error("image request failed", "error", err)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
// Params defines dependencies for Healthcheck.
|
// Params defines dependencies for Healthcheck.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
@@ -53,6 +54,8 @@ func New(lc fx.Lifecycle, params Params) (*Healthcheck, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Response is the JSON response for health checks.
|
// Response is the JSON response for health checks.
|
||||||
|
//
|
||||||
|
//nolint:tagliatelle // health endpoint response format uses snake_case
|
||||||
type Response struct {
|
type Response struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Now string `json:"now"`
|
Now string `json:"now"`
|
||||||
@@ -63,10 +66,6 @@ type Response struct {
|
|||||||
Maintenance bool `json:"maintenance_mode"`
|
Maintenance bool `json:"maintenance_mode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Healthcheck) uptime() time.Duration {
|
|
||||||
return time.Since(s.StartupTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Healthcheck returns the current health status.
|
// Healthcheck returns the current health status.
|
||||||
func (s *Healthcheck) Healthcheck() *Response {
|
func (s *Healthcheck) Healthcheck() *Response {
|
||||||
resp := &Response{
|
resp := &Response{
|
||||||
@@ -81,3 +80,7 @@ func (s *Healthcheck) Healthcheck() *Response {
|
|||||||
|
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Healthcheck) uptime() time.Duration {
|
||||||
|
return time.Since(s.StartupTime)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
package imgcache
|
// Package httpfetcher fetches content from upstream HTTP origins with SSRF
|
||||||
|
// protection, per-host connection limits, and content-type validation.
|
||||||
|
package httpfetcher
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -9,6 +11,8 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptrace"
|
"net/http/httptrace"
|
||||||
|
neturl "net/url"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -25,6 +29,23 @@ const (
|
|||||||
DefaultMaxConnectionsPerHost = 20
|
DefaultMaxConnectionsPerHost = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MIME content types.
|
||||||
|
const (
|
||||||
|
contentTypeJPEG = "image/jpeg"
|
||||||
|
contentTypePNG = "image/png"
|
||||||
|
contentTypeGIF = "image/gif"
|
||||||
|
contentTypeWebP = "image/webp"
|
||||||
|
contentTypeAVIF = "image/avif"
|
||||||
|
contentTypeSVG = "image/svg+xml"
|
||||||
|
contentTypeOctetStream = "application/octet-stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Loopback addresses blocked by SSRF protection.
|
||||||
|
const (
|
||||||
|
localhostIPv4 = "127.0.0.1"
|
||||||
|
localhostIPv6 = "::1"
|
||||||
|
)
|
||||||
|
|
||||||
// Fetcher errors.
|
// Fetcher errors.
|
||||||
var (
|
var (
|
||||||
ErrSSRFBlocked = errors.New("request blocked: private or internal IP")
|
ErrSSRFBlocked = errors.New("request blocked: private or internal IP")
|
||||||
@@ -36,53 +57,89 @@ var (
|
|||||||
ErrUpstreamTimeout = errors.New("upstream request timeout")
|
ErrUpstreamTimeout = errors.New("upstream request timeout")
|
||||||
)
|
)
|
||||||
|
|
||||||
// FetcherConfig holds configuration for the upstream fetcher.
|
// Internal fetcher errors.
|
||||||
type FetcherConfig struct {
|
var (
|
||||||
// Timeout for upstream requests
|
errTooManyRedirects = errors.New("too many redirects")
|
||||||
|
errConnectFailed = errors.New("failed to connect")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fetcher retrieves content from upstream origins.
|
||||||
|
type Fetcher interface {
|
||||||
|
// Fetch retrieves content from the given URL.
|
||||||
|
Fetch(ctx context.Context, url string) (*FetchResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchResult contains the result of fetching from upstream.
|
||||||
|
type FetchResult struct {
|
||||||
|
// Content is the raw image data.
|
||||||
|
Content io.ReadCloser
|
||||||
|
// ContentLength is the size in bytes (-1 if unknown).
|
||||||
|
ContentLength int64
|
||||||
|
// ContentType is the MIME type from upstream.
|
||||||
|
ContentType string
|
||||||
|
// Headers contains all response headers from upstream.
|
||||||
|
Headers map[string][]string
|
||||||
|
// StatusCode is the HTTP status code from upstream.
|
||||||
|
StatusCode int
|
||||||
|
// FetchDurationMs is how long the fetch took in milliseconds.
|
||||||
|
FetchDurationMs int64
|
||||||
|
// RemoteAddr is the IP:port of the upstream server.
|
||||||
|
RemoteAddr string
|
||||||
|
// HTTPVersion is the protocol version (e.g., "1.1", "2.0").
|
||||||
|
HTTPVersion string
|
||||||
|
// TLSVersion is the TLS protocol version (e.g., "TLS 1.3").
|
||||||
|
TLSVersion string
|
||||||
|
// TLSCipherSuite is the negotiated cipher suite name.
|
||||||
|
TLSCipherSuite string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config holds configuration for the upstream fetcher.
|
||||||
|
type Config struct {
|
||||||
|
// Timeout for upstream requests.
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// MaxResponseSize is the maximum allowed response body size
|
// MaxResponseSize is the maximum allowed response body size.
|
||||||
MaxResponseSize int64
|
MaxResponseSize int64
|
||||||
// UserAgent to send to upstream servers
|
// UserAgent to send to upstream servers.
|
||||||
UserAgent string
|
UserAgent string
|
||||||
// AllowedContentTypes is a whitelist of MIME types to accept
|
// AllowedContentTypes is an allow list of MIME types to accept.
|
||||||
AllowedContentTypes []string
|
AllowedContentTypes []string
|
||||||
// AllowHTTP allows non-TLS connections (for testing only)
|
// AllowHTTP allows non-TLS connections (for testing only).
|
||||||
AllowHTTP bool
|
AllowHTTP bool
|
||||||
// MaxConnectionsPerHost limits concurrent connections to each upstream host
|
// MaxConnectionsPerHost limits concurrent connections to each upstream host.
|
||||||
MaxConnectionsPerHost int
|
MaxConnectionsPerHost int
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultFetcherConfig returns sensible defaults.
|
// DefaultConfig returns a Config with sensible defaults.
|
||||||
func DefaultFetcherConfig() *FetcherConfig {
|
func DefaultConfig() *Config {
|
||||||
return &FetcherConfig{
|
return &Config{
|
||||||
Timeout: DefaultFetchTimeout,
|
Timeout: DefaultFetchTimeout,
|
||||||
MaxResponseSize: DefaultMaxResponseSize,
|
MaxResponseSize: DefaultMaxResponseSize,
|
||||||
UserAgent: "pixa/1.0",
|
UserAgent: "pixa/1.0",
|
||||||
AllowedContentTypes: []string{
|
AllowedContentTypes: []string{
|
||||||
"image/jpeg",
|
contentTypeJPEG,
|
||||||
"image/png",
|
contentTypePNG,
|
||||||
"image/gif",
|
contentTypeGIF,
|
||||||
"image/webp",
|
contentTypeWebP,
|
||||||
"image/avif",
|
contentTypeAVIF,
|
||||||
"image/svg+xml",
|
contentTypeSVG,
|
||||||
},
|
},
|
||||||
AllowHTTP: false,
|
AllowHTTP: false,
|
||||||
MaxConnectionsPerHost: DefaultMaxConnectionsPerHost,
|
MaxConnectionsPerHost: DefaultMaxConnectionsPerHost,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTPFetcher implements the Fetcher interface with SSRF protection.
|
// HTTPFetcher implements Fetcher with SSRF protection and per-host connection limits.
|
||||||
type HTTPFetcher struct {
|
type HTTPFetcher struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
config *FetcherConfig
|
config *Config
|
||||||
hostSems map[string]chan struct{} // per-host semaphores
|
hostSems map[string]chan struct{} // per-host semaphores
|
||||||
hostSemMu sync.Mutex // protects hostSems map
|
hostSemMu sync.Mutex // protects hostSems map
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHTTPFetcher creates a new fetcher with SSRF protection.
|
// New creates a new HTTPFetcher with SSRF protection.
|
||||||
func NewHTTPFetcher(config *FetcherConfig) *HTTPFetcher {
|
func New(config *Config) *HTTPFetcher {
|
||||||
if config == nil {
|
if config == nil {
|
||||||
config = DefaultFetcherConfig()
|
config = DefaultConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create transport with SSRF-safe dialer
|
// Create transport with SSRF-safe dialer
|
||||||
@@ -99,10 +156,12 @@ func NewHTTPFetcher(config *FetcherConfig) *HTTPFetcher {
|
|||||||
// Don't follow redirects automatically - we need to validate each hop
|
// Don't follow redirects automatically - we need to validate each hop
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
if len(via) >= DefaultMaxRedirects {
|
if len(via) >= DefaultMaxRedirects {
|
||||||
return errors.New("too many redirects")
|
return errTooManyRedirects
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the redirect target
|
// Validate the redirect target
|
||||||
if err := validateURL(req.URL.String(), config.AllowHTTP); err != nil {
|
err := validateURL(req.Context(), req.URL.String(), config.AllowHTTP)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("redirect blocked: %w", err)
|
return fmt.Errorf("redirect blocked: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,24 +176,11 @@ func NewHTTPFetcher(config *FetcherConfig) *HTTPFetcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getHostSemaphore returns the semaphore for a host, creating it if necessary.
|
|
||||||
func (f *HTTPFetcher) getHostSemaphore(host string) chan struct{} {
|
|
||||||
f.hostSemMu.Lock()
|
|
||||||
defer f.hostSemMu.Unlock()
|
|
||||||
|
|
||||||
sem, ok := f.hostSems[host]
|
|
||||||
if !ok {
|
|
||||||
sem = make(chan struct{}, f.config.MaxConnectionsPerHost)
|
|
||||||
f.hostSems[host] = sem
|
|
||||||
}
|
|
||||||
|
|
||||||
return sem
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch retrieves content from the given URL with SSRF protection.
|
// Fetch retrieves content from the given URL with SSRF protection.
|
||||||
func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) {
|
func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) {
|
||||||
// Validate URL before making request
|
// Validate URL before making request
|
||||||
if err := validateURL(url, f.config.AllowHTTP); err != nil {
|
err := validateURL(ctx, url, f.config.AllowHTTP)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,9 +204,15 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
parsedURL, err := neturl.Parse(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to parse URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &http.Request{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: parsedURL,
|
||||||
|
Header: make(http.Header),
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", f.config.UserAgent)
|
req.Header.Set("User-Agent", f.config.UserAgent)
|
||||||
@@ -176,7 +228,7 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
|
req = req.WithContext(httptrace.WithClientTrace(ctx, trace))
|
||||||
|
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
|
||||||
@@ -192,6 +244,39 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
|||||||
return nil, fmt.Errorf("upstream request failed: %w", err)
|
return nil, fmt.Errorf("upstream request failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result, err := f.buildResult(resp, remoteAddr, fetchDuration, sem)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark success so defer doesn't release the semaphore
|
||||||
|
success = true
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getHostSemaphore returns the semaphore for a host, creating it if necessary.
|
||||||
|
func (f *HTTPFetcher) getHostSemaphore(host string) chan struct{} {
|
||||||
|
f.hostSemMu.Lock()
|
||||||
|
defer f.hostSemMu.Unlock()
|
||||||
|
|
||||||
|
sem, ok := f.hostSems[host]
|
||||||
|
if !ok {
|
||||||
|
sem = make(chan struct{}, f.config.MaxConnectionsPerHost)
|
||||||
|
f.hostSems[host] = sem
|
||||||
|
}
|
||||||
|
|
||||||
|
return sem
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildResult validates the upstream response and assembles a FetchResult
|
||||||
|
// whose Content releases the host semaphore slot when closed.
|
||||||
|
func (f *HTTPFetcher) buildResult(
|
||||||
|
resp *http.Response,
|
||||||
|
remoteAddr string,
|
||||||
|
fetchDuration time.Duration,
|
||||||
|
sem chan struct{},
|
||||||
|
) (*FetchResult, error) {
|
||||||
// Extract HTTP version (strip "HTTP/" prefix)
|
// Extract HTTP version (strip "HTTP/" prefix)
|
||||||
httpVersion := strings.TrimPrefix(resp.Proto, "HTTP/")
|
httpVersion := strings.TrimPrefix(resp.Proto, "HTTP/")
|
||||||
|
|
||||||
@@ -224,9 +309,6 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
|||||||
remaining: f.config.MaxResponseSize,
|
remaining: f.config.MaxResponseSize,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark success so defer doesn't release the semaphore
|
|
||||||
success = true
|
|
||||||
|
|
||||||
return &FetchResult{
|
return &FetchResult{
|
||||||
Content: &semaphoreReleasingReadCloser{limitedBody, resp.Body, sem},
|
Content: &semaphoreReleasingReadCloser{limitedBody, resp.Body, sem},
|
||||||
ContentLength: resp.ContentLength,
|
ContentLength: resp.ContentLength,
|
||||||
@@ -241,7 +323,7 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isAllowedContentType checks if the content type is in the whitelist.
|
// isAllowedContentType checks if the content type is in the allow list.
|
||||||
func (f *HTTPFetcher) isAllowedContentType(contentType string) bool {
|
func (f *HTTPFetcher) isAllowedContentType(contentType string) bool {
|
||||||
// Extract the MIME type without parameters
|
// Extract the MIME type without parameters
|
||||||
mediaType := strings.TrimSpace(strings.Split(contentType, ";")[0])
|
mediaType := strings.TrimSpace(strings.Split(contentType, ";")[0])
|
||||||
@@ -256,7 +338,7 @@ func (f *HTTPFetcher) isAllowedContentType(contentType string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// validateURL checks if a URL is safe to fetch (not internal/private).
|
// validateURL checks if a URL is safe to fetch (not internal/private).
|
||||||
func validateURL(rawURL string, allowHTTP bool) error {
|
func validateURL(ctx context.Context, rawURL string, allowHTTP bool) error {
|
||||||
if !allowHTTP && !strings.HasPrefix(rawURL, "https://") {
|
if !allowHTTP && !strings.HasPrefix(rawURL, "https://") {
|
||||||
return ErrUnsupportedScheme
|
return ErrUnsupportedScheme
|
||||||
}
|
}
|
||||||
@@ -268,7 +350,8 @@ func validateURL(rawURL string, allowHTTP bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove port if present
|
// Remove port if present
|
||||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
h, _, err := net.SplitHostPort(host)
|
||||||
|
if err == nil {
|
||||||
host = h
|
host = h
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,15 +361,16 @@ func validateURL(rawURL string, allowHTTP bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the host to check IP addresses
|
// Resolve the host to check IP addresses
|
||||||
ips, err := net.LookupIP(host)
|
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%w: %s", ErrInvalidHost, host)
|
return fmt.Errorf("%w: %s", ErrInvalidHost, host)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, ip := range ips {
|
private := slices.ContainsFunc(addrs, func(addr net.IPAddr) bool {
|
||||||
if isPrivateIP(ip) {
|
return isPrivateIP(addr.IP)
|
||||||
return ErrSSRFBlocked
|
})
|
||||||
}
|
if private {
|
||||||
|
return ErrSSRFBlocked
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -299,9 +383,11 @@ func extractHost(rawURL string) string {
|
|||||||
if idx := strings.Index(url, "://"); idx != -1 {
|
if idx := strings.Index(url, "://"); idx != -1 {
|
||||||
url = url[idx+3:]
|
url = url[idx+3:]
|
||||||
}
|
}
|
||||||
|
|
||||||
if idx := strings.Index(url, "/"); idx != -1 {
|
if idx := strings.Index(url, "/"); idx != -1 {
|
||||||
url = url[:idx]
|
url = url[:idx]
|
||||||
}
|
}
|
||||||
|
|
||||||
if idx := strings.Index(url, "?"); idx != -1 {
|
if idx := strings.Index(url, "?"); idx != -1 {
|
||||||
url = url[:idx]
|
url = url[:idx]
|
||||||
}
|
}
|
||||||
@@ -314,8 +400,8 @@ func isLocalhost(host string) bool {
|
|||||||
host = strings.ToLower(host)
|
host = strings.ToLower(host)
|
||||||
|
|
||||||
return host == "localhost" ||
|
return host == "localhost" ||
|
||||||
host == "127.0.0.1" ||
|
host == localhostIPv4 ||
|
||||||
host == "::1" ||
|
host == localhostIPv6 ||
|
||||||
host == "[::1]" ||
|
host == "[::1]" ||
|
||||||
strings.HasSuffix(host, ".localhost") ||
|
strings.HasSuffix(host, ".localhost") ||
|
||||||
strings.HasSuffix(host, ".local")
|
strings.HasSuffix(host, ".local")
|
||||||
@@ -381,23 +467,23 @@ func ssrfSafeDialer(ctx context.Context, network, addr string) (net.Conn, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check all resolved IPs
|
// Check all resolved IPs
|
||||||
for _, ip := range ips {
|
if slices.ContainsFunc(ips, isPrivateIP) {
|
||||||
if isPrivateIP(ip) {
|
return nil, ErrSSRFBlocked
|
||||||
return nil, ErrSSRFBlocked
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect using the first valid IP
|
// Connect using the first valid IP
|
||||||
var dialer net.Dialer
|
var dialer net.Dialer
|
||||||
|
|
||||||
for _, ip := range ips {
|
for _, ip := range ips {
|
||||||
addr := net.JoinHostPort(ip.String(), port)
|
addr := net.JoinHostPort(ip.String(), port)
|
||||||
|
|
||||||
conn, err := dialer.DialContext(ctx, network, addr)
|
conn, err := dialer.DialContext(ctx, network, addr)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to connect to %s", host)
|
return nil, fmt.Errorf("%w to %s", errConnectFailed, host)
|
||||||
}
|
}
|
||||||
|
|
||||||
// limitedReader wraps a reader and limits the number of bytes read.
|
// limitedReader wraps a reader and limits the number of bytes read.
|
||||||
@@ -424,6 +510,7 @@ func (r *limitedReader) Read(p []byte) (int, error) {
|
|||||||
// semaphoreReleasingReadCloser releases a semaphore slot when closed.
|
// semaphoreReleasingReadCloser releases a semaphore slot when closed.
|
||||||
type semaphoreReleasingReadCloser struct {
|
type semaphoreReleasingReadCloser struct {
|
||||||
*limitedReader
|
*limitedReader
|
||||||
|
|
||||||
closer io.Closer
|
closer io.Closer
|
||||||
sem chan struct{}
|
sem chan struct{}
|
||||||
}
|
}
|
||||||
373
internal/httpfetcher/httpfetcher_internal_test.go
Normal file
373
internal/httpfetcher/httpfetcher_internal_test.go
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
package httpfetcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testHost is the hostname used by mock fetch tests.
|
||||||
|
const testHost = "example.com"
|
||||||
|
|
||||||
|
func TestDefaultConfig(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
|
if cfg.Timeout != DefaultFetchTimeout {
|
||||||
|
t.Errorf("Timeout = %v, want %v", cfg.Timeout, DefaultFetchTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.MaxResponseSize != DefaultMaxResponseSize {
|
||||||
|
t.Errorf("MaxResponseSize = %d, want %d", cfg.MaxResponseSize, DefaultMaxResponseSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.MaxConnectionsPerHost != DefaultMaxConnectionsPerHost {
|
||||||
|
t.Errorf("MaxConnectionsPerHost = %d, want %d",
|
||||||
|
cfg.MaxConnectionsPerHost, DefaultMaxConnectionsPerHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.AllowHTTP {
|
||||||
|
t.Error("AllowHTTP should default to false")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.AllowedContentTypes) == 0 {
|
||||||
|
t.Error("AllowedContentTypes should not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewWithNilConfigUsesDefaults(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := New(nil)
|
||||||
|
|
||||||
|
if f == nil {
|
||||||
|
t.Fatal("New(nil) returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.config == nil {
|
||||||
|
t.Fatal("config should be populated from DefaultConfig")
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.config.Timeout != DefaultFetchTimeout {
|
||||||
|
t.Errorf("Timeout = %v, want %v", f.config.Timeout, DefaultFetchTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAllowedContentType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := New(DefaultConfig())
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
contentType string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{contentTypeJPEG, true},
|
||||||
|
{contentTypePNG, true},
|
||||||
|
{contentTypeWebP, true},
|
||||||
|
{"image/jpeg; charset=utf-8", true},
|
||||||
|
{"IMAGE/JPEG", true},
|
||||||
|
{"text/html", false},
|
||||||
|
{contentTypeOctetStream, false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.contentType, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := f.isAllowedContentType(tc.contentType)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("isAllowedContentType(%q) = %v, want %v", tc.contentType, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractHost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
url string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"https://example.com/path", testHost},
|
||||||
|
{"http://example.com:8080/path", "example.com:8080"},
|
||||||
|
{"https://example.com", testHost},
|
||||||
|
{"https://example.com?q=1", testHost},
|
||||||
|
{"example.com/path", testHost},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.url, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := extractHost(tc.url)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("extractHost(%q) = %q, want %q", tc.url, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsLocalhost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
host string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"localhost", true},
|
||||||
|
{"LOCALHOST", true},
|
||||||
|
{localhostIPv4, true},
|
||||||
|
{localhostIPv6, true},
|
||||||
|
{"[::1]", true},
|
||||||
|
{"foo.localhost", true},
|
||||||
|
{"foo.local", true},
|
||||||
|
{testHost, false},
|
||||||
|
{"127.0.0.2", false}, // Handled by isPrivateIP, not isLocalhost string match
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.host, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := isLocalhost(tc.host)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("isLocalhost(%q) = %v, want %v", tc.host, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPrivateIP(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
ip string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{localhostIPv4, true}, // loopback
|
||||||
|
{"10.0.0.1", true}, // private
|
||||||
|
{"192.168.1.1", true}, // private
|
||||||
|
{"172.16.0.1", true}, // private
|
||||||
|
{"169.254.1.1", true}, // link-local
|
||||||
|
{"0.0.0.0", true}, // unspecified
|
||||||
|
{"224.0.0.1", true}, // multicast
|
||||||
|
{localhostIPv6, true}, // IPv6 loopback
|
||||||
|
{"fe80::1", true}, // IPv6 link-local
|
||||||
|
{"8.8.8.8", false}, // public
|
||||||
|
{"2001:4860:4860::8888", false}, // public IPv6
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.ip, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ip := net.ParseIP(tc.ip)
|
||||||
|
if ip == nil {
|
||||||
|
t.Fatalf("failed to parse IP %q", tc.ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := isPrivateIP(ip)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("isPrivateIP(%q) = %v, want %v", tc.ip, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isPrivateIP(nil) {
|
||||||
|
t.Error("isPrivateIP(nil) should return true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateURL_RejectsNonHTTPS(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := validateURL(t.Context(), "http://example.com/path", false)
|
||||||
|
if !errors.Is(err, ErrUnsupportedScheme) {
|
||||||
|
t.Errorf("validateURL http = %v, want ErrUnsupportedScheme", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateURL_AllowsHTTPWhenConfigured(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Use a host that won't resolve (explicit .invalid TLD) so we don't hit DNS.
|
||||||
|
err := validateURL(t.Context(), "http://nonexistent.invalid/path", true)
|
||||||
|
// We expect a host resolution error, not ErrUnsupportedScheme.
|
||||||
|
if errors.Is(err, ErrUnsupportedScheme) {
|
||||||
|
t.Error("validateURL with AllowHTTP should not return ErrUnsupportedScheme")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateURL_RejectsLocalhost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := validateURL(t.Context(), "https://localhost/path", false)
|
||||||
|
if !errors.Is(err, ErrSSRFBlocked) {
|
||||||
|
t.Errorf("validateURL localhost = %v, want ErrSSRFBlocked", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateURL_EmptyHost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := validateURL(t.Context(), "https:///path", false)
|
||||||
|
if !errors.Is(err, ErrInvalidHost) {
|
||||||
|
t.Errorf("validateURL empty host = %v, want ErrInvalidHost", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMockFetcher_FetchesFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mockFS := fstest.MapFS{
|
||||||
|
"example.com/images/photo.jpg": &fstest.MapFile{Data: []byte("fake-jpeg-data")},
|
||||||
|
}
|
||||||
|
|
||||||
|
m := NewMock(mockFS)
|
||||||
|
|
||||||
|
result, err := m.Fetch(context.Background(), "https://example.com/images/photo.jpg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fetch() error = %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
|
if result.ContentType != contentTypeJPEG {
|
||||||
|
t.Errorf("ContentType = %q, want image/jpeg", result.ContentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(result.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read content: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(data) != "fake-jpeg-data" {
|
||||||
|
t.Errorf("Content = %q, want %q", string(data), "fake-jpeg-data")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.ContentLength != int64(len("fake-jpeg-data")) {
|
||||||
|
t.Errorf("ContentLength = %d, want %d", result.ContentLength, len("fake-jpeg-data"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMockFetcher_MissingFileReturnsUpstreamError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mockFS := fstest.MapFS{}
|
||||||
|
m := NewMock(mockFS)
|
||||||
|
|
||||||
|
_, err := m.Fetch(context.Background(), "https://example.com/missing.jpg")
|
||||||
|
if !errors.Is(err, ErrUpstreamError) {
|
||||||
|
t.Errorf("Fetch() error = %v, want ErrUpstreamError", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMockFetcher_RespectsContextCancellation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mockFS := fstest.MapFS{
|
||||||
|
"example.com/photo.jpg": &fstest.MapFile{Data: []byte("data")},
|
||||||
|
}
|
||||||
|
m := NewMock(mockFS)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, err := m.Fetch(ctx, "https://example.com/photo.jpg")
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Errorf("Fetch() error = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectContentTypeFromPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"foo/bar.jpg", contentTypeJPEG},
|
||||||
|
{"foo/bar.JPG", contentTypeJPEG},
|
||||||
|
{"foo/bar.jpeg", contentTypeJPEG},
|
||||||
|
{"foo/bar.png", contentTypePNG},
|
||||||
|
{"foo/bar.gif", contentTypeGIF},
|
||||||
|
{"foo/bar.webp", contentTypeWebP},
|
||||||
|
{"foo/bar.avif", contentTypeAVIF},
|
||||||
|
{"foo/bar.svg", contentTypeSVG},
|
||||||
|
{"foo/bar.bin", contentTypeOctetStream},
|
||||||
|
{"foo/bar", contentTypeOctetStream},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.path, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := detectContentTypeFromPath(tc.path)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("detectContentTypeFromPath(%q) = %q, want %q", tc.path, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLimitedReader_EnforcesLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
src := make([]byte, 100)
|
||||||
|
r := &limitedReader{
|
||||||
|
reader: &byteReader{data: src},
|
||||||
|
remaining: 50,
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, 100)
|
||||||
|
|
||||||
|
n, err := r.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Read error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n > 50 {
|
||||||
|
t.Errorf("read %d bytes, should be capped at 50", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain until limit is exhausted.
|
||||||
|
total := n
|
||||||
|
for total < 50 {
|
||||||
|
nn, err := r.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("during drain: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
total += nn
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now the limit is exhausted — next read should error.
|
||||||
|
_, err = r.Read(buf)
|
||||||
|
if !errors.Is(err, ErrResponseTooLarge) {
|
||||||
|
t.Errorf("exhausted Read error = %v, want ErrResponseTooLarge", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// byteReader is a minimal io.Reader over a byte slice for testing.
|
||||||
|
type byteReader struct {
|
||||||
|
data []byte
|
||||||
|
pos int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *byteReader) Read(p []byte) (int, error) {
|
||||||
|
if r.pos >= len(r.data) {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
n := copy(p, r.data[r.pos:])
|
||||||
|
r.pos += n
|
||||||
|
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
@@ -1,24 +1,26 @@
|
|||||||
package imgcache
|
package httpfetcher
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockFetcher implements the Fetcher interface using an embedded filesystem.
|
// errEmptyURLPath is returned when a mock URL has no usable path.
|
||||||
|
var errEmptyURLPath = errors.New("empty URL path")
|
||||||
|
|
||||||
|
// MockFetcher implements Fetcher using an embedded filesystem.
|
||||||
// Files are organized as: hostname/path/to/file.ext
|
// Files are organized as: hostname/path/to/file.ext
|
||||||
// URLs like https://example.com/images/photo.jpg map to example.com/images/photo.jpg
|
// URLs like https://example.com/images/photo.jpg map to example.com/images/photo.jpg.
|
||||||
type MockFetcher struct {
|
type MockFetcher struct {
|
||||||
fs fs.FS
|
fs fs.FS
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMockFetcher creates a new mock fetcher backed by the given filesystem.
|
// NewMock creates a new mock fetcher backed by the given filesystem.
|
||||||
func NewMockFetcher(fsys fs.FS) *MockFetcher {
|
func NewMock(fsys fs.FS) *MockFetcher {
|
||||||
return &MockFetcher{fs: fsys}
|
return &MockFetcher{fs: fsys}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +61,7 @@ func (m *MockFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
|||||||
contentType := detectContentTypeFromPath(path)
|
contentType := detectContentTypeFromPath(path)
|
||||||
|
|
||||||
return &FetchResult{
|
return &FetchResult{
|
||||||
Content: f.(io.ReadCloser),
|
Content: f,
|
||||||
ContentLength: stat.Size(),
|
ContentLength: stat.Size(),
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
Headers: make(http.Header),
|
Headers: make(http.Header),
|
||||||
@@ -86,7 +88,7 @@ func urlToFSPath(rawURL string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if url == "" {
|
if url == "" {
|
||||||
return "", errors.New("empty URL path")
|
return "", errEmptyURLPath
|
||||||
}
|
}
|
||||||
|
|
||||||
return url, nil
|
return url, nil
|
||||||
@@ -98,18 +100,18 @@ func detectContentTypeFromPath(path string) string {
|
|||||||
|
|
||||||
switch {
|
switch {
|
||||||
case strings.HasSuffix(path, ".jpg"), strings.HasSuffix(path, ".jpeg"):
|
case strings.HasSuffix(path, ".jpg"), strings.HasSuffix(path, ".jpeg"):
|
||||||
return "image/jpeg"
|
return contentTypeJPEG
|
||||||
case strings.HasSuffix(path, ".png"):
|
case strings.HasSuffix(path, ".png"):
|
||||||
return "image/png"
|
return contentTypePNG
|
||||||
case strings.HasSuffix(path, ".gif"):
|
case strings.HasSuffix(path, ".gif"):
|
||||||
return "image/gif"
|
return contentTypeGIF
|
||||||
case strings.HasSuffix(path, ".webp"):
|
case strings.HasSuffix(path, ".webp"):
|
||||||
return "image/webp"
|
return contentTypeWebP
|
||||||
case strings.HasSuffix(path, ".avif"):
|
case strings.HasSuffix(path, ".avif"):
|
||||||
return "image/avif"
|
return contentTypeAVIF
|
||||||
case strings.HasSuffix(path, ".svg"):
|
case strings.HasSuffix(path, ".svg"):
|
||||||
return "image/svg+xml"
|
return contentTypeSVG
|
||||||
default:
|
default:
|
||||||
return "application/octet-stream"
|
return contentTypeOctetStream
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
package imgcache
|
// Package imageprocessor provides image format conversion and resizing using libvips.
|
||||||
|
package imageprocessor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -12,6 +13,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// vipsOnce ensures vips is initialized exactly once.
|
// vipsOnce ensures vips is initialized exactly once.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // package-level sync.Once for one-time vips init
|
||||||
var vipsOnce sync.Once
|
var vipsOnce sync.Once
|
||||||
|
|
||||||
// initVips initializes libvips with quiet logging.
|
// initVips initializes libvips with quiet logging.
|
||||||
@@ -22,37 +25,135 @@ func initVips() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format represents supported output image formats.
|
||||||
|
type Format string
|
||||||
|
|
||||||
|
// Supported image output formats.
|
||||||
|
const (
|
||||||
|
FormatOriginal Format = "orig"
|
||||||
|
FormatJPEG Format = "jpeg"
|
||||||
|
FormatPNG Format = "png"
|
||||||
|
FormatWebP Format = "webp"
|
||||||
|
FormatAVIF Format = "avif"
|
||||||
|
FormatGIF Format = "gif"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FitMode represents how to fit an image into requested dimensions.
|
||||||
|
type FitMode string
|
||||||
|
|
||||||
|
// Supported image fit modes.
|
||||||
|
const (
|
||||||
|
FitCover FitMode = "cover"
|
||||||
|
FitContain FitMode = "contain"
|
||||||
|
FitFill FitMode = "fill"
|
||||||
|
FitInside FitMode = "inside"
|
||||||
|
FitOutside FitMode = "outside"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrInvalidFitMode is returned when an invalid fit mode is provided.
|
||||||
|
var ErrInvalidFitMode = errors.New("invalid fit mode")
|
||||||
|
|
||||||
|
// Size represents requested image dimensions.
|
||||||
|
type Size struct {
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request holds the parameters for image processing.
|
||||||
|
type Request struct {
|
||||||
|
Size Size
|
||||||
|
Format Format
|
||||||
|
Quality int
|
||||||
|
FitMode FitMode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result contains the output of image processing.
|
||||||
|
type Result struct {
|
||||||
|
// Content is the processed image data.
|
||||||
|
Content io.ReadCloser
|
||||||
|
// ContentLength is the size in bytes.
|
||||||
|
ContentLength int64
|
||||||
|
// ContentType is the MIME type of the output.
|
||||||
|
ContentType string
|
||||||
|
// Width is the output image width.
|
||||||
|
Width int
|
||||||
|
// Height is the output image height.
|
||||||
|
Height int
|
||||||
|
// InputWidth is the original image width before processing.
|
||||||
|
InputWidth int
|
||||||
|
// InputHeight is the original image height before processing.
|
||||||
|
InputHeight int
|
||||||
|
// InputFormat is the detected input format (e.g., "jpeg", "png").
|
||||||
|
InputFormat string
|
||||||
|
}
|
||||||
|
|
||||||
// MaxInputDimension is the maximum allowed width or height for input images.
|
// MaxInputDimension is the maximum allowed width or height for input images.
|
||||||
// Images larger than this are rejected to prevent DoS via decompression bombs.
|
// Images larger than this are rejected to prevent DoS via decompression bombs.
|
||||||
const MaxInputDimension = 8192
|
const MaxInputDimension = 8192
|
||||||
|
|
||||||
|
// DefaultMaxInputBytes is the default maximum input size in bytes (50 MiB).
|
||||||
|
// This matches the default upstream fetcher limit.
|
||||||
|
const DefaultMaxInputBytes = 50 << 20
|
||||||
|
|
||||||
// ErrInputTooLarge is returned when input image dimensions exceed MaxInputDimension.
|
// ErrInputTooLarge is returned when input image dimensions exceed MaxInputDimension.
|
||||||
var ErrInputTooLarge = errors.New("input image dimensions exceed maximum")
|
var ErrInputTooLarge = errors.New("input image dimensions exceed maximum")
|
||||||
|
|
||||||
// ErrUnsupportedOutputFormat is returned when the requested output format is not supported.
|
// ErrInputDataTooLarge is returned when the raw input data exceeds the
|
||||||
|
// configured byte limit.
|
||||||
|
var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size")
|
||||||
|
|
||||||
|
// ErrUnsupportedOutputFormat is returned when the requested output format is
|
||||||
|
// not supported.
|
||||||
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
|
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
|
||||||
|
|
||||||
// ImageProcessor implements the Processor interface using libvips via govips.
|
// ImageProcessor implements image transformation using libvips via govips.
|
||||||
type ImageProcessor struct{}
|
type ImageProcessor struct {
|
||||||
|
maxInputBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
// NewImageProcessor creates a new image processor.
|
// Params holds configuration for creating an ImageProcessor.
|
||||||
func NewImageProcessor() *ImageProcessor {
|
// Zero values use sensible defaults (MaxInputBytes defaults to DefaultMaxInputBytes).
|
||||||
|
type Params struct {
|
||||||
|
// MaxInputBytes is the maximum allowed input size in bytes.
|
||||||
|
// If <= 0, DefaultMaxInputBytes is used.
|
||||||
|
MaxInputBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new image processor with the given parameters.
|
||||||
|
// A zero-value Params{} uses sensible defaults.
|
||||||
|
func New(params Params) *ImageProcessor {
|
||||||
initVips()
|
initVips()
|
||||||
return &ImageProcessor{}
|
|
||||||
|
maxInputBytes := params.MaxInputBytes
|
||||||
|
if maxInputBytes <= 0 {
|
||||||
|
maxInputBytes = DefaultMaxInputBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ImageProcessor{
|
||||||
|
maxInputBytes: maxInputBytes,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process transforms an image according to the request.
|
// Process transforms an image according to the request.
|
||||||
func (p *ImageProcessor) Process(
|
func (p *ImageProcessor) Process(
|
||||||
_ context.Context,
|
_ context.Context,
|
||||||
input io.Reader,
|
input io.Reader,
|
||||||
req *ImageRequest,
|
req *Request,
|
||||||
) (*ProcessResult, error) {
|
) (*Result, error) {
|
||||||
// Read input
|
// Read input with a size limit to prevent unbounded memory consumption.
|
||||||
data, err := io.ReadAll(input)
|
// We read at most maxInputBytes+1 so we can detect if the input exceeds
|
||||||
|
// the limit without consuming additional memory.
|
||||||
|
limited := io.LimitReader(input, p.maxInputBytes+1)
|
||||||
|
|
||||||
|
data, err := io.ReadAll(limited)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read input: %w", err)
|
return nil, fmt.Errorf("failed to read input: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if int64(len(data)) > p.maxInputBytes {
|
||||||
|
return nil, ErrInputDataTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
// Decode image
|
// Decode image
|
||||||
img, err := vips.NewImageFromBuffer(data)
|
img, err := vips.NewImageFromBuffer(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -73,25 +174,12 @@ func (p *ImageProcessor) Process(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Determine target dimensions
|
// Determine target dimensions
|
||||||
targetWidth := req.Size.Width
|
targetWidth, targetHeight := targetDimensions(req.Size, origWidth, origHeight)
|
||||||
targetHeight := req.Size.Height
|
|
||||||
|
|
||||||
// Handle dimension calculation
|
|
||||||
if targetWidth == 0 && targetHeight == 0 {
|
|
||||||
// Both are 0: keep original size
|
|
||||||
targetWidth = origWidth
|
|
||||||
targetHeight = origHeight
|
|
||||||
} else if targetWidth == 0 {
|
|
||||||
// Only height specified: calculate width proportionally
|
|
||||||
targetWidth = origWidth * targetHeight / origHeight
|
|
||||||
} else if targetHeight == 0 {
|
|
||||||
// Only width specified: calculate height proportionally
|
|
||||||
targetHeight = origHeight * targetWidth / origWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resize if needed
|
// Resize if needed
|
||||||
if targetWidth != origWidth || targetHeight != origHeight {
|
if targetWidth != origWidth || targetHeight != origHeight {
|
||||||
if err := p.resize(img, targetWidth, targetHeight, req.FitMode); err != nil {
|
err := p.resize(img, targetWidth, targetHeight, req.FitMode)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to resize: %w", err)
|
return nil, fmt.Errorf("failed to resize: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,10 +196,10 @@ func (p *ImageProcessor) Process(
|
|||||||
return nil, fmt.Errorf("failed to encode: %w", err)
|
return nil, fmt.Errorf("failed to encode: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ProcessResult{
|
return &Result{
|
||||||
Content: io.NopCloser(bytes.NewReader(output)),
|
Content: io.NopCloser(bytes.NewReader(output)),
|
||||||
ContentLength: int64(len(output)),
|
ContentLength: int64(len(output)),
|
||||||
ContentType: ImageFormatToMIME(outputFormat),
|
ContentType: FormatToMIME(outputFormat),
|
||||||
Width: img.Width(),
|
Width: img.Width(),
|
||||||
Height: img.Height(),
|
Height: img.Height(),
|
||||||
InputWidth: origWidth,
|
InputWidth: origWidth,
|
||||||
@@ -120,20 +208,48 @@ func (p *ImageProcessor) Process(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// targetDimensions calculates the output dimensions for a requested size,
|
||||||
|
// scaling proportionally when only one dimension is given and keeping the
|
||||||
|
// original dimensions when both are zero.
|
||||||
|
func targetDimensions(size Size, origWidth, origHeight int) (int, int) {
|
||||||
|
switch {
|
||||||
|
case size.Width == 0 && size.Height == 0:
|
||||||
|
// Both are 0: keep original size
|
||||||
|
return origWidth, origHeight
|
||||||
|
case size.Width == 0:
|
||||||
|
// Only height specified: calculate width proportionally
|
||||||
|
return origWidth * size.Height / origHeight, size.Height
|
||||||
|
case size.Height == 0:
|
||||||
|
// Only width specified: calculate height proportionally
|
||||||
|
return size.Width, origHeight * size.Width / origWidth
|
||||||
|
default:
|
||||||
|
return size.Width, size.Height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MIME types for the supported image formats.
|
||||||
|
const (
|
||||||
|
mimeJPEG = "image/jpeg"
|
||||||
|
mimePNG = "image/png"
|
||||||
|
mimeGIF = "image/gif"
|
||||||
|
mimeWebP = "image/webp"
|
||||||
|
mimeAVIF = "image/avif"
|
||||||
|
)
|
||||||
|
|
||||||
// SupportedInputFormats returns MIME types this processor can read.
|
// SupportedInputFormats returns MIME types this processor can read.
|
||||||
func (p *ImageProcessor) SupportedInputFormats() []string {
|
func (p *ImageProcessor) SupportedInputFormats() []string {
|
||||||
return []string{
|
return []string{
|
||||||
string(MIMETypeJPEG),
|
mimeJPEG,
|
||||||
string(MIMETypePNG),
|
mimePNG,
|
||||||
string(MIMETypeGIF),
|
mimeGIF,
|
||||||
string(MIMETypeWebP),
|
mimeWebP,
|
||||||
string(MIMETypeAVIF),
|
mimeAVIF,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SupportedOutputFormats returns formats this processor can write.
|
// SupportedOutputFormats returns formats this processor can write.
|
||||||
func (p *ImageProcessor) SupportedOutputFormats() []ImageFormat {
|
func (p *ImageProcessor) SupportedOutputFormats() []Format {
|
||||||
return []ImageFormat{
|
return []Format{
|
||||||
FormatJPEG,
|
FormatJPEG,
|
||||||
FormatPNG,
|
FormatPNG,
|
||||||
FormatGIF,
|
FormatGIF,
|
||||||
@@ -142,6 +258,26 @@ func (p *ImageProcessor) SupportedOutputFormats() []ImageFormat {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FormatToMIME converts a Format to its MIME type string.
|
||||||
|
func FormatToMIME(format Format) string {
|
||||||
|
switch format {
|
||||||
|
case FormatJPEG:
|
||||||
|
return mimeJPEG
|
||||||
|
case FormatPNG:
|
||||||
|
return mimePNG
|
||||||
|
case FormatWebP:
|
||||||
|
return mimeWebP
|
||||||
|
case FormatGIF:
|
||||||
|
return mimeGIF
|
||||||
|
case FormatAVIF:
|
||||||
|
return mimeAVIF
|
||||||
|
case FormatOriginal:
|
||||||
|
return "application/octet-stream"
|
||||||
|
default:
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// detectFormat returns the format string from a vips image.
|
// detectFormat returns the format string from a vips image.
|
||||||
func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
|
func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
|
||||||
format := img.Format()
|
format := img.Format()
|
||||||
@@ -155,14 +291,20 @@ func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
|
|||||||
case vips.ImageTypeWEBP:
|
case vips.ImageTypeWEBP:
|
||||||
return "webp"
|
return "webp"
|
||||||
case vips.ImageTypeAVIF, vips.ImageTypeHEIF:
|
case vips.ImageTypeAVIF, vips.ImageTypeHEIF:
|
||||||
return "avif"
|
return string(FormatAVIF)
|
||||||
|
case vips.ImageTypeUnknown, vips.ImageTypeMagick, vips.ImageTypePDF,
|
||||||
|
vips.ImageTypeSVG, vips.ImageTypeTIFF, vips.ImageTypeBMP,
|
||||||
|
vips.ImageTypeJP2K, vips.ImageTypeJXL:
|
||||||
|
return "unknown"
|
||||||
default:
|
default:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// resize resizes the image according to the fit mode.
|
// resize resizes the image according to the fit mode.
|
||||||
func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMode) error {
|
func (p *ImageProcessor) resize(
|
||||||
|
img *vips.ImageRef, width, height int, fit FitMode,
|
||||||
|
) error {
|
||||||
switch fit {
|
switch fit {
|
||||||
case FitCover, "":
|
case FitCover, "":
|
||||||
// Resize and crop to fill exact dimensions (default)
|
// Resize and crop to fill exact dimensions (default)
|
||||||
@@ -170,17 +312,17 @@ func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMo
|
|||||||
|
|
||||||
case FitContain:
|
case FitContain:
|
||||||
// Resize to fit within dimensions, maintaining aspect ratio
|
// Resize to fit within dimensions, maintaining aspect ratio
|
||||||
// Calculate target dimensions maintaining aspect ratio
|
|
||||||
imgW, imgH := img.Width(), img.Height()
|
imgW, imgH := img.Width(), img.Height()
|
||||||
scaleW := float64(width) / float64(imgW)
|
scaleW := float64(width) / float64(imgW)
|
||||||
scaleH := float64(height) / float64(imgH)
|
scaleH := float64(height) / float64(imgH)
|
||||||
scale := min(scaleW, scaleH)
|
scale := min(scaleW, scaleH)
|
||||||
newW := int(float64(imgW) * scale)
|
newW := int(float64(imgW) * scale)
|
||||||
newH := int(float64(imgH) * scale)
|
newH := int(float64(imgH) * scale)
|
||||||
|
|
||||||
return img.Thumbnail(newW, newH, vips.InterestingNone)
|
return img.Thumbnail(newW, newH, vips.InterestingNone)
|
||||||
|
|
||||||
case FitFill:
|
case FitFill:
|
||||||
// Resize to exact dimensions (may distort) - use ThumbnailWithSize with Force
|
// Resize to exact dimensions (may distort)
|
||||||
return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce)
|
return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce)
|
||||||
|
|
||||||
case FitInside:
|
case FitInside:
|
||||||
@@ -188,12 +330,14 @@ func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMo
|
|||||||
if img.Width() <= width && img.Height() <= height {
|
if img.Width() <= width && img.Height() <= height {
|
||||||
return nil // Already fits
|
return nil // Already fits
|
||||||
}
|
}
|
||||||
|
|
||||||
imgW, imgH := img.Width(), img.Height()
|
imgW, imgH := img.Width(), img.Height()
|
||||||
scaleW := float64(width) / float64(imgW)
|
scaleW := float64(width) / float64(imgW)
|
||||||
scaleH := float64(height) / float64(imgH)
|
scaleH := float64(height) / float64(imgH)
|
||||||
scale := min(scaleW, scaleH)
|
scale := min(scaleW, scaleH)
|
||||||
newW := int(float64(imgW) * scale)
|
newW := int(float64(imgW) * scale)
|
||||||
newH := int(float64(imgH) * scale)
|
newH := int(float64(imgH) * scale)
|
||||||
|
|
||||||
return img.Thumbnail(newW, newH, vips.InterestingNone)
|
return img.Thumbnail(newW, newH, vips.InterestingNone)
|
||||||
|
|
||||||
case FitOutside:
|
case FitOutside:
|
||||||
@@ -204,6 +348,7 @@ func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMo
|
|||||||
scale := max(scaleW, scaleH)
|
scale := max(scaleW, scaleH)
|
||||||
newW := int(float64(imgW) * scale)
|
newW := int(float64(imgW) * scale)
|
||||||
newH := int(float64(imgH) * scale)
|
newH := int(float64(imgH) * scale)
|
||||||
|
|
||||||
return img.Thumbnail(newW, newH, vips.InterestingNone)
|
return img.Thumbnail(newW, newH, vips.InterestingNone)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -214,7 +359,9 @@ func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMo
|
|||||||
const defaultQuality = 85
|
const defaultQuality = 85
|
||||||
|
|
||||||
// encode encodes an image to the specified format.
|
// encode encodes an image to the specified format.
|
||||||
func (p *ImageProcessor) encode(img *vips.ImageRef, format ImageFormat, quality int) ([]byte, error) {
|
func (p *ImageProcessor) encode(
|
||||||
|
img *vips.ImageRef, format Format, quality int,
|
||||||
|
) ([]byte, error) {
|
||||||
if quality <= 0 {
|
if quality <= 0 {
|
||||||
quality = defaultQuality
|
quality = defaultQuality
|
||||||
}
|
}
|
||||||
@@ -250,8 +397,11 @@ func (p *ImageProcessor) encode(img *vips.ImageRef, format ImageFormat, quality
|
|||||||
Quality: quality,
|
Quality: quality,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case FormatOriginal:
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported output format: %s", format)
|
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
||||||
}
|
}
|
||||||
|
|
||||||
output, _, err := img.Export(¶ms)
|
output, _, err := img.Export(¶ms)
|
||||||
@@ -262,8 +412,8 @@ func (p *ImageProcessor) encode(img *vips.ImageRef, format ImageFormat, quality
|
|||||||
return output, nil
|
return output, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatFromString converts a format string to ImageFormat.
|
// formatFromString converts a format string to Format.
|
||||||
func (p *ImageProcessor) formatFromString(format string) ImageFormat {
|
func (p *ImageProcessor) formatFromString(format string) Format {
|
||||||
switch format {
|
switch format {
|
||||||
case "jpeg":
|
case "jpeg":
|
||||||
return FormatJPEG
|
return FormatJPEG
|
||||||
@@ -273,7 +423,7 @@ func (p *ImageProcessor) formatFromString(format string) ImageFormat {
|
|||||||
return FormatGIF
|
return FormatGIF
|
||||||
case "webp":
|
case "webp":
|
||||||
return FormatWebP
|
return FormatWebP
|
||||||
case "avif":
|
case string(FormatAVIF):
|
||||||
return FormatAVIF
|
return FormatAVIF
|
||||||
default:
|
default:
|
||||||
return FormatJPEG
|
return FormatJPEG
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
package imgcache
|
package imageprocessor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
@@ -16,7 +17,9 @@ import (
|
|||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
initVips()
|
initVips()
|
||||||
|
|
||||||
code := m.Run()
|
code := m.Run()
|
||||||
|
|
||||||
vips.Shutdown()
|
vips.Shutdown()
|
||||||
os.Exit(code)
|
os.Exit(code)
|
||||||
}
|
}
|
||||||
@@ -27,11 +30,11 @@ func createTestJPEG(t *testing.T, width, height int) []byte {
|
|||||||
|
|
||||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
// Fill with a gradient
|
// Fill with a gradient
|
||||||
for y := 0; y < height; y++ {
|
for y := range height {
|
||||||
for x := 0; x < width; x++ {
|
for x := range width {
|
||||||
img.Set(x, y, color.RGBA{
|
img.Set(x, y, color.RGBA{
|
||||||
R: uint8(x * 255 / width),
|
R: uint8((x * 255 / width) & 0xff),
|
||||||
G: uint8(y * 255 / height),
|
G: uint8((y * 255 / height) & 0xff),
|
||||||
B: 128,
|
B: 128,
|
||||||
A: 255,
|
A: 255,
|
||||||
})
|
})
|
||||||
@@ -39,7 +42,9 @@ func createTestJPEG(t *testing.T, width, height int) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
|
|
||||||
|
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90})
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("failed to encode test JPEG: %v", err)
|
t.Fatalf("failed to encode test JPEG: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,11 +56,11 @@ func createTestPNG(t *testing.T, width, height int) []byte {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
for y := 0; y < height; y++ {
|
for y := range height {
|
||||||
for x := 0; x < width; x++ {
|
for x := range width {
|
||||||
img.Set(x, y, color.RGBA{
|
img.Set(x, y, color.RGBA{
|
||||||
R: uint8(x * 255 / width),
|
R: uint8((x * 255 / width) & 0xff),
|
||||||
G: uint8(y * 255 / height),
|
G: uint8((y * 255 / height) & 0xff),
|
||||||
B: 128,
|
B: 128,
|
||||||
A: 255,
|
A: 255,
|
||||||
})
|
})
|
||||||
@@ -63,20 +68,60 @@ func createTestPNG(t *testing.T, width, height int) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := png.Encode(&buf, img); err != nil {
|
|
||||||
|
err := png.Encode(&buf, img)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("failed to encode test PNG: %v", err)
|
t.Fatalf("failed to encode test PNG: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return buf.Bytes()
|
return buf.Bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isAVIF reports whether data starts with an AVIF ftyp box.
|
||||||
|
func isAVIF(data []byte) bool {
|
||||||
|
if len(data) < 12 || string(data[4:8]) != "ftyp" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
brand := string(data[8:12])
|
||||||
|
|
||||||
|
return brand == string(FormatAVIF) || brand == "avis"
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectMIME is a minimal magic-byte detector for test assertions.
|
||||||
|
func detectMIME(data []byte) string {
|
||||||
|
if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
|
||||||
|
return mimeJPEG
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) >= 8 && string(data[:8]) == "\x89PNG\r\n\x1a\n" {
|
||||||
|
return mimePNG
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) >= 4 && string(data[:4]) == "GIF8" {
|
||||||
|
return mimeGIF
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
|
||||||
|
return mimeWebP
|
||||||
|
}
|
||||||
|
|
||||||
|
if isAVIF(data) {
|
||||||
|
return mimeAVIF
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
||||||
proc := NewImageProcessor()
|
t.Parallel()
|
||||||
|
|
||||||
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestJPEG(t, 800, 600)
|
input := createTestJPEG(t, 800, 600)
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &Request{
|
||||||
Size: Size{Width: 400, Height: 300},
|
Size: Size{Width: 400, Height: 300},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -87,7 +132,8 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
defer result.Content.Close()
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
if result.Width != 400 {
|
if result.Width != 400 {
|
||||||
t.Errorf("Process() width = %d, want 400", result.Width)
|
t.Errorf("Process() width = %d, want 400", result.Width)
|
||||||
@@ -107,23 +153,21 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
|||||||
t.Fatalf("failed to read result: %v", err)
|
t.Fatalf("failed to read result: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
mime, err := DetectFormat(data)
|
mime := detectMIME(data)
|
||||||
if err != nil {
|
if mime != mimeJPEG {
|
||||||
t.Fatalf("DetectFormat() error = %v", err)
|
t.Errorf("Output format = %v, want image/jpeg", mime)
|
||||||
}
|
|
||||||
|
|
||||||
if mime != MIMETypeJPEG {
|
|
||||||
t.Errorf("Output format = %v, want %v", mime, MIMETypeJPEG)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
||||||
proc := NewImageProcessor()
|
t.Parallel()
|
||||||
|
|
||||||
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestJPEG(t, 200, 150)
|
input := createTestJPEG(t, 200, 150)
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &Request{
|
||||||
Size: Size{Width: 200, Height: 150},
|
Size: Size{Width: 200, Height: 150},
|
||||||
Format: FormatPNG,
|
Format: FormatPNG,
|
||||||
FitMode: FitCover,
|
FitMode: FitCover,
|
||||||
@@ -133,31 +177,34 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
defer result.Content.Close()
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
data, err := io.ReadAll(result.Content)
|
data, err := io.ReadAll(result.Content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to read result: %v", err)
|
t.Fatalf("failed to read result: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
mime, err := DetectFormat(data)
|
mime := detectMIME(data)
|
||||||
if err != nil {
|
if mime != mimePNG {
|
||||||
t.Fatalf("DetectFormat() error = %v", err)
|
t.Errorf("Output format = %v, want image/png", mime)
|
||||||
}
|
|
||||||
|
|
||||||
if mime != MIMETypePNG {
|
|
||||||
t.Errorf("Output format = %v, want %v", mime, MIMETypePNG)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_OriginalSize(t *testing.T) {
|
// processAndCheckSize processes a test JPEG of the given input dimensions
|
||||||
proc := NewImageProcessor()
|
// with the requested size and asserts the resulting dimensions.
|
||||||
|
func processAndCheckSize(
|
||||||
|
t *testing.T, inputW, inputH int, size Size, wantW, wantH int,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestJPEG(t, 640, 480)
|
input := createTestJPEG(t, inputW, inputH)
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &Request{
|
||||||
Size: Size{Width: 0, Height: 0}, // Original size
|
Size: size,
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: FitCover,
|
||||||
@@ -167,26 +214,36 @@ func TestImageProcessor_OriginalSize(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
defer result.Content.Close()
|
|
||||||
|
|
||||||
if result.Width != 640 {
|
defer func() { _ = result.Content.Close() }()
|
||||||
t.Errorf("Process() width = %d, want 640", result.Width)
|
|
||||||
|
if result.Width != wantW {
|
||||||
|
t.Errorf("Process() width = %d, want %d", result.Width, wantW)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.Height != 480 {
|
if result.Height != wantH {
|
||||||
t.Errorf("Process() height = %d, want 480", result.Height)
|
t.Errorf("Process() height = %d, want %d", result.Height, wantH)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_OriginalSize(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Width and height 0: keep original size
|
||||||
|
processAndCheckSize(t, 640, 480, Size{Width: 0, Height: 0}, 640, 480)
|
||||||
|
}
|
||||||
|
|
||||||
func TestImageProcessor_FitContain(t *testing.T) {
|
func TestImageProcessor_FitContain(t *testing.T) {
|
||||||
proc := NewImageProcessor()
|
t.Parallel()
|
||||||
|
|
||||||
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// 800x400 image (2:1 aspect) into 400x400 box with contain
|
// 800x400 image (2:1 aspect) into 400x400 box with contain
|
||||||
// Should result in 400x200 (maintaining aspect ratio)
|
// Should result in 400x200 (maintaining aspect ratio)
|
||||||
input := createTestJPEG(t, 800, 400)
|
input := createTestJPEG(t, 800, 400)
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &Request{
|
||||||
Size: Size{Width: 400, Height: 400},
|
Size: Size{Width: 400, Height: 400},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -197,7 +254,8 @@ func TestImageProcessor_FitContain(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
defer result.Content.Close()
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
// With contain, the image should fit within the box
|
// With contain, the image should fit within the box
|
||||||
if result.Width > 400 || result.Height > 400 {
|
if result.Width > 400 || result.Height > 400 {
|
||||||
@@ -206,72 +264,30 @@ func TestImageProcessor_FitContain(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) {
|
func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) {
|
||||||
proc := NewImageProcessor()
|
t.Parallel()
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// 800x600 image, request width=400 height=0
|
// 800x600 image, request width=400 height=0
|
||||||
// Should scale proportionally to 400x300
|
// Should scale proportionally to 400x300
|
||||||
input := createTestJPEG(t, 800, 600)
|
processAndCheckSize(t, 800, 600, Size{Width: 400, Height: 0}, 400, 300)
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
Size: Size{Width: 400, Height: 0},
|
|
||||||
Format: FormatJPEG,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: FitCover,
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Process() error = %v", err)
|
|
||||||
}
|
|
||||||
defer result.Content.Close()
|
|
||||||
|
|
||||||
if result.Width != 400 {
|
|
||||||
t.Errorf("Process() width = %d, want 400", result.Width)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Height != 300 {
|
|
||||||
t.Errorf("Process() height = %d, want 300", result.Height)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
|
func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
|
||||||
proc := NewImageProcessor()
|
t.Parallel()
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// 800x600 image, request width=0 height=300
|
// 800x600 image, request width=0 height=300
|
||||||
// Should scale proportionally to 400x300
|
// Should scale proportionally to 400x300
|
||||||
input := createTestJPEG(t, 800, 600)
|
processAndCheckSize(t, 800, 600, Size{Width: 0, Height: 300}, 400, 300)
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
Size: Size{Width: 0, Height: 300},
|
|
||||||
Format: FormatJPEG,
|
|
||||||
Quality: 85,
|
|
||||||
FitMode: FitCover,
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Process() error = %v", err)
|
|
||||||
}
|
|
||||||
defer result.Content.Close()
|
|
||||||
|
|
||||||
if result.Width != 400 {
|
|
||||||
t.Errorf("Process() width = %d, want 400", result.Width)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Height != 300 {
|
|
||||||
t.Errorf("Process() height = %d, want 300", result.Height)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_ProcessPNG(t *testing.T) {
|
func TestImageProcessor_ProcessPNG(t *testing.T) {
|
||||||
proc := NewImageProcessor()
|
t.Parallel()
|
||||||
|
|
||||||
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestPNG(t, 400, 300)
|
input := createTestPNG(t, 400, 300)
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &Request{
|
||||||
Size: Size{Width: 200, Height: 150},
|
Size: Size{Width: 200, Height: 150},
|
||||||
Format: FormatPNG,
|
Format: FormatPNG,
|
||||||
FitMode: FitCover,
|
FitMode: FitCover,
|
||||||
@@ -281,7 +297,8 @@ func TestImageProcessor_ProcessPNG(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
defer result.Content.Close()
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
if result.Width != 200 {
|
if result.Width != 200 {
|
||||||
t.Errorf("Process() width = %d, want 200", result.Width)
|
t.Errorf("Process() width = %d, want 200", result.Width)
|
||||||
@@ -292,13 +309,10 @@ func TestImageProcessor_ProcessPNG(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_ImplementsInterface(t *testing.T) {
|
|
||||||
// Verify ImageProcessor implements Processor interface
|
|
||||||
var _ Processor = (*ImageProcessor)(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestImageProcessor_SupportedFormats(t *testing.T) {
|
func TestImageProcessor_SupportedFormats(t *testing.T) {
|
||||||
proc := NewImageProcessor()
|
t.Parallel()
|
||||||
|
|
||||||
|
proc := New(Params{})
|
||||||
|
|
||||||
inputFormats := proc.SupportedInputFormats()
|
inputFormats := proc.SupportedInputFormats()
|
||||||
if len(inputFormats) == 0 {
|
if len(inputFormats) == 0 {
|
||||||
@@ -312,63 +326,56 @@ func TestImageProcessor_SupportedFormats(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
|
func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
|
||||||
proc := NewImageProcessor()
|
t.Parallel()
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Create an image that exceeds MaxInputDimension (e.g., 10000x100)
|
// Images exceeding MaxInputDimension in either dimension must be
|
||||||
// This should be rejected before processing to prevent DoS
|
// rejected before processing to prevent DoS.
|
||||||
input := createTestJPEG(t, 10000, 100)
|
tests := []struct {
|
||||||
|
name string
|
||||||
req := &ImageRequest{
|
width int
|
||||||
Size: Size{Width: 100, Height: 100},
|
height int
|
||||||
Format: FormatJPEG,
|
}{
|
||||||
Quality: 85,
|
{name: "oversized width", width: 10000, height: 100},
|
||||||
FitMode: FitCover,
|
{name: "oversized height", width: 100, height: 10000},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := proc.Process(ctx, bytes.NewReader(input), req)
|
for _, tt := range tests {
|
||||||
if err == nil {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Error("Process() should reject oversized input images")
|
t.Parallel()
|
||||||
}
|
|
||||||
|
|
||||||
if err != ErrInputTooLarge {
|
proc := New(Params{})
|
||||||
t.Errorf("Process() error = %v, want ErrInputTooLarge", err)
|
ctx := context.Background()
|
||||||
}
|
input := createTestJPEG(t, tt.width, tt.height)
|
||||||
}
|
|
||||||
|
|
||||||
func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) {
|
req := &Request{
|
||||||
proc := NewImageProcessor()
|
Size: Size{Width: 100, Height: 100},
|
||||||
ctx := context.Background()
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
|
||||||
// Create an image with oversized height
|
_, err := proc.Process(ctx, bytes.NewReader(input), req)
|
||||||
input := createTestJPEG(t, 100, 10000)
|
if err == nil {
|
||||||
|
t.Error("Process() should reject oversized input images")
|
||||||
|
}
|
||||||
|
|
||||||
req := &ImageRequest{
|
if !errors.Is(err, ErrInputTooLarge) {
|
||||||
Size: Size{Width: 100, Height: 100},
|
t.Errorf("Process() error = %v, want ErrInputTooLarge", err)
|
||||||
Format: FormatJPEG,
|
}
|
||||||
Quality: 85,
|
})
|
||||||
FitMode: FitCover,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := proc.Process(ctx, bytes.NewReader(input), req)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Process() should reject oversized input images")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != ErrInputTooLarge {
|
|
||||||
t.Errorf("Process() error = %v, want ErrInputTooLarge", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
|
func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
|
||||||
proc := NewImageProcessor()
|
t.Parallel()
|
||||||
|
|
||||||
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Create an image at exactly MaxInputDimension - should be accepted
|
// Create an image at exactly MaxInputDimension - should be accepted
|
||||||
// Using smaller dimensions to keep test fast
|
|
||||||
input := createTestJPEG(t, MaxInputDimension, 100)
|
input := createTestJPEG(t, MaxInputDimension, 100)
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &Request{
|
||||||
Size: Size{Width: 100, Height: 100},
|
Size: Size{Width: 100, Height: 100},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -377,21 +384,29 @@ func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
|
|||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() should accept images at MaxInputDimension, got error: %v", err)
|
t.Fatalf(
|
||||||
|
"Process() should accept images at MaxInputDimension, got error: %v",
|
||||||
|
err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
defer result.Content.Close()
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_EncodeWebP(t *testing.T) {
|
// encodeAndCheck processes a 200x150 test JPEG into a 100x75 output of the
|
||||||
proc := NewImageProcessor()
|
// given format and asserts the output MIME type and dimensions.
|
||||||
|
func encodeAndCheck(t *testing.T, format Format, quality int, wantMIME string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestJPEG(t, 200, 150)
|
input := createTestJPEG(t, 200, 150)
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &Request{
|
||||||
Size: Size{Width: 100, Height: 75},
|
Size: Size{Width: 100, Height: 75},
|
||||||
Format: FormatWebP,
|
Format: format,
|
||||||
Quality: 80,
|
Quality: quality,
|
||||||
FitMode: FitCover,
|
FitMode: FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,34 +414,40 @@ func TestImageProcessor_EncodeWebP(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v, want nil", err)
|
t.Fatalf("Process() error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
defer result.Content.Close()
|
|
||||||
|
|
||||||
// Verify output is valid WebP
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
|
// Verify output format
|
||||||
data, err := io.ReadAll(result.Content)
|
data, err := io.ReadAll(result.Content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to read result: %v", err)
|
t.Fatalf("failed to read result: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
mime, err := DetectFormat(data)
|
mime := detectMIME(data)
|
||||||
if err != nil {
|
if mime != wantMIME {
|
||||||
t.Fatalf("DetectFormat() error = %v", err)
|
t.Errorf("Output format = %v, want %v", mime, wantMIME)
|
||||||
}
|
|
||||||
|
|
||||||
if mime != MIMETypeWebP {
|
|
||||||
t.Errorf("Output format = %v, want %v", mime, MIMETypeWebP)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify dimensions
|
// Verify dimensions
|
||||||
if result.Width != 100 {
|
if result.Width != 100 {
|
||||||
t.Errorf("Width = %d, want 100", result.Width)
|
t.Errorf("Width = %d, want 100", result.Width)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.Height != 75 {
|
if result.Height != 75 {
|
||||||
t.Errorf("Height = %d, want 75", result.Height)
|
t.Errorf("Height = %d, want 75", result.Height)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_EncodeWebP(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
encodeAndCheck(t, FormatWebP, 80, mimeWebP)
|
||||||
|
}
|
||||||
|
|
||||||
func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
||||||
proc := NewImageProcessor()
|
t.Parallel()
|
||||||
|
|
||||||
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Load test AVIF file
|
// Load test AVIF file
|
||||||
@@ -436,7 +457,7 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Request resize and convert to JPEG
|
// Request resize and convert to JPEG
|
||||||
req := &ImageRequest{
|
req := &Request{
|
||||||
Size: Size{Width: 2, Height: 2},
|
Size: Size{Width: 2, Height: 2},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -447,7 +468,8 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v, want nil (AVIF decoding should work)", err)
|
t.Fatalf("Process() error = %v, want nil (AVIF decoding should work)", err)
|
||||||
}
|
}
|
||||||
defer result.Content.Close()
|
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
// Verify output is valid JPEG
|
// Verify output is valid JPEG
|
||||||
data, err := io.ReadAll(result.Content)
|
data, err := io.ReadAll(result.Content)
|
||||||
@@ -455,55 +477,87 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
|||||||
t.Fatalf("failed to read result: %v", err)
|
t.Fatalf("failed to read result: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
mime, err := DetectFormat(data)
|
mime := detectMIME(data)
|
||||||
if err != nil {
|
if mime != mimeJPEG {
|
||||||
t.Fatalf("DetectFormat() error = %v", err)
|
t.Errorf("Output format = %v, want image/jpeg", mime)
|
||||||
}
|
|
||||||
|
|
||||||
if mime != MIMETypeJPEG {
|
|
||||||
t.Errorf("Output format = %v, want %v", mime, MIMETypeJPEG)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_EncodeAVIF(t *testing.T) {
|
func TestImageProcessor_RejectsOversizedInputData(t *testing.T) {
|
||||||
proc := NewImageProcessor()
|
t.Parallel()
|
||||||
|
|
||||||
|
// Create a processor with a very small byte limit
|
||||||
|
const limit = 1024
|
||||||
|
|
||||||
|
proc := New(Params{MaxInputBytes: limit})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestJPEG(t, 200, 150)
|
// Create a valid JPEG that exceeds the byte limit
|
||||||
|
input := createTestJPEG(t, 800, 600) // will be well over 1 KiB
|
||||||
|
if int64(len(input)) <= limit {
|
||||||
|
t.Fatalf("test JPEG must exceed %d bytes, got %d", limit, len(input))
|
||||||
|
}
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &Request{
|
||||||
Size: Size{Width: 100, Height: 75},
|
Size: Size{Width: 100, Height: 75},
|
||||||
Format: FormatAVIF,
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := proc.Process(ctx, bytes.NewReader(input), req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Process() should reject input exceeding maxInputBytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, ErrInputDataTooLarge) {
|
||||||
|
t.Errorf("Process() error = %v, want ErrInputDataTooLarge", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Create a small image and set limit well above its size
|
||||||
|
input := createTestJPEG(t, 10, 10)
|
||||||
|
limit := int64(len(input)) * 10 // 10× headroom
|
||||||
|
|
||||||
|
proc := New(Params{MaxInputBytes: limit})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
req := &Request{
|
||||||
|
Size: Size{Width: 10, Height: 10},
|
||||||
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v, want nil (AVIF encoding should work)", err)
|
t.Fatalf("Process() error = %v, want nil", err)
|
||||||
}
|
|
||||||
defer result.Content.Close()
|
|
||||||
|
|
||||||
// Verify output is valid AVIF
|
|
||||||
data, err := io.ReadAll(result.Content)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to read result: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mime, err := DetectFormat(data)
|
defer func() { _ = result.Content.Close() }()
|
||||||
if err != nil {
|
}
|
||||||
t.Fatalf("DetectFormat() error = %v", err)
|
|
||||||
|
func TestImageProcessor_DefaultMaxInputBytes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Passing 0 should use the default
|
||||||
|
proc := New(Params{})
|
||||||
|
if proc.maxInputBytes != DefaultMaxInputBytes {
|
||||||
|
t.Errorf("maxInputBytes = %d, want %d", proc.maxInputBytes, DefaultMaxInputBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
if mime != MIMETypeAVIF {
|
// Passing negative should also use the default
|
||||||
t.Errorf("Output format = %v, want %v", mime, MIMETypeAVIF)
|
proc = New(Params{MaxInputBytes: -1})
|
||||||
}
|
if proc.maxInputBytes != DefaultMaxInputBytes {
|
||||||
|
t.Errorf("maxInputBytes = %d, want %d", proc.maxInputBytes, DefaultMaxInputBytes)
|
||||||
// Verify dimensions
|
|
||||||
if result.Width != 100 {
|
|
||||||
t.Errorf("Width = %d, want 100", result.Width)
|
|
||||||
}
|
|
||||||
if result.Height != 75 {
|
|
||||||
t.Errorf("Height = %d, want 75", result.Height)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_EncodeAVIF(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
encodeAndCheck(t, FormatAVIF, 85, mimeAVIF)
|
||||||
|
}
|
||||||
BIN
internal/imageprocessor/testdata/red.avif
vendored
Normal file
BIN
internal/imageprocessor/testdata/red.avif
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 281 B |
@@ -8,8 +8,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Cache errors.
|
// Cache errors.
|
||||||
@@ -42,24 +43,30 @@ type Cache struct {
|
|||||||
srcMetadata *MetadataStorage // source metadata by host/path
|
srcMetadata *MetadataStorage // source metadata by host/path
|
||||||
config CacheConfig
|
config CacheConfig
|
||||||
|
|
||||||
// In-memory cache of variant metadata (content type, size) to avoid reading .meta files
|
// In-memory cache of variant metadata (content type, size) to avoid
|
||||||
metaCache map[VariantKey]variantMeta
|
// reading .meta files
|
||||||
metaCacheMu sync.RWMutex
|
metaCache map[VariantKey]variantMeta
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCache creates a new cache instance.
|
// NewCache creates a new cache instance.
|
||||||
func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
||||||
srcContent, err := NewContentStorage(filepath.Join(config.StateDir, "cache", "sources"))
|
srcContent, err := NewContentStorage(
|
||||||
|
filepath.Join(config.StateDir, "cache", "sources"),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create source content storage: %w", err)
|
return nil, fmt.Errorf("failed to create source content storage: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
variants, err := NewVariantStorage(filepath.Join(config.StateDir, "cache", "variants"))
|
variants, err := NewVariantStorage(
|
||||||
|
filepath.Join(config.StateDir, "cache", "variants"),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create variant storage: %w", err)
|
return nil, fmt.Errorf("failed to create variant storage: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
srcMetadata, err := NewMetadataStorage(filepath.Join(config.StateDir, "cache", "metadata"))
|
srcMetadata, err := NewMetadataStorage(
|
||||||
|
filepath.Join(config.StateDir, "cache", "metadata"),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create source metadata storage: %w", err)
|
return nil, fmt.Errorf("failed to create source metadata storage: %w", err)
|
||||||
}
|
}
|
||||||
@@ -113,7 +120,7 @@ func (c *Cache) StoreSource(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req *ImageRequest,
|
req *ImageRequest,
|
||||||
content io.Reader,
|
content io.Reader,
|
||||||
result *FetchResult,
|
result *httpfetcher.FetchResult,
|
||||||
) (ContentHash, error) {
|
) (ContentHash, error) {
|
||||||
// Store content
|
// Store content
|
||||||
contentHash, size, err := c.srcContent.Store(content)
|
contentHash, size, err := c.srcContent.Store(content)
|
||||||
@@ -123,7 +130,11 @@ func (c *Cache) StoreSource(
|
|||||||
|
|
||||||
// Store in database
|
// Store in database
|
||||||
pathHash := HashPath(req.SourcePath + "?" + req.SourceQuery)
|
pathHash := HashPath(req.SourcePath + "?" + req.SourceQuery)
|
||||||
headersJSON, _ := json.Marshal(result.Headers)
|
|
||||||
|
headersJSON, err := json.Marshal(result.Headers)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal response headers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
_, err = c.db.ExecContext(ctx, `
|
_, err = c.db.ExecContext(ctx, `
|
||||||
INSERT INTO source_content (content_hash, content_type, size_bytes)
|
INSERT INTO source_content (content_hash, content_type, size_bytes)
|
||||||
@@ -166,23 +177,26 @@ func (c *Cache) StoreSource(
|
|||||||
RemoteAddr: result.RemoteAddr,
|
RemoteAddr: result.RemoteAddr,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.srcMetadata.Store(req.SourceHost, pathHash, meta); err != nil {
|
// A failure here is non-fatal; the metadata is in the database.
|
||||||
// Non-fatal, we have it in the database
|
_ = c.srcMetadata.Store(req.SourceHost, pathHash, meta)
|
||||||
_ = err
|
|
||||||
}
|
|
||||||
|
|
||||||
return contentHash, nil
|
return contentHash, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoreVariant stores a processed variant by its cache key.
|
// StoreVariant stores a processed variant by its cache key.
|
||||||
func (c *Cache) StoreVariant(cacheKey VariantKey, content io.Reader, contentType string) error {
|
func (c *Cache) StoreVariant(
|
||||||
|
cacheKey VariantKey, content io.Reader, contentType string,
|
||||||
|
) error {
|
||||||
_, err := c.variants.Store(cacheKey, content, contentType)
|
_, err := c.variants.Store(cacheKey, content, contentType)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// LookupSource checks if we have cached source content for a request.
|
// LookupSource checks if we have cached source content for a request.
|
||||||
// Returns the content hash and content type if found, or empty values if not.
|
// Returns the content hash and content type if found, or empty values if not.
|
||||||
func (c *Cache) LookupSource(ctx context.Context, req *ImageRequest) (ContentHash, string, error) {
|
func (c *Cache) LookupSource(
|
||||||
|
ctx context.Context, req *ImageRequest,
|
||||||
|
) (ContentHash, string, error) {
|
||||||
var hashStr, contentType string
|
var hashStr, contentType string
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx, `
|
err := c.db.QueryRowContext(ctx, `
|
||||||
@@ -209,11 +223,15 @@ func (c *Cache) LookupSource(ctx context.Context, req *ImageRequest) (ContentHas
|
|||||||
}
|
}
|
||||||
|
|
||||||
// StoreNegative stores a negative cache entry for a failed fetch.
|
// StoreNegative stores a negative cache entry for a failed fetch.
|
||||||
func (c *Cache) StoreNegative(ctx context.Context, req *ImageRequest, statusCode int, errMsg string) error {
|
func (c *Cache) StoreNegative(
|
||||||
|
ctx context.Context, req *ImageRequest, statusCode int, errMsg string,
|
||||||
|
) error {
|
||||||
expiresAt := time.Now().UTC().Add(c.config.NegativeTTL)
|
expiresAt := time.Now().UTC().Add(c.config.NegativeTTL)
|
||||||
|
|
||||||
_, err := c.db.ExecContext(ctx, `
|
_, err := c.db.ExecContext(ctx, `
|
||||||
INSERT INTO negative_cache (source_host, source_path, source_query, status_code, error_message, expires_at)
|
INSERT INTO negative_cache
|
||||||
|
(source_host, source_path, source_query, status_code,
|
||||||
|
error_message, expires_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(source_host, source_path, source_query) DO UPDATE SET
|
ON CONFLICT(source_host, source_path, source_query) DO UPDATE SET
|
||||||
status_code = excluded.status_code,
|
status_code = excluded.status_code,
|
||||||
@@ -228,46 +246,16 @@ func (c *Cache) StoreNegative(ctx context.Context, req *ImageRequest, statusCode
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkNegativeCache checks if a request is in the negative cache.
|
|
||||||
func (c *Cache) checkNegativeCache(ctx context.Context, req *ImageRequest) (bool, error) {
|
|
||||||
var expiresAt time.Time
|
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx, `
|
|
||||||
SELECT expires_at FROM negative_cache
|
|
||||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
|
||||||
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&expiresAt)
|
|
||||||
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("failed to check negative cache: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if expired
|
|
||||||
if time.Now().After(expiresAt) {
|
|
||||||
// Clean up expired entry
|
|
||||||
_, _ = c.db.ExecContext(ctx, `
|
|
||||||
DELETE FROM negative_cache
|
|
||||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
|
||||||
`, req.SourceHost, req.SourcePath, req.SourceQuery)
|
|
||||||
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSourceMetadataID returns the source metadata ID for a request.
|
// GetSourceMetadataID returns the source metadata ID for a request.
|
||||||
func (c *Cache) GetSourceMetadataID(ctx context.Context, req *ImageRequest) (int64, error) {
|
func (c *Cache) GetSourceMetadataID(
|
||||||
|
ctx context.Context, req *ImageRequest,
|
||||||
|
) (int64, error) {
|
||||||
var id int64
|
var id int64
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx, `
|
err := c.db.QueryRowContext(ctx, `
|
||||||
SELECT id FROM source_metadata
|
SELECT id FROM source_metadata
|
||||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
||||||
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&id)
|
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&id)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to get source metadata ID: %w", err)
|
return 0, fmt.Errorf("failed to get source metadata ID: %w", err)
|
||||||
}
|
}
|
||||||
@@ -308,8 +296,12 @@ func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get actual item count and total size from content tables
|
// Get actual item count and total size from content tables
|
||||||
_ = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM request_cache`).Scan(&stats.TotalItems)
|
_ = c.db.QueryRowContext(ctx,
|
||||||
_ = c.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(size_bytes), 0) FROM output_content`).Scan(&stats.TotalSizeBytes)
|
`SELECT COUNT(*) FROM request_cache`,
|
||||||
|
).Scan(&stats.TotalItems)
|
||||||
|
_ = c.db.QueryRowContext(ctx,
|
||||||
|
`SELECT COALESCE(SUM(size_bytes), 0) FROM output_content`,
|
||||||
|
).Scan(&stats.TotalSizeBytes)
|
||||||
|
|
||||||
// Compute hit rate as a ratio
|
// Compute hit rate as a ratio
|
||||||
if stats.HitCount+stats.MissCount > 0 {
|
if stats.HitCount+stats.MissCount > 0 {
|
||||||
@@ -323,11 +315,17 @@ func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
|
|||||||
func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64) {
|
func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64) {
|
||||||
if hit {
|
if hit {
|
||||||
_, _ = c.db.ExecContext(ctx, `
|
_, _ = c.db.ExecContext(ctx, `
|
||||||
UPDATE cache_stats SET hit_count = hit_count + 1, last_updated_at = CURRENT_TIMESTAMP WHERE id = 1
|
UPDATE cache_stats
|
||||||
|
SET hit_count = hit_count + 1,
|
||||||
|
last_updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = 1
|
||||||
`)
|
`)
|
||||||
} else {
|
} else {
|
||||||
_, _ = c.db.ExecContext(ctx, `
|
_, _ = c.db.ExecContext(ctx, `
|
||||||
UPDATE cache_stats SET miss_count = miss_count + 1, last_updated_at = CURRENT_TIMESTAMP WHERE id = 1
|
UPDATE cache_stats
|
||||||
|
SET miss_count = miss_count + 1,
|
||||||
|
last_updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = 1
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,3 +339,36 @@ func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64)
|
|||||||
`, fetchBytes)
|
`, fetchBytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkNegativeCache checks if a request is in the negative cache.
|
||||||
|
func (c *Cache) checkNegativeCache(
|
||||||
|
ctx context.Context, req *ImageRequest,
|
||||||
|
) (bool, error) {
|
||||||
|
var expiresAt time.Time
|
||||||
|
|
||||||
|
err := c.db.QueryRowContext(ctx, `
|
||||||
|
SELECT expires_at FROM negative_cache
|
||||||
|
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
||||||
|
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&expiresAt)
|
||||||
|
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to check negative cache: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
|
if time.Now().After(expiresAt) {
|
||||||
|
// Clean up expired entry
|
||||||
|
_, _ = c.db.ExecContext(ctx, `
|
||||||
|
DELETE FROM negative_cache
|
||||||
|
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
||||||
|
`, req.SourceHost, req.SourcePath, req.SourceQuery)
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupTestDB(t *testing.T) *sql.DB {
|
func setupTestDB(t *testing.T) *sql.DB {
|
||||||
@@ -85,14 +86,15 @@ func setupTestDB(t *testing.T) *sql.DB {
|
|||||||
INSERT INTO cache_stats (id) VALUES (1);
|
INSERT INTO cache_stats (id) VALUES (1);
|
||||||
`
|
`
|
||||||
|
|
||||||
if _, err := db.Exec(schema); err != nil {
|
_, err = db.ExecContext(t.Context(), schema)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("failed to create schema: %v", err)
|
t.Fatalf("failed to create schema: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupTestCache(t *testing.T) (*Cache, string) {
|
func setupTestCache(t *testing.T) *Cache {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
@@ -107,16 +109,18 @@ func setupTestCache(t *testing.T) (*Cache, string) {
|
|||||||
t.Fatalf("failed to create cache: %v", err)
|
t.Fatalf("failed to create cache: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return cache, tmpDir
|
return cache
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_LookupMiss(t *testing.T) {
|
func TestCache_LookupMiss(t *testing.T) {
|
||||||
cache, _ := setupTestCache(t)
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := setupTestCache(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/cat.jpg",
|
SourcePath: testPathCat,
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -138,12 +142,14 @@ func TestCache_LookupMiss(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_StoreAndLookup(t *testing.T) {
|
func TestCache_StoreAndLookup(t *testing.T) {
|
||||||
cache, _ := setupTestCache(t)
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := setupTestCache(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/cat.jpg",
|
SourcePath: testPathCat,
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -152,12 +158,13 @@ func TestCache_StoreAndLookup(t *testing.T) {
|
|||||||
|
|
||||||
// Store source content
|
// Store source content
|
||||||
sourceContent := []byte("fake jpeg data")
|
sourceContent := []byte("fake jpeg data")
|
||||||
fetchResult := &FetchResult{
|
fetchResult := &httpfetcher.FetchResult{
|
||||||
ContentType: "image/jpeg",
|
ContentType: testContentTypeJPEG,
|
||||||
Headers: map[string][]string{"Content-Type": {"image/jpeg"}},
|
Headers: map[string][]string{"Content-Type": {testContentTypeJPEG}},
|
||||||
}
|
}
|
||||||
|
|
||||||
contentHash, err := cache.StoreSource(ctx, req, bytes.NewReader(sourceContent), fetchResult)
|
contentHash, err := cache.StoreSource(
|
||||||
|
ctx, req, bytes.NewReader(sourceContent), fetchResult)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreSource() error = %v", err)
|
t.Fatalf("StoreSource() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -169,6 +176,7 @@ func TestCache_StoreAndLookup(t *testing.T) {
|
|||||||
// Store variant
|
// Store variant
|
||||||
cacheKey := CacheKey(req)
|
cacheKey := CacheKey(req)
|
||||||
outputContent := []byte("fake webp data")
|
outputContent := []byte("fake webp data")
|
||||||
|
|
||||||
err = cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
|
err = cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreVariant() error = %v", err)
|
t.Fatalf("StoreVariant() error = %v", err)
|
||||||
@@ -194,11 +202,13 @@ func TestCache_StoreAndLookup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_NegativeCache(t *testing.T) {
|
func TestCache_NegativeCache(t *testing.T) {
|
||||||
cache, _ := setupTestCache(t)
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := setupTestCache(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/notfound.jpg",
|
SourcePath: "/photos/notfound.jpg",
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
@@ -222,6 +232,8 @@ func TestCache_NegativeCache(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_NegativeCacheExpiry(t *testing.T) {
|
func TestCache_NegativeCacheExpiry(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
db := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
|
|
||||||
@@ -238,7 +250,7 @@ func TestCache_NegativeCacheExpiry(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/expired.jpg",
|
SourcePath: "/photos/expired.jpg",
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
@@ -265,11 +277,13 @@ func TestCache_NegativeCacheExpiry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_VariantLookup(t *testing.T) {
|
func TestCache_VariantLookup(t *testing.T) {
|
||||||
cache, _ := setupTestCache(t)
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := setupTestCache(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/variant.jpg",
|
SourcePath: "/photos/variant.jpg",
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
@@ -280,6 +294,7 @@ func TestCache_VariantLookup(t *testing.T) {
|
|||||||
// Store variant
|
// Store variant
|
||||||
cacheKey := CacheKey(req)
|
cacheKey := CacheKey(req)
|
||||||
outputContent := []byte("output data")
|
outputContent := []byte("output data")
|
||||||
|
|
||||||
err := cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
|
err := cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreVariant() error = %v", err)
|
t.Fatalf("StoreVariant() error = %v", err)
|
||||||
@@ -311,11 +326,13 @@ func TestCache_VariantLookup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
|
func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
|
||||||
cache, _ := setupTestCache(t)
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := setupTestCache(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/variantct.jpg",
|
SourcePath: "/photos/variantct.jpg",
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
@@ -326,6 +343,7 @@ func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
|
|||||||
// Store variant
|
// Store variant
|
||||||
cacheKey := CacheKey(req)
|
cacheKey := CacheKey(req)
|
||||||
outputContent := []byte("output webp data")
|
outputContent := []byte("output webp data")
|
||||||
|
|
||||||
err := cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
|
err := cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreVariant() error = %v", err)
|
t.Fatalf("StoreVariant() error = %v", err)
|
||||||
@@ -346,7 +364,8 @@ func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetVariant() error = %v", err)
|
t.Fatalf("GetVariant() error = %v", err)
|
||||||
}
|
}
|
||||||
defer reader.Close()
|
|
||||||
|
defer func() { _ = reader.Close() }()
|
||||||
|
|
||||||
if contentType != "image/webp" {
|
if contentType != "image/webp" {
|
||||||
t.Errorf("GetVariant() ContentType = %q, want %q", contentType, "image/webp")
|
t.Errorf("GetVariant() ContentType = %q, want %q", contentType, "image/webp")
|
||||||
@@ -358,11 +377,13 @@ func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_GetVariant(t *testing.T) {
|
func TestCache_GetVariant(t *testing.T) {
|
||||||
cache, _ := setupTestCache(t)
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := setupTestCache(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/output.jpg",
|
SourcePath: "/photos/output.jpg",
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
@@ -373,6 +394,7 @@ func TestCache_GetVariant(t *testing.T) {
|
|||||||
// Store variant
|
// Store variant
|
||||||
cacheKey := CacheKey(req)
|
cacheKey := CacheKey(req)
|
||||||
outputContent := []byte("the actual output content")
|
outputContent := []byte("the actual output content")
|
||||||
|
|
||||||
err := cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
|
err := cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreVariant() error = %v", err)
|
t.Fatalf("StoreVariant() error = %v", err)
|
||||||
@@ -389,7 +411,8 @@ func TestCache_GetVariant(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetVariant() error = %v", err)
|
t.Fatalf("GetVariant() error = %v", err)
|
||||||
}
|
}
|
||||||
defer reader.Close()
|
|
||||||
|
defer func() { _ = reader.Close() }()
|
||||||
|
|
||||||
buf := make([]byte, 100)
|
buf := make([]byte, 100)
|
||||||
n, _ := reader.Read(buf)
|
n, _ := reader.Read(buf)
|
||||||
@@ -400,7 +423,9 @@ func TestCache_GetVariant(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_Stats(t *testing.T) {
|
func TestCache_Stats(t *testing.T) {
|
||||||
cache, _ := setupTestCache(t)
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := setupTestCache(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Increment some stats
|
// Increment some stats
|
||||||
@@ -423,6 +448,8 @@ func TestCache_Stats(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_CleanExpired(t *testing.T) {
|
func TestCache_CleanExpired(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
db := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
|
|
||||||
@@ -435,7 +462,8 @@ func TestCache_CleanExpired(t *testing.T) {
|
|||||||
|
|
||||||
// Insert expired negative cache entry directly
|
// Insert expired negative cache entry directly
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
INSERT INTO negative_cache (source_host, source_path, source_query, status_code, expires_at)
|
INSERT INTO negative_cache
|
||||||
|
(source_host, source_path, source_query, status_code, expires_at)
|
||||||
VALUES ('example.com', '/old.jpg', '', 404, datetime('now', '-1 hour'))
|
VALUES ('example.com', '/old.jpg', '', 404, datetime('now', '-1 hour'))
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -444,7 +472,12 @@ func TestCache_CleanExpired(t *testing.T) {
|
|||||||
|
|
||||||
// Verify it exists
|
// Verify it exists
|
||||||
var count int
|
var count int
|
||||||
db.QueryRowContext(ctx, `SELECT COUNT(*) FROM negative_cache`).Scan(&count)
|
|
||||||
|
err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM negative_cache`).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to count negative cache entries: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if count != 1 {
|
if count != 1 {
|
||||||
t.Fatalf("expected 1 negative cache entry, got %d", count)
|
t.Fatalf("expected 1 negative cache entry, got %d", count)
|
||||||
}
|
}
|
||||||
@@ -456,13 +489,19 @@ func TestCache_CleanExpired(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify it's gone
|
// Verify it's gone
|
||||||
db.QueryRowContext(ctx, `SELECT COUNT(*) FROM negative_cache`).Scan(&count)
|
err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM negative_cache`).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to count negative cache entries: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if count != 0 {
|
if count != 0 {
|
||||||
t.Errorf("expected 0 negative cache entries after clean, got %d", count)
|
t.Errorf("expected 0 negative cache entries after clean, got %d", count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCache_StorageDirectoriesCreated(t *testing.T) {
|
func TestCache_StorageDirectoriesCreated(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
db := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
|
|
||||||
@@ -482,7 +521,9 @@ func TestCache_StorageDirectoriesCreated(t *testing.T) {
|
|||||||
|
|
||||||
for _, dir := range dirs {
|
for _, dir := range dirs {
|
||||||
path := tmpDir + "/" + dir
|
path := tmpDir + "/" + dir
|
||||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
t.Errorf("directory %s was not created", dir)
|
t.Errorf("directory %s was not created", dir)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestSizePercentSafeWithZeroFetchBytes(t *testing.T) {
|
func TestSizePercentSafeWithZeroFetchBytes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// Simulate the calculation from processAndStore
|
// Simulate the calculation from processAndStore
|
||||||
fetchBytes := int64(0)
|
fetchBytes := int64(0)
|
||||||
outputSize := int64(100)
|
outputSize := int64(100)
|
||||||
@@ -29,6 +31,8 @@ func TestSizePercentSafeWithZeroFetchBytes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSizePercentNormalCase(t *testing.T) {
|
func TestSizePercentNormalCase(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fetchBytes := int64(1000)
|
fetchBytes := int64(1000)
|
||||||
outputSize := int64(500)
|
outputSize := int64(500)
|
||||||
|
|
||||||
@@ -75,15 +75,23 @@ type ImageRequest struct {
|
|||||||
Quality int
|
Quality int
|
||||||
// FitMode is how to fit the image into requested dimensions
|
// FitMode is how to fit the image into requested dimensions
|
||||||
FitMode FitMode
|
FitMode FitMode
|
||||||
// Signature is the HMAC signature for non-whitelisted hosts
|
// Signature is the HMAC signature for non-allowlisted hosts
|
||||||
Signature string
|
Signature string
|
||||||
// Expires is the signature expiration timestamp
|
// Expires is the signature expiration timestamp
|
||||||
Expires time.Time
|
Expires time.Time
|
||||||
|
// AllowHTTP indicates whether HTTP (non-TLS) is allowed for this request
|
||||||
|
AllowHTTP bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// SourceURL returns the full upstream URL to fetch
|
// SourceURL returns the full upstream URL to fetch.
|
||||||
|
// Uses http:// scheme when AllowHTTP is true, otherwise https://.
|
||||||
func (r *ImageRequest) SourceURL() string {
|
func (r *ImageRequest) SourceURL() string {
|
||||||
url := "https://" + r.SourceHost + r.SourcePath
|
scheme := "https"
|
||||||
|
if r.AllowHTTP {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
|
||||||
|
url := scheme + "://" + r.SourceHost + r.SourcePath
|
||||||
if r.SourceQuery != "" {
|
if r.SourceQuery != "" {
|
||||||
url += "?" + r.SourceQuery
|
url += "?" + r.SourceQuery
|
||||||
}
|
}
|
||||||
@@ -156,70 +164,10 @@ type SignatureValidator interface {
|
|||||||
Generate(req *ImageRequest) string
|
Generate(req *ImageRequest) string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whitelist checks if a URL is whitelisted (no signature required)
|
// Allowlist checks if a URL is allowlisted (no signature required)
|
||||||
type Whitelist interface {
|
type Allowlist interface {
|
||||||
// IsWhitelisted returns true if the URL doesn't require a signature
|
// IsAllowlisted returns true if the URL doesn't require a signature
|
||||||
IsWhitelisted(u *url.URL) bool
|
IsAllowlisted(u *url.URL) bool
|
||||||
}
|
|
||||||
|
|
||||||
// Fetcher fetches images from upstream origins
|
|
||||||
type Fetcher interface {
|
|
||||||
// Fetch retrieves an image from the origin
|
|
||||||
Fetch(ctx context.Context, url string) (*FetchResult, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FetchResult contains the result of fetching from upstream
|
|
||||||
type FetchResult struct {
|
|
||||||
// Content is the raw image data
|
|
||||||
Content io.ReadCloser
|
|
||||||
// ContentLength is the size in bytes (-1 if unknown)
|
|
||||||
ContentLength int64
|
|
||||||
// ContentType is the MIME type from upstream
|
|
||||||
ContentType string
|
|
||||||
// Headers contains all response headers from upstream
|
|
||||||
Headers map[string][]string
|
|
||||||
// StatusCode is the HTTP status code from upstream
|
|
||||||
StatusCode int
|
|
||||||
// FetchDurationMs is how long the fetch took in milliseconds
|
|
||||||
FetchDurationMs int64
|
|
||||||
// RemoteAddr is the IP:port of the upstream server
|
|
||||||
RemoteAddr string
|
|
||||||
// HTTPVersion is the protocol version (e.g., "1.1", "2.0")
|
|
||||||
HTTPVersion string
|
|
||||||
// TLSVersion is the TLS protocol version (e.g., "TLS 1.3")
|
|
||||||
TLSVersion string
|
|
||||||
// TLSCipherSuite is the negotiated cipher suite name
|
|
||||||
TLSCipherSuite string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Processor handles image transformation (resize, format conversion)
|
|
||||||
type Processor interface {
|
|
||||||
// Process transforms an image according to the request
|
|
||||||
Process(ctx context.Context, input io.Reader, req *ImageRequest) (*ProcessResult, error)
|
|
||||||
// SupportedInputFormats returns MIME types this processor can read
|
|
||||||
SupportedInputFormats() []string
|
|
||||||
// SupportedOutputFormats returns formats this processor can write
|
|
||||||
SupportedOutputFormats() []ImageFormat
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProcessResult contains the result of image processing
|
|
||||||
type ProcessResult struct {
|
|
||||||
// Content is the processed image data
|
|
||||||
Content io.ReadCloser
|
|
||||||
// ContentLength is the size in bytes
|
|
||||||
ContentLength int64
|
|
||||||
// ContentType is the MIME type of the output
|
|
||||||
ContentType string
|
|
||||||
// Width is the output image width
|
|
||||||
Width int
|
|
||||||
// Height is the output image height
|
|
||||||
Height int
|
|
||||||
// InputWidth is the original image width before processing
|
|
||||||
InputWidth int
|
|
||||||
// InputHeight is the original image height before processing
|
|
||||||
InputHeight int
|
|
||||||
// InputFormat is the detected input format (e.g., "jpeg", "png")
|
|
||||||
InputFormat string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Storage handles persistent storage of cached content
|
// Storage handles persistent storage of cached content
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestNegativeCache_StoreAndCheck(t *testing.T) {
|
func TestNegativeCache_StoreAndCheck(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
db := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
||||||
@@ -22,7 +24,7 @@ func TestNegativeCache_StoreAndCheck(t *testing.T) {
|
|||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "example.com",
|
SourceHost: testHostExample,
|
||||||
SourcePath: "/missing.jpg",
|
SourcePath: "/missing.jpg",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +33,7 @@ func TestNegativeCache_StoreAndCheck(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if hit {
|
if hit {
|
||||||
t.Error("expected no negative cache hit initially")
|
t.Error("expected no negative cache hit initially")
|
||||||
}
|
}
|
||||||
@@ -46,12 +49,15 @@ func TestNegativeCache_StoreAndCheck(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !hit {
|
if !hit {
|
||||||
t.Error("expected negative cache hit after storing")
|
t.Error("expected negative cache hit after storing")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNegativeCache_Expired(t *testing.T) {
|
func TestNegativeCache_Expired(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
db := setupTestDB(t)
|
db := setupTestDB(t)
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
||||||
@@ -66,7 +72,7 @@ func TestNegativeCache_Expired(t *testing.T) {
|
|||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: "example.com",
|
SourceHost: testHostExample,
|
||||||
SourcePath: "/expired.jpg",
|
SourcePath: "/expired.jpg",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,12 +90,15 @@ func TestNegativeCache_Expired(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if hit {
|
if hit {
|
||||||
t.Error("expected expired negative cache entry to be a miss")
|
t.Error("expected expired negative cache entry to be a miss")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_ReturnsErrorForNegativeCachedURL(t *testing.T) {
|
func TestService_Get_ReturnsErrorForNegativeCachedURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// This test verifies that Service.Get() checks the negative cache
|
// This test verifies that Service.Get() checks the negative cache
|
||||||
// We can't easily test the full pipeline without vips, but we can
|
// We can't easily test the full pipeline without vips, but we can
|
||||||
// verify the error type
|
// verify the error type
|
||||||
@@ -11,16 +11,24 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
|
"sneak.berlin/go/pixa/internal/allowlist"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/magic"
|
||||||
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service implements the ImageCache interface, orchestrating cache, fetcher, and processor.
|
// Service implements the ImageCache interface, orchestrating cache,
|
||||||
|
// fetcher, and processor.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
cache *Cache
|
cache *Cache
|
||||||
fetcher Fetcher
|
fetcher httpfetcher.Fetcher
|
||||||
processor Processor
|
processor *imageprocessor.ImageProcessor
|
||||||
signer *Signer
|
signer *signature.Signer
|
||||||
whitelist *HostWhitelist
|
allowlist *allowlist.HostAllowList
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
|
allowHTTP bool
|
||||||
|
maxResponseSize int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceConfig holds configuration for the image service.
|
// ServiceConfig holds configuration for the image service.
|
||||||
@@ -28,53 +36,74 @@ type ServiceConfig struct {
|
|||||||
// Cache is the cache instance
|
// Cache is the cache instance
|
||||||
Cache *Cache
|
Cache *Cache
|
||||||
// FetcherConfig configures the upstream fetcher (ignored if Fetcher is set)
|
// FetcherConfig configures the upstream fetcher (ignored if Fetcher is set)
|
||||||
FetcherConfig *FetcherConfig
|
FetcherConfig *httpfetcher.Config
|
||||||
// Fetcher is an optional custom fetcher (for testing)
|
// Fetcher is an optional custom fetcher (for testing)
|
||||||
Fetcher Fetcher
|
Fetcher httpfetcher.Fetcher
|
||||||
// SigningKey is the HMAC signing key (empty disables signing)
|
// SigningKey is the HMAC signing key (empty disables signing)
|
||||||
SigningKey string
|
SigningKey string
|
||||||
// Whitelist is the list of hosts that don't require signatures
|
// Allowlist is the list of hosts that don't require signatures
|
||||||
Whitelist []string
|
Allowlist []string
|
||||||
// Logger for logging
|
// Logger for logging
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Static errors for service construction and unimplemented operations.
|
||||||
|
var (
|
||||||
|
errCacheRequired = errors.New("cache is required")
|
||||||
|
errSigningKeyRequired = errors.New("signing key is required")
|
||||||
|
errPurgeNotImplemented = errors.New("purge not implemented")
|
||||||
|
)
|
||||||
|
|
||||||
// NewService creates a new image service.
|
// NewService creates a new image service.
|
||||||
func NewService(cfg *ServiceConfig) (*Service, error) {
|
func NewService(cfg *ServiceConfig) (*Service, error) {
|
||||||
if cfg.Cache == nil {
|
if cfg.Cache == nil {
|
||||||
return nil, errors.New("cache is required")
|
return nil, errCacheRequired
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.SigningKey == "" {
|
if cfg.SigningKey == "" {
|
||||||
return nil, errors.New("signing key is required")
|
return nil, errSigningKeyRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve fetcher config for defaults
|
||||||
|
fetcherCfg := cfg.FetcherConfig
|
||||||
|
if fetcherCfg == nil {
|
||||||
|
fetcherCfg = httpfetcher.DefaultConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use custom fetcher if provided, otherwise create HTTP fetcher
|
// Use custom fetcher if provided, otherwise create HTTP fetcher
|
||||||
var fetcher Fetcher
|
var fetcher httpfetcher.Fetcher
|
||||||
if cfg.Fetcher != nil {
|
if cfg.Fetcher != nil {
|
||||||
fetcher = cfg.Fetcher
|
fetcher = cfg.Fetcher
|
||||||
} else {
|
} else {
|
||||||
fetcherCfg := cfg.FetcherConfig
|
fetcher = httpfetcher.New(fetcherCfg)
|
||||||
if fetcherCfg == nil {
|
|
||||||
fetcherCfg = DefaultFetcherConfig()
|
|
||||||
}
|
|
||||||
fetcher = NewHTTPFetcher(fetcherCfg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
signer := NewSigner(cfg.SigningKey)
|
signer := signature.New(cfg.SigningKey)
|
||||||
|
|
||||||
log := cfg.Logger
|
log := cfg.Logger
|
||||||
if log == nil {
|
if log == nil {
|
||||||
log = slog.Default()
|
log = slog.Default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allowHTTP := false
|
||||||
|
if cfg.FetcherConfig != nil {
|
||||||
|
allowHTTP = cfg.FetcherConfig.AllowHTTP
|
||||||
|
}
|
||||||
|
|
||||||
|
maxResponseSize := fetcherCfg.MaxResponseSize
|
||||||
|
processor := imageprocessor.New(
|
||||||
|
imageprocessor.Params{MaxInputBytes: maxResponseSize},
|
||||||
|
)
|
||||||
|
|
||||||
return &Service{
|
return &Service{
|
||||||
cache: cfg.Cache,
|
cache: cfg.Cache,
|
||||||
fetcher: fetcher,
|
fetcher: fetcher,
|
||||||
processor: NewImageProcessor(),
|
processor: processor,
|
||||||
signer: signer,
|
signer: signer,
|
||||||
whitelist: NewHostWhitelist(cfg.Whitelist),
|
allowlist: allowlist.New(cfg.Allowlist),
|
||||||
log: log,
|
log: log,
|
||||||
|
allowHTTP: allowHTTP,
|
||||||
|
maxResponseSize: maxResponseSize,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,17 +112,22 @@ var ErrNegativeCached = errors.New("request is in negative cache (recently faile
|
|||||||
|
|
||||||
// Get retrieves a processed image, fetching and processing if necessary.
|
// Get retrieves a processed image, fetching and processing if necessary.
|
||||||
func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, error) {
|
func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, error) {
|
||||||
|
// Propagate AllowHTTP setting to the request
|
||||||
|
req.AllowHTTP = s.allowHTTP
|
||||||
|
|
||||||
// Check negative cache first - skip fetching for recently-failed URLs
|
// Check negative cache first - skip fetching for recently-failed URLs
|
||||||
negHit, err := s.cache.checkNegativeCache(ctx, req)
|
negHit, err := s.cache.checkNegativeCache(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("negative cache check failed", "error", err)
|
s.log.Warn("negative cache check failed", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if negHit {
|
if negHit {
|
||||||
s.log.Debug("negative cache hit",
|
s.log.Debug("negative cache hit",
|
||||||
"host", req.SourceHost,
|
"host", req.SourceHost,
|
||||||
"path", req.SourcePath,
|
"path", req.SourcePath,
|
||||||
)
|
)
|
||||||
return nil, fmt.Errorf("%w: %w", ErrUpstreamError, ErrNegativeCached)
|
|
||||||
|
return nil, fmt.Errorf("%w: %w", httpfetcher.ErrUpstreamError, ErrNegativeCached)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check variant cache first (disk only, no DB)
|
// Check variant cache first (disk only, no DB)
|
||||||
@@ -123,6 +157,7 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
|
|||||||
|
|
||||||
// Cache miss - check if we have source content cached
|
// Cache miss - check if we have source content cached
|
||||||
cacheKey := CacheKey(req)
|
cacheKey := CacheKey(req)
|
||||||
|
|
||||||
s.cache.IncrementStats(ctx, false, 0)
|
s.cache.IncrementStats(ctx, false, 0)
|
||||||
|
|
||||||
response, err := s.processFromSourceOrFetch(ctx, req, cacheKey)
|
response, err := s.processFromSourceOrFetch(ctx, req, cacheKey)
|
||||||
@@ -135,7 +170,93 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// processFromSourceOrFetch processes an image, using cached source content if available.
|
// Warm pre-fetches and caches an image without returning it.
|
||||||
|
func (s *Service) Warm(ctx context.Context, req *ImageRequest) error {
|
||||||
|
_, err := s.Get(ctx, req)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purge removes a cached image. Purging is not implemented yet.
|
||||||
|
func (s *Service) Purge(_ context.Context, _ *ImageRequest) error {
|
||||||
|
return errPurgeNotImplemented
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats returns cache statistics.
|
||||||
|
func (s *Service) Stats(ctx context.Context) (*CacheStats, error) {
|
||||||
|
return s.cache.Stats(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateRequest validates the request signature if required.
|
||||||
|
func (s *Service) ValidateRequest(req *ImageRequest) error {
|
||||||
|
// Check if host is allowed (no signature required)
|
||||||
|
sourceURL := req.SourceURL()
|
||||||
|
|
||||||
|
parsedURL, err := url.Parse(sourceURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid source URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.allowlist.IsAllowed(parsedURL) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signature required for non-allowed hosts
|
||||||
|
return s.signer.Verify(signatureRequest(req))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateSignedURL generates a signed URL for the given request.
|
||||||
|
func (s *Service) GenerateSignedURL(
|
||||||
|
baseURL string,
|
||||||
|
req *ImageRequest,
|
||||||
|
ttl time.Duration,
|
||||||
|
) (string, error) {
|
||||||
|
sigReq := signatureRequest(req)
|
||||||
|
path, sig, exp := s.signer.GenerateSignedURL(sigReq, ttl)
|
||||||
|
|
||||||
|
// Propagate the generated signature and expiration back onto the request.
|
||||||
|
req.Expires = sigReq.Expires
|
||||||
|
req.Signature = sigReq.Signature
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s%s?sig=%s&exp=%d", baseURL, path, sig, exp), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadCachedSource attempts to load source content from cache, returning nil
|
||||||
|
// if the cached data is unavailable or exceeds maxResponseSize.
|
||||||
|
func (s *Service) loadCachedSource(contentHash ContentHash) []byte {
|
||||||
|
reader, err := s.cache.GetSourceContent(contentHash)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("failed to load cached source, fetching", "error", err)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bound the read to maxResponseSize to prevent unbounded memory use
|
||||||
|
// from unexpectedly large cached files.
|
||||||
|
limited := io.LimitReader(reader, s.maxResponseSize+1)
|
||||||
|
data, err := io.ReadAll(limited)
|
||||||
|
_ = reader.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("failed to read cached source, fetching", "error", err)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if int64(len(data)) > s.maxResponseSize {
|
||||||
|
s.log.Warn("cached source exceeds max response size, discarding",
|
||||||
|
"hash", contentHash,
|
||||||
|
"max_bytes", s.maxResponseSize,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// processFromSourceOrFetch processes an image, using cached source content
|
||||||
|
// if available.
|
||||||
func (s *Service) processFromSourceOrFetch(
|
func (s *Service) processFromSourceOrFetch(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req *ImageRequest,
|
req *ImageRequest,
|
||||||
@@ -147,26 +268,14 @@ func (s *Service) processFromSourceOrFetch(
|
|||||||
s.log.Warn("source lookup failed", "error", err)
|
s.log.Warn("source lookup failed", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var sourceData []byte
|
var (
|
||||||
var fetchBytes int64
|
sourceData []byte
|
||||||
|
fetchBytes int64
|
||||||
|
)
|
||||||
|
|
||||||
if contentHash != "" {
|
if contentHash != "" {
|
||||||
// We have cached source - load it
|
|
||||||
s.log.Debug("using cached source", "hash", contentHash)
|
s.log.Debug("using cached source", "hash", contentHash)
|
||||||
|
sourceData = s.loadCachedSource(contentHash)
|
||||||
reader, err := s.cache.GetSourceContent(contentHash)
|
|
||||||
if err != nil {
|
|
||||||
s.log.Warn("failed to load cached source, fetching", "error", err)
|
|
||||||
// Fall through to fetch
|
|
||||||
} else {
|
|
||||||
sourceData, err = io.ReadAll(reader)
|
|
||||||
_ = reader.Close()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
s.log.Warn("failed to read cached source, fetching", "error", err)
|
|
||||||
// Fall through to fetch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch from upstream if we don't have source data or it's empty
|
// Fetch from upstream if we don't have source data or it's empty
|
||||||
@@ -216,6 +325,7 @@ func (s *Service) fetchAndProcess(
|
|||||||
|
|
||||||
// Calculate download bitrate
|
// Calculate download bitrate
|
||||||
fetchBytes := int64(len(sourceData))
|
fetchBytes := int64(len(sourceData))
|
||||||
|
|
||||||
var downloadRate string
|
var downloadRate string
|
||||||
|
|
||||||
if fetchResult.FetchDurationMs > 0 {
|
if fetchResult.FetchDurationMs > 0 {
|
||||||
@@ -238,7 +348,8 @@ func (s *Service) fetchAndProcess(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Validate magic bytes match content type
|
// Validate magic bytes match content type
|
||||||
if err := ValidateMagicBytes(sourceData, fetchResult.ContentType); err != nil {
|
err = magic.ValidateMagicBytes(sourceData, fetchResult.ContentType)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("content validation failed: %w", err)
|
return nil, fmt.Errorf("content validation failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +374,14 @@ func (s *Service) processAndStore(
|
|||||||
// Process the image
|
// Process the image
|
||||||
processStart := time.Now()
|
processStart := time.Now()
|
||||||
|
|
||||||
processResult, err := s.processor.Process(ctx, bytes.NewReader(sourceData), req)
|
processReq := &imageprocessor.Request{
|
||||||
|
Size: imageprocessor.Size{Width: req.Size.Width, Height: req.Size.Height},
|
||||||
|
Format: imageprocessor.Format(req.Format),
|
||||||
|
Quality: req.Quality,
|
||||||
|
FitMode: imageprocessor.FitMode(req.FitMode),
|
||||||
|
}
|
||||||
|
|
||||||
|
processResult, err := s.processor.Process(ctx, bytes.NewReader(sourceData), processReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("image processing failed: %w", err)
|
return nil, fmt.Errorf("image processing failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -283,7 +401,8 @@ func (s *Service) processAndStore(
|
|||||||
|
|
||||||
var sizePercent float64
|
var sizePercent float64
|
||||||
if fetchBytes > 0 {
|
if fetchBytes > 0 {
|
||||||
sizePercent = float64(outputSize) / float64(fetchBytes) * 100.0 //nolint:mnd // percentage calculation
|
//nolint:mnd // percentage calculation
|
||||||
|
sizePercent = float64(outputSize) / float64(fetchBytes) * 100.0
|
||||||
}
|
}
|
||||||
|
|
||||||
s.log.Info("image converted",
|
s.log.Info("image converted",
|
||||||
@@ -293,8 +412,10 @@ func (s *Service) processAndStore(
|
|||||||
"dst_format", req.Format,
|
"dst_format", req.Format,
|
||||||
"src_bytes", fetchBytes,
|
"src_bytes", fetchBytes,
|
||||||
"dst_bytes", outputSize,
|
"dst_bytes", outputSize,
|
||||||
"src_dimensions", fmt.Sprintf("%dx%d", processResult.InputWidth, processResult.InputHeight),
|
"src_dimensions", fmt.Sprintf("%dx%d",
|
||||||
"dst_dimensions", fmt.Sprintf("%dx%d", processResult.Width, processResult.Height),
|
processResult.InputWidth, processResult.InputHeight),
|
||||||
|
"dst_dimensions", fmt.Sprintf("%dx%d",
|
||||||
|
processResult.Width, processResult.Height),
|
||||||
"size_ratio", fmt.Sprintf("%.1f%%", sizePercent),
|
"size_ratio", fmt.Sprintf("%.1f%%", sizePercent),
|
||||||
"convert_ms", processDuration.Milliseconds(),
|
"convert_ms", processDuration.Milliseconds(),
|
||||||
"quality", req.Quality,
|
"quality", req.Quality,
|
||||||
@@ -302,7 +423,10 @@ func (s *Service) processAndStore(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Store variant to cache
|
// Store variant to cache
|
||||||
if err := s.cache.StoreVariant(cacheKey, bytes.NewReader(processedData), processResult.ContentType); err != nil {
|
err = s.cache.StoreVariant(
|
||||||
|
cacheKey, bytes.NewReader(processedData), processResult.ContentType,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
s.log.Warn("failed to store variant", "error", err)
|
s.log.Warn("failed to store variant", "error", err)
|
||||||
// Continue even if caching fails
|
// Continue even if caching fails
|
||||||
}
|
}
|
||||||
@@ -316,51 +440,20 @@ func (s *Service) processAndStore(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warm pre-fetches and caches an image without returning it.
|
// signatureRequest projects an ImageRequest onto the standalone
|
||||||
func (s *Service) Warm(ctx context.Context, req *ImageRequest) error {
|
// signature.Request type used by the signature package. This keeps the
|
||||||
_, err := s.Get(ctx, req)
|
// import edge one-way: imgcache depends on signature, never the reverse.
|
||||||
|
func signatureRequest(req *ImageRequest) *signature.Request {
|
||||||
return err
|
return &signature.Request{
|
||||||
}
|
SourceHost: req.SourceHost,
|
||||||
|
SourcePath: req.SourcePath,
|
||||||
// Purge removes a cached image.
|
SourceQuery: req.SourceQuery,
|
||||||
func (s *Service) Purge(_ context.Context, _ *ImageRequest) error {
|
Width: req.Size.Width,
|
||||||
// TODO: Implement purge
|
Height: req.Size.Height,
|
||||||
return errors.New("purge not implemented")
|
Format: string(req.Format),
|
||||||
}
|
Signature: req.Signature,
|
||||||
|
Expires: req.Expires,
|
||||||
// Stats returns cache statistics.
|
|
||||||
func (s *Service) Stats(ctx context.Context) (*CacheStats, error) {
|
|
||||||
return s.cache.Stats(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateRequest validates the request signature if required.
|
|
||||||
func (s *Service) ValidateRequest(req *ImageRequest) error {
|
|
||||||
// Check if host is whitelisted (no signature required)
|
|
||||||
sourceURL := req.SourceURL()
|
|
||||||
|
|
||||||
parsedURL, err := url.Parse(sourceURL)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid source URL: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.whitelist.IsWhitelisted(parsedURL) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signature required for non-whitelisted hosts
|
|
||||||
return s.signer.Verify(req)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateSignedURL generates a signed URL for the given request.
|
|
||||||
func (s *Service) GenerateSignedURL(
|
|
||||||
baseURL string,
|
|
||||||
req *ImageRequest,
|
|
||||||
ttl time.Duration,
|
|
||||||
) (string, error) {
|
|
||||||
path, sig, exp := s.signer.GenerateSignedURL(req, ttl)
|
|
||||||
|
|
||||||
return fmt.Sprintf("%s%s?sig=%s&exp=%d", baseURL, path, sig, exp), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTP status codes for error responses.
|
// HTTP status codes for error responses.
|
||||||
@@ -371,13 +464,13 @@ const (
|
|||||||
|
|
||||||
// isNegativeCacheable returns true if the error should be cached.
|
// isNegativeCacheable returns true if the error should be cached.
|
||||||
func isNegativeCacheable(err error) bool {
|
func isNegativeCacheable(err error) bool {
|
||||||
return errors.Is(err, ErrUpstreamError)
|
return errors.Is(err, httpfetcher.ErrUpstreamError)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractStatusCode extracts HTTP status code from error message.
|
// extractStatusCode extracts HTTP status code from error message.
|
||||||
func extractStatusCode(err error) int {
|
func extractStatusCode(err error) int {
|
||||||
// Default to 502 Bad Gateway for upstream errors
|
// Default to 502 Bad Gateway for upstream errors
|
||||||
if errors.Is(err, ErrUpstreamError) {
|
if errors.Is(err, httpfetcher.ErrUpstreamError) {
|
||||||
return httpStatusBadGateway
|
return httpStatusBadGateway
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,15 +5,27 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/magic"
|
||||||
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestService_Get_WhitelistedHost(t *testing.T) {
|
// Test data literals used repeatedly in this file (goconst).
|
||||||
|
const (
|
||||||
|
testPathPhoto = "/images/photo.jpg"
|
||||||
|
testPathUpload = "/uploads/image.jpg"
|
||||||
|
testSigningKey = "test-signing-key-12345"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestService_Get_AllowlistedHost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := SetupTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: fixtures.GoodHost,
|
SourceHost: fixtures.GoodHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testPathPhoto,
|
||||||
Size: Size{Width: 50, Height: 50},
|
Size: Size{Width: 50, Height: 50},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -24,7 +36,8 @@ func TestService_Get_WhitelistedHost(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Get() error = %v", err)
|
t.Fatalf("Get() error = %v", err)
|
||||||
}
|
}
|
||||||
defer resp.Content.Close()
|
|
||||||
|
defer func() { _ = resp.Content.Close() }()
|
||||||
|
|
||||||
// Verify we got content
|
// Verify we got content
|
||||||
data, err := io.ReadAll(resp.Content)
|
data, err := io.ReadAll(resp.Content)
|
||||||
@@ -36,38 +49,42 @@ func TestService_Get_WhitelistedHost(t *testing.T) {
|
|||||||
t.Error("expected non-empty response")
|
t.Error("expected non-empty response")
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.ContentType != "image/jpeg" {
|
if resp.ContentType != testContentTypeJPEG {
|
||||||
t.Errorf("ContentType = %q, want %q", resp.ContentType, "image/jpeg")
|
t.Errorf("ContentType = %q, want %q", resp.ContentType, testContentTypeJPEG)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_NonWhitelistedHost_NoSignature(t *testing.T) {
|
func TestService_Get_NonAllowlistedHost_NoSignature(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t, WithSigningKey("test-key"))
|
svc, fixtures := SetupTestService(t, WithSigningKey("test-key"))
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: fixtures.OtherHost,
|
SourceHost: fixtures.OtherHost,
|
||||||
SourcePath: "/uploads/image.jpg",
|
SourcePath: testPathUpload,
|
||||||
Size: Size{Width: 50, Height: 50},
|
Size: Size{Width: 50, Height: 50},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
FitMode: FitCover,
|
FitMode: FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should fail validation - not whitelisted and no signature
|
// Should fail validation - not allowlisted and no signature
|
||||||
err := svc.ValidateRequest(req)
|
err := svc.ValidateRequest(req)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("ValidateRequest() expected error for non-whitelisted host without signature")
|
t.Error("ValidateRequest() expected error for non-allowlisted host without signature")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_NonWhitelistedHost_ValidSignature(t *testing.T) {
|
func TestService_Get_NonAllowlistedHost_ValidSignature(t *testing.T) {
|
||||||
signingKey := "test-signing-key-12345"
|
t.Parallel()
|
||||||
|
|
||||||
|
signingKey := testSigningKey
|
||||||
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
|
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: fixtures.OtherHost,
|
SourceHost: fixtures.OtherHost,
|
||||||
SourcePath: "/uploads/image.jpg",
|
SourcePath: testPathUpload,
|
||||||
Size: Size{Width: 50, Height: 50},
|
Size: Size{Width: 50, Height: 50},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -75,9 +92,9 @@ func TestService_Get_NonWhitelistedHost_ValidSignature(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate a valid signature
|
// Generate a valid signature
|
||||||
signer := NewSigner(signingKey)
|
signer := signature.New(signingKey)
|
||||||
req.Expires = time.Now().Add(time.Hour)
|
req.Expires = time.Now().Add(time.Hour)
|
||||||
req.Signature = signer.Sign(req)
|
req.Signature = signer.Sign(signatureRequest(req))
|
||||||
|
|
||||||
// Should pass validation
|
// Should pass validation
|
||||||
err := svc.ValidateRequest(req)
|
err := svc.ValidateRequest(req)
|
||||||
@@ -90,7 +107,8 @@ func TestService_Get_NonWhitelistedHost_ValidSignature(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Get() error = %v", err)
|
t.Fatalf("Get() error = %v", err)
|
||||||
}
|
}
|
||||||
defer resp.Content.Close()
|
|
||||||
|
defer func() { _ = resp.Content.Close() }()
|
||||||
|
|
||||||
data, err := io.ReadAll(resp.Content)
|
data, err := io.ReadAll(resp.Content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -102,13 +120,15 @@ func TestService_Get_NonWhitelistedHost_ValidSignature(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_NonWhitelistedHost_ExpiredSignature(t *testing.T) {
|
func TestService_Get_NonAllowlistedHost_ExpiredSignature(t *testing.T) {
|
||||||
signingKey := "test-signing-key-12345"
|
t.Parallel()
|
||||||
|
|
||||||
|
signingKey := testSigningKey
|
||||||
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
|
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: fixtures.OtherHost,
|
SourceHost: fixtures.OtherHost,
|
||||||
SourcePath: "/uploads/image.jpg",
|
SourcePath: testPathUpload,
|
||||||
Size: Size{Width: 50, Height: 50},
|
Size: Size{Width: 50, Height: 50},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -116,9 +136,9 @@ func TestService_Get_NonWhitelistedHost_ExpiredSignature(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate an expired signature
|
// Generate an expired signature
|
||||||
signer := NewSigner(signingKey)
|
signer := signature.New(signingKey)
|
||||||
req.Expires = time.Now().Add(-time.Hour) // Already expired
|
req.Expires = time.Now().Add(-time.Hour) // Already expired
|
||||||
req.Signature = signer.Sign(req)
|
req.Signature = signer.Sign(signatureRequest(req))
|
||||||
|
|
||||||
// Should fail validation
|
// Should fail validation
|
||||||
err := svc.ValidateRequest(req)
|
err := svc.ValidateRequest(req)
|
||||||
@@ -127,13 +147,15 @@ func TestService_Get_NonWhitelistedHost_ExpiredSignature(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_NonWhitelistedHost_InvalidSignature(t *testing.T) {
|
func TestService_Get_NonAllowlistedHost_InvalidSignature(t *testing.T) {
|
||||||
signingKey := "test-signing-key-12345"
|
t.Parallel()
|
||||||
|
|
||||||
|
signingKey := testSigningKey
|
||||||
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
|
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: fixtures.OtherHost,
|
SourceHost: fixtures.OtherHost,
|
||||||
SourcePath: "/uploads/image.jpg",
|
SourcePath: testPathUpload,
|
||||||
Size: Size{Width: 50, Height: 50},
|
Size: Size{Width: 50, Height: 50},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -151,7 +173,84 @@ func TestService_Get_NonWhitelistedHost_InvalidSignature(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestService_ValidateRequest_SignatureExactHostMatch verifies that
|
||||||
|
// ValidateRequest enforces exact host matching for signatures. A
|
||||||
|
// signature for one host must not verify for a different host, even
|
||||||
|
// if they share a domain suffix.
|
||||||
|
func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
signingKey := "test-signing-key-must-be-32-chars"
|
||||||
|
svc, _ := SetupTestService(t,
|
||||||
|
WithSigningKey(signingKey),
|
||||||
|
WithNoAllowlist(),
|
||||||
|
)
|
||||||
|
|
||||||
|
signer := signature.New(signingKey)
|
||||||
|
|
||||||
|
// Sign a request for "cdn.example.com"
|
||||||
|
signedReq := &ImageRequest{
|
||||||
|
SourceHost: testHostCDN,
|
||||||
|
SourcePath: testPathCat,
|
||||||
|
Size: Size{Width: 50, Height: 50},
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
Expires: time.Now().Add(time.Hour),
|
||||||
|
}
|
||||||
|
signedReq.Signature = signer.Sign(signatureRequest(signedReq))
|
||||||
|
|
||||||
|
// The original request should pass validation
|
||||||
|
t.Run("exact host passes", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := svc.ValidateRequest(signedReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ValidateRequest() exact host failed: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Try to reuse the signature with different hosts
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
host string
|
||||||
|
}{
|
||||||
|
{"parent domain", testHostExample},
|
||||||
|
{"sibling subdomain", "images.example.com"},
|
||||||
|
{"deeper subdomain", "a.cdn.example.com"},
|
||||||
|
{"evil suffix domain", "cdn.example.com.evil.com"},
|
||||||
|
{"prefixed host", "evilcdn.example.com"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name+" rejected", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
req := &ImageRequest{
|
||||||
|
SourceHost: tt.host,
|
||||||
|
SourcePath: signedReq.SourcePath,
|
||||||
|
SourceQuery: signedReq.SourceQuery,
|
||||||
|
Size: signedReq.Size,
|
||||||
|
Format: signedReq.Format,
|
||||||
|
Quality: signedReq.Quality,
|
||||||
|
FitMode: signedReq.FitMode,
|
||||||
|
Expires: signedReq.Expires,
|
||||||
|
Signature: signedReq.Signature,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.ValidateRequest(req)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf(
|
||||||
|
"ValidateRequest() should reject signature for host %q (signed for %q)",
|
||||||
|
tt.host, signedReq.SourceHost)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestService_Get_InvalidFile(t *testing.T) {
|
func TestService_Get_InvalidFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := SetupTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -172,6 +271,8 @@ func TestService_Get_InvalidFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_NotFound(t *testing.T) {
|
func TestService_Get_NotFound(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := SetupTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -191,6 +292,8 @@ func TestService_Get_NotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_FormatConversion(t *testing.T) {
|
func TestService_Get_FormatConversion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := SetupTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -202,7 +305,7 @@ func TestService_Get_FormatConversion(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "JPEG to PNG",
|
name: "JPEG to PNG",
|
||||||
sourcePath: "/images/photo.jpg",
|
sourcePath: testPathPhoto,
|
||||||
outFormat: FormatPNG,
|
outFormat: FormatPNG,
|
||||||
wantMIME: "image/png",
|
wantMIME: "image/png",
|
||||||
},
|
},
|
||||||
@@ -210,7 +313,7 @@ func TestService_Get_FormatConversion(t *testing.T) {
|
|||||||
name: "PNG to JPEG",
|
name: "PNG to JPEG",
|
||||||
sourcePath: "/images/logo.png",
|
sourcePath: "/images/logo.png",
|
||||||
outFormat: FormatJPEG,
|
outFormat: FormatJPEG,
|
||||||
wantMIME: "image/jpeg",
|
wantMIME: testContentTypeJPEG,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "GIF to PNG",
|
name: "GIF to PNG",
|
||||||
@@ -222,6 +325,8 @@ func TestService_Get_FormatConversion(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: fixtures.GoodHost,
|
SourceHost: fixtures.GoodHost,
|
||||||
SourcePath: tt.sourcePath,
|
SourcePath: tt.sourcePath,
|
||||||
@@ -235,7 +340,8 @@ func TestService_Get_FormatConversion(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Get() error = %v", err)
|
t.Fatalf("Get() error = %v", err)
|
||||||
}
|
}
|
||||||
defer resp.Content.Close()
|
|
||||||
|
defer func() { _ = resp.Content.Close() }()
|
||||||
|
|
||||||
if resp.ContentType != tt.wantMIME {
|
if resp.ContentType != tt.wantMIME {
|
||||||
t.Errorf("ContentType = %q, want %q", resp.ContentType, tt.wantMIME)
|
t.Errorf("ContentType = %q, want %q", resp.ContentType, tt.wantMIME)
|
||||||
@@ -247,17 +353,17 @@ func TestService_Get_FormatConversion(t *testing.T) {
|
|||||||
t.Fatalf("failed to read response: %v", err)
|
t.Fatalf("failed to read response: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
detectedMIME, err := DetectFormat(data)
|
detectedMIME, err := magic.DetectFormat(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to detect format: %v", err)
|
t.Fatalf("failed to detect format: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
expectedFormat, ok := MIMEToImageFormat(tt.wantMIME)
|
expectedFormat, ok := magic.MIMEToImageFormat(tt.wantMIME)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("unknown format for MIME type: %s", tt.wantMIME)
|
t.Fatalf("unknown format for MIME type: %s", tt.wantMIME)
|
||||||
}
|
}
|
||||||
|
|
||||||
detectedFormat, ok := MIMEToImageFormat(string(detectedMIME))
|
detectedFormat, ok := magic.MIMEToImageFormat(string(detectedMIME))
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("unknown format for detected MIME type: %s", detectedMIME)
|
t.Fatalf("unknown format for detected MIME type: %s", detectedMIME)
|
||||||
}
|
}
|
||||||
@@ -270,12 +376,14 @@ func TestService_Get_FormatConversion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_Caching(t *testing.T) {
|
func TestService_Get_Caching(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := SetupTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: fixtures.GoodHost,
|
SourceHost: fixtures.GoodHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testPathPhoto,
|
||||||
Size: Size{Width: 50, Height: 50},
|
Size: Size{Width: 50, Height: 50},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -296,7 +404,8 @@ func TestService_Get_Caching(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to read first response: %v", err)
|
t.Fatalf("failed to read first response: %v", err)
|
||||||
}
|
}
|
||||||
resp1.Content.Close()
|
|
||||||
|
_ = resp1.Content.Close()
|
||||||
|
|
||||||
// Second request - should be a cache hit
|
// Second request - should be a cache hit
|
||||||
resp2, err := svc.Get(ctx, req)
|
resp2, err := svc.Get(ctx, req)
|
||||||
@@ -312,7 +421,8 @@ func TestService_Get_Caching(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to read second response: %v", err)
|
t.Fatalf("failed to read second response: %v", err)
|
||||||
}
|
}
|
||||||
resp2.Content.Close()
|
|
||||||
|
_ = resp2.Content.Close()
|
||||||
|
|
||||||
// Content should be identical
|
// Content should be identical
|
||||||
if len(data1) != len(data2) {
|
if len(data1) != len(data2) {
|
||||||
@@ -321,6 +431,8 @@ func TestService_Get_Caching(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_DifferentSizes(t *testing.T) {
|
func TestService_Get_DifferentSizes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := SetupTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -331,12 +443,12 @@ func TestService_Get_DifferentSizes(t *testing.T) {
|
|||||||
{Width: 75, Height: 75},
|
{Width: 75, Height: 75},
|
||||||
}
|
}
|
||||||
|
|
||||||
var responses [][]byte
|
responses := make([][]byte, 0, len(sizes))
|
||||||
|
|
||||||
for _, size := range sizes {
|
for _, size := range sizes {
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: fixtures.GoodHost,
|
SourceHost: fixtures.GoodHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testPathPhoto,
|
||||||
Size: size,
|
Size: size,
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -352,27 +464,31 @@ func TestService_Get_DifferentSizes(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to read response: %v", err)
|
t.Fatalf("failed to read response: %v", err)
|
||||||
}
|
}
|
||||||
resp.Content.Close()
|
|
||||||
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
responses = append(responses, data)
|
responses = append(responses, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// All responses should be different sizes (different cache entries)
|
// All responses should be different sizes (different cache entries)
|
||||||
for i := 0; i < len(responses)-1; i++ {
|
for i := range len(responses) - 1 {
|
||||||
if len(responses[i]) == len(responses[i+1]) {
|
if len(responses[i]) == len(responses[i+1]) {
|
||||||
// Not necessarily an error, but worth noting
|
// Not necessarily an error, but worth noting
|
||||||
t.Logf("responses %d and %d have same size: %d bytes", i, i+1, len(responses[i]))
|
t.Logf("responses %d and %d have same size: %d bytes",
|
||||||
|
i, i+1, len(responses[i]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_ValidateRequest_NoSigningKey(t *testing.T) {
|
func TestService_ValidateRequest_NoSigningKey(t *testing.T) {
|
||||||
// Service with no signing key - all non-whitelisted requests should fail
|
t.Parallel()
|
||||||
svc, fixtures := SetupTestService(t, WithNoWhitelist())
|
|
||||||
|
// Service with no signing key - all non-allowlisted requests should fail
|
||||||
|
svc, fixtures := SetupTestService(t, WithNoAllowlist())
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: fixtures.OtherHost,
|
SourceHost: fixtures.OtherHost,
|
||||||
SourcePath: "/uploads/image.jpg",
|
SourcePath: testPathUpload,
|
||||||
Size: Size{Width: 50, Height: 50},
|
Size: Size{Width: 50, Height: 50},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -381,11 +497,15 @@ func TestService_ValidateRequest_NoSigningKey(t *testing.T) {
|
|||||||
|
|
||||||
err := svc.ValidateRequest(req)
|
err := svc.ValidateRequest(req)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("ValidateRequest() expected error when no signing key and host not whitelisted")
|
t.Error(
|
||||||
|
"ValidateRequest() expected error when no signing key and host not allowlisted",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_ContextCancellation(t *testing.T) {
|
func TestService_Get_ContextCancellation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := SetupTestService(t)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
@@ -393,7 +513,7 @@ func TestService_Get_ContextCancellation(t *testing.T) {
|
|||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: fixtures.GoodHost,
|
SourceHost: fixtures.GoodHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testPathPhoto,
|
||||||
Size: Size{Width: 50, Height: 50},
|
Size: Size{Width: 50, Height: 50},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -407,12 +527,14 @@ func TestService_Get_ContextCancellation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_ReturnsETag(t *testing.T) {
|
func TestService_Get_ReturnsETag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := SetupTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: fixtures.GoodHost,
|
SourceHost: fixtures.GoodHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testPathPhoto,
|
||||||
Size: Size{Width: 50, Height: 50},
|
Size: Size{Width: 50, Height: 50},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -423,7 +545,8 @@ func TestService_Get_ReturnsETag(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Get() error = %v", err)
|
t.Fatalf("Get() error = %v", err)
|
||||||
}
|
}
|
||||||
defer resp.Content.Close()
|
|
||||||
|
defer func() { _ = resp.Content.Close() }()
|
||||||
|
|
||||||
// ETag should be set
|
// ETag should be set
|
||||||
if resp.ETag == "" {
|
if resp.ETag == "" {
|
||||||
@@ -437,12 +560,14 @@ func TestService_Get_ReturnsETag(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_ETagConsistency(t *testing.T) {
|
func TestService_Get_ETagConsistency(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := SetupTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: fixtures.GoodHost,
|
SourceHost: fixtures.GoodHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testPathPhoto,
|
||||||
Size: Size{Width: 50, Height: 50},
|
Size: Size{Width: 50, Height: 50},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -454,16 +579,20 @@ func TestService_Get_ETagConsistency(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Get() first request error = %v", err)
|
t.Fatalf("Get() first request error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
etag1 := resp1.ETag
|
etag1 := resp1.ETag
|
||||||
resp1.Content.Close()
|
|
||||||
|
_ = resp1.Content.Close()
|
||||||
|
|
||||||
// Second request (from cache)
|
// Second request (from cache)
|
||||||
resp2, err := svc.Get(ctx, req)
|
resp2, err := svc.Get(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Get() second request error = %v", err)
|
t.Fatalf("Get() second request error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
etag2 := resp2.ETag
|
etag2 := resp2.ETag
|
||||||
resp2.Content.Close()
|
|
||||||
|
_ = resp2.Content.Close()
|
||||||
|
|
||||||
// ETags should be identical for the same content
|
// ETags should be identical for the same content
|
||||||
if etag1 != etag2 {
|
if etag1 != etag2 {
|
||||||
@@ -472,13 +601,15 @@ func TestService_Get_ETagConsistency(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_DifferentETagsForDifferentContent(t *testing.T) {
|
func TestService_Get_DifferentETagsForDifferentContent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := SetupTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Request same image at different sizes - should get different ETags
|
// Request same image at different sizes - should get different ETags
|
||||||
req1 := &ImageRequest{
|
req1 := &ImageRequest{
|
||||||
SourceHost: fixtures.GoodHost,
|
SourceHost: fixtures.GoodHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testPathPhoto,
|
||||||
Size: Size{Width: 25, Height: 25},
|
Size: Size{Width: 25, Height: 25},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -487,7 +618,7 @@ func TestService_Get_DifferentETagsForDifferentContent(t *testing.T) {
|
|||||||
|
|
||||||
req2 := &ImageRequest{
|
req2 := &ImageRequest{
|
||||||
SourceHost: fixtures.GoodHost,
|
SourceHost: fixtures.GoodHost,
|
||||||
SourcePath: "/images/photo.jpg",
|
SourcePath: testPathPhoto,
|
||||||
Size: Size{Width: 50, Height: 50},
|
Size: Size{Width: 50, Height: 50},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -498,15 +629,19 @@ func TestService_Get_DifferentETagsForDifferentContent(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Get() first request error = %v", err)
|
t.Fatalf("Get() first request error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
etag1 := resp1.ETag
|
etag1 := resp1.ETag
|
||||||
resp1.Content.Close()
|
|
||||||
|
_ = resp1.Content.Close()
|
||||||
|
|
||||||
resp2, err := svc.Get(ctx, req2)
|
resp2, err := svc.Get(ctx, req2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Get() second request error = %v", err)
|
t.Fatalf("Get() second request error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
etag2 := resp2.ETag
|
etag2 := resp2.ETag
|
||||||
resp2.Content.Close()
|
|
||||||
|
_ = resp2.Content.Close()
|
||||||
|
|
||||||
// ETags should be different for different content
|
// ETags should be different for different content
|
||||||
if etag1 == etag2 {
|
if etag1 == etag2 {
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
package imgcache
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Signature errors.
|
|
||||||
var (
|
|
||||||
ErrSignatureRequired = errors.New("signature required for non-whitelisted host")
|
|
||||||
ErrSignatureInvalid = errors.New("invalid signature")
|
|
||||||
ErrSignatureExpired = errors.New("signature has expired")
|
|
||||||
ErrMissingExpiration = errors.New("signature expiration is required")
|
|
||||||
)
|
|
||||||
|
|
||||||
// Signer handles HMAC-SHA256 signature generation and verification.
|
|
||||||
type Signer struct {
|
|
||||||
secretKey []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSigner creates a new Signer with the given secret key.
|
|
||||||
func NewSigner(secretKey string) *Signer {
|
|
||||||
return &Signer{
|
|
||||||
secretKey: []byte(secretKey),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sign generates an HMAC-SHA256 signature for the given image request.
|
|
||||||
// The signature covers: host + path + query + width + height + format + expiration.
|
|
||||||
func (s *Signer) Sign(req *ImageRequest) string {
|
|
||||||
data := s.buildSignatureData(req)
|
|
||||||
mac := hmac.New(sha256.New, s.secretKey)
|
|
||||||
mac.Write([]byte(data))
|
|
||||||
sig := mac.Sum(nil)
|
|
||||||
|
|
||||||
return base64.URLEncoding.EncodeToString(sig)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify checks if the signature on the request is valid and not expired.
|
|
||||||
func (s *Signer) Verify(req *ImageRequest) error {
|
|
||||||
// Check expiration first
|
|
||||||
if req.Expires.IsZero() {
|
|
||||||
return ErrMissingExpiration
|
|
||||||
}
|
|
||||||
|
|
||||||
if time.Now().After(req.Expires) {
|
|
||||||
return ErrSignatureExpired
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute expected signature
|
|
||||||
expected := s.Sign(req)
|
|
||||||
|
|
||||||
// Constant-time comparison to prevent timing attacks
|
|
||||||
if !hmac.Equal([]byte(req.Signature), []byte(expected)) {
|
|
||||||
return ErrSignatureInvalid
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildSignatureData creates the string to be signed.
|
|
||||||
// Format: "host:path:query:width:height:format:expiration"
|
|
||||||
func (s *Signer) buildSignatureData(req *ImageRequest) string {
|
|
||||||
return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d",
|
|
||||||
req.SourceHost,
|
|
||||||
req.SourcePath,
|
|
||||||
req.SourceQuery,
|
|
||||||
req.Size.Width,
|
|
||||||
req.Size.Height,
|
|
||||||
req.Format,
|
|
||||||
req.Expires.Unix(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateSignedURL creates a complete URL with signature and expiration.
|
|
||||||
// Returns the path portion that should be appended to the base URL.
|
|
||||||
func (s *Signer) GenerateSignedURL(req *ImageRequest, ttl time.Duration) (path string, sig string, exp int64) {
|
|
||||||
// Set expiration
|
|
||||||
req.Expires = time.Now().Add(ttl)
|
|
||||||
exp = req.Expires.Unix()
|
|
||||||
|
|
||||||
// Generate signature
|
|
||||||
sig = s.Sign(req)
|
|
||||||
req.Signature = sig
|
|
||||||
|
|
||||||
// Build the size component
|
|
||||||
var sizeStr string
|
|
||||||
if req.Size.OriginalSize() {
|
|
||||||
sizeStr = "orig"
|
|
||||||
} else {
|
|
||||||
sizeStr = fmt.Sprintf("%dx%d", req.Size.Width, req.Size.Height)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the path.
|
|
||||||
// When a source query is present, it is embedded as a path segment
|
|
||||||
// (e.g. /host/path?query/size.fmt) so that ParseImagePath can extract
|
|
||||||
// it from the last-slash split. The "?" inside a path segment is
|
|
||||||
// percent-encoded by clients but chi delivers it decoded, which is
|
|
||||||
// exactly what the URL parser expects.
|
|
||||||
if req.SourceQuery != "" {
|
|
||||||
path = fmt.Sprintf("/v1/image/%s%s%%3F%s/%s.%s",
|
|
||||||
req.SourceHost,
|
|
||||||
req.SourcePath,
|
|
||||||
url.PathEscape(req.SourceQuery),
|
|
||||||
sizeStr,
|
|
||||||
req.Format,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
path = fmt.Sprintf("/v1/image/%s%s/%s.%s",
|
|
||||||
req.SourceHost,
|
|
||||||
req.SourcePath,
|
|
||||||
sizeStr,
|
|
||||||
req.Format,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return path, sig, exp
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseSignatureParams extracts signature and expiration from query parameters.
|
|
||||||
func ParseSignatureParams(sig, expStr string) (signature string, expires time.Time, err error) {
|
|
||||||
signature = sig
|
|
||||||
|
|
||||||
if expStr == "" {
|
|
||||||
return signature, time.Time{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
expUnix, err := strconv.ParseInt(expStr, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return "", time.Time{}, fmt.Errorf("invalid expiration: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
expires = time.Unix(expUnix, 0)
|
|
||||||
|
|
||||||
return signature, expires, nil
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package imgcache
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGenerateSignedURL_WithQueryString(t *testing.T) {
|
|
||||||
signer := NewSigner("test-secret-key-for-testing!")
|
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
SourceQuery: "token=abc&v=2",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
}
|
|
||||||
|
|
||||||
path, _, _ := signer.GenerateSignedURL(req, time.Hour)
|
|
||||||
|
|
||||||
// The path must NOT contain a bare "?" that would be interpreted as a query string delimiter.
|
|
||||||
// The size segment must appear as the last path component.
|
|
||||||
if strings.Contains(path, "?token=abc") {
|
|
||||||
t.Errorf("GenerateSignedURL() produced bare query string in path: %q", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The size segment must be present in the path
|
|
||||||
if !strings.Contains(path, "/800x600.webp") {
|
|
||||||
t.Errorf("GenerateSignedURL() missing size segment in path: %q", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Path should end with the size.format, not with query params
|
|
||||||
if !strings.HasSuffix(path, "/800x600.webp") {
|
|
||||||
t.Errorf("GenerateSignedURL() path should end with size.format: %q", path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateSignedURL_WithoutQueryString(t *testing.T) {
|
|
||||||
signer := NewSigner("test-secret-key-for-testing!")
|
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
}
|
|
||||||
|
|
||||||
path, _, _ := signer.GenerateSignedURL(req, time.Hour)
|
|
||||||
|
|
||||||
expected := "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp"
|
|
||||||
if path != expected {
|
|
||||||
t.Errorf("GenerateSignedURL() path = %q, want %q", path, expected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
package imgcache
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSigner_Sign(t *testing.T) {
|
|
||||||
signer := NewSigner("test-secret-key")
|
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
SourceQuery: "",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Expires: time.Unix(1704067200, 0), // Fixed timestamp for reproducibility
|
|
||||||
}
|
|
||||||
|
|
||||||
sig1 := signer.Sign(req)
|
|
||||||
sig2 := signer.Sign(req)
|
|
||||||
|
|
||||||
// Same input should produce same signature
|
|
||||||
if sig1 != sig2 {
|
|
||||||
t.Errorf("Sign() produced different signatures for same input: %q vs %q", sig1, sig2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signature should be non-empty
|
|
||||||
if sig1 == "" {
|
|
||||||
t.Error("Sign() produced empty signature")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Different input should produce different signature
|
|
||||||
req2 := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/dog.jpg", // Different path
|
|
||||||
SourceQuery: "",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Expires: time.Unix(1704067200, 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
sig3 := signer.Sign(req2)
|
|
||||||
if sig1 == sig3 {
|
|
||||||
t.Error("Sign() produced same signature for different input")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSigner_Verify(t *testing.T) {
|
|
||||||
signer := NewSigner("test-secret-key")
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
setup func() *ImageRequest
|
|
||||||
wantErr error
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "valid signature",
|
|
||||||
setup: func() *ImageRequest {
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Expires: time.Now().Add(1 * time.Hour),
|
|
||||||
}
|
|
||||||
req.Signature = signer.Sign(req)
|
|
||||||
|
|
||||||
return req
|
|
||||||
},
|
|
||||||
wantErr: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "expired signature",
|
|
||||||
setup: func() *ImageRequest {
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Expires: time.Now().Add(-1 * time.Hour), // Expired
|
|
||||||
}
|
|
||||||
req.Signature = signer.Sign(req)
|
|
||||||
|
|
||||||
return req
|
|
||||||
},
|
|
||||||
wantErr: ErrSignatureExpired,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid signature",
|
|
||||||
setup: func() *ImageRequest {
|
|
||||||
return &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Expires: time.Now().Add(1 * time.Hour),
|
|
||||||
Signature: "invalid-signature",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
wantErr: ErrSignatureInvalid,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing expiration",
|
|
||||||
setup: func() *ImageRequest {
|
|
||||||
return &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Signature: "some-signature",
|
|
||||||
// Expires is zero
|
|
||||||
}
|
|
||||||
},
|
|
||||||
wantErr: ErrMissingExpiration,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "tampered request",
|
|
||||||
setup: func() *ImageRequest {
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Expires: time.Now().Add(1 * time.Hour),
|
|
||||||
}
|
|
||||||
req.Signature = signer.Sign(req)
|
|
||||||
// Tamper with the request
|
|
||||||
req.SourcePath = "/photos/secret.jpg"
|
|
||||||
|
|
||||||
return req
|
|
||||||
},
|
|
||||||
wantErr: ErrSignatureInvalid,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
req := tt.setup()
|
|
||||||
err := signer.Verify(req)
|
|
||||||
|
|
||||||
if tt.wantErr == nil {
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Verify() unexpected error = %v", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err != tt.wantErr {
|
|
||||||
t.Errorf("Verify() error = %v, wantErr %v", err, tt.wantErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSigner_DifferentKeys(t *testing.T) {
|
|
||||||
signer1 := NewSigner("secret-key-1")
|
|
||||||
signer2 := NewSigner("secret-key-2")
|
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Expires: time.Now().Add(1 * time.Hour),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sign with key 1
|
|
||||||
req.Signature = signer1.Sign(req)
|
|
||||||
|
|
||||||
// Verify with key 1 should succeed
|
|
||||||
if err := signer1.Verify(req); err != nil {
|
|
||||||
t.Errorf("Verify() with same key failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify with key 2 should fail
|
|
||||||
if err := signer2.Verify(req); err != ErrSignatureInvalid {
|
|
||||||
t.Errorf("Verify() with different key should fail, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateSignedURL(t *testing.T) {
|
|
||||||
signer := NewSigner("test-secret-key")
|
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
SourceQuery: "",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
}
|
|
||||||
|
|
||||||
ttl := 1 * time.Hour
|
|
||||||
path, sig, exp := signer.GenerateSignedURL(req, ttl)
|
|
||||||
|
|
||||||
// Path should be correct format
|
|
||||||
expectedPath := "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp"
|
|
||||||
if path != expectedPath {
|
|
||||||
t.Errorf("GenerateSignedURL() path = %q, want %q", path, expectedPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signature should be non-empty
|
|
||||||
if sig == "" {
|
|
||||||
t.Error("GenerateSignedURL() produced empty signature")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expiration should be approximately now + TTL
|
|
||||||
expTime := time.Unix(exp, 0)
|
|
||||||
expectedExp := time.Now().Add(ttl)
|
|
||||||
if expTime.Sub(expectedExp) > time.Second {
|
|
||||||
t.Errorf("GenerateSignedURL() exp time off by too much")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request should have been updated with signature and expiration
|
|
||||||
if req.Signature != sig {
|
|
||||||
t.Errorf("GenerateSignedURL() didn't update request signature")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateSignedURL_OrigSize(t *testing.T) {
|
|
||||||
signer := NewSigner("test-secret-key")
|
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
Size: Size{Width: 0, Height: 0}, // Original size
|
|
||||||
Format: FormatPNG,
|
|
||||||
}
|
|
||||||
|
|
||||||
path, _, _ := signer.GenerateSignedURL(req, time.Hour)
|
|
||||||
|
|
||||||
expectedPath := "/v1/image/cdn.example.com/photos/cat.jpg/orig.png"
|
|
||||||
if path != expectedPath {
|
|
||||||
t.Errorf("GenerateSignedURL() path = %q, want %q", path, expectedPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSignatureParams(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
sig string
|
|
||||||
expStr string
|
|
||||||
wantSig string
|
|
||||||
wantErr bool
|
|
||||||
checkTime bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "valid params",
|
|
||||||
sig: "abc123",
|
|
||||||
expStr: "1704067200",
|
|
||||||
wantSig: "abc123",
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty expiration",
|
|
||||||
sig: "abc123",
|
|
||||||
expStr: "",
|
|
||||||
wantSig: "abc123",
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid expiration",
|
|
||||||
sig: "abc123",
|
|
||||||
expStr: "not-a-number",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
sig, exp, err := ParseSignatureParams(tt.sig, tt.expStr)
|
|
||||||
|
|
||||||
if tt.wantErr {
|
|
||||||
if err == nil {
|
|
||||||
t.Error("ParseSignatureParams() expected error, got nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("ParseSignatureParams() unexpected error = %v", err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if sig != tt.wantSig {
|
|
||||||
t.Errorf("sig = %q, want %q", sig, tt.wantSig)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tt.expStr != "" && exp.IsZero() {
|
|
||||||
t.Error("exp should not be zero when expStr is provided")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
52
internal/imgcache/sourceurl_internal_test.go
Normal file
52
internal/imgcache/sourceurl_internal_test.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestImageRequest_SourceURL_DefaultHTTPS(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
req := &ImageRequest{
|
||||||
|
SourceHost: testHostCDN,
|
||||||
|
SourcePath: testPathCat,
|
||||||
|
SourceQuery: "v=2",
|
||||||
|
}
|
||||||
|
|
||||||
|
got := req.SourceURL()
|
||||||
|
|
||||||
|
want := "https://cdn.example.com/photos/cat.jpg?v=2"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("SourceURL() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageRequest_SourceURL_AllowHTTP(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
req := &ImageRequest{
|
||||||
|
SourceHost: "localhost:8080",
|
||||||
|
SourcePath: testPathCat,
|
||||||
|
AllowHTTP: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
got := req.SourceURL()
|
||||||
|
|
||||||
|
want := "http://localhost:8080/photos/cat.jpg"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("SourceURL() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageRequest_SourceURL_AllowHTTPFalse(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
req := &ImageRequest{
|
||||||
|
SourceHost: testHostCDN,
|
||||||
|
SourcePath: "/img.jpg",
|
||||||
|
AllowHTTP: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
got := req.SourceURL()
|
||||||
|
if got != "https://cdn.example.com/img.jpg" {
|
||||||
|
t.Errorf("SourceURL() = %q, want https scheme", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,18 +12,25 @@ import (
|
|||||||
|
|
||||||
func setupStatsTestDB(t *testing.T) *sql.DB {
|
func setupStatsTestDB(t *testing.T) *sql.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
db, err := sql.Open("sqlite", ":memory:")
|
db, err := sql.Open("sqlite", ":memory:")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := database.ApplyMigrations(db); err != nil {
|
|
||||||
|
err = database.ApplyMigrations(context.Background(), db, nil)
|
||||||
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { db.Close() })
|
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStats_HitRateIsRatio(t *testing.T) {
|
func TestStats_HitRateIsRatio(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
db := setupStatsTestDB(t)
|
db := setupStatsTestDB(t)
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
||||||
@@ -40,7 +47,9 @@ func TestStats_HitRateIsRatio(t *testing.T) {
|
|||||||
|
|
||||||
// Set some hit/miss counts and a transform_count
|
// Set some hit/miss counts and a transform_count
|
||||||
_, err = db.ExecContext(ctx, `
|
_, err = db.ExecContext(ctx, `
|
||||||
UPDATE cache_stats SET hit_count = 75, miss_count = 25, transform_count = 9999 WHERE id = 1
|
UPDATE cache_stats
|
||||||
|
SET hit_count = 75, miss_count = 25, transform_count = 9999
|
||||||
|
WHERE id = 1
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -54,6 +63,7 @@ func TestStats_HitRateIsRatio(t *testing.T) {
|
|||||||
if stats.HitCount != 75 {
|
if stats.HitCount != 75 {
|
||||||
t.Errorf("HitCount = %d, want 75", stats.HitCount)
|
t.Errorf("HitCount = %d, want 75", stats.HitCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
if stats.MissCount != 25 {
|
if stats.MissCount != 25 {
|
||||||
t.Errorf("MissCount = %d, want 25", stats.MissCount)
|
t.Errorf("MissCount = %d, want 25", stats.MissCount)
|
||||||
}
|
}
|
||||||
@@ -61,11 +71,14 @@ func TestStats_HitRateIsRatio(t *testing.T) {
|
|||||||
// HitRate should be 0.75, NOT 9999 (transform_count)
|
// HitRate should be 0.75, NOT 9999 (transform_count)
|
||||||
expectedRate := 0.75
|
expectedRate := 0.75
|
||||||
if math.Abs(stats.HitRate-expectedRate) > 0.001 {
|
if math.Abs(stats.HitRate-expectedRate) > 0.001 {
|
||||||
t.Errorf("HitRate = %f, want %f (was it scanning transform_count?)", stats.HitRate, expectedRate)
|
t.Errorf("HitRate = %f, want %f (was it scanning transform_count?)",
|
||||||
|
stats.HitRate, expectedRate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStats_ZeroCounts(t *testing.T) {
|
func TestStats_ZeroCounts(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
db := setupStatsTestDB(t)
|
db := setupStatsTestDB(t)
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
||||||
@@ -16,6 +16,8 @@ import (
|
|||||||
const (
|
const (
|
||||||
// StorageDirPerm is the permission mode for storage directories.
|
// StorageDirPerm is the permission mode for storage directories.
|
||||||
StorageDirPerm = 0750
|
StorageDirPerm = 0750
|
||||||
|
// StorageFilePerm is the permission mode for storage files.
|
||||||
|
StorageFilePerm = 0600
|
||||||
// MinHashLength is the minimum hash length for path splitting.
|
// MinHashLength is the minimum hash length for path splitting.
|
||||||
MinHashLength = 4
|
MinHashLength = 4
|
||||||
)
|
)
|
||||||
@@ -42,7 +44,8 @@ type ContentStorage struct {
|
|||||||
|
|
||||||
// NewContentStorage creates a new content storage at the given base directory.
|
// NewContentStorage creates a new content storage at the given base directory.
|
||||||
func NewContentStorage(baseDir string) (*ContentStorage, error) {
|
func NewContentStorage(baseDir string) (*ContentStorage, error) {
|
||||||
if err := os.MkdirAll(baseDir, StorageDirPerm); err != nil {
|
err := os.MkdirAll(baseDir, StorageDirPerm)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create storage directory: %w", err)
|
return nil, fmt.Errorf("failed to create storage directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +54,7 @@ func NewContentStorage(baseDir string) (*ContentStorage, error) {
|
|||||||
|
|
||||||
// Store writes content to storage and returns its SHA256 hash.
|
// Store writes content to storage and returns its SHA256 hash.
|
||||||
// The content is read fully into memory to compute the hash before writing.
|
// The content is read fully into memory to compute the hash before writing.
|
||||||
func (s *ContentStorage) Store(r io.Reader) (hash ContentHash, size int64, err error) {
|
func (s *ContentStorage) Store(r io.Reader) (ContentHash, int64, error) {
|
||||||
// Read all content to compute hash
|
// Read all content to compute hash
|
||||||
data, err := io.ReadAll(r)
|
data, err := io.ReadAll(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -60,20 +63,23 @@ func (s *ContentStorage) Store(r io.Reader) (hash ContentHash, size int64, err e
|
|||||||
|
|
||||||
// Compute hash
|
// Compute hash
|
||||||
h := sha256.Sum256(data)
|
h := sha256.Sum256(data)
|
||||||
hash = ContentHash(hex.EncodeToString(h[:]))
|
hash := ContentHash(hex.EncodeToString(h[:]))
|
||||||
size = int64(len(data))
|
size := int64(len(data))
|
||||||
|
|
||||||
// Build path: <basedir>/<ab>/<cd>/<hash>
|
// Build path: <basedir>/<ab>/<cd>/<hash>
|
||||||
path := s.hashToPath(hash)
|
path := s.hashToPath(hash)
|
||||||
|
|
||||||
// Check if already exists
|
// Check if already exists
|
||||||
if _, err := os.Stat(path); err == nil {
|
_, err = os.Stat(path)
|
||||||
|
if err == nil {
|
||||||
return hash, size, nil
|
return hash, size, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create directory structure
|
// Create directory structure
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
if err := os.MkdirAll(dir, StorageDirPerm); err != nil {
|
|
||||||
|
err = os.MkdirAll(dir, StorageDirPerm)
|
||||||
|
if err != nil {
|
||||||
return "", 0, fmt.Errorf("failed to create directory: %w", err)
|
return "", 0, fmt.Errorf("failed to create directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,26 +88,29 @@ func (s *ContentStorage) Store(r io.Reader) (hash ContentHash, size int64, err e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, fmt.Errorf("failed to create temp file: %w", err)
|
return "", 0, fmt.Errorf("failed to create temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpPath := tmpFile.Name()
|
tmpPath := tmpFile.Name()
|
||||||
|
|
||||||
defer func() {
|
_, err = tmpFile.Write(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = os.Remove(tmpPath)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if _, err := tmpFile.Write(data); err != nil {
|
|
||||||
_ = tmpFile.Close()
|
_ = tmpFile.Close()
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
|
||||||
return "", 0, fmt.Errorf("failed to write content: %w", err)
|
return "", 0, fmt.Errorf("failed to write content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tmpFile.Close(); err != nil {
|
err = tmpFile.Close()
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
|
||||||
return "", 0, fmt.Errorf("failed to close temp file: %w", err)
|
return "", 0, fmt.Errorf("failed to close temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomic rename
|
// Atomic rename
|
||||||
if err := os.Rename(tmpPath, path); err != nil {
|
err = os.Rename(filepath.Clean(tmpPath), filepath.Clean(path))
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
|
||||||
return "", 0, fmt.Errorf("failed to rename temp file: %w", err)
|
return "", 0, fmt.Errorf("failed to rename temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,10 +180,10 @@ func (s *ContentStorage) Exists(hash ContentHash) bool {
|
|||||||
func (s *ContentStorage) hashToPath(hash ContentHash) string {
|
func (s *ContentStorage) hashToPath(hash ContentHash) string {
|
||||||
h := string(hash)
|
h := string(hash)
|
||||||
if len(h) < MinHashLength {
|
if len(h) < MinHashLength {
|
||||||
return filepath.Join(s.baseDir, h)
|
return filepath.Clean(filepath.Join(s.baseDir, h))
|
||||||
}
|
}
|
||||||
|
|
||||||
return filepath.Join(s.baseDir, h[0:2], h[2:4], h)
|
return filepath.Clean(filepath.Join(s.baseDir, h[0:2], h[2:4], h))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MetadataStorage handles JSON metadata file storage.
|
// MetadataStorage handles JSON metadata file storage.
|
||||||
@@ -185,7 +194,8 @@ type MetadataStorage struct {
|
|||||||
|
|
||||||
// NewMetadataStorage creates a new metadata storage at the given base directory.
|
// NewMetadataStorage creates a new metadata storage at the given base directory.
|
||||||
func NewMetadataStorage(baseDir string) (*MetadataStorage, error) {
|
func NewMetadataStorage(baseDir string) (*MetadataStorage, error) {
|
||||||
if err := os.MkdirAll(baseDir, StorageDirPerm); err != nil {
|
err := os.MkdirAll(baseDir, StorageDirPerm)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create metadata directory: %w", err)
|
return nil, fmt.Errorf("failed to create metadata directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +203,8 @@ func NewMetadataStorage(baseDir string) (*MetadataStorage, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SourceMetadata represents cached metadata about a source URL.
|
// SourceMetadata represents cached metadata about a source URL.
|
||||||
|
//
|
||||||
|
//nolint:tagliatelle // stored metadata format uses snake_case
|
||||||
type SourceMetadata struct {
|
type SourceMetadata struct {
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
@@ -211,12 +223,16 @@ type SourceMetadata struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store writes metadata to storage.
|
// Store writes metadata to storage.
|
||||||
func (s *MetadataStorage) Store(host string, pathHash PathHash, meta *SourceMetadata) error {
|
func (s *MetadataStorage) Store(
|
||||||
|
host string, pathHash PathHash, meta *SourceMetadata,
|
||||||
|
) error {
|
||||||
path := s.metaPath(host, pathHash)
|
path := s.metaPath(host, pathHash)
|
||||||
|
|
||||||
// Create directory structure
|
// Create directory structure
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
if err := os.MkdirAll(dir, StorageDirPerm); err != nil {
|
|
||||||
|
err := os.MkdirAll(dir, StorageDirPerm)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create directory: %w", err)
|
return fmt.Errorf("failed to create directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,26 +247,29 @@ func (s *MetadataStorage) Store(host string, pathHash PathHash, meta *SourceMeta
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create temp file: %w", err)
|
return fmt.Errorf("failed to create temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpPath := tmpFile.Name()
|
tmpPath := tmpFile.Name()
|
||||||
|
|
||||||
defer func() {
|
_, err = tmpFile.Write(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = os.Remove(tmpPath)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if _, err := tmpFile.Write(data); err != nil {
|
|
||||||
_ = tmpFile.Close()
|
_ = tmpFile.Close()
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
|
||||||
return fmt.Errorf("failed to write metadata: %w", err)
|
return fmt.Errorf("failed to write metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tmpFile.Close(); err != nil {
|
err = tmpFile.Close()
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
|
||||||
return fmt.Errorf("failed to close temp file: %w", err)
|
return fmt.Errorf("failed to close temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomic rename
|
// Atomic rename
|
||||||
if err := os.Rename(tmpPath, path); err != nil {
|
err = os.Rename(filepath.Clean(tmpPath), filepath.Clean(path))
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
|
||||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +277,9 @@ func (s *MetadataStorage) Store(host string, pathHash PathHash, meta *SourceMeta
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load reads metadata from storage.
|
// Load reads metadata from storage.
|
||||||
func (s *MetadataStorage) Load(host string, pathHash PathHash) (*SourceMetadata, error) {
|
func (s *MetadataStorage) Load(
|
||||||
|
host string, pathHash PathHash,
|
||||||
|
) (*SourceMetadata, error) {
|
||||||
path := s.metaPath(host, pathHash)
|
path := s.metaPath(host, pathHash)
|
||||||
|
|
||||||
data, err := os.ReadFile(path) //nolint:gosec // path derived from host+hash
|
data, err := os.ReadFile(path) //nolint:gosec // path derived from host+hash
|
||||||
@@ -271,7 +292,9 @@ func (s *MetadataStorage) Load(host string, pathHash PathHash) (*SourceMetadata,
|
|||||||
}
|
}
|
||||||
|
|
||||||
var meta SourceMetadata
|
var meta SourceMetadata
|
||||||
if err := json.Unmarshal(data, &meta); err != nil {
|
|
||||||
|
err = json.Unmarshal(data, &meta)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to unmarshal metadata: %w", err)
|
return nil, fmt.Errorf("failed to unmarshal metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,7 +323,7 @@ func (s *MetadataStorage) Exists(host string, pathHash PathHash) bool {
|
|||||||
|
|
||||||
// metaPath returns the file path for metadata: <basedir>/<host>/<path_hash>.json
|
// metaPath returns the file path for metadata: <basedir>/<host>/<path_hash>.json
|
||||||
func (s *MetadataStorage) metaPath(host string, pathHash PathHash) string {
|
func (s *MetadataStorage) metaPath(host string, pathHash PathHash) string {
|
||||||
return filepath.Join(s.baseDir, host, string(pathHash)+".json")
|
return filepath.Clean(filepath.Join(s.baseDir, host, string(pathHash)+".json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// HashPath computes the SHA256 hash of a path string.
|
// HashPath computes the SHA256 hash of a path string.
|
||||||
@@ -337,6 +360,8 @@ type VariantStorage struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// VariantMeta contains metadata about a cached variant.
|
// VariantMeta contains metadata about a cached variant.
|
||||||
|
//
|
||||||
|
//nolint:tagliatelle // stored metadata format uses snake_case
|
||||||
type VariantMeta struct {
|
type VariantMeta struct {
|
||||||
ContentType string `json:"content_type"`
|
ContentType string `json:"content_type"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
@@ -345,7 +370,8 @@ type VariantMeta struct {
|
|||||||
|
|
||||||
// NewVariantStorage creates a new variant storage at the given base directory.
|
// NewVariantStorage creates a new variant storage at the given base directory.
|
||||||
func NewVariantStorage(baseDir string) (*VariantStorage, error) {
|
func NewVariantStorage(baseDir string) (*VariantStorage, error) {
|
||||||
if err := os.MkdirAll(baseDir, StorageDirPerm); err != nil {
|
err := os.MkdirAll(baseDir, StorageDirPerm)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create variant storage directory: %w", err)
|
return nil, fmt.Errorf("failed to create variant storage directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,19 +379,23 @@ func NewVariantStorage(baseDir string) (*VariantStorage, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store writes content and metadata to storage at the given key.
|
// Store writes content and metadata to storage at the given key.
|
||||||
func (s *VariantStorage) Store(key VariantKey, r io.Reader, contentType string) (size int64, err error) {
|
func (s *VariantStorage) Store(
|
||||||
|
key VariantKey, r io.Reader, contentType string,
|
||||||
|
) (int64, error) {
|
||||||
data, err := io.ReadAll(r)
|
data, err := io.ReadAll(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to read content: %w", err)
|
return 0, fmt.Errorf("failed to read content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
size = int64(len(data))
|
size := int64(len(data))
|
||||||
path := s.keyToPath(key)
|
path := s.keyToPath(key)
|
||||||
metaPath := path + ".meta"
|
metaPath := path + ".meta"
|
||||||
|
|
||||||
// Create directory structure
|
// Create directory structure
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
if err := os.MkdirAll(dir, StorageDirPerm); err != nil {
|
|
||||||
|
err = os.MkdirAll(dir, StorageDirPerm)
|
||||||
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to create directory: %w", err)
|
return 0, fmt.Errorf("failed to create directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,26 +404,29 @@ func (s *VariantStorage) Store(key VariantKey, r io.Reader, contentType string)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to create temp file: %w", err)
|
return 0, fmt.Errorf("failed to create temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpPath := tmpFile.Name()
|
tmpPath := tmpFile.Name()
|
||||||
|
|
||||||
defer func() {
|
_, err = tmpFile.Write(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = os.Remove(tmpPath)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if _, err := tmpFile.Write(data); err != nil {
|
|
||||||
_ = tmpFile.Close()
|
_ = tmpFile.Close()
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
|
||||||
return 0, fmt.Errorf("failed to write content: %w", err)
|
return 0, fmt.Errorf("failed to write content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tmpFile.Close(); err != nil {
|
err = tmpFile.Close()
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
|
||||||
return 0, fmt.Errorf("failed to close temp file: %w", err)
|
return 0, fmt.Errorf("failed to close temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomic rename content
|
// Atomic rename content
|
||||||
if err := os.Rename(tmpPath, path); err != nil {
|
err = os.Rename(filepath.Clean(tmpPath), filepath.Clean(path))
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
|
||||||
return 0, fmt.Errorf("failed to rename temp file: %w", err)
|
return 0, fmt.Errorf("failed to rename temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,10 +442,8 @@ func (s *VariantStorage) Store(key VariantKey, r io.Reader, contentType string)
|
|||||||
return 0, fmt.Errorf("failed to marshal metadata: %w", err)
|
return 0, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(metaPath, metaData, 0640); err != nil {
|
// Metadata write failure is non-fatal; content is already stored.
|
||||||
// Non-fatal, content is stored
|
_ = os.WriteFile(metaPath, metaData, StorageFilePerm)
|
||||||
_ = err
|
|
||||||
}
|
|
||||||
|
|
||||||
return size, nil
|
return size, nil
|
||||||
}
|
}
|
||||||
@@ -433,8 +464,11 @@ func (s *VariantStorage) Load(key VariantKey) (io.ReadCloser, error) {
|
|||||||
return f, nil
|
return f, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadWithMeta returns a reader, size, and content type for the content at the given key.
|
// LoadWithMeta returns a reader, size, and content type for the content at
|
||||||
func (s *VariantStorage) LoadWithMeta(key VariantKey) (io.ReadCloser, int64, string, error) {
|
// the given key.
|
||||||
|
func (s *VariantStorage) LoadWithMeta(
|
||||||
|
key VariantKey,
|
||||||
|
) (io.ReadCloser, int64, string, error) {
|
||||||
path := s.keyToPath(key)
|
path := s.keyToPath(key)
|
||||||
metaPath := path + ".meta"
|
metaPath := path + ".meta"
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package imgcache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -9,13 +10,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestContentStorage_StoreAndLoad(t *testing.T) {
|
func TestContentStorage_StoreAndLoad(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
storage, err := NewContentStorage(tmpDir)
|
storage, err := NewContentStorage(tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewContentStorage() error = %v", err)
|
t.Fatalf("NewContentStorage() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
content := []byte("hello world")
|
content := []byte("hello world")
|
||||||
|
|
||||||
hash, size, err := storage.Store(bytes.NewReader(content))
|
hash, size, err := storage.Store(bytes.NewReader(content))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Store() error = %v", err)
|
t.Fatalf("Store() error = %v", err)
|
||||||
@@ -31,8 +36,11 @@ func TestContentStorage_StoreAndLoad(t *testing.T) {
|
|||||||
|
|
||||||
// Verify file exists at expected path
|
// Verify file exists at expected path
|
||||||
hashStr := string(hash)
|
hashStr := string(hash)
|
||||||
|
|
||||||
expectedPath := filepath.Join(tmpDir, hashStr[0:2], hashStr[2:4], hashStr)
|
expectedPath := filepath.Join(tmpDir, hashStr[0:2], hashStr[2:4], hashStr)
|
||||||
if _, err := os.Stat(expectedPath); err != nil {
|
|
||||||
|
_, err = os.Stat(expectedPath)
|
||||||
|
if err != nil {
|
||||||
t.Errorf("File not at expected path %s: %v", expectedPath, err)
|
t.Errorf("File not at expected path %s: %v", expectedPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +49,8 @@ func TestContentStorage_StoreAndLoad(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load() error = %v", err)
|
t.Fatalf("Load() error = %v", err)
|
||||||
}
|
}
|
||||||
defer r.Close()
|
|
||||||
|
defer func() { _ = r.Close() }()
|
||||||
|
|
||||||
loaded, err := io.ReadAll(r)
|
loaded, err := io.ReadAll(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -54,7 +63,10 @@ func TestContentStorage_StoreAndLoad(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestContentStorage_StoreIdempotent(t *testing.T) {
|
func TestContentStorage_StoreIdempotent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
storage, err := NewContentStorage(tmpDir)
|
storage, err := NewContentStorage(tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewContentStorage() error = %v", err)
|
t.Fatalf("NewContentStorage() error = %v", err)
|
||||||
@@ -78,26 +90,33 @@ func TestContentStorage_StoreIdempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestContentStorage_LoadNotFound(t *testing.T) {
|
func TestContentStorage_LoadNotFound(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
storage, err := NewContentStorage(tmpDir)
|
storage, err := NewContentStorage(tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewContentStorage() error = %v", err)
|
t.Fatalf("NewContentStorage() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = storage.Load(ContentHash("nonexistent"))
|
_, err = storage.Load(ContentHash("nonexistent"))
|
||||||
if err != ErrNotFound {
|
if !errors.Is(err, ErrNotFound) {
|
||||||
t.Errorf("Load() error = %v, want ErrNotFound", err)
|
t.Errorf("Load() error = %v, want ErrNotFound", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestContentStorage_Delete(t *testing.T) {
|
func TestContentStorage_Delete(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
storage, err := NewContentStorage(tmpDir)
|
storage, err := NewContentStorage(tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewContentStorage() error = %v", err)
|
t.Fatalf("NewContentStorage() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
content := []byte("to be deleted")
|
content := []byte("to be deleted")
|
||||||
|
|
||||||
hash, _, err := storage.Store(bytes.NewReader(content))
|
hash, _, err := storage.Store(bytes.NewReader(content))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Store() error = %v", err)
|
t.Fatalf("Store() error = %v", err)
|
||||||
@@ -107,7 +126,8 @@ func TestContentStorage_Delete(t *testing.T) {
|
|||||||
t.Error("Exists() = false, want true")
|
t.Error("Exists() = false, want true")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := storage.Delete(hash); err != nil {
|
err = storage.Delete(hash)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("Delete() error = %v", err)
|
t.Fatalf("Delete() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,20 +137,27 @@ func TestContentStorage_Delete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestContentStorage_DeleteNonexistent(t *testing.T) {
|
func TestContentStorage_DeleteNonexistent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
storage, err := NewContentStorage(tmpDir)
|
storage, err := NewContentStorage(tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewContentStorage() error = %v", err)
|
t.Fatalf("NewContentStorage() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should not error
|
// Should not error
|
||||||
if err := storage.Delete(ContentHash("nonexistent")); err != nil {
|
err = storage.Delete(ContentHash("nonexistent"))
|
||||||
|
if err != nil {
|
||||||
t.Errorf("Delete() error = %v, want nil", err)
|
t.Errorf("Delete() error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestContentStorage_HashToPath(t *testing.T) {
|
func TestContentStorage_HashToPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
storage, err := NewContentStorage(tmpDir)
|
storage, err := NewContentStorage(tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewContentStorage() error = %v", err)
|
t.Fatalf("NewContentStorage() error = %v", err)
|
||||||
@@ -138,50 +165,59 @@ func TestContentStorage_HashToPath(t *testing.T) {
|
|||||||
|
|
||||||
// Test by storing and verifying the resulting path structure
|
// Test by storing and verifying the resulting path structure
|
||||||
content := []byte("test content for path verification")
|
content := []byte("test content for path verification")
|
||||||
|
|
||||||
hash, _, err := storage.Store(bytes.NewReader(content))
|
hash, _, err := storage.Store(bytes.NewReader(content))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Store() error = %v", err)
|
t.Fatalf("Store() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
hashStr := string(hash)
|
hashStr := string(hash)
|
||||||
|
|
||||||
expectedPath := filepath.Join(tmpDir, hashStr[0:2], hashStr[2:4], hashStr)
|
expectedPath := filepath.Join(tmpDir, hashStr[0:2], hashStr[2:4], hashStr)
|
||||||
if _, err := os.Stat(expectedPath); err != nil {
|
|
||||||
|
_, err = os.Stat(expectedPath)
|
||||||
|
if err != nil {
|
||||||
t.Errorf("File not at expected path %s: %v", expectedPath, err)
|
t.Errorf("File not at expected path %s: %v", expectedPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMetadataStorage_StoreAndLoad(t *testing.T) {
|
func TestMetadataStorage_StoreAndLoad(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
storage, err := NewMetadataStorage(tmpDir)
|
storage, err := NewMetadataStorage(tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewMetadataStorage() error = %v", err)
|
t.Fatalf("NewMetadataStorage() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
meta := &SourceMetadata{
|
meta := &SourceMetadata{
|
||||||
Host: "cdn.example.com",
|
Host: testHostCDN,
|
||||||
Path: "/photos/cat.jpg",
|
Path: testPathCat,
|
||||||
ContentHash: "abc123",
|
ContentHash: "abc123",
|
||||||
StatusCode: 200,
|
StatusCode: 200,
|
||||||
ContentType: "image/jpeg",
|
ContentType: testContentTypeJPEG,
|
||||||
FetchedAt: 1704067200,
|
FetchedAt: 1704067200,
|
||||||
ETag: `"etag123"`,
|
ETag: `"etag123"`,
|
||||||
}
|
}
|
||||||
|
|
||||||
pathHash := HashPath("/photos/cat.jpg")
|
pathHash := HashPath(testPathCat)
|
||||||
|
|
||||||
err = storage.Store("cdn.example.com", pathHash, meta)
|
err = storage.Store(testHostCDN, pathHash, meta)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Store() error = %v", err)
|
t.Fatalf("Store() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify file exists at expected path
|
// Verify file exists at expected path
|
||||||
expectedPath := filepath.Join(tmpDir, "cdn.example.com", string(pathHash)+".json")
|
expectedPath := filepath.Join(tmpDir, testHostCDN, string(pathHash)+".json")
|
||||||
if _, err := os.Stat(expectedPath); err != nil {
|
|
||||||
|
_, err = os.Stat(expectedPath)
|
||||||
|
if err != nil {
|
||||||
t.Errorf("File not at expected path %s: %v", expectedPath, err)
|
t.Errorf("File not at expected path %s: %v", expectedPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load and verify
|
// Load and verify
|
||||||
loaded, err := storage.Load("cdn.example.com", pathHash)
|
loaded, err := storage.Load(testHostCDN, pathHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load() error = %v", err)
|
t.Fatalf("Load() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -208,55 +244,64 @@ func TestMetadataStorage_StoreAndLoad(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMetadataStorage_LoadNotFound(t *testing.T) {
|
func TestMetadataStorage_LoadNotFound(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
storage, err := NewMetadataStorage(tmpDir)
|
storage, err := NewMetadataStorage(tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewMetadataStorage() error = %v", err)
|
t.Fatalf("NewMetadataStorage() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = storage.Load("example.com", PathHash("nonexistent"))
|
_, err = storage.Load(testHostExample, PathHash("nonexistent"))
|
||||||
if err != ErrNotFound {
|
if !errors.Is(err, ErrNotFound) {
|
||||||
t.Errorf("Load() error = %v, want ErrNotFound", err)
|
t.Errorf("Load() error = %v, want ErrNotFound", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMetadataStorage_Delete(t *testing.T) {
|
func TestMetadataStorage_Delete(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
storage, err := NewMetadataStorage(tmpDir)
|
storage, err := NewMetadataStorage(tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewMetadataStorage() error = %v", err)
|
t.Fatalf("NewMetadataStorage() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
meta := &SourceMetadata{
|
meta := &SourceMetadata{
|
||||||
Host: "example.com",
|
Host: testHostExample,
|
||||||
Path: "/test.jpg",
|
Path: "/test.jpg",
|
||||||
StatusCode: 200,
|
StatusCode: 200,
|
||||||
}
|
}
|
||||||
|
|
||||||
pathHash := HashPath("/test.jpg")
|
pathHash := HashPath("/test.jpg")
|
||||||
|
|
||||||
err = storage.Store("example.com", pathHash, meta)
|
err = storage.Store(testHostExample, pathHash, meta)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Store() error = %v", err)
|
t.Fatalf("Store() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !storage.Exists("example.com", pathHash) {
|
if !storage.Exists(testHostExample, pathHash) {
|
||||||
t.Error("Exists() = false, want true")
|
t.Error("Exists() = false, want true")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := storage.Delete("example.com", pathHash); err != nil {
|
err = storage.Delete(testHostExample, pathHash)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("Delete() error = %v", err)
|
t.Fatalf("Delete() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if storage.Exists("example.com", pathHash) {
|
if storage.Exists(testHostExample, pathHash) {
|
||||||
t.Error("Exists() = true after delete, want false")
|
t.Error("Exists() = true after delete, want false")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHashPath(t *testing.T) {
|
func TestHashPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// Same input should produce same hash
|
// Same input should produce same hash
|
||||||
hash1 := HashPath("/photos/cat.jpg")
|
hash1 := HashPath(testPathCat)
|
||||||
hash2 := HashPath("/photos/cat.jpg")
|
hash2 := HashPath(testPathCat)
|
||||||
|
|
||||||
if hash1 != hash2 {
|
if hash1 != hash2 {
|
||||||
t.Errorf("HashPath() not deterministic: %s vs %s", hash1, hash2)
|
t.Errorf("HashPath() not deterministic: %s vs %s", hash1, hash2)
|
||||||
@@ -276,9 +321,11 @@ func TestHashPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCacheKey(t *testing.T) {
|
func TestCacheKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
req1 := &ImageRequest{
|
req1 := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/cat.jpg",
|
SourcePath: testPathCat,
|
||||||
SourceQuery: "",
|
SourceQuery: "",
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
@@ -287,8 +334,8 @@ func TestCacheKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
req2 := &ImageRequest{
|
req2 := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/cat.jpg",
|
SourcePath: testPathCat,
|
||||||
SourceQuery: "",
|
SourceQuery: "",
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
@@ -311,8 +358,8 @@ func TestCacheKey(t *testing.T) {
|
|||||||
|
|
||||||
// Different size should produce different key
|
// Different size should produce different key
|
||||||
req3 := &ImageRequest{
|
req3 := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/cat.jpg",
|
SourcePath: testPathCat,
|
||||||
SourceQuery: "",
|
SourceQuery: "",
|
||||||
Size: Size{Width: 400, Height: 300}, // Different size
|
Size: Size{Width: 400, Height: 300}, // Different size
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
@@ -327,8 +374,8 @@ func TestCacheKey(t *testing.T) {
|
|||||||
|
|
||||||
// Different format should produce different key
|
// Different format should produce different key
|
||||||
req4 := &ImageRequest{
|
req4 := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/cat.jpg",
|
SourcePath: testPathCat,
|
||||||
SourceQuery: "",
|
SourceQuery: "",
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatPNG, // Different format
|
Format: FormatPNG, // Different format
|
||||||
@@ -343,8 +390,8 @@ func TestCacheKey(t *testing.T) {
|
|||||||
|
|
||||||
// Different quality should produce different key
|
// Different quality should produce different key
|
||||||
req5 := &ImageRequest{
|
req5 := &ImageRequest{
|
||||||
SourceHost: "cdn.example.com",
|
SourceHost: testHostCDN,
|
||||||
SourcePath: "/photos/cat.jpg",
|
SourcePath: testPathCat,
|
||||||
SourceQuery: "",
|
SourceQuery: "",
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
@@ -2,6 +2,7 @@ package imgcache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
@@ -14,16 +15,25 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/database"
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Shared test data literals, extracted as constants for goconst.
|
||||||
|
const (
|
||||||
|
testHostCDN = "cdn.example.com"
|
||||||
|
testHostExample = "example.com"
|
||||||
|
testPathCat = "/photos/cat.jpg"
|
||||||
|
testContentTypeJPEG = "image/jpeg"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestFixtures contains paths to test files in the mock filesystem.
|
// TestFixtures contains paths to test files in the mock filesystem.
|
||||||
type TestFixtures struct {
|
type TestFixtures struct {
|
||||||
// Valid image files
|
// Valid image files
|
||||||
GoodHostJPEG string // whitelisted host, valid JPEG
|
GoodHostJPEG string // allowlisted host, valid JPEG
|
||||||
GoodHostPNG string // whitelisted host, valid PNG
|
GoodHostPNG string // allowlisted host, valid PNG
|
||||||
GoodHostGIF string // whitelisted host, valid GIF
|
GoodHostGIF string // allowlisted host, valid GIF
|
||||||
OtherHostJPEG string // non-whitelisted host, valid JPEG
|
OtherHostJPEG string // non-allowlisted host, valid JPEG
|
||||||
OtherHostPNG string // non-whitelisted host, valid PNG
|
OtherHostPNG string // non-allowlisted host, valid PNG
|
||||||
|
|
||||||
// Invalid/edge case files
|
// Invalid/edge case files
|
||||||
InvalidFile string // file with wrong magic bytes
|
InvalidFile string // file with wrong magic bytes
|
||||||
@@ -31,8 +41,8 @@ type TestFixtures struct {
|
|||||||
TextFile string // text file masquerading as image
|
TextFile string // text file masquerading as image
|
||||||
|
|
||||||
// Hostnames
|
// Hostnames
|
||||||
GoodHost string // whitelisted hostname
|
GoodHost string // allowlisted hostname
|
||||||
OtherHost string // non-whitelisted hostname
|
OtherHost string // non-allowlisted hostname
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultFixtures returns the standard test fixture paths.
|
// DefaultFixtures returns the standard test fixture paths.
|
||||||
@@ -87,14 +97,16 @@ func generateTestJPEG(t *testing.T, width, height int, c color.Color) []byte {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
for y := 0; y < height; y++ {
|
for y := range height {
|
||||||
for x := 0; x < width; x++ {
|
for x := range width {
|
||||||
img.Set(x, y, c)
|
img.Set(x, y, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
|
|
||||||
|
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85})
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("failed to encode test JPEG: %v", err)
|
t.Fatalf("failed to encode test JPEG: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,14 +118,16 @@ func generateTestPNG(t *testing.T, width, height int, c color.Color) []byte {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
for y := 0; y < height; y++ {
|
for y := range height {
|
||||||
for x := 0; x < width; x++ {
|
for x := range width {
|
||||||
img.Set(x, y, c)
|
img.Set(x, y, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := png.Encode(&buf, img); err != nil {
|
|
||||||
|
err := png.Encode(&buf, img)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("failed to encode test PNG: %v", err)
|
t.Fatalf("failed to encode test PNG: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,15 +138,20 @@ func generateTestPNG(t *testing.T, width, height int, c color.Color) []byte {
|
|||||||
func generateTestGIF(t *testing.T, width, height int, c color.Color) []byte {
|
func generateTestGIF(t *testing.T, width, height int, c color.Color) []byte {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
img := image.NewPaletted(image.Rect(0, 0, width, height), []color.Color{c, color.White})
|
img := image.NewPaletted(
|
||||||
for y := 0; y < height; y++ {
|
image.Rect(0, 0, width, height),
|
||||||
for x := 0; x < width; x++ {
|
[]color.Color{c, color.White},
|
||||||
|
)
|
||||||
|
for y := range height {
|
||||||
|
for x := range width {
|
||||||
img.SetColorIndex(x, y, 0)
|
img.SetColorIndex(x, y, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := gif.Encode(&buf, img, nil); err != nil {
|
|
||||||
|
err := gif.Encode(&buf, img, nil)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("failed to encode test GIF: %v", err)
|
t.Fatalf("failed to encode test GIF: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,13 +159,15 @@ func generateTestGIF(t *testing.T, width, height int, c color.Color) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetupTestService creates a Service with mock fetcher for testing.
|
// SetupTestService creates a Service with mock fetcher for testing.
|
||||||
func SetupTestService(t *testing.T, opts ...TestServiceOption) (*Service, *TestFixtures) {
|
func SetupTestService(
|
||||||
|
t *testing.T, opts ...TestServiceOption,
|
||||||
|
) (*Service, *TestFixtures) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
mockFS, fixtures := NewTestFS(t)
|
mockFS, fixtures := NewTestFS(t)
|
||||||
|
|
||||||
cfg := &testServiceConfig{
|
cfg := &testServiceConfig{
|
||||||
whitelist: []string{fixtures.GoodHost},
|
allowlist: []string{fixtures.GoodHost},
|
||||||
signingKey: "test-signing-key-must-be-32-chars",
|
signingKey: "test-signing-key-must-be-32-chars",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,9 +192,9 @@ func SetupTestService(t *testing.T, opts ...TestServiceOption) (*Service, *TestF
|
|||||||
|
|
||||||
svc, err := NewService(&ServiceConfig{
|
svc, err := NewService(&ServiceConfig{
|
||||||
Cache: cache,
|
Cache: cache,
|
||||||
Fetcher: NewMockFetcher(mockFS),
|
Fetcher: httpfetcher.NewMock(mockFS),
|
||||||
SigningKey: cfg.signingKey,
|
SigningKey: cfg.signingKey,
|
||||||
Whitelist: cfg.whitelist,
|
Allowlist: cfg.allowlist,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create service: %v", err)
|
t.Fatalf("failed to create service: %v", err)
|
||||||
@@ -193,7 +214,8 @@ func setupServiceTestDB(t *testing.T) *sql.DB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use the real production schema via migrations
|
// Use the real production schema via migrations
|
||||||
if err := database.ApplyMigrations(db); err != nil {
|
err = database.ApplyMigrations(context.Background(), db, nil)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("failed to apply migrations: %v", err)
|
t.Fatalf("failed to apply migrations: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,17 +223,17 @@ func setupServiceTestDB(t *testing.T) *sql.DB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type testServiceConfig struct {
|
type testServiceConfig struct {
|
||||||
whitelist []string
|
allowlist []string
|
||||||
signingKey string
|
signingKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestServiceOption configures the test service.
|
// TestServiceOption configures the test service.
|
||||||
type TestServiceOption func(*testServiceConfig)
|
type TestServiceOption func(*testServiceConfig)
|
||||||
|
|
||||||
// WithWhitelist sets the whitelist for the test service.
|
// WithAllowlist sets the allowlist for the test service.
|
||||||
func WithWhitelist(hosts ...string) TestServiceOption {
|
func WithAllowlist(hosts ...string) TestServiceOption {
|
||||||
return func(c *testServiceConfig) {
|
return func(c *testServiceConfig) {
|
||||||
c.whitelist = hosts
|
c.allowlist = hosts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,9 +244,9 @@ func WithSigningKey(key string) TestServiceOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithNoWhitelist removes all whitelisted hosts.
|
// WithNoAllowlist removes all allowlisted hosts.
|
||||||
func WithNoWhitelist() TestServiceOption {
|
func WithNoAllowlist() TestServiceOption {
|
||||||
return func(c *testServiceConfig) {
|
return func(c *testServiceConfig) {
|
||||||
c.whitelist = nil
|
c.allowlist = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,8 @@ type ParsedURL struct {
|
|||||||
Format ImageFormat
|
Format ImageFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseImagePath parses the path captured by chi's wildcard: <host>/<path>/<size>.<format>
|
// ParseImagePath parses the path captured by chi's wildcard:
|
||||||
|
// <host>/<path>/<size>.<format>
|
||||||
// This is the primary entry point when using chi routing.
|
// This is the primary entry point when using chi routing.
|
||||||
// Examples:
|
// Examples:
|
||||||
// - cdn.example.com/photos/cat.jpg/800x600.webp
|
// - cdn.example.com/photos/cat.jpg/800x600.webp
|
||||||
@@ -76,7 +77,8 @@ func ParseImageURL(urlPath string) (*ParsedURL, error) {
|
|||||||
// parseImageComponents parses <host>/<path>/<size>.<format> structure.
|
// parseImageComponents parses <host>/<path>/<size>.<format> structure.
|
||||||
func parseImageComponents(remainder string) (*ParsedURL, error) {
|
func parseImageComponents(remainder string) (*ParsedURL, error) {
|
||||||
// Check for path traversal before any other processing
|
// Check for path traversal before any other processing
|
||||||
if err := checkPathTraversal(remainder); err != nil {
|
err := checkPathTraversal(remainder)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +104,7 @@ func parseImageComponents(remainder string) (*ParsedURL, error) {
|
|||||||
// Split host from path
|
// Split host from path
|
||||||
// The first segment is the host, everything after is the path
|
// The first segment is the host, everything after is the path
|
||||||
firstSlash := strings.Index(hostAndPath, "/")
|
firstSlash := strings.Index(hostAndPath, "/")
|
||||||
|
|
||||||
var host, path, query string
|
var host, path, query string
|
||||||
|
|
||||||
if firstSlash == -1 {
|
if firstSlash == -1 {
|
||||||
@@ -181,8 +184,7 @@ func checkPathTraversal(path string) error {
|
|||||||
|
|
||||||
// Also check for ".." as a path segment in the original path
|
// Also check for ".." as a path segment in the original path
|
||||||
// This catches cases where the path hasn't been normalized
|
// This catches cases where the path hasn't been normalized
|
||||||
segments := strings.Split(path, "/")
|
for seg := range strings.SplitSeq(path, "/") {
|
||||||
for _, seg := range segments {
|
|
||||||
// URL decode the segment
|
// URL decode the segment
|
||||||
decodedSeg, _ := url.PathUnescape(seg)
|
decodedSeg, _ := url.PathUnescape(seg)
|
||||||
decodedSeg = strings.ReplaceAll(decodedSeg, "\\", "/")
|
decodedSeg = strings.ReplaceAll(decodedSeg, "\\", "/")
|
||||||
@@ -202,8 +204,10 @@ func parseSizeFormat(s string) (Size, ImageFormat, error) {
|
|||||||
return Size{}, "", ErrInvalidSize
|
return Size{}, "", ErrInvalidSize
|
||||||
}
|
}
|
||||||
|
|
||||||
var size Size
|
var (
|
||||||
var formatStr string
|
size Size
|
||||||
|
formatStr string
|
||||||
|
)
|
||||||
|
|
||||||
if matches[4] == "orig" {
|
if matches[4] == "orig" {
|
||||||
// "orig.format" pattern
|
// "orig.format" pattern
|
||||||
|
|||||||
@@ -1,93 +1,124 @@
|
|||||||
package imgcache
|
package imgcache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// assertParsedURL compares all fields of a parsed URL against the
|
||||||
|
// expected value.
|
||||||
|
func assertParsedURL(t *testing.T, got, want *ParsedURL) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if got.Host != want.Host {
|
||||||
|
t.Errorf("Host = %q, want %q", got.Host, want.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Path != want.Path {
|
||||||
|
t.Errorf("Path = %q, want %q", got.Path, want.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Query != want.Query {
|
||||||
|
t.Errorf("Query = %q, want %q", got.Query, want.Query)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Size != want.Size {
|
||||||
|
t.Errorf("Size = %v, want %v", got.Size, want.Size)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Format != want.Format {
|
||||||
|
t.Errorf("Format = %q, want %q", got.Format, want.Format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseImageURL(t *testing.T) {
|
func TestParseImageURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
input string
|
||||||
want *ParsedURL
|
want *ParsedURL
|
||||||
wantErr error
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "basic path with size",
|
name: "basic path with size",
|
||||||
input: "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp",
|
input: "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||||
want: &ParsedURL{
|
want: &ParsedURL{
|
||||||
Host: "cdn.example.com",
|
Host: testHostCDN, Path: testPathCat,
|
||||||
Path: "/photos/cat.jpg",
|
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
|
||||||
Query: "",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "original size with 0x0",
|
name: "original size with 0x0",
|
||||||
input: "/v1/image/cdn.example.com/photos/cat.jpg/0x0.jpeg",
|
input: "/v1/image/cdn.example.com/photos/cat.jpg/0x0.jpeg",
|
||||||
want: &ParsedURL{
|
want: &ParsedURL{
|
||||||
Host: "cdn.example.com",
|
Host: testHostCDN, Path: testPathCat,
|
||||||
Path: "/photos/cat.jpg",
|
Size: Size{Width: 0, Height: 0}, Format: FormatJPEG,
|
||||||
Query: "",
|
|
||||||
Size: Size{Width: 0, Height: 0},
|
|
||||||
Format: FormatJPEG,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "original size with orig keyword",
|
name: "original size with orig keyword",
|
||||||
input: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png",
|
input: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png",
|
||||||
want: &ParsedURL{
|
want: &ParsedURL{
|
||||||
Host: "cdn.example.com",
|
Host: testHostCDN, Path: testPathCat,
|
||||||
Path: "/photos/cat.jpg",
|
Size: Size{Width: 0, Height: 0}, Format: FormatPNG,
|
||||||
Query: "",
|
|
||||||
Size: Size{Width: 0, Height: 0},
|
|
||||||
Format: FormatPNG,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "path with query string",
|
name: "path with query string",
|
||||||
input: "/v1/image/cdn.example.com/photos/cat.jpg?arg1=val1&arg2=val2/800x600.webp",
|
input: "/v1/image/cdn.example.com/photos/cat.jpg?arg1=val1&arg2=val2/800x600.webp",
|
||||||
want: &ParsedURL{
|
want: &ParsedURL{
|
||||||
Host: "cdn.example.com",
|
Host: testHostCDN, Path: testPathCat, Query: "arg1=val1&arg2=val2",
|
||||||
Path: "/photos/cat.jpg",
|
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
|
||||||
Query: "arg1=val1&arg2=val2",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "deep nested path",
|
name: "deep nested path",
|
||||||
input: "/v1/image/cdn.example.com/a/b/c/d/image.jpg/1920x1080.avif",
|
input: "/v1/image/cdn.example.com/a/b/c/d/image.jpg/1920x1080.avif",
|
||||||
want: &ParsedURL{
|
want: &ParsedURL{
|
||||||
Host: "cdn.example.com",
|
Host: testHostCDN, Path: "/a/b/c/d/image.jpg",
|
||||||
Path: "/a/b/c/d/image.jpg",
|
Size: Size{Width: 1920, Height: 1080}, Format: FormatAVIF,
|
||||||
Query: "",
|
|
||||||
Size: Size{Width: 1920, Height: 1080},
|
|
||||||
Format: FormatAVIF,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "jpg alias for jpeg",
|
name: "jpg alias for jpeg",
|
||||||
input: "/v1/image/example.com/img.png/100x100.jpg",
|
input: "/v1/image/example.com/img.png/100x100.jpg",
|
||||||
want: &ParsedURL{
|
want: &ParsedURL{
|
||||||
Host: "example.com",
|
Host: testHostExample, Path: "/img.png",
|
||||||
Path: "/img.png",
|
Size: Size{Width: 100, Height: 100}, Format: FormatJPEG,
|
||||||
Query: "",
|
|
||||||
Size: Size{Width: 100, Height: 100},
|
|
||||||
Format: FormatJPEG,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "gif format",
|
name: "gif format",
|
||||||
input: "/v1/image/example.com/animated.gif/200x200.gif",
|
input: "/v1/image/example.com/animated.gif/200x200.gif",
|
||||||
want: &ParsedURL{
|
want: &ParsedURL{
|
||||||
Host: "example.com",
|
Host: testHostExample, Path: "/animated.gif",
|
||||||
Path: "/animated.gif",
|
Size: Size{Width: 200, Height: 200}, Format: FormatGIF,
|
||||||
Query: "",
|
|
||||||
Size: Size{Width: 200, Height: 200},
|
|
||||||
Format: FormatGIF,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got, err := ParseImageURL(tt.input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseImageURL() unexpected error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertParsedURL(t, got, tt.want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseImageURL_Errors(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
{
|
{
|
||||||
name: "missing prefix",
|
name: "missing prefix",
|
||||||
input: "/image/cdn.example.com/photo.jpg/800x600.webp",
|
input: "/image/cdn.example.com/photo.jpg/800x600.webp",
|
||||||
@@ -122,47 +153,23 @@ func TestParseImageURL(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got, err := ParseImageURL(tt.input)
|
t.Parallel()
|
||||||
|
|
||||||
if tt.wantErr != nil {
|
_, err := ParseImageURL(tt.input)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Errorf("ParseImageURL() error = nil, wantErr %v", tt.wantErr)
|
t.Fatalf("ParseImageURL() error = nil, wantErr %v", tt.wantErr)
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !errorIs(err, tt.wantErr) {
|
|
||||||
t.Errorf("ParseImageURL() error = %v, wantErr %v", err, tt.wantErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if !errorIs(err, tt.wantErr) {
|
||||||
t.Errorf("ParseImageURL() unexpected error = %v", err)
|
t.Errorf("ParseImageURL() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if got.Host != tt.want.Host {
|
|
||||||
t.Errorf("Host = %q, want %q", got.Host, tt.want.Host)
|
|
||||||
}
|
|
||||||
if got.Path != tt.want.Path {
|
|
||||||
t.Errorf("Path = %q, want %q", got.Path, tt.want.Path)
|
|
||||||
}
|
|
||||||
if got.Query != tt.want.Query {
|
|
||||||
t.Errorf("Query = %q, want %q", got.Query, tt.want.Query)
|
|
||||||
}
|
|
||||||
if got.Size != tt.want.Size {
|
|
||||||
t.Errorf("Size = %v, want %v", got.Size, tt.want.Size)
|
|
||||||
}
|
|
||||||
if got.Format != tt.want.Format {
|
|
||||||
t.Errorf("Format = %q, want %q", got.Format, tt.want.Format)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseImagePath(t *testing.T) {
|
func TestParseImagePath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// ParseImagePath is for chi wildcard capture (no /v1/image/ prefix)
|
// ParseImagePath is for chi wildcard capture (no /v1/image/ prefix)
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -174,8 +181,8 @@ func TestParseImagePath(t *testing.T) {
|
|||||||
name: "chi wildcard capture",
|
name: "chi wildcard capture",
|
||||||
input: "cdn.example.com/photos/cat.jpg/800x600.webp",
|
input: "cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||||
want: &ParsedURL{
|
want: &ParsedURL{
|
||||||
Host: "cdn.example.com",
|
Host: testHostCDN,
|
||||||
Path: "/photos/cat.jpg",
|
Path: testPathCat,
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
},
|
},
|
||||||
@@ -184,8 +191,8 @@ func TestParseImagePath(t *testing.T) {
|
|||||||
name: "with leading slash from chi",
|
name: "with leading slash from chi",
|
||||||
input: "/cdn.example.com/photos/cat.jpg/800x600.webp",
|
input: "/cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||||
want: &ParsedURL{
|
want: &ParsedURL{
|
||||||
Host: "cdn.example.com",
|
Host: testHostCDN,
|
||||||
Path: "/photos/cat.jpg",
|
Path: testPathCat,
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
},
|
},
|
||||||
@@ -194,35 +201,30 @@ func TestParseImagePath(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
got, err := ParseImagePath(tt.input)
|
got, err := ParseImagePath(tt.input)
|
||||||
if (err != nil) != tt.wantErr {
|
if (err != nil) != tt.wantErr {
|
||||||
t.Errorf("ParseImagePath() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("ParseImagePath() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if got.Host != tt.want.Host {
|
|
||||||
t.Errorf("Host = %q, want %q", got.Host, tt.want.Host)
|
assertParsedURL(t, got, tt.want)
|
||||||
}
|
|
||||||
if got.Path != tt.want.Path {
|
|
||||||
t.Errorf("Path = %q, want %q", got.Path, tt.want.Path)
|
|
||||||
}
|
|
||||||
if got.Size != tt.want.Size {
|
|
||||||
t.Errorf("Size = %v, want %v", got.Size, tt.want.Size)
|
|
||||||
}
|
|
||||||
if got.Format != tt.want.Format {
|
|
||||||
t.Errorf("Format = %q, want %q", got.Format, tt.want.Format)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParsedURL_ToImageRequest(t *testing.T) {
|
func TestParsedURL_ToImageRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
parsed := &ParsedURL{
|
parsed := &ParsedURL{
|
||||||
Host: "cdn.example.com",
|
Host: testHostCDN,
|
||||||
Path: "/photos/cat.jpg",
|
Path: testPathCat,
|
||||||
Query: "version=2",
|
Query: "version=2",
|
||||||
Size: Size{Width: 800, Height: 600},
|
Size: Size{Width: 800, Height: 600},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
@@ -233,21 +235,27 @@ func TestParsedURL_ToImageRequest(t *testing.T) {
|
|||||||
if req.SourceHost != parsed.Host {
|
if req.SourceHost != parsed.Host {
|
||||||
t.Errorf("SourceHost = %q, want %q", req.SourceHost, parsed.Host)
|
t.Errorf("SourceHost = %q, want %q", req.SourceHost, parsed.Host)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.SourcePath != parsed.Path {
|
if req.SourcePath != parsed.Path {
|
||||||
t.Errorf("SourcePath = %q, want %q", req.SourcePath, parsed.Path)
|
t.Errorf("SourcePath = %q, want %q", req.SourcePath, parsed.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.SourceQuery != parsed.Query {
|
if req.SourceQuery != parsed.Query {
|
||||||
t.Errorf("SourceQuery = %q, want %q", req.SourceQuery, parsed.Query)
|
t.Errorf("SourceQuery = %q, want %q", req.SourceQuery, parsed.Query)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Size != parsed.Size {
|
if req.Size != parsed.Size {
|
||||||
t.Errorf("Size = %v, want %v", req.Size, parsed.Size)
|
t.Errorf("Size = %v, want %v", req.Size, parsed.Size)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Format != parsed.Format {
|
if req.Format != parsed.Format {
|
||||||
t.Errorf("Format = %q, want %q", req.Format, parsed.Format)
|
t.Errorf("Format = %q, want %q", req.Format, parsed.Format)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseImageURL_PathTraversal(t *testing.T) {
|
func TestParseImageURL_PathTraversal(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// All path traversal attempts should be rejected
|
// All path traversal attempts should be rejected
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -293,12 +301,14 @@ func TestParseImageURL_PathTraversal(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
_, err := ParseImageURL(tt.input)
|
_, err := ParseImageURL(tt.input)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("ParseImageURL() should reject path traversal attempts")
|
t.Error("ParseImageURL() should reject path traversal attempts")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != ErrPathTraversal {
|
if !errors.Is(err, ErrPathTraversal) {
|
||||||
t.Errorf("ParseImageURL() error = %v, want ErrPathTraversal", err)
|
t.Errorf("ParseImageURL() error = %v, want ErrPathTraversal", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -306,6 +316,8 @@ func TestParseImageURL_PathTraversal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseImagePath_PathTraversal(t *testing.T) {
|
func TestParseImagePath_PathTraversal(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// Test path traversal via ParseImagePath (chi wildcard)
|
// Test path traversal via ParseImagePath (chi wildcard)
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -323,12 +335,14 @@ func TestParseImagePath_PathTraversal(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
_, err := ParseImagePath(tt.input)
|
_, err := ParseImagePath(tt.input)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("ParseImagePath() should reject path traversal attempts")
|
t.Error("ParseImagePath() should reject path traversal attempts")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != ErrPathTraversal {
|
if !errors.Is(err, ErrPathTraversal) {
|
||||||
t.Errorf("ParseImagePath() error = %v, want ErrPathTraversal", err)
|
t.Errorf("ParseImagePath() error = %v, want ErrPathTraversal", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -337,7 +351,7 @@ func TestParseImagePath_PathTraversal(t *testing.T) {
|
|||||||
|
|
||||||
// errorIs checks if err matches target (handles wrapped errors).
|
// errorIs checks if err matches target (handles wrapped errors).
|
||||||
func errorIs(err, target error) bool {
|
func errorIs(err, target error) bool {
|
||||||
if err == target {
|
if errors.Is(err, target) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Check if error message contains target message for wrapped errors
|
// Check if error message contains target message for wrapped errors
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
// Params defines dependencies for Logger.
|
// Params defines dependencies for Logger.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,12 +41,13 @@ func New(_ fx.Lifecycle, params Params) (*Logger, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// replaceAttr simplifies the source attribute to "file.go:line"
|
// replaceAttr simplifies the source attribute to "file.go:line"
|
||||||
replaceAttr := func(groups []string, a slog.Attr) slog.Attr {
|
replaceAttr := func(_ []string, a slog.Attr) slog.Attr {
|
||||||
if a.Key == slog.SourceKey {
|
if a.Key == slog.SourceKey {
|
||||||
if src, ok := a.Value.Any().(*slog.Source); ok {
|
if src, ok := a.Value.Any().(*slog.Source); ok {
|
||||||
a.Value = slog.StringValue(fmt.Sprintf("%s:%d", filepath.Base(src.File), src.Line))
|
a.Value = slog.StringValue(fmt.Sprintf("%s:%d", filepath.Base(src.File), src.Line))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
package imgcache
|
// Package magic detects image formats from magic bytes and validates
|
||||||
|
// content against declared MIME types.
|
||||||
|
package magic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -27,9 +29,27 @@ const (
|
|||||||
MIMETypeSVG = MIMEType("image/svg+xml")
|
MIMETypeSVG = MIMEType("image/svg+xml")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ImageFormat represents supported output image formats.
|
||||||
|
// This mirrors the type in imgcache to avoid circular imports.
|
||||||
|
type ImageFormat string
|
||||||
|
|
||||||
|
// Supported image output formats.
|
||||||
|
const (
|
||||||
|
FormatOriginal ImageFormat = "orig"
|
||||||
|
FormatJPEG ImageFormat = "jpeg"
|
||||||
|
FormatPNG ImageFormat = "png"
|
||||||
|
FormatWebP ImageFormat = "webp"
|
||||||
|
FormatAVIF ImageFormat = "avif"
|
||||||
|
FormatGIF ImageFormat = "gif"
|
||||||
|
)
|
||||||
|
|
||||||
// MinMagicBytes is the minimum number of bytes needed to detect format.
|
// MinMagicBytes is the minimum number of bytes needed to detect format.
|
||||||
const MinMagicBytes = 12
|
const MinMagicBytes = 12
|
||||||
|
|
||||||
|
// mimeOctetStream is the fallback MIME type for formats without a
|
||||||
|
// specific MIME type.
|
||||||
|
const mimeOctetStream = "application/octet-stream"
|
||||||
|
|
||||||
// Magic byte signatures for supported formats.
|
// Magic byte signatures for supported formats.
|
||||||
// These are effectively constants but Go doesn't support const slices.
|
// These are effectively constants but Go doesn't support const slices.
|
||||||
//
|
//
|
||||||
@@ -174,14 +194,17 @@ func IsSupportedMIMEType(mimeType string) bool {
|
|||||||
func PeekAndValidate(r io.Reader, declaredType string) (io.Reader, error) {
|
func PeekAndValidate(r io.Reader, declaredType string) (io.Reader, error) {
|
||||||
// Read minimum bytes for detection
|
// Read minimum bytes for detection
|
||||||
buf := make([]byte, MinMagicBytes)
|
buf := make([]byte, MinMagicBytes)
|
||||||
|
|
||||||
n, err := io.ReadFull(r, buf)
|
n, err := io.ReadFull(r, buf)
|
||||||
if err != nil && err != io.ErrUnexpectedEOF {
|
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
buf = buf[:n]
|
buf = buf[:n]
|
||||||
|
|
||||||
// Validate magic bytes
|
// Validate magic bytes
|
||||||
if err := ValidateMagicBytes(buf, declaredType); err != nil {
|
err = ValidateMagicBytes(buf, declaredType)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +212,7 @@ func PeekAndValidate(r io.Reader, declaredType string) (io.Reader, error) {
|
|||||||
return io.MultiReader(bytes.NewReader(buf), r), nil
|
return io.MultiReader(bytes.NewReader(buf), r), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MIMEToImageFormat converts a MIME type to our ImageFormat type.
|
// MIMEToImageFormat converts a MIME type to an ImageFormat.
|
||||||
func MIMEToImageFormat(mimeType string) (ImageFormat, bool) {
|
func MIMEToImageFormat(mimeType string) (ImageFormat, bool) {
|
||||||
normalized := normalizeMIMEType(mimeType)
|
normalized := normalizeMIMEType(mimeType)
|
||||||
switch MIMEType(normalized) {
|
switch MIMEType(normalized) {
|
||||||
@@ -203,12 +226,15 @@ func MIMEToImageFormat(mimeType string) (ImageFormat, bool) {
|
|||||||
return FormatGIF, true
|
return FormatGIF, true
|
||||||
case MIMETypeAVIF:
|
case MIMETypeAVIF:
|
||||||
return FormatAVIF, true
|
return FormatAVIF, true
|
||||||
|
case MIMETypeSVG:
|
||||||
|
// SVG has no corresponding output format.
|
||||||
|
return "", false
|
||||||
default:
|
default:
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImageFormatToMIME converts our ImageFormat to a MIME type string.
|
// ImageFormatToMIME converts an ImageFormat to a MIME type string.
|
||||||
func ImageFormatToMIME(format ImageFormat) string {
|
func ImageFormatToMIME(format ImageFormat) string {
|
||||||
switch format {
|
switch format {
|
||||||
case FormatJPEG:
|
case FormatJPEG:
|
||||||
@@ -221,7 +247,10 @@ func ImageFormatToMIME(format ImageFormat) string {
|
|||||||
return string(MIMETypeGIF)
|
return string(MIMETypeGIF)
|
||||||
case FormatAVIF:
|
case FormatAVIF:
|
||||||
return string(MIMETypeAVIF)
|
return string(MIMETypeAVIF)
|
||||||
|
case FormatOriginal:
|
||||||
|
// Original format passes content through unchanged.
|
||||||
|
return mimeOctetStream
|
||||||
default:
|
default:
|
||||||
return "application/octet-stream"
|
return mimeOctetStream
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,122 +1,91 @@
|
|||||||
package imgcache
|
package magic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Shared test fixture strings.
|
||||||
|
const (
|
||||||
|
testNameEmpty = "empty"
|
||||||
|
testMIMEJPEG = "image/jpeg"
|
||||||
|
testMIMEJPEGParams = "image/jpeg; charset=utf-8"
|
||||||
|
testMIMEPNG = "image/png"
|
||||||
|
testMIMEWebP = "image/webp"
|
||||||
|
testMIMEGIF = "image/gif"
|
||||||
|
testMIMEAVIF = "image/avif"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pad appends zero bytes so data is comfortably above MinMagicBytes.
|
||||||
|
func pad(b ...byte) []byte {
|
||||||
|
return append(b, make([]byte, 100)...)
|
||||||
|
}
|
||||||
|
|
||||||
func TestDetectFormat(t *testing.T) {
|
func TestDetectFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
jpeg := pad(0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01)
|
||||||
|
png := pad(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D)
|
||||||
|
gif87a := pad(0x47, 0x49, 0x46, 0x38, 0x37, 0x61, 0, 0, 0, 0, 0, 0)
|
||||||
|
gif89a := pad(0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0, 0, 0, 0, 0, 0)
|
||||||
|
// RIFF + size placeholder + WEBP
|
||||||
|
webp := pad(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50)
|
||||||
|
// box size + ftyp + brand
|
||||||
|
avif := pad(0x00, 0x00, 0x00, 0x1C, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66)
|
||||||
|
avis := pad(0x00, 0x00, 0x00, 0x1C, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x73)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
data []byte
|
data []byte
|
||||||
wantMIME MIMEType
|
wantMIME MIMEType
|
||||||
wantErr error
|
wantErr error
|
||||||
}{
|
}{
|
||||||
{
|
{name: "JPEG", data: jpeg, wantMIME: MIMETypeJPEG},
|
||||||
name: "JPEG",
|
{name: "PNG", data: png, wantMIME: MIMETypePNG},
|
||||||
data: append([]byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01}, make([]byte, 100)...),
|
{name: "GIF87a", data: gif87a, wantMIME: MIMETypeGIF},
|
||||||
wantMIME: MIMETypeJPEG,
|
{name: "GIF89a", data: gif89a, wantMIME: MIMETypeGIF},
|
||||||
wantErr: nil,
|
{name: "WebP", data: webp, wantMIME: MIMETypeWebP},
|
||||||
},
|
{name: "AVIF", data: avif, wantMIME: MIMETypeAVIF},
|
||||||
{
|
{name: "AVIF sequence", data: avis, wantMIME: MIMETypeAVIF},
|
||||||
name: "PNG",
|
|
||||||
data: append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D}, make([]byte, 100)...),
|
|
||||||
wantMIME: MIMETypePNG,
|
|
||||||
wantErr: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "GIF87a",
|
|
||||||
data: append([]byte{0x47, 0x49, 0x46, 0x38, 0x37, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, make([]byte, 100)...),
|
|
||||||
wantMIME: MIMETypeGIF,
|
|
||||||
wantErr: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "GIF89a",
|
|
||||||
data: append([]byte{0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, make([]byte, 100)...),
|
|
||||||
wantMIME: MIMETypeGIF,
|
|
||||||
wantErr: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "WebP",
|
|
||||||
data: append([]byte{
|
|
||||||
0x52, 0x49, 0x46, 0x46, // RIFF
|
|
||||||
0x00, 0x00, 0x00, 0x00, // file size (placeholder)
|
|
||||||
0x57, 0x45, 0x42, 0x50, // WEBP
|
|
||||||
}, make([]byte, 100)...),
|
|
||||||
wantMIME: MIMETypeWebP,
|
|
||||||
wantErr: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "AVIF",
|
|
||||||
data: append([]byte{
|
|
||||||
0x00, 0x00, 0x00, 0x1C, // box size
|
|
||||||
0x66, 0x74, 0x79, 0x70, // ftyp
|
|
||||||
0x61, 0x76, 0x69, 0x66, // avif brand
|
|
||||||
}, make([]byte, 100)...),
|
|
||||||
wantMIME: MIMETypeAVIF,
|
|
||||||
wantErr: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "AVIF sequence",
|
|
||||||
data: append([]byte{
|
|
||||||
0x00, 0x00, 0x00, 0x1C, // box size
|
|
||||||
0x66, 0x74, 0x79, 0x70, // ftyp
|
|
||||||
0x61, 0x76, 0x69, 0x73, // avis brand
|
|
||||||
}, make([]byte, 100)...),
|
|
||||||
wantMIME: MIMETypeAVIF,
|
|
||||||
wantErr: nil,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "SVG with XML declaration",
|
name: "SVG with XML declaration",
|
||||||
data: []byte(`<?xml version="1.0"?><svg></svg>`),
|
data: []byte(`<?xml version="1.0"?><svg></svg>`),
|
||||||
wantMIME: MIMETypeSVG,
|
wantMIME: MIMETypeSVG,
|
||||||
wantErr: nil,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SVG without declaration",
|
name: "SVG without declaration",
|
||||||
data: []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`),
|
data: []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`),
|
||||||
wantMIME: MIMETypeSVG,
|
wantMIME: MIMETypeSVG,
|
||||||
wantErr: nil,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SVG with whitespace",
|
name: "SVG with whitespace",
|
||||||
data: []byte(` <?xml version="1.0"?><svg></svg>`),
|
data: []byte(` <?xml version="1.0"?><svg></svg>`),
|
||||||
wantMIME: MIMETypeSVG,
|
wantMIME: MIMETypeSVG,
|
||||||
wantErr: nil,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SVG with BOM",
|
name: "SVG with BOM",
|
||||||
data: append([]byte{0xEF, 0xBB, 0xBF}, []byte(`<svg></svg>`)...),
|
data: append([]byte{0xEF, 0xBB, 0xBF}, []byte(`<svg></svg>`)...),
|
||||||
wantMIME: MIMETypeSVG,
|
wantMIME: MIMETypeSVG,
|
||||||
wantErr: nil,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "unknown format",
|
name: "unknown format",
|
||||||
data: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
|
data: make([]byte, MinMagicBytes),
|
||||||
wantMIME: "",
|
wantErr: ErrUnknownFormat,
|
||||||
wantErr: ErrUnknownFormat,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "too short",
|
|
||||||
data: []byte{0xFF, 0xD8},
|
|
||||||
wantMIME: "",
|
|
||||||
wantErr: ErrNotEnoughData,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty",
|
|
||||||
data: []byte{},
|
|
||||||
wantMIME: "",
|
|
||||||
wantErr: ErrNotEnoughData,
|
|
||||||
},
|
},
|
||||||
|
{name: "too short", data: []byte{0xFF, 0xD8}, wantErr: ErrNotEnoughData},
|
||||||
|
{name: testNameEmpty, data: []byte{}, wantErr: ErrNotEnoughData},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got, err := DetectFormat(tt.data)
|
t.Parallel()
|
||||||
|
|
||||||
if err != tt.wantErr {
|
got, err := DetectFormat(tt.data)
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
t.Errorf("DetectFormat() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("DetectFormat() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -130,8 +99,10 @@ func TestDetectFormat(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateMagicBytes(t *testing.T) {
|
func TestValidateMagicBytes(t *testing.T) {
|
||||||
jpegData := append([]byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01}, make([]byte, 100)...)
|
t.Parallel()
|
||||||
pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D}, make([]byte, 100)...)
|
|
||||||
|
jpegData := pad(0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01)
|
||||||
|
pngData := pad(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -142,40 +113,42 @@ func TestValidateMagicBytes(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "matching JPEG",
|
name: "matching JPEG",
|
||||||
data: jpegData,
|
data: jpegData,
|
||||||
declaredType: "image/jpeg",
|
declaredType: testMIMEJPEG,
|
||||||
wantErr: nil,
|
wantErr: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "matching JPEG with params",
|
name: "matching JPEG with params",
|
||||||
data: jpegData,
|
data: jpegData,
|
||||||
declaredType: "image/jpeg; charset=utf-8",
|
declaredType: testMIMEJPEGParams,
|
||||||
wantErr: nil,
|
wantErr: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "matching PNG",
|
name: "matching PNG",
|
||||||
data: pngData,
|
data: pngData,
|
||||||
declaredType: "image/png",
|
declaredType: testMIMEPNG,
|
||||||
wantErr: nil,
|
wantErr: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "mismatched type",
|
name: "mismatched type",
|
||||||
data: jpegData,
|
data: jpegData,
|
||||||
declaredType: "image/png",
|
declaredType: testMIMEPNG,
|
||||||
wantErr: ErrMagicByteMismatch,
|
wantErr: ErrMagicByteMismatch,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "unknown data",
|
name: "unknown data",
|
||||||
data: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
|
data: make([]byte, MinMagicBytes),
|
||||||
declaredType: "image/jpeg",
|
declaredType: testMIMEJPEG,
|
||||||
wantErr: ErrUnknownFormat,
|
wantErr: ErrUnknownFormat,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
err := ValidateMagicBytes(tt.data, tt.declaredType)
|
err := ValidateMagicBytes(tt.data, tt.declaredType)
|
||||||
|
|
||||||
if err != tt.wantErr {
|
if !errors.Is(err, tt.wantErr) {
|
||||||
t.Errorf("ValidateMagicBytes() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("ValidateMagicBytes() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -183,27 +156,31 @@ func TestValidateMagicBytes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestIsSupportedMIMEType(t *testing.T) {
|
func TestIsSupportedMIMEType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
mimeType string
|
mimeType string
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{"image/jpeg", true},
|
{testMIMEJPEG, true},
|
||||||
{"image/png", true},
|
{testMIMEPNG, true},
|
||||||
{"image/webp", true},
|
{testMIMEWebP, true},
|
||||||
{"image/gif", true},
|
{testMIMEGIF, true},
|
||||||
{"image/avif", true},
|
{testMIMEAVIF, true},
|
||||||
{"image/svg+xml", true},
|
{"image/svg+xml", true},
|
||||||
{"IMAGE/JPEG", true},
|
{"IMAGE/JPEG", true},
|
||||||
{"image/jpeg; charset=utf-8", true},
|
{testMIMEJPEGParams, true},
|
||||||
{"image/tiff", false},
|
{"image/tiff", false},
|
||||||
{"image/bmp", false},
|
{"image/bmp", false},
|
||||||
{"application/octet-stream", false},
|
{mimeOctetStream, false},
|
||||||
{"text/plain", false},
|
{"text/plain", false},
|
||||||
{"", false},
|
{"", false},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.mimeType, func(t *testing.T) {
|
t.Run(tt.mimeType, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
if got := IsSupportedMIMEType(tt.mimeType); got != tt.want {
|
if got := IsSupportedMIMEType(tt.mimeType); got != tt.want {
|
||||||
t.Errorf("IsSupportedMIMEType(%q) = %v, want %v", tt.mimeType, got, tt.want)
|
t.Errorf("IsSupportedMIMEType(%q) = %v, want %v", tt.mimeType, got, tt.want)
|
||||||
}
|
}
|
||||||
@@ -212,8 +189,16 @@ func TestIsSupportedMIMEType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPeekAndValidate(t *testing.T) {
|
func TestPeekAndValidate(t *testing.T) {
|
||||||
jpegData := append([]byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01}, []byte("rest of jpeg data")...)
|
t.Parallel()
|
||||||
pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D}, []byte("rest of png data")...)
|
|
||||||
|
jpegMagic := []byte{
|
||||||
|
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01,
|
||||||
|
}
|
||||||
|
pngMagic := []byte{
|
||||||
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D,
|
||||||
|
}
|
||||||
|
jpegData := slices.Concat(jpegMagic, []byte("rest of jpeg data"))
|
||||||
|
pngData := slices.Concat(pngMagic, []byte("rest of png data"))
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -225,30 +210,32 @@ func TestPeekAndValidate(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "valid JPEG",
|
name: "valid JPEG",
|
||||||
data: jpegData,
|
data: jpegData,
|
||||||
declaredType: "image/jpeg",
|
declaredType: testMIMEJPEG,
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
wantData: jpegData,
|
wantData: jpegData,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "valid PNG",
|
name: "valid PNG",
|
||||||
data: pngData,
|
data: pngData,
|
||||||
declaredType: "image/png",
|
declaredType: testMIMEPNG,
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
wantData: pngData,
|
wantData: pngData,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "mismatched type",
|
name: "mismatched type",
|
||||||
data: jpegData,
|
data: jpegData,
|
||||||
declaredType: "image/png",
|
declaredType: testMIMEPNG,
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
r := bytes.NewReader(tt.data)
|
t.Parallel()
|
||||||
result, err := PeekAndValidate(r, tt.declaredType)
|
|
||||||
|
|
||||||
|
r := bytes.NewReader(tt.data)
|
||||||
|
|
||||||
|
result, err := PeekAndValidate(r, tt.declaredType)
|
||||||
if tt.wantErr {
|
if tt.wantErr {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("PeekAndValidate() expected error, got nil")
|
t.Error("PeekAndValidate() expected error, got nil")
|
||||||
@@ -272,23 +259,28 @@ func TestPeekAndValidate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !bytes.Equal(got, tt.wantData) {
|
if !bytes.Equal(got, tt.wantData) {
|
||||||
t.Errorf("PeekAndValidate() data mismatch: got %d bytes, want %d bytes", len(got), len(tt.wantData))
|
t.Errorf(
|
||||||
|
"PeekAndValidate() data mismatch: got %d bytes, want %d bytes",
|
||||||
|
len(got), len(tt.wantData),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMIMEToImageFormat(t *testing.T) {
|
func TestMIMEToImageFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
mimeType string
|
mimeType string
|
||||||
wantFormat ImageFormat
|
wantFormat ImageFormat
|
||||||
wantOk bool
|
wantOk bool
|
||||||
}{
|
}{
|
||||||
{"image/jpeg", FormatJPEG, true},
|
{testMIMEJPEG, FormatJPEG, true},
|
||||||
{"image/png", FormatPNG, true},
|
{testMIMEPNG, FormatPNG, true},
|
||||||
{"image/webp", FormatWebP, true},
|
{testMIMEWebP, FormatWebP, true},
|
||||||
{"image/gif", FormatGIF, true},
|
{testMIMEGIF, FormatGIF, true},
|
||||||
{"image/avif", FormatAVIF, true},
|
{testMIMEAVIF, FormatAVIF, true},
|
||||||
{"image/svg+xml", "", false}, // SVG doesn't convert to ImageFormat
|
{"image/svg+xml", "", false}, // SVG doesn't convert to ImageFormat
|
||||||
{"image/tiff", "", false},
|
{"image/tiff", "", false},
|
||||||
{"text/plain", "", false},
|
{"text/plain", "", false},
|
||||||
@@ -296,6 +288,8 @@ func TestMIMEToImageFormat(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.mimeType, func(t *testing.T) {
|
t.Run(tt.mimeType, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
got, ok := MIMEToImageFormat(tt.mimeType)
|
got, ok := MIMEToImageFormat(tt.mimeType)
|
||||||
|
|
||||||
if ok != tt.wantOk {
|
if ok != tt.wantOk {
|
||||||
@@ -310,21 +304,25 @@ func TestMIMEToImageFormat(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestImageFormatToMIME(t *testing.T) {
|
func TestImageFormatToMIME(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
format ImageFormat
|
format ImageFormat
|
||||||
wantMIME string
|
wantMIME string
|
||||||
}{
|
}{
|
||||||
{FormatJPEG, "image/jpeg"},
|
{FormatJPEG, testMIMEJPEG},
|
||||||
{FormatPNG, "image/png"},
|
{FormatPNG, testMIMEPNG},
|
||||||
{FormatWebP, "image/webp"},
|
{FormatWebP, testMIMEWebP},
|
||||||
{FormatGIF, "image/gif"},
|
{FormatGIF, testMIMEGIF},
|
||||||
{FormatAVIF, "image/avif"},
|
{FormatAVIF, testMIMEAVIF},
|
||||||
{FormatOriginal, "application/octet-stream"},
|
{FormatOriginal, mimeOctetStream},
|
||||||
{"unknown", "application/octet-stream"},
|
{"unknown", mimeOctetStream},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(string(tt.format), func(t *testing.T) {
|
t.Run(string(tt.format), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
got := ImageFormatToMIME(tt.format)
|
got := ImageFormatToMIME(tt.format)
|
||||||
|
|
||||||
if got != tt.wantMIME {
|
if got != tt.wantMIME {
|
||||||
@@ -335,19 +333,23 @@ func TestImageFormatToMIME(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeMIMEType(t *testing.T) {
|
func TestNormalizeMIMEType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
input string
|
input string
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"image/jpeg", "image/jpeg"},
|
{testMIMEJPEG, testMIMEJPEG},
|
||||||
{"IMAGE/JPEG", "image/jpeg"},
|
{"IMAGE/JPEG", testMIMEJPEG},
|
||||||
{"image/jpeg; charset=utf-8", "image/jpeg"},
|
{testMIMEJPEGParams, testMIMEJPEG},
|
||||||
{" image/jpeg ", "image/jpeg"},
|
{" image/jpeg ", testMIMEJPEG},
|
||||||
{"image/jpeg; boundary=something", "image/jpeg"},
|
{"image/jpeg; boundary=something", testMIMEJPEG},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.input, func(t *testing.T) {
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
got := normalizeMIMEType(tt.input)
|
got := normalizeMIMEType(tt.input)
|
||||||
|
|
||||||
if got != tt.want {
|
if got != tt.want {
|
||||||
@@ -358,6 +360,8 @@ func TestNormalizeMIMEType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectSVG(t *testing.T) {
|
func TestDetectSVG(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
data string
|
data string
|
||||||
@@ -365,17 +369,24 @@ func TestDetectSVG(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"xml declaration", `<?xml version="1.0"?><svg></svg>`, true},
|
{"xml declaration", `<?xml version="1.0"?><svg></svg>`, true},
|
||||||
{"svg element", `<svg xmlns="http://www.w3.org/2000/svg"></svg>`, true},
|
{"svg element", `<svg xmlns="http://www.w3.org/2000/svg"></svg>`, true},
|
||||||
{"doctype", `<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">`, true},
|
{
|
||||||
|
"doctype",
|
||||||
|
`<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" ` +
|
||||||
|
`"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">`,
|
||||||
|
true,
|
||||||
|
},
|
||||||
{"with whitespace", `
|
{"with whitespace", `
|
||||||
<?xml version="1.0"?><svg></svg>`, true},
|
<?xml version="1.0"?><svg></svg>`, true},
|
||||||
{"uppercase", `<SVG></SVG>`, true},
|
{"uppercase", `<SVG></SVG>`, true},
|
||||||
{"not svg", `<html></html>`, false},
|
{"not svg", `<html></html>`, false},
|
||||||
{"random text", `hello world`, false},
|
{"random text", `hello world`, false},
|
||||||
{"empty", ``, false},
|
{testNameEmpty, ``, false},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
got := detectSVG([]byte(tt.data))
|
got := detectSVG([]byte(tt.data))
|
||||||
|
|
||||||
if got != tt.want {
|
if got != tt.want {
|
||||||
@@ -386,6 +397,8 @@ func TestDetectSVG(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSkipBOM(t *testing.T) {
|
func TestSkipBOM(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
data []byte
|
data []byte
|
||||||
@@ -393,13 +406,15 @@ func TestSkipBOM(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"with BOM", []byte{0xEF, 0xBB, 0xBF, 'h', 'e', 'l', 'l', 'o'}, []byte("hello")},
|
{"with BOM", []byte{0xEF, 0xBB, 0xBF, 'h', 'e', 'l', 'l', 'o'}, []byte("hello")},
|
||||||
{"without BOM", []byte("hello"), []byte("hello")},
|
{"without BOM", []byte("hello"), []byte("hello")},
|
||||||
{"empty", []byte{}, []byte{}},
|
{testNameEmpty, []byte{}, []byte{}},
|
||||||
{"only BOM", []byte{0xEF, 0xBB, 0xBF}, []byte{}},
|
{"only BOM", []byte{0xEF, 0xBB, 0xBF}, []byte{}},
|
||||||
{"partial BOM", []byte{0xEF, 0xBB}, []byte{0xEF, 0xBB}},
|
{"partial BOM", []byte{0xEF, 0xBB}, []byte{0xEF, 0xBB}},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
got := skipBOM(tt.data)
|
got := skipBOM(tt.data)
|
||||||
|
|
||||||
if !bytes.Equal(got, tt.want) {
|
if !bytes.Equal(got, tt.want) {
|
||||||
@@ -410,6 +425,8 @@ func TestSkipBOM(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRealWorldSVGPatterns(t *testing.T) {
|
func TestRealWorldSVGPatterns(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// Test various real-world SVG patterns
|
// Test various real-world SVG patterns
|
||||||
svgPatterns := []string{
|
svgPatterns := []string{
|
||||||
`<?xml version="1.0" encoding="UTF-8"?>
|
`<?xml version="1.0" encoding="UTF-8"?>
|
||||||
@@ -419,7 +436,8 @@ func TestRealWorldSVGPatterns(t *testing.T) {
|
|||||||
`<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
`<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||||
</svg>`,
|
</svg>`,
|
||||||
`<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
`<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" ` +
|
||||||
|
`"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">` + `
|
||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
</svg>`,
|
</svg>`,
|
||||||
}
|
}
|
||||||
@@ -445,6 +463,8 @@ func TestRealWorldSVGPatterns(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectFormatRIFFNotWebP(t *testing.T) {
|
func TestDetectFormatRIFFNotWebP(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// RIFF container but not WebP (e.g., WAV file)
|
// RIFF container but not WebP (e.g., WAV file)
|
||||||
wavData := []byte{
|
wavData := []byte{
|
||||||
0x52, 0x49, 0x46, 0x46, // RIFF
|
0x52, 0x49, 0x46, 0x46, // RIFF
|
||||||
@@ -453,12 +473,14 @@ func TestDetectFormatRIFFNotWebP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err := DetectFormat(wavData)
|
_, err := DetectFormat(wavData)
|
||||||
if err != ErrUnknownFormat {
|
if !errors.Is(err, ErrUnknownFormat) {
|
||||||
t.Errorf("DetectFormat(WAV) error = %v, want %v", err, ErrUnknownFormat)
|
t.Errorf("DetectFormat(WAV) error = %v, want %v", err, ErrUnknownFormat)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectFormatFtypNotAVIF(t *testing.T) {
|
func TestDetectFormatFtypNotAVIF(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// ftyp container but not AVIF (e.g., MP4)
|
// ftyp container but not AVIF (e.g., MP4)
|
||||||
mp4Data := []byte{
|
mp4Data := []byte{
|
||||||
0x00, 0x00, 0x00, 0x1C, // box size
|
0x00, 0x00, 0x00, 0x1C, // box size
|
||||||
@@ -467,20 +489,24 @@ func TestDetectFormatFtypNotAVIF(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err := DetectFormat(mp4Data)
|
_, err := DetectFormat(mp4Data)
|
||||||
if err != ErrUnknownFormat {
|
if !errors.Is(err, ErrUnknownFormat) {
|
||||||
t.Errorf("DetectFormat(MP4) error = %v, want %v", err, ErrUnknownFormat)
|
t.Errorf("DetectFormat(MP4) error = %v, want %v", err, ErrUnknownFormat)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPeekAndValidatePreservesReader(t *testing.T) {
|
func TestPeekAndValidatePreservesReader(t *testing.T) {
|
||||||
// Ensure that after PeekAndValidate, we can read the complete original content
|
t.Parallel()
|
||||||
|
|
||||||
|
// Ensure that after PeekAndValidate, we can read the complete
|
||||||
|
// original content
|
||||||
originalContent := append(
|
originalContent := append(
|
||||||
[]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D},
|
[]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D},
|
||||||
[]byte(strings.Repeat("PNG IDAT chunk data here ", 100))...,
|
[]byte(strings.Repeat("PNG IDAT chunk data here ", 100))...,
|
||||||
)
|
)
|
||||||
|
|
||||||
r := bytes.NewReader(originalContent)
|
r := bytes.NewReader(originalContent)
|
||||||
validated, err := PeekAndValidate(r, "image/png")
|
|
||||||
|
validated, err := PeekAndValidate(r, testMIMEPNG)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("PeekAndValidate() error = %v", err)
|
t.Fatalf("PeekAndValidate() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -492,6 +518,9 @@ func TestPeekAndValidatePreservesReader(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !bytes.Equal(got, originalContent) {
|
if !bytes.Equal(got, originalContent) {
|
||||||
t.Errorf("Content mismatch: got %d bytes, want %d bytes", len(got), len(originalContent))
|
t.Errorf(
|
||||||
|
"Content mismatch: got %d bytes, want %d bytes",
|
||||||
|
len(got), len(originalContent),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,7 @@ const CORSMaxAgeSeconds = 86400
|
|||||||
// Params defines dependencies for Middleware.
|
// Params defines dependencies for Middleware.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
}
|
}
|
||||||
@@ -49,6 +50,7 @@ func ipFromHostPort(hp string) string {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(h) > 0 && h[0] == '[' {
|
if len(h) > 0 && h[0] == '[' {
|
||||||
return h[1 : len(h)-1]
|
return h[1 : len(h)-1]
|
||||||
}
|
}
|
||||||
@@ -58,6 +60,7 @@ func ipFromHostPort(hp string) string {
|
|||||||
|
|
||||||
type loggingResponseWriter struct {
|
type loggingResponseWriter struct {
|
||||||
http.ResponseWriter
|
http.ResponseWriter
|
||||||
|
|
||||||
statusCode int
|
statusCode int
|
||||||
bytesWritten int64
|
bytesWritten int64
|
||||||
}
|
}
|
||||||
@@ -85,6 +88,7 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
|||||||
start := time.Now()
|
start := time.Now()
|
||||||
lrw := newLoggingResponseWriter(w)
|
lrw := newLoggingResponseWriter(w)
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
latency := time.Since(start)
|
latency := time.Since(start)
|
||||||
reqID, _ := ctx.Value(middleware.RequestIDKey).(string)
|
reqID, _ := ctx.Value(middleware.RequestIDKey).(string)
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestSecurityHeaders(t *testing.T) {
|
func TestSecurityHeaders(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// Create middleware instance
|
// Create middleware instance
|
||||||
cfg := &config.Config{}
|
cfg := &config.Config{}
|
||||||
mw := &Middleware{
|
mw := &Middleware{
|
||||||
@@ -26,7 +28,7 @@ func TestSecurityHeaders(t *testing.T) {
|
|||||||
handler := mw.SecurityHeaders()(testHandler)
|
handler := mw.SecurityHeaders()(testHandler)
|
||||||
|
|
||||||
// Make a test request
|
// Make a test request
|
||||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
handler.ServeHTTP(rec, req)
|
handler.ServeHTTP(rec, req)
|
||||||
@@ -44,6 +46,8 @@ func TestSecurityHeaders(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.header, func(t *testing.T) {
|
t.Run(tt.header, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
got := rec.Header().Get(tt.header)
|
got := rec.Header().Get(tt.header)
|
||||||
if got != tt.want {
|
if got != tt.want {
|
||||||
t.Errorf("%s = %q, want %q", tt.header, got, tt.want)
|
t.Errorf("%s = %q, want %q", tt.header, got, tt.want)
|
||||||
@@ -53,6 +57,8 @@ func TestSecurityHeaders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSecurityHeaders_PreservesExistingHeaders(t *testing.T) {
|
func TestSecurityHeaders_PreservesExistingHeaders(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
cfg := &config.Config{}
|
cfg := &config.Config{}
|
||||||
mw := &Middleware{
|
mw := &Middleware{
|
||||||
log: slog.Default(),
|
log: slog.Default(),
|
||||||
@@ -68,7 +74,7 @@ func TestSecurityHeaders_PreservesExistingHeaders(t *testing.T) {
|
|||||||
|
|
||||||
handler := mw.SecurityHeaders()(testHandler)
|
handler := mw.SecurityHeaders()(testHandler)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
handler.ServeHTTP(rec, req)
|
handler.ServeHTTP(rec, req)
|
||||||
@@ -34,7 +34,8 @@ func DeriveKey(masterKey []byte, salt string) ([KeySize]byte, error) {
|
|||||||
|
|
||||||
hkdfReader := hkdf.New(sha256.New, masterKey, []byte(salt), nil)
|
hkdfReader := hkdf.New(sha256.New, masterKey, []byte(salt), nil)
|
||||||
|
|
||||||
if _, err := io.ReadFull(hkdfReader, key[:]); err != nil {
|
_, err := io.ReadFull(hkdfReader, key[:])
|
||||||
|
if err != nil {
|
||||||
return key, ErrKeyDerivation
|
return key, ErrKeyDerivation
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +47,9 @@ func DeriveKey(masterKey []byte, salt string) ([KeySize]byte, error) {
|
|||||||
func Encrypt(key [KeySize]byte, plaintext []byte) (string, error) {
|
func Encrypt(key [KeySize]byte, plaintext []byte) (string, error) {
|
||||||
// Generate random nonce
|
// Generate random nonce
|
||||||
var nonce [NonceSize]byte
|
var nonce [NonceSize]byte
|
||||||
if _, err := rand.Read(nonce[:]); err != nil {
|
|
||||||
|
_, err := rand.Read(nonce[:])
|
||||||
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
package seal
|
package seal_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/seal"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDeriveKey_Consistent(t *testing.T) {
|
func TestDeriveKey_Consistent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
masterKey := []byte("test-master-key-12345")
|
masterKey := []byte("test-master-key-12345")
|
||||||
salt := "test-salt-v1"
|
salt := "test-salt-v1"
|
||||||
|
|
||||||
key1, err := DeriveKey(masterKey, salt)
|
key1, err := seal.DeriveKey(masterKey, salt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("DeriveKey() error = %v", err)
|
t.Fatalf("DeriveKey() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
key2, err := DeriveKey(masterKey, salt)
|
key2, err := seal.DeriveKey(masterKey, salt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("DeriveKey() error = %v", err)
|
t.Fatalf("DeriveKey() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -25,14 +30,16 @@ func TestDeriveKey_Consistent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDeriveKey_DifferentSalts(t *testing.T) {
|
func TestDeriveKey_DifferentSalts(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
masterKey := []byte("test-master-key-12345")
|
masterKey := []byte("test-master-key-12345")
|
||||||
|
|
||||||
key1, err := DeriveKey(masterKey, "salt-1")
|
key1, err := seal.DeriveKey(masterKey, "salt-1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("DeriveKey() error = %v", err)
|
t.Fatalf("DeriveKey() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
key2, err := DeriveKey(masterKey, "salt-2")
|
key2, err := seal.DeriveKey(masterKey, "salt-2")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("DeriveKey() error = %v", err)
|
t.Fatalf("DeriveKey() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -43,14 +50,16 @@ func TestDeriveKey_DifferentSalts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDeriveKey_DifferentMasterKeys(t *testing.T) {
|
func TestDeriveKey_DifferentMasterKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
salt := "test-salt"
|
salt := "test-salt"
|
||||||
|
|
||||||
key1, err := DeriveKey([]byte("master-key-1"), salt)
|
key1, err := seal.DeriveKey([]byte("master-key-1"), salt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("DeriveKey() error = %v", err)
|
t.Fatalf("DeriveKey() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
key2, err := DeriveKey([]byte("master-key-2"), salt)
|
key2, err := seal.DeriveKey([]byte("master-key-2"), salt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("DeriveKey() error = %v", err)
|
t.Fatalf("DeriveKey() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -61,19 +70,21 @@ func TestDeriveKey_DifferentMasterKeys(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestEncryptDecrypt_RoundTrip(t *testing.T) {
|
func TestEncryptDecrypt_RoundTrip(t *testing.T) {
|
||||||
key, err := DeriveKey([]byte("test-key"), "test-salt")
|
t.Parallel()
|
||||||
|
|
||||||
|
key, err := seal.DeriveKey([]byte("test-key"), "test-salt")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("DeriveKey() error = %v", err)
|
t.Fatalf("DeriveKey() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
plaintext := []byte("hello, world! this is a test message.")
|
plaintext := []byte("hello, world! this is a test message.")
|
||||||
|
|
||||||
ciphertext, err := Encrypt(key, plaintext)
|
ciphertext, err := seal.Encrypt(key, plaintext)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Encrypt() error = %v", err)
|
t.Fatalf("Encrypt() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
decrypted, err := Decrypt(key, ciphertext)
|
decrypted, err := seal.Decrypt(key, ciphertext)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Decrypt() error = %v", err)
|
t.Fatalf("Decrypt() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -84,15 +95,17 @@ func TestEncryptDecrypt_RoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestEncryptDecrypt_EmptyPlaintext(t *testing.T) {
|
func TestEncryptDecrypt_EmptyPlaintext(t *testing.T) {
|
||||||
key, _ := DeriveKey([]byte("test-key"), "test-salt")
|
t.Parallel()
|
||||||
|
|
||||||
|
key, _ := seal.DeriveKey([]byte("test-key"), "test-salt")
|
||||||
plaintext := []byte{}
|
plaintext := []byte{}
|
||||||
|
|
||||||
ciphertext, err := Encrypt(key, plaintext)
|
ciphertext, err := seal.Encrypt(key, plaintext)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Encrypt() error = %v", err)
|
t.Fatalf("Encrypt() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
decrypted, err := Decrypt(key, ciphertext)
|
decrypted, err := seal.Decrypt(key, ciphertext)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Decrypt() error = %v", err)
|
t.Fatalf("Decrypt() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -103,31 +116,35 @@ func TestEncryptDecrypt_EmptyPlaintext(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDecrypt_WrongKey(t *testing.T) {
|
func TestDecrypt_WrongKey(t *testing.T) {
|
||||||
key1, _ := DeriveKey([]byte("key-1"), "salt")
|
t.Parallel()
|
||||||
key2, _ := DeriveKey([]byte("key-2"), "salt")
|
|
||||||
|
key1, _ := seal.DeriveKey([]byte("key-1"), "salt")
|
||||||
|
key2, _ := seal.DeriveKey([]byte("key-2"), "salt")
|
||||||
|
|
||||||
plaintext := []byte("secret message")
|
plaintext := []byte("secret message")
|
||||||
|
|
||||||
ciphertext, err := Encrypt(key1, plaintext)
|
ciphertext, err := seal.Encrypt(key1, plaintext)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Encrypt() error = %v", err)
|
t.Fatalf("Encrypt() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = Decrypt(key2, ciphertext)
|
_, err = seal.Decrypt(key2, ciphertext)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Decrypt() should fail with wrong key")
|
t.Error("Decrypt() should fail with wrong key")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != ErrDecryptionFailed {
|
if !errors.Is(err, seal.ErrDecryptionFailed) {
|
||||||
t.Errorf("Decrypt() error = %v, want %v", err, ErrDecryptionFailed)
|
t.Errorf("Decrypt() error = %v, want %v", err, seal.ErrDecryptionFailed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDecrypt_TamperedCiphertext(t *testing.T) {
|
func TestDecrypt_TamperedCiphertext(t *testing.T) {
|
||||||
key, _ := DeriveKey([]byte("test-key"), "test-salt")
|
t.Parallel()
|
||||||
|
|
||||||
|
key, _ := seal.DeriveKey([]byte("test-key"), "test-salt")
|
||||||
plaintext := []byte("secret message")
|
plaintext := []byte("secret message")
|
||||||
|
|
||||||
ciphertext, err := Encrypt(key, plaintext)
|
ciphertext, err := seal.Encrypt(key, plaintext)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Encrypt() error = %v", err)
|
t.Fatalf("Encrypt() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -138,45 +155,51 @@ func TestDecrypt_TamperedCiphertext(t *testing.T) {
|
|||||||
tampered[10] ^= 0x01
|
tampered[10] ^= 0x01
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = Decrypt(key, string(tampered))
|
_, err = seal.Decrypt(key, string(tampered))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Decrypt() should fail with tampered ciphertext")
|
t.Error("Decrypt() should fail with tampered ciphertext")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDecrypt_InvalidBase64(t *testing.T) {
|
func TestDecrypt_InvalidBase64(t *testing.T) {
|
||||||
key, _ := DeriveKey([]byte("test-key"), "test-salt")
|
t.Parallel()
|
||||||
|
|
||||||
_, err := Decrypt(key, "not-valid-base64!!!")
|
key, _ := seal.DeriveKey([]byte("test-key"), "test-salt")
|
||||||
|
|
||||||
|
_, err := seal.Decrypt(key, "not-valid-base64!!!")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Decrypt() should fail with invalid base64")
|
t.Error("Decrypt() should fail with invalid base64")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != ErrInvalidPayload {
|
if !errors.Is(err, seal.ErrInvalidPayload) {
|
||||||
t.Errorf("Decrypt() error = %v, want %v", err, ErrInvalidPayload)
|
t.Errorf("Decrypt() error = %v, want %v", err, seal.ErrInvalidPayload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDecrypt_TooShort(t *testing.T) {
|
func TestDecrypt_TooShort(t *testing.T) {
|
||||||
key, _ := DeriveKey([]byte("test-key"), "test-salt")
|
t.Parallel()
|
||||||
|
|
||||||
|
key, _ := seal.DeriveKey([]byte("test-key"), "test-salt")
|
||||||
|
|
||||||
// Create a base64 string that's too short to contain nonce + auth tag
|
// Create a base64 string that's too short to contain nonce + auth tag
|
||||||
_, err := Decrypt(key, "dG9vLXNob3J0")
|
_, err := seal.Decrypt(key, "dG9vLXNob3J0")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Decrypt() should fail with too-short ciphertext")
|
t.Error("Decrypt() should fail with too-short ciphertext")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != ErrInvalidPayload {
|
if !errors.Is(err, seal.ErrInvalidPayload) {
|
||||||
t.Errorf("Decrypt() error = %v, want %v", err, ErrInvalidPayload)
|
t.Errorf("Decrypt() error = %v, want %v", err, seal.ErrInvalidPayload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEncrypt_ProducesDifferentCiphertexts(t *testing.T) {
|
func TestEncrypt_ProducesDifferentCiphertexts(t *testing.T) {
|
||||||
key, _ := DeriveKey([]byte("test-key"), "test-salt")
|
t.Parallel()
|
||||||
|
|
||||||
|
key, _ := seal.DeriveKey([]byte("test-key"), "test-salt")
|
||||||
plaintext := []byte("same message")
|
plaintext := []byte("same message")
|
||||||
|
|
||||||
ciphertext1, _ := Encrypt(key, plaintext)
|
ciphertext1, _ := seal.Encrypt(key, plaintext)
|
||||||
ciphertext2, _ := Encrypt(key, plaintext)
|
ciphertext2, _ := seal.Encrypt(key, plaintext)
|
||||||
|
|
||||||
if ciphertext1 == ciphertext2 {
|
if ciphertext1 == ciphertext2 {
|
||||||
t.Error("Encrypt() should produce different ciphertexts due to random nonce")
|
t.Error("Encrypt() should produce different ciphertexts due to random nonce")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
@@ -26,8 +27,11 @@ func (s *Server) serveUntilShutdown() {
|
|||||||
s.SetupRoutes()
|
s.SetupRoutes()
|
||||||
|
|
||||||
s.log.Info("http begin listen", "listenaddr", listenAddr)
|
s.log.Info("http begin listen", "listenaddr", listenAddr)
|
||||||
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
||||||
|
err := s.httpServer.ListenAndServe()
|
||||||
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
s.log.Error("listen error", "error", err)
|
s.log.Error("listen error", "error", err)
|
||||||
|
|
||||||
if s.cancelFunc != nil {
|
if s.cancelFunc != nil {
|
||||||
s.cancelFunc()
|
s.cancelFunc()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ func (s *Server) SetupRoutes() {
|
|||||||
s.router.Head("/v1/image/*", s.h.HandleImage())
|
s.router.Head("/v1/image/*", s.h.HandleImage())
|
||||||
|
|
||||||
// Encrypted image URL route
|
// Encrypted image URL route
|
||||||
// The trailing filename (e.g., /img.jpg) is ignored but helps browsers with content type
|
// The trailing filename (e.g., /img.jpg) is ignored but helps
|
||||||
|
// browsers with content type
|
||||||
s.router.Get("/v1/e/{token}/*", s.h.HandleImageEnc())
|
s.router.Get("/v1/e/{token}/*", s.h.HandleImageEnc())
|
||||||
|
|
||||||
// Metrics endpoint with auth
|
// Metrics endpoint with auth
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const (
|
|||||||
// Params defines dependencies for Server.
|
// Params defines dependencies for Server.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
@@ -47,7 +48,6 @@ type Server struct {
|
|||||||
startupTime time.Time
|
startupTime time.Time
|
||||||
exitCode int
|
exitCode int
|
||||||
sentryEnabled bool
|
sentryEnabled bool
|
||||||
ctx context.Context
|
|
||||||
cancelFunc context.CancelFunc
|
cancelFunc context.CancelFunc
|
||||||
httpServer *http.Server
|
httpServer *http.Server
|
||||||
router *chi.Mux
|
router *chi.Mux
|
||||||
@@ -64,9 +64,9 @@ func New(lc fx.Lifecycle, params Params) (*Server, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
OnStart: func(_ context.Context) error {
|
OnStart: func(ctx context.Context) error {
|
||||||
s.startupTime = time.Now()
|
s.startupTime = time.Now()
|
||||||
go s.Run()
|
go s.Run(context.WithoutCancel(ctx))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -83,9 +83,14 @@ func New(lc fx.Lifecycle, params Params) (*Server, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the server.
|
// Run starts the server.
|
||||||
func (s *Server) Run() {
|
func (s *Server) Run(ctx context.Context) {
|
||||||
s.enableSentry()
|
s.enableSentry()
|
||||||
s.serve()
|
s.serve(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaintenanceMode returns whether maintenance mode is enabled.
|
||||||
|
func (s *Server) MaintenanceMode() bool {
|
||||||
|
return s.config.MaintenanceMode
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) enableSentry() {
|
func (s *Server) enableSentry() {
|
||||||
@@ -103,19 +108,24 @@ func (s *Server) enableSentry() {
|
|||||||
s.log.Error("sentry init failure", "error", err)
|
s.log.Error("sentry init failure", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.log.Info("sentry error reporting activated")
|
s.log.Info("sentry error reporting activated")
|
||||||
s.sentryEnabled = true
|
s.sentryEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) serve() int {
|
func (s *Server) serve(ctx context.Context) int {
|
||||||
s.ctx, s.cancelFunc = context.WithCancel(context.Background())
|
ctx, cancelFunc := context.WithCancel(ctx)
|
||||||
|
s.cancelFunc = cancelFunc
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
c := make(chan os.Signal, 1)
|
c := make(chan os.Signal, 1)
|
||||||
|
|
||||||
signal.Ignore(syscall.SIGPIPE)
|
signal.Ignore(syscall.SIGPIPE)
|
||||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
||||||
|
|
||||||
sig := <-c
|
sig := <-c
|
||||||
s.log.Info("signal received", "signal", sig)
|
s.log.Info("signal received", "signal", sig)
|
||||||
|
|
||||||
if s.cancelFunc != nil {
|
if s.cancelFunc != nil {
|
||||||
s.cancelFunc()
|
s.cancelFunc()
|
||||||
}
|
}
|
||||||
@@ -123,19 +133,22 @@ func (s *Server) serve() int {
|
|||||||
|
|
||||||
go s.serveUntilShutdown()
|
go s.serveUntilShutdown()
|
||||||
|
|
||||||
<-s.ctx.Done()
|
<-ctx.Done()
|
||||||
s.cleanShutdown()
|
s.cleanShutdown(ctx)
|
||||||
|
|
||||||
return s.exitCode
|
return s.exitCode
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) cleanShutdown() {
|
func (s *Server) cleanShutdown(ctx context.Context) {
|
||||||
s.exitCode = 0
|
s.exitCode = 0
|
||||||
ctxShutdown, shutdownCancel := context.WithTimeout(context.Background(), ShutdownTimeout)
|
|
||||||
|
ctxShutdown, shutdownCancel := context.WithTimeout(
|
||||||
|
context.WithoutCancel(ctx), ShutdownTimeout)
|
||||||
defer shutdownCancel()
|
defer shutdownCancel()
|
||||||
|
|
||||||
if s.httpServer != nil {
|
if s.httpServer != nil {
|
||||||
if err := s.httpServer.Shutdown(ctxShutdown); err != nil {
|
err := s.httpServer.Shutdown(ctxShutdown)
|
||||||
|
if err != nil {
|
||||||
s.log.Error("server clean shutdown failed", "error", err)
|
s.log.Error("server clean shutdown failed", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,8 +157,3 @@ func (s *Server) cleanShutdown() {
|
|||||||
sentry.Flush(SentryFlushTimeout)
|
sentry.Flush(SentryFlushTimeout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MaintenanceMode returns whether maintenance mode is enabled.
|
|
||||||
func (s *Server) MaintenanceMode() bool {
|
|
||||||
return s.config.MaintenanceMode
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -36,14 +36,16 @@ type Data struct {
|
|||||||
|
|
||||||
// Manager handles session creation and validation using encrypted cookies.
|
// Manager handles session creation and validation using encrypted cookies.
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
sc *securecookie.SecureCookie
|
sc *securecookie.SecureCookie
|
||||||
secure bool // Set Secure flag on cookies (should be true in production)
|
|
||||||
sameSite http.SameSite
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a session manager with keys derived from the signing key.
|
// NewManager creates a session manager with keys derived from the signing key.
|
||||||
// Set secure=true in production to require HTTPS for cookies.
|
//
|
||||||
func NewManager(signingKey string, secure bool) (*Manager, error) {
|
// Session cookies always carry the Secure, HttpOnly, and SameSite=Strict
|
||||||
|
// attributes; this cannot be configured. Browsers treat http://localhost as a
|
||||||
|
// trustworthy origin and accept Secure cookies there, so local development
|
||||||
|
// keeps working.
|
||||||
|
func NewManager(signingKey string) (*Manager, error) {
|
||||||
masterKey := []byte(signingKey)
|
masterKey := []byte(signingKey)
|
||||||
|
|
||||||
// Derive separate keys for HMAC (hash) and encryption (block)
|
// Derive separate keys for HMAC (hash) and encryption (block)
|
||||||
@@ -61,9 +63,7 @@ func NewManager(signingKey string, secure bool) (*Manager, error) {
|
|||||||
sc.MaxAge(int(SessionTTL.Seconds()))
|
sc.MaxAge(int(SessionTTL.Seconds()))
|
||||||
|
|
||||||
return &Manager{
|
return &Manager{
|
||||||
sc: sc,
|
sc: sc,
|
||||||
secure: secure,
|
|
||||||
sameSite: http.SameSiteStrictMode,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,8 +87,8 @@ func (m *Manager) CreateSession(w http.ResponseWriter) error {
|
|||||||
Path: "/",
|
Path: "/",
|
||||||
MaxAge: int(SessionTTL.Seconds()),
|
MaxAge: int(SessionTTL.Seconds()),
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: m.secure,
|
Secure: true,
|
||||||
SameSite: m.sameSite,
|
SameSite: http.SameSiteStrictMode,
|
||||||
})
|
})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -107,7 +107,9 @@ func (m *Manager) ValidateSession(r *http.Request) (*Data, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var data Data
|
var data Data
|
||||||
if err := m.sc.Decode(CookieName, cookie.Value, &data); err != nil {
|
|
||||||
|
err = m.sc.Decode(CookieName, cookie.Value, &data)
|
||||||
|
if err != nil {
|
||||||
return nil, ErrInvalidSession
|
return nil, ErrInvalidSession
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,8 +133,8 @@ func (m *Manager) ClearSession(w http.ResponseWriter) {
|
|||||||
Path: "/",
|
Path: "/",
|
||||||
MaxAge: -1, // Delete immediately
|
MaxAge: -1, // Delete immediately
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: m.secure,
|
Secure: true,
|
||||||
SameSite: m.sameSite,
|
SameSite: http.SameSiteStrictMode,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
91
internal/session/session_cookie_attributes_test.go
Normal file
91
internal/session/session_cookie_attributes_test.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package session_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSessionCookieAttributesAlwaysSecure verifies that every cookie
|
||||||
|
// emitted by the session manager carries HttpOnly, Secure, and a
|
||||||
|
// SameSite mode of Lax or stricter. Session cookies contain the
|
||||||
|
// authentication state and must never be exposed to script (HttpOnly),
|
||||||
|
// sent over plaintext HTTP (Secure), or attached to cross-site
|
||||||
|
// requests (SameSite). Nothing may weaken these attributes.
|
||||||
|
//
|
||||||
|
// This covers both cookie-writing paths: CreateSession (the login
|
||||||
|
// set-cookie path) and ClearSession (the logout delete-cookie path).
|
||||||
|
func TestSessionCookieAttributesAlwaysSecure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mgr, err := session.NewManager("test-signing-key-12345")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewManager() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
writePaths := []struct {
|
||||||
|
name string
|
||||||
|
setCookie func(t *testing.T, w http.ResponseWriter)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "CreateSession",
|
||||||
|
setCookie: func(t *testing.T, w http.ResponseWriter) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
err := mgr.CreateSession(w)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession() error = %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ClearSession",
|
||||||
|
setCookie: func(t *testing.T, w http.ResponseWriter) {
|
||||||
|
t.Helper()
|
||||||
|
mgr.ClearSession(w)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, writePath := range writePaths {
|
||||||
|
t.Run(writePath.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
writePath.setCookie(t, w)
|
||||||
|
|
||||||
|
var sessionCookie *http.Cookie
|
||||||
|
|
||||||
|
for _, c := range w.Result().Cookies() {
|
||||||
|
if c.Name == session.CookieName {
|
||||||
|
sessionCookie = c
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sessionCookie == nil {
|
||||||
|
t.Fatalf("no cookie named %q was set", session.CookieName)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("cookie attributes: HttpOnly=%v Secure=%v SameSite=%v",
|
||||||
|
sessionCookie.HttpOnly, sessionCookie.Secure, sessionCookie.SameSite)
|
||||||
|
|
||||||
|
if !sessionCookie.HttpOnly {
|
||||||
|
t.Error("session cookie must have HttpOnly set")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !sessionCookie.Secure {
|
||||||
|
t.Error("session cookie must have Secure set")
|
||||||
|
}
|
||||||
|
|
||||||
|
if sessionCookie.SameSite != http.SameSiteLaxMode &&
|
||||||
|
sessionCookie.SameSite != http.SameSiteStrictMode {
|
||||||
|
t.Errorf("session cookie SameSite = %v, want Lax (%v) or Strict (%v)",
|
||||||
|
sessionCookie.SameSite, http.SameSiteLaxMode, http.SameSiteStrictMode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,45 +1,55 @@
|
|||||||
package session
|
package session_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestManager_CreateAndValidate(t *testing.T) {
|
func TestManager_CreateAndValidate(t *testing.T) {
|
||||||
mgr, err := NewManager("test-signing-key-12345", false)
|
t.Parallel()
|
||||||
|
|
||||||
|
mgr, err := session.NewManager("test-signing-key-12345")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewManager() error = %v", err)
|
t.Fatalf("NewManager() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a session
|
// Create a session
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
if err := mgr.CreateSession(w); err != nil {
|
|
||||||
|
err = mgr.CreateSession(w)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("CreateSession() error = %v", err)
|
t.Fatalf("CreateSession() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the cookie from response
|
// Extract the cookie from response
|
||||||
resp := w.Result()
|
resp := w.Result()
|
||||||
|
|
||||||
cookies := resp.Cookies()
|
cookies := resp.Cookies()
|
||||||
if len(cookies) == 0 {
|
if len(cookies) == 0 {
|
||||||
t.Fatal("CreateSession() did not set a cookie")
|
t.Fatal("CreateSession() did not set a cookie")
|
||||||
}
|
}
|
||||||
|
|
||||||
var sessionCookie *http.Cookie
|
var sessionCookie *http.Cookie
|
||||||
|
|
||||||
for _, c := range cookies {
|
for _, c := range cookies {
|
||||||
if c.Name == CookieName {
|
if c.Name == session.CookieName {
|
||||||
sessionCookie = c
|
sessionCookie = c
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if sessionCookie == nil {
|
if sessionCookie == nil {
|
||||||
t.Fatalf("CreateSession() did not set cookie named %q", CookieName)
|
t.Fatalf("CreateSession() did not set cookie named %q", session.CookieName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the session
|
// Validate the session
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||||
req.AddCookie(sessionCookie)
|
req.AddCookie(sessionCookie)
|
||||||
|
|
||||||
data, err := mgr.ValidateSession(req)
|
data, err := mgr.ValidateSession(req)
|
||||||
@@ -57,27 +67,34 @@ func TestManager_CreateAndValidate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_ValidateSession_NoCookie(t *testing.T) {
|
func TestManager_ValidateSession_NoCookie(t *testing.T) {
|
||||||
mgr, _ := NewManager("test-signing-key-12345", false)
|
t.Parallel()
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
mgr, _ := session.NewManager("test-signing-key-12345")
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||||
|
|
||||||
_, err := mgr.ValidateSession(req)
|
_, err := mgr.ValidateSession(req)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("ValidateSession() should fail with no cookie")
|
t.Error("ValidateSession() should fail with no cookie")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != ErrNoSession {
|
if !errors.Is(err, session.ErrNoSession) {
|
||||||
t.Errorf("ValidateSession() error = %v, want %v", err, ErrNoSession)
|
t.Errorf("ValidateSession() error = %v, want %v", err, session.ErrNoSession)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_ValidateSession_TamperedCookie(t *testing.T) {
|
func TestManager_ValidateSession_TamperedCookie(t *testing.T) {
|
||||||
mgr, _ := NewManager("test-signing-key-12345", false)
|
t.Parallel()
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
mgr, _ := session.NewManager("test-signing-key-12345")
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||||
req.AddCookie(&http.Cookie{
|
req.AddCookie(&http.Cookie{
|
||||||
Name: CookieName,
|
Name: session.CookieName,
|
||||||
Value: "tampered-invalid-cookie-value",
|
Value: "tampered-invalid-cookie-value",
|
||||||
|
Secure: true,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
})
|
})
|
||||||
|
|
||||||
_, err := mgr.ValidateSession(req)
|
_, err := mgr.ValidateSession(req)
|
||||||
@@ -85,30 +102,35 @@ func TestManager_ValidateSession_TamperedCookie(t *testing.T) {
|
|||||||
t.Error("ValidateSession() should fail with tampered cookie")
|
t.Error("ValidateSession() should fail with tampered cookie")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != ErrInvalidSession {
|
if !errors.Is(err, session.ErrInvalidSession) {
|
||||||
t.Errorf("ValidateSession() error = %v, want %v", err, ErrInvalidSession)
|
t.Errorf("ValidateSession() error = %v, want %v", err, session.ErrInvalidSession)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_ValidateSession_WrongKey(t *testing.T) {
|
func TestManager_ValidateSession_WrongKey(t *testing.T) {
|
||||||
mgr1, _ := NewManager("signing-key-1", false)
|
t.Parallel()
|
||||||
mgr2, _ := NewManager("signing-key-2", false)
|
|
||||||
|
mgr1, _ := session.NewManager("signing-key-1")
|
||||||
|
mgr2, _ := session.NewManager("signing-key-2")
|
||||||
|
|
||||||
// Create session with mgr1
|
// Create session with mgr1
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
_ = mgr1.CreateSession(w)
|
_ = mgr1.CreateSession(w)
|
||||||
|
|
||||||
resp := w.Result()
|
resp := w.Result()
|
||||||
|
|
||||||
var sessionCookie *http.Cookie
|
var sessionCookie *http.Cookie
|
||||||
|
|
||||||
for _, c := range resp.Cookies() {
|
for _, c := range resp.Cookies() {
|
||||||
if c.Name == CookieName {
|
if c.Name == session.CookieName {
|
||||||
sessionCookie = c
|
sessionCookie = c
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to validate with mgr2 (different key)
|
// Try to validate with mgr2 (different key)
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||||
req.AddCookie(sessionCookie)
|
req.AddCookie(sessionCookie)
|
||||||
|
|
||||||
_, err := mgr2.ValidateSession(req)
|
_, err := mgr2.ValidateSession(req)
|
||||||
@@ -118,7 +140,9 @@ func TestManager_ValidateSession_WrongKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_ClearSession(t *testing.T) {
|
func TestManager_ClearSession(t *testing.T) {
|
||||||
mgr, _ := NewManager("test-signing-key-12345", false)
|
t.Parallel()
|
||||||
|
|
||||||
|
mgr, _ := session.NewManager("test-signing-key-12345")
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
mgr.ClearSession(w)
|
mgr.ClearSession(w)
|
||||||
@@ -127,9 +151,11 @@ func TestManager_ClearSession(t *testing.T) {
|
|||||||
cookies := resp.Cookies()
|
cookies := resp.Cookies()
|
||||||
|
|
||||||
var sessionCookie *http.Cookie
|
var sessionCookie *http.Cookie
|
||||||
|
|
||||||
for _, c := range cookies {
|
for _, c := range cookies {
|
||||||
if c.Name == CookieName {
|
if c.Name == session.CookieName {
|
||||||
sessionCookie = c
|
sessionCookie = c
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,10 +170,12 @@ func TestManager_ClearSession(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_IsAuthenticated(t *testing.T) {
|
func TestManager_IsAuthenticated(t *testing.T) {
|
||||||
mgr, _ := NewManager("test-signing-key-12345", false)
|
t.Parallel()
|
||||||
|
|
||||||
|
mgr, _ := session.NewManager("test-signing-key-12345")
|
||||||
|
|
||||||
// No session - should return false
|
// No session - should return false
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||||
if mgr.IsAuthenticated(req) {
|
if mgr.IsAuthenticated(req) {
|
||||||
t.Error("IsAuthenticated() should return false with no session")
|
t.Error("IsAuthenticated() should return false with no session")
|
||||||
}
|
}
|
||||||
@@ -157,16 +185,19 @@ func TestManager_IsAuthenticated(t *testing.T) {
|
|||||||
_ = mgr.CreateSession(w)
|
_ = mgr.CreateSession(w)
|
||||||
|
|
||||||
resp := w.Result()
|
resp := w.Result()
|
||||||
|
|
||||||
var sessionCookie *http.Cookie
|
var sessionCookie *http.Cookie
|
||||||
|
|
||||||
for _, c := range resp.Cookies() {
|
for _, c := range resp.Cookies() {
|
||||||
if c.Name == CookieName {
|
if c.Name == session.CookieName {
|
||||||
sessionCookie = c
|
sessionCookie = c
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// With valid session - should return true
|
// With valid session - should return true
|
||||||
req = httptest.NewRequest(http.MethodGet, "/", nil)
|
req = httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||||
req.AddCookie(sessionCookie)
|
req.AddCookie(sessionCookie)
|
||||||
|
|
||||||
if !mgr.IsAuthenticated(req) {
|
if !mgr.IsAuthenticated(req) {
|
||||||
@@ -175,17 +206,21 @@ func TestManager_IsAuthenticated(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_CookieAttributes(t *testing.T) {
|
func TestManager_CookieAttributes(t *testing.T) {
|
||||||
// Test with secure=true
|
t.Parallel()
|
||||||
mgr, _ := NewManager("test-key", true)
|
|
||||||
|
mgr, _ := session.NewManager("test-key")
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
_ = mgr.CreateSession(w)
|
_ = mgr.CreateSession(w)
|
||||||
|
|
||||||
resp := w.Result()
|
resp := w.Result()
|
||||||
|
|
||||||
var sessionCookie *http.Cookie
|
var sessionCookie *http.Cookie
|
||||||
|
|
||||||
for _, c := range resp.Cookies() {
|
for _, c := range resp.Cookies() {
|
||||||
if c.Name == CookieName {
|
if c.Name == session.CookieName {
|
||||||
sessionCookie = c
|
sessionCookie = c
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,6 +234,7 @@ func TestManager_CookieAttributes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if sessionCookie.SameSite != http.SameSiteStrictMode {
|
if sessionCookie.SameSite != http.SameSiteStrictMode {
|
||||||
t.Errorf("Cookie SameSite = %v, want %v", sessionCookie.SameSite, http.SameSiteStrictMode)
|
t.Errorf("Cookie SameSite = %v, want %v",
|
||||||
|
sessionCookie.SameSite, http.SameSiteStrictMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
119
internal/signature/golden_test.go
Normal file
119
internal/signature/golden_test.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package signature_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
|
)
|
||||||
|
|
||||||
|
// goldenExpiresUnix is the fixed expiration timestamp used by all golden
|
||||||
|
// vectors: 2024-01-01T00:00:00Z.
|
||||||
|
const goldenExpiresUnix int64 = 1704067200
|
||||||
|
|
||||||
|
// goldenSigningKey is the fixed signing key used by all golden vectors.
|
||||||
|
const goldenSigningKey = "golden-test-key"
|
||||||
|
|
||||||
|
type goldenVector struct {
|
||||||
|
name string
|
||||||
|
req signature.Request
|
||||||
|
// wantSignature is the exact base64url (RFC 4648 URL-safe,
|
||||||
|
// padded) HMAC-SHA256 signature for the request with Expires
|
||||||
|
// set to goldenExpiresUnix.
|
||||||
|
wantSignature string
|
||||||
|
// wantSignedPath is the exact path returned by
|
||||||
|
// GenerateSignedURL for the request. The signature and
|
||||||
|
// expiration are returned separately by GenerateSignedURL and
|
||||||
|
// are not embedded in the path.
|
||||||
|
wantSignedPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// goldenVectors returns the known-answer vectors. The expected values
|
||||||
|
// were computed once and are hardcoded here.
|
||||||
|
func goldenVectors() []goldenVector {
|
||||||
|
return []goldenVector{
|
||||||
|
{
|
||||||
|
name: "resized without query",
|
||||||
|
req: signature.Request{
|
||||||
|
SourceHost: testHost,
|
||||||
|
SourcePath: testPath,
|
||||||
|
SourceQuery: "",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: testFormatWebP,
|
||||||
|
},
|
||||||
|
// Signed data: "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200"
|
||||||
|
wantSignature: "x5PfPp8QSDo0cJT96od-AEgrQyOVLfqifH5sst61_-w=",
|
||||||
|
wantSignedPath: testSignedPath,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resized with query string",
|
||||||
|
req: signature.Request{
|
||||||
|
SourceHost: testHost,
|
||||||
|
SourcePath: testPath,
|
||||||
|
SourceQuery: "token=abc&v=2",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: testFormatWebP,
|
||||||
|
},
|
||||||
|
// Signed data:
|
||||||
|
// "cdn.example.com:/photos/cat.jpg:token=abc&v=2:800:600:webp:1704067200"
|
||||||
|
wantSignature: "394_Vf9TdQFkpQ3XKFDQSyxgqKq8N7mApf2S4QaHqyo=",
|
||||||
|
wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg" +
|
||||||
|
"%3Ftoken=abc&v=2/800x600.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "original size without query",
|
||||||
|
req: signature.Request{
|
||||||
|
SourceHost: testHost,
|
||||||
|
SourcePath: testPath,
|
||||||
|
SourceQuery: "",
|
||||||
|
Width: 0,
|
||||||
|
Height: 0,
|
||||||
|
Format: testFormatPNG,
|
||||||
|
},
|
||||||
|
// Signed data: "cdn.example.com:/photos/cat.jpg::0:0:png:1704067200"
|
||||||
|
wantSignature: "7Be7oteeQwvnSPU4bchyQ4ZGYGsAGBKpeEtuQ02ox60=",
|
||||||
|
wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSigner_GoldenVectors pins the exact HMAC-SHA256 signature output and
|
||||||
|
// the exact generated signed URL path for fully-specified requests with a
|
||||||
|
// hardcoded signing key.
|
||||||
|
//
|
||||||
|
// If any of these assertions fail, the signed byte format
|
||||||
|
// ("host:path:query:width:height:format:expiration"), the base64url
|
||||||
|
// encoding, or the signed URL layout has changed. Such a change breaks
|
||||||
|
// every signature already issued to clients, so it must be made
|
||||||
|
// deliberately: update these constants only as part of an intentional,
|
||||||
|
// documented signature format migration.
|
||||||
|
func TestSigner_GoldenVectors(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
signer := signature.New(goldenSigningKey)
|
||||||
|
|
||||||
|
for _, tt := range goldenVectors() {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
signReq := tt.req
|
||||||
|
signReq.Expires = time.Unix(goldenExpiresUnix, 0)
|
||||||
|
|
||||||
|
gotSignature := signer.Sign(&signReq)
|
||||||
|
if gotSignature != tt.wantSignature {
|
||||||
|
t.Errorf("Sign() = %q, want %q (signed byte format changed?)",
|
||||||
|
gotSignature, tt.wantSignature)
|
||||||
|
}
|
||||||
|
|
||||||
|
urlReq := tt.req
|
||||||
|
|
||||||
|
gotPath, _, _ := signer.GenerateSignedURL(&urlReq, time.Hour)
|
||||||
|
if gotPath != tt.wantSignedPath {
|
||||||
|
t.Errorf("GenerateSignedURL() path = %q, want %q (layout changed?)",
|
||||||
|
gotPath, tt.wantSignedPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
172
internal/signature/signature.go
Normal file
172
internal/signature/signature.go
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
// Package signature provides HMAC-SHA256 signing and verification of image
|
||||||
|
// requests.
|
||||||
|
package signature
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Signature errors.
|
||||||
|
var (
|
||||||
|
ErrRequired = errors.New("signature required for non-allowlisted host")
|
||||||
|
ErrInvalid = errors.New("invalid signature")
|
||||||
|
ErrExpired = errors.New("signature has expired")
|
||||||
|
ErrMissingExpiration = errors.New("signature expiration is required")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Request carries the components an image request signature covers. It is a
|
||||||
|
// standalone type so that this package does not depend on imgcache, keeping
|
||||||
|
// the import edge one-way (imgcache depends on signature, never the reverse).
|
||||||
|
type Request struct {
|
||||||
|
// SourceHost is the origin host (e.g. "cdn.example.com").
|
||||||
|
SourceHost string
|
||||||
|
// SourcePath is the path on the origin (e.g. "/photos/cat.jpg").
|
||||||
|
SourcePath string
|
||||||
|
// SourceQuery is the optional query string for the origin URL.
|
||||||
|
SourceQuery string
|
||||||
|
// Width is the requested output width in pixels.
|
||||||
|
Width int
|
||||||
|
// Height is the requested output height in pixels.
|
||||||
|
Height int
|
||||||
|
// Format is the requested output format (e.g. "webp").
|
||||||
|
Format string
|
||||||
|
// Signature is the HMAC signature to verify.
|
||||||
|
Signature string
|
||||||
|
// Expires is the signature expiration timestamp.
|
||||||
|
Expires time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signer handles HMAC-SHA256 signature generation and verification.
|
||||||
|
type Signer struct {
|
||||||
|
secretKey []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Signer with the given secret key.
|
||||||
|
func New(secretKey string) *Signer {
|
||||||
|
return &Signer{
|
||||||
|
secretKey: []byte(secretKey),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign generates an HMAC-SHA256 signature for the given request.
|
||||||
|
// The signature covers: host + path + query + width + height + format + expiration.
|
||||||
|
func (s *Signer) Sign(req *Request) string {
|
||||||
|
data := s.buildSignatureData(req)
|
||||||
|
mac := hmac.New(sha256.New, s.secretKey)
|
||||||
|
mac.Write([]byte(data))
|
||||||
|
sig := mac.Sum(nil)
|
||||||
|
|
||||||
|
return base64.URLEncoding.EncodeToString(sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checks if the signature on the request is valid and not expired.
|
||||||
|
// Signatures are exact-match only: every component of the signed data
|
||||||
|
// (host, path, query, dimensions, format, expiration) must match exactly.
|
||||||
|
// No suffix matching, wildcard matching, or partial matching is supported.
|
||||||
|
// A signature for "cdn.example.com" will NOT verify for "example.com" or
|
||||||
|
// "other.cdn.example.com", and vice versa.
|
||||||
|
func (s *Signer) Verify(req *Request) error {
|
||||||
|
// Check expiration first
|
||||||
|
if req.Expires.IsZero() {
|
||||||
|
return ErrMissingExpiration
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(req.Expires) {
|
||||||
|
return ErrExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute expected signature
|
||||||
|
expected := s.Sign(req)
|
||||||
|
|
||||||
|
// Constant-time comparison to prevent timing attacks
|
||||||
|
if !hmac.Equal([]byte(req.Signature), []byte(expected)) {
|
||||||
|
return ErrInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateSignedURL creates a complete URL with signature and expiration.
|
||||||
|
// Returns the path portion that should be appended to the base URL.
|
||||||
|
func (s *Signer) GenerateSignedURL(
|
||||||
|
req *Request, ttl time.Duration,
|
||||||
|
) (string, string, int64) {
|
||||||
|
// Set expiration
|
||||||
|
req.Expires = time.Now().Add(ttl)
|
||||||
|
exp := req.Expires.Unix()
|
||||||
|
|
||||||
|
// Generate signature
|
||||||
|
sig := s.Sign(req)
|
||||||
|
req.Signature = sig
|
||||||
|
|
||||||
|
// Build the size component
|
||||||
|
var sizeStr string
|
||||||
|
if req.Width == 0 && req.Height == 0 {
|
||||||
|
sizeStr = "orig"
|
||||||
|
} else {
|
||||||
|
sizeStr = fmt.Sprintf("%dx%d", req.Width, req.Height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the path.
|
||||||
|
// When a source query is present, it is embedded as a path segment
|
||||||
|
// (e.g. /host/path?query/size.fmt) so that the URL parser can extract
|
||||||
|
// it from the last-slash split. The "?" inside a path segment is
|
||||||
|
// percent-encoded by clients but chi delivers it decoded, which is
|
||||||
|
// exactly what the URL parser expects.
|
||||||
|
var path string
|
||||||
|
if req.SourceQuery != "" {
|
||||||
|
path = fmt.Sprintf("/v1/image/%s%s%%3F%s/%s.%s",
|
||||||
|
req.SourceHost,
|
||||||
|
req.SourcePath,
|
||||||
|
url.PathEscape(req.SourceQuery),
|
||||||
|
sizeStr,
|
||||||
|
req.Format,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
path = fmt.Sprintf("/v1/image/%s%s/%s.%s",
|
||||||
|
req.SourceHost,
|
||||||
|
req.SourcePath,
|
||||||
|
sizeStr,
|
||||||
|
req.Format,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path, sig, exp
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSignatureData creates the string to be signed.
|
||||||
|
// Format: "host:path:query:width:height:format:expiration"
|
||||||
|
// All components are used verbatim (exact match). No normalization,
|
||||||
|
// suffix matching, or wildcard expansion is performed.
|
||||||
|
func (s *Signer) buildSignatureData(req *Request) string {
|
||||||
|
return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d",
|
||||||
|
req.SourceHost,
|
||||||
|
req.SourcePath,
|
||||||
|
req.SourceQuery,
|
||||||
|
req.Width,
|
||||||
|
req.Height,
|
||||||
|
req.Format,
|
||||||
|
req.Expires.Unix(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseParams extracts signature and expiration from query parameters.
|
||||||
|
func ParseParams(sig, expStr string) (string, time.Time, error) {
|
||||||
|
if expStr == "" {
|
||||||
|
return sig, time.Time{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
expUnix, err := strconv.ParseInt(expStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, fmt.Errorf("invalid expiration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sig, time.Unix(expUnix, 0), nil
|
||||||
|
}
|
||||||
538
internal/signature/signature_test.go
Normal file
538
internal/signature/signature_test.go
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
package signature_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Shared fixture values used across the signature tests.
|
||||||
|
const (
|
||||||
|
testHost = "cdn.example.com"
|
||||||
|
testPath = "/photos/cat.jpg"
|
||||||
|
testFormatWebP = "webp"
|
||||||
|
testFormatPNG = "png"
|
||||||
|
testSignedPath = "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp"
|
||||||
|
testSig = "abc123"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSigner_Sign(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
signer := signature.New("test-secret-key")
|
||||||
|
|
||||||
|
req := &signature.Request{
|
||||||
|
SourceHost: testHost,
|
||||||
|
SourcePath: testPath,
|
||||||
|
SourceQuery: "",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: testFormatWebP,
|
||||||
|
Expires: time.Unix(1704067200, 0), // Fixed timestamp for reproducibility
|
||||||
|
}
|
||||||
|
|
||||||
|
sig1 := signer.Sign(req)
|
||||||
|
sig2 := signer.Sign(req)
|
||||||
|
|
||||||
|
// Same input should produce same signature
|
||||||
|
if sig1 != sig2 {
|
||||||
|
t.Errorf("Sign() produced different signatures for same input: %q vs %q",
|
||||||
|
sig1, sig2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signature should be non-empty
|
||||||
|
if sig1 == "" {
|
||||||
|
t.Error("Sign() produced empty signature")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different input should produce different signature
|
||||||
|
req2 := &signature.Request{
|
||||||
|
SourceHost: testHost,
|
||||||
|
SourcePath: "/photos/dog.jpg", // Different path
|
||||||
|
SourceQuery: "",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: testFormatWebP,
|
||||||
|
Expires: time.Unix(1704067200, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
sig3 := signer.Sign(req2)
|
||||||
|
if sig1 == sig3 {
|
||||||
|
t.Error("Sign() produced same signature for different input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validVerifyRequest returns a fully-populated request that verifies
|
||||||
|
// successfully once signed.
|
||||||
|
func validVerifyRequest() *signature.Request {
|
||||||
|
return &signature.Request{
|
||||||
|
SourceHost: testHost,
|
||||||
|
SourcePath: testPath,
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: testFormatWebP,
|
||||||
|
Expires: time.Now().Add(1 * time.Hour),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type verifyCase struct {
|
||||||
|
name string
|
||||||
|
setup func() *signature.Request
|
||||||
|
wantErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyCases(signer *signature.Signer) []verifyCase {
|
||||||
|
return []verifyCase{
|
||||||
|
{
|
||||||
|
name: "valid signature",
|
||||||
|
setup: func() *signature.Request {
|
||||||
|
req := validVerifyRequest()
|
||||||
|
req.Signature = signer.Sign(req)
|
||||||
|
|
||||||
|
return req
|
||||||
|
},
|
||||||
|
wantErr: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expired signature",
|
||||||
|
setup: func() *signature.Request {
|
||||||
|
req := validVerifyRequest()
|
||||||
|
req.Expires = time.Now().Add(-1 * time.Hour)
|
||||||
|
req.Signature = signer.Sign(req)
|
||||||
|
|
||||||
|
return req
|
||||||
|
},
|
||||||
|
wantErr: signature.ErrExpired,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid signature",
|
||||||
|
setup: func() *signature.Request {
|
||||||
|
req := validVerifyRequest()
|
||||||
|
req.Signature = "invalid-signature"
|
||||||
|
|
||||||
|
return req
|
||||||
|
},
|
||||||
|
wantErr: signature.ErrInvalid,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing expiration",
|
||||||
|
setup: func() *signature.Request {
|
||||||
|
req := validVerifyRequest()
|
||||||
|
req.Expires = time.Time{}
|
||||||
|
req.Signature = "some-signature"
|
||||||
|
|
||||||
|
return req
|
||||||
|
},
|
||||||
|
wantErr: signature.ErrMissingExpiration,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tampered request",
|
||||||
|
setup: func() *signature.Request {
|
||||||
|
req := validVerifyRequest()
|
||||||
|
req.Signature = signer.Sign(req)
|
||||||
|
req.SourcePath = "/photos/secret.jpg"
|
||||||
|
|
||||||
|
return req
|
||||||
|
},
|
||||||
|
wantErr: signature.ErrInvalid,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSigner_Verify(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
signer := signature.New("test-secret-key")
|
||||||
|
|
||||||
|
for _, tt := range verifyCases(signer) {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
req := tt.setup()
|
||||||
|
err := signer.Verify(req)
|
||||||
|
|
||||||
|
if tt.wantErr == nil {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Verify() unexpected error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Errorf("Verify() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type tamperCase struct {
|
||||||
|
name string
|
||||||
|
tamper func(r *signature.Request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// exactMatchTamperCases mutates one signed component per case; every
|
||||||
|
// mutation must cause verification to fail with ErrInvalid.
|
||||||
|
func exactMatchTamperCases() []tamperCase {
|
||||||
|
return []tamperCase{
|
||||||
|
{
|
||||||
|
name: "parent domain does not match subdomain",
|
||||||
|
tamper: func(r *signature.Request) { r.SourceHost = "example.com" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subdomain does not match parent domain",
|
||||||
|
tamper: func(r *signature.Request) { r.SourceHost = "images.cdn.example.com" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sibling subdomain does not match",
|
||||||
|
tamper: func(r *signature.Request) { r.SourceHost = "images.example.com" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "host with suffix appended does not match",
|
||||||
|
tamper: func(r *signature.Request) { r.SourceHost = testHost + ".evil.com" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "host with prefix does not match",
|
||||||
|
tamper: func(r *signature.Request) { r.SourceHost = "evilcdn.example.com" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different path does not match",
|
||||||
|
tamper: func(r *signature.Request) { r.SourcePath = "/photos/dog.jpg" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path suffix does not match",
|
||||||
|
tamper: func(r *signature.Request) { r.SourcePath = testPath + "/extra" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path prefix does not match",
|
||||||
|
tamper: func(r *signature.Request) { r.SourcePath = "/other" + testPath },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different query does not match",
|
||||||
|
tamper: func(r *signature.Request) { r.SourceQuery = "token=xyz" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "added query does not match empty query",
|
||||||
|
tamper: func(r *signature.Request) { r.SourceQuery = "extra=1" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "removed query does not match",
|
||||||
|
tamper: func(r *signature.Request) { r.SourceQuery = "" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different width does not match",
|
||||||
|
tamper: func(r *signature.Request) { r.Width = 801 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different height does not match",
|
||||||
|
tamper: func(r *signature.Request) { r.Height = 601 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different format does not match",
|
||||||
|
tamper: func(r *signature.Request) { r.Format = testFormatPNG },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSigner_Verify_ExactMatchOnly verifies that signatures enforce exact
|
||||||
|
// matching on every URL component. No suffix matching, wildcard matching,
|
||||||
|
// or partial matching is supported.
|
||||||
|
func TestSigner_Verify_ExactMatchOnly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
signer := signature.New("test-secret-key")
|
||||||
|
|
||||||
|
// Base request that we'll sign, then tamper with individual fields.
|
||||||
|
baseReq := func() *signature.Request {
|
||||||
|
req := &signature.Request{
|
||||||
|
SourceHost: testHost,
|
||||||
|
SourcePath: testPath,
|
||||||
|
SourceQuery: "token=abc",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: testFormatWebP,
|
||||||
|
Expires: time.Now().Add(1 * time.Hour),
|
||||||
|
}
|
||||||
|
req.Signature = signer.Sign(req)
|
||||||
|
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range exactMatchTamperCases() {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
req := baseReq()
|
||||||
|
tt.tamper(req)
|
||||||
|
|
||||||
|
err := signer.Verify(req)
|
||||||
|
if !errors.Is(err, signature.ErrInvalid) {
|
||||||
|
t.Errorf("Verify() = %v, want %v", err, signature.ErrInvalid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the unmodified base request still passes
|
||||||
|
t.Run("unmodified request passes", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
req := baseReq()
|
||||||
|
|
||||||
|
err := signer.Verify(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Verify() unmodified request failed: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSigner_Sign_ExactHostInData verifies that Sign uses the exact host
|
||||||
|
// string in the signature data, producing different signatures for
|
||||||
|
// suffix-related hosts.
|
||||||
|
func TestSigner_Sign_ExactHostInData(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
signer := signature.New("test-secret-key")
|
||||||
|
|
||||||
|
hosts := []string{
|
||||||
|
testHost,
|
||||||
|
"example.com",
|
||||||
|
"images.example.com",
|
||||||
|
"images.cdn.example.com",
|
||||||
|
"cdn.example.com.evil.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
sigs := make(map[string]string)
|
||||||
|
|
||||||
|
for _, host := range hosts {
|
||||||
|
req := &signature.Request{
|
||||||
|
SourceHost: host,
|
||||||
|
SourcePath: testPath,
|
||||||
|
SourceQuery: "",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: testFormatWebP,
|
||||||
|
Expires: time.Unix(1704067200, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
sig := signer.Sign(req)
|
||||||
|
if existing, ok := sigs[sig]; ok {
|
||||||
|
t.Errorf("hosts %q and %q produced the same signature", existing, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
sigs[sig] = host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSigner_DifferentKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
signer1 := signature.New("secret-key-1")
|
||||||
|
signer2 := signature.New("secret-key-2")
|
||||||
|
|
||||||
|
req := &signature.Request{
|
||||||
|
SourceHost: testHost,
|
||||||
|
SourcePath: testPath,
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: testFormatWebP,
|
||||||
|
Expires: time.Now().Add(1 * time.Hour),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign with key 1
|
||||||
|
req.Signature = signer1.Sign(req)
|
||||||
|
|
||||||
|
// Verify with key 1 should succeed
|
||||||
|
err := signer1.Verify(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Verify() with same key failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify with key 2 should fail
|
||||||
|
err = signer2.Verify(req)
|
||||||
|
if !errors.Is(err, signature.ErrInvalid) {
|
||||||
|
t.Errorf("Verify() with different key should fail, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateSignedURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
signer := signature.New("test-secret-key")
|
||||||
|
|
||||||
|
req := &signature.Request{
|
||||||
|
SourceHost: testHost,
|
||||||
|
SourcePath: testPath,
|
||||||
|
SourceQuery: "",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: testFormatWebP,
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl := 1 * time.Hour
|
||||||
|
path, sig, exp := signer.GenerateSignedURL(req, ttl)
|
||||||
|
|
||||||
|
// Path should be correct format
|
||||||
|
if path != testSignedPath {
|
||||||
|
t.Errorf("GenerateSignedURL() path = %q, want %q", path, testSignedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signature should be non-empty
|
||||||
|
if sig == "" {
|
||||||
|
t.Error("GenerateSignedURL() produced empty signature")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expiration should be approximately now + TTL
|
||||||
|
expTime := time.Unix(exp, 0)
|
||||||
|
|
||||||
|
expectedExp := time.Now().Add(ttl)
|
||||||
|
if expTime.Sub(expectedExp) > time.Second {
|
||||||
|
t.Errorf("GenerateSignedURL() exp time off by too much")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request should have been updated with signature and expiration
|
||||||
|
if req.Signature != sig {
|
||||||
|
t.Errorf("GenerateSignedURL() didn't update request signature")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateSignedURL_OrigSize(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
signer := signature.New("test-secret-key")
|
||||||
|
|
||||||
|
req := &signature.Request{
|
||||||
|
SourceHost: testHost,
|
||||||
|
SourcePath: testPath,
|
||||||
|
Width: 0, // Original size
|
||||||
|
Height: 0,
|
||||||
|
Format: testFormatPNG,
|
||||||
|
}
|
||||||
|
|
||||||
|
path, _, _ := signer.GenerateSignedURL(req, time.Hour)
|
||||||
|
|
||||||
|
expectedPath := "/v1/image/cdn.example.com/photos/cat.jpg/orig.png"
|
||||||
|
if path != expectedPath {
|
||||||
|
t.Errorf("GenerateSignedURL() path = %q, want %q", path, expectedPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateSignedURL_WithQueryString(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
signer := signature.New("test-secret-key-for-testing!")
|
||||||
|
|
||||||
|
req := &signature.Request{
|
||||||
|
SourceHost: testHost,
|
||||||
|
SourcePath: testPath,
|
||||||
|
SourceQuery: "token=abc&v=2",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: testFormatWebP,
|
||||||
|
}
|
||||||
|
|
||||||
|
path, _, _ := signer.GenerateSignedURL(req, time.Hour)
|
||||||
|
|
||||||
|
// The path must NOT contain a bare "?" that would be interpreted as
|
||||||
|
// a query string delimiter. The size segment must appear as the last
|
||||||
|
// path component.
|
||||||
|
if strings.Contains(path, "?token=abc") {
|
||||||
|
t.Errorf("GenerateSignedURL() produced bare query string in path: %q", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The size segment must be present in the path
|
||||||
|
if !strings.Contains(path, "/800x600.webp") {
|
||||||
|
t.Errorf("GenerateSignedURL() missing size segment in path: %q", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path should end with the size.format, not with query params
|
||||||
|
if !strings.HasSuffix(path, "/800x600.webp") {
|
||||||
|
t.Errorf("GenerateSignedURL() path should end with size.format: %q", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateSignedURL_WithoutQueryString(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
signer := signature.New("test-secret-key-for-testing!")
|
||||||
|
|
||||||
|
req := &signature.Request{
|
||||||
|
SourceHost: testHost,
|
||||||
|
SourcePath: testPath,
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: testFormatWebP,
|
||||||
|
}
|
||||||
|
|
||||||
|
path, _, _ := signer.GenerateSignedURL(req, time.Hour)
|
||||||
|
|
||||||
|
if path != testSignedPath {
|
||||||
|
t.Errorf("GenerateSignedURL() path = %q, want %q", path, testSignedPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseParams(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sig string
|
||||||
|
expStr string
|
||||||
|
wantSig string
|
||||||
|
wantErr bool
|
||||||
|
checkTime bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid params",
|
||||||
|
sig: testSig,
|
||||||
|
expStr: "1704067200",
|
||||||
|
wantSig: testSig,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty expiration",
|
||||||
|
sig: testSig,
|
||||||
|
expStr: "",
|
||||||
|
wantSig: testSig,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid expiration",
|
||||||
|
sig: testSig,
|
||||||
|
expStr: "not-a-number",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sig, exp, err := signature.ParseParams(tt.sig, tt.expStr)
|
||||||
|
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Error("ParseParams() expected error, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParseParams() unexpected error = %v", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if sig != tt.wantSig {
|
||||||
|
t.Errorf("sig = %q, want %q", sig, tt.wantSig)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.expStr != "" && exp.IsZero() {
|
||||||
|
t.Error("exp should not be zero when expStr is provided")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
138
script/bootstrap
Executable file
138
script/bootstrap
Executable file
@@ -0,0 +1,138 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/bootstrap: install all dependencies needed to build and develop
|
||||||
|
# this repo. Idempotent: every install is guarded by a check so already
|
||||||
|
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||||
|
# or apk (detected in that order); assumes NOTHING is present (not git,
|
||||||
|
# make, or go). golangci-lint is packaged in nix, brew, and apk; on apt
|
||||||
|
# it is installed from a hash-verified GitHub release archive (never
|
||||||
|
# curl | sh). CGO image libraries (pkg-config, vips, libheif) are
|
||||||
|
# installed for the govips bindings.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
# Pinned versions, 2026-08-07. Never "latest"; exact versions only.
|
||||||
|
GOLANGCI_LINT_VERSION="2.12.2"
|
||||||
|
# sha256 of golangci-lint-2.12.2-linux-<arch>.tar.gz release archives
|
||||||
|
GOLANGCI_LINT_SHA256_AMD64="8df580d2670fed8fa984aac0507099af8df275e665215f5c7a2ae3943893a553"
|
||||||
|
GOLANGCI_LINT_SHA256_ARM64="44cd40a8c76c86755375adfeea52cfd3533cb43d7bd647771e0ae065e166df3a"
|
||||||
|
|
||||||
|
PKGMGR=""
|
||||||
|
SUDO=""
|
||||||
|
|
||||||
|
detect_pkgmgr() {
|
||||||
|
[ -n "$PKGMGR" ] && return 0
|
||||||
|
if command -v nix-env >/dev/null 2>&1; then
|
||||||
|
PKGMGR="nix"
|
||||||
|
elif command -v apt-get >/dev/null 2>&1; then
|
||||||
|
PKGMGR="apt"
|
||||||
|
elif command -v brew >/dev/null 2>&1; then
|
||||||
|
PKGMGR="brew"
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
PKGMGR="apk"
|
||||||
|
else
|
||||||
|
echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$PKGMGR" = "apt" ]; then
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
if [ "$(id -u)" != "0" ]; then
|
||||||
|
SUDO="sudo"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
|
||||||
|
pkg_install() {
|
||||||
|
detect_pkgmgr
|
||||||
|
case "$PKGMGR" in
|
||||||
|
nix) nix-env -iA "nixpkgs.$1" ;;
|
||||||
|
apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;;
|
||||||
|
brew) brew install "$3" ;;
|
||||||
|
apk) apk add --no-cache "$4" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
missing() {
|
||||||
|
! command -v "$1" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# verify_sha256 <file> <expected-hash>
|
||||||
|
verify_sha256() {
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
actual="$(sha256sum "$1" | cut -d' ' -f1)"
|
||||||
|
else
|
||||||
|
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
|
||||||
|
fi
|
||||||
|
if [ "$actual" != "$2" ]; then
|
||||||
|
echo "bootstrap: sha256 mismatch for $1" >&2
|
||||||
|
echo " expected: $2" >&2
|
||||||
|
echo " actual: $actual" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# apt has no golangci-lint package: install a pinned release archive
|
||||||
|
# from GitHub, verified by hardcoded sha256 (never curl | sh).
|
||||||
|
install_golangci_lint_release() {
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64) goarch="amd64"; sha="$GOLANGCI_LINT_SHA256_AMD64" ;;
|
||||||
|
aarch64|arm64) goarch="arm64"; sha="$GOLANGCI_LINT_SHA256_ARM64" ;;
|
||||||
|
*)
|
||||||
|
echo "bootstrap: unsupported architecture $(uname -m)" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if missing curl; then pkg_install curl curl curl curl; fi
|
||||||
|
name="golangci-lint-${GOLANGCI_LINT_VERSION}-linux-${goarch}"
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fsSL -o "$tmp/$name.tar.gz" \
|
||||||
|
"https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_LINT_VERSION}/${name}.tar.gz"
|
||||||
|
verify_sha256 "$tmp/$name.tar.gz" "$sha"
|
||||||
|
tar -xzf "$tmp/$name.tar.gz" -C "$tmp"
|
||||||
|
$SUDO install -m 0755 "$tmp/$name/golangci-lint" /usr/local/bin/golangci-lint
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_golangci_lint() {
|
||||||
|
if ! missing golangci-lint; then return 0; fi
|
||||||
|
detect_pkgmgr
|
||||||
|
case "$PKGMGR" in
|
||||||
|
apt) install_golangci_lint_release ;;
|
||||||
|
*) pkg_install golangci-lint golangci-lint golangci-lint golangci-lint ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# CGO dependencies for govips (image processing)
|
||||||
|
ensure_cgo_deps() {
|
||||||
|
if missing pkg-config; then
|
||||||
|
pkg_install pkg-config pkg-config pkg-config pkgconfig
|
||||||
|
fi
|
||||||
|
if ! pkg-config --exists vips; then
|
||||||
|
pkg_install vips libvips-dev vips vips-dev
|
||||||
|
fi
|
||||||
|
if ! pkg-config --exists libheif; then
|
||||||
|
pkg_install libheif libheif-dev libheif libheif-dev
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
# Base tooling
|
||||||
|
if missing git; then pkg_install git git git git; fi
|
||||||
|
if missing make; then pkg_install gnumake make make make; fi
|
||||||
|
|
||||||
|
# Go toolchain and linter
|
||||||
|
if missing go; then pkg_install go golang go go; fi
|
||||||
|
ensure_golangci_lint
|
||||||
|
|
||||||
|
# CGO image libraries
|
||||||
|
ensure_cgo_deps
|
||||||
|
|
||||||
|
go mod download
|
||||||
|
|
||||||
|
echo "bootstrap complete"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
15
script/check
Executable file
15
script/check
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/check: run all checks (test, lint, fmt-check). Our own
|
||||||
|
# extension to scripts-to-rule-them-all. Must not modify any files.
|
||||||
|
# Generic: usually needs no adaptation.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
"$SCRIPT_DIR/test"
|
||||||
|
"$SCRIPT_DIR/lint"
|
||||||
|
"$SCRIPT_DIR/fmt-check"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
15
script/cibuild
Executable file
15
script/cibuild
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/cibuild: run the CI build. The Dockerfile runs the checks
|
||||||
|
# (make fmt-check, lint, test), so a successful build implies a green
|
||||||
|
# repo. Generic: needs no adaptation. The Gitea workflow runs this on
|
||||||
|
# push.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
docker build .
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
15
script/docker
Executable file
15
script/docker
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/docker: build the Docker image tagged with the project name.
|
||||||
|
# Identical in all repos; the tag comes from script/projectname.
|
||||||
|
# Generic: needs no adaptation.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
docker build -t "$("$SCRIPT_DIR/projectname")" .
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
14
script/fmt
Executable file
14
script/fmt
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/fmt: format all files (writes).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
echo "Formatting code..."
|
||||||
|
# shellcheck disable=SC2046 # word splitting of file list is wanted
|
||||||
|
gofmt -w $(find . -name '*.go' -not -path './vendor/*')
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
18
script/fmt-check
Executable file
18
script/fmt-check
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/fmt-check: check formatting (read-only). Same scope as
|
||||||
|
# script/fmt, but fails instead of writing.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
echo "Checking formatting..."
|
||||||
|
if [ -n "$(gofmt -l . | grep -v '^vendor/')" ]; then
|
||||||
|
echo "Files need formatting:"
|
||||||
|
gofmt -l . | grep -v '^vendor/'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
16
script/install-precommit
Executable file
16
script/install-precommit
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/install-precommit: install the git pre-commit hook that runs
|
||||||
|
# script/precommit. Our own extension to scripts-to-rule-them-all.
|
||||||
|
# Generic: needs no adaptation.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit
|
||||||
|
chmod +x .git/hooks/pre-commit
|
||||||
|
echo "pre-commit hook installed: runs script/precommit"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
23
script/lint
Executable file
23
script/lint
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/lint: run the linter. CGO dependencies (pkg-config, vips,
|
||||||
|
# libheif) come from nix-shell when not already available (e.g. inside
|
||||||
|
# a Docker build or an existing nix-shell).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
run_with_cgo_deps() {
|
||||||
|
if command -v pkg-config >/dev/null 2>&1; then
|
||||||
|
sh -c "$1"
|
||||||
|
else
|
||||||
|
nix-shell -p pkg-config vips libheif golangci-lint git --run "$1"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
echo "Running linter..."
|
||||||
|
run_with_cgo_deps "golangci-lint run"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
21
script/precommit
Executable file
21
script/precommit
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/precommit: run by the git pre-commit hook; fails the commit if
|
||||||
|
# checks fail. Our own extension to scripts-to-rule-them-all. Go repo
|
||||||
|
# extras: go mod tidy must not change go.mod/go.sum.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
go mod tidy
|
||||||
|
git diff --exit-code -- go.mod go.sum || {
|
||||||
|
echo "precommit: go mod tidy changed go.mod/go.sum;" \
|
||||||
|
"stage the changes and retry" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
"$SCRIPT_DIR/check"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
12
script/projectname
Executable file
12
script/projectname
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/projectname: output the name of this project. Our own
|
||||||
|
# extension to scripts-to-rule-them-all. Other scripts that need the
|
||||||
|
# name (e.g. script/docker) call this, so they can stay identical
|
||||||
|
# across all repos.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
main() {
|
||||||
|
echo "pixa"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
14
script/setup
Executable file
14
script/setup
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/setup: set up the repo for development after a fresh clone:
|
||||||
|
# installs dependencies (script/bootstrap) and the git pre-commit hook.
|
||||||
|
# Add any repo-specific initialization (db init, .env template) here.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
"$SCRIPT_DIR/bootstrap"
|
||||||
|
"$SCRIPT_DIR/install-precommit"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
23
script/test
Executable file
23
script/test
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/test: run the test suite. CGO dependencies (pkg-config, vips,
|
||||||
|
# libheif) come from nix-shell when not already available (e.g. inside
|
||||||
|
# a Docker build or an existing nix-shell).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
run_with_cgo_deps() {
|
||||||
|
if command -v pkg-config >/dev/null 2>&1; then
|
||||||
|
sh -c "$1"
|
||||||
|
else
|
||||||
|
nix-shell -p pkg-config vips libheif golangci-lint git --run "$1"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
echo "Running tests..."
|
||||||
|
run_with_cgo_deps "CGO_ENABLED=1 go test -timeout 30s -v ./..."
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -48,7 +48,7 @@ fi
|
|||||||
|
|
||||||
# Test 3: Wrong password shows error
|
# Test 3: Wrong password shows error
|
||||||
echo "--- Test 3: Login with wrong password ---"
|
echo "--- Test 3: Login with wrong password ---"
|
||||||
WRONG_LOGIN=$(curl -sf -X POST "$BASE_URL/" -d "password=wrong-key" -c "$COOKIE_JAR")
|
WRONG_LOGIN=$(curl -sf -X POST "$BASE_URL/" -d "key=wrong-key" -c "$COOKIE_JAR")
|
||||||
if echo "$WRONG_LOGIN" | grep -qi "invalid\|error\|incorrect\|wrong"; then
|
if echo "$WRONG_LOGIN" | grep -qi "invalid\|error\|incorrect\|wrong"; then
|
||||||
pass "Wrong password shows error message"
|
pass "Wrong password shows error message"
|
||||||
else
|
else
|
||||||
@@ -57,7 +57,7 @@ fi
|
|||||||
|
|
||||||
# Test 4: Correct password redirects to generator
|
# Test 4: Correct password redirects to generator
|
||||||
echo "--- Test 4: Login with correct signing key ---"
|
echo "--- Test 4: Login with correct signing key ---"
|
||||||
curl -sf -X POST "$BASE_URL/" -d "password=$SIGNING_KEY" -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L -o /dev/null
|
curl -sf -X POST "$BASE_URL/" -d "key=$SIGNING_KEY" -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L -o /dev/null
|
||||||
GENERATOR_PAGE=$(curl -sf "$BASE_URL/" -b "$COOKIE_JAR")
|
GENERATOR_PAGE=$(curl -sf "$BASE_URL/" -b "$COOKIE_JAR")
|
||||||
if echo "$GENERATOR_PAGE" | grep -qi "generate\|url\|source\|logout"; then
|
if echo "$GENERATOR_PAGE" | grep -qi "generate\|url\|source\|logout"; then
|
||||||
pass "Correct password shows generator page"
|
pass "Correct password shows generator page"
|
||||||
@@ -68,12 +68,12 @@ fi
|
|||||||
# Test 5: Generate encrypted URL
|
# Test 5: Generate encrypted URL
|
||||||
echo "--- Test 5: Generate encrypted URL ---"
|
echo "--- Test 5: Generate encrypted URL ---"
|
||||||
GEN_RESULT=$(curl -sf -X POST "$BASE_URL/generate" -b "$COOKIE_JAR" \
|
GEN_RESULT=$(curl -sf -X POST "$BASE_URL/generate" -b "$COOKIE_JAR" \
|
||||||
-d "source_url=$TEST_IMAGE_URL" \
|
-d "url=$TEST_IMAGE_URL" \
|
||||||
-d "width=800" \
|
-d "width=800" \
|
||||||
-d "height=600" \
|
-d "height=600" \
|
||||||
-d "format=jpeg" \
|
-d "format=jpeg" \
|
||||||
-d "quality=85" \
|
-d "quality=85" \
|
||||||
-d "fit_mode=cover" \
|
-d "fit=cover" \
|
||||||
-d "ttl=3600")
|
-d "ttl=3600")
|
||||||
if echo "$GEN_RESULT" | grep -q "/v1/e/"; then
|
if echo "$GEN_RESULT" | grep -q "/v1/e/"; then
|
||||||
pass "Encrypted URL generated"
|
pass "Encrypted URL generated"
|
||||||
@@ -97,8 +97,8 @@ else
|
|||||||
fail "No encrypted URL to test"
|
fail "No encrypted URL to test"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Test 7: Fetch image via whitelisted host (direct proxy)
|
# Test 7: Fetch image via allowlisted host (direct proxy)
|
||||||
echo "--- Test 7: Fetch image via direct proxy (whitelisted host) ---"
|
echo "--- Test 7: Fetch image via direct proxy (allowlisted host) ---"
|
||||||
# URL format: /v1/image/<host>/<path>/<WxH>.<format>
|
# URL format: /v1/image/<host>/<path>/<WxH>.<format>
|
||||||
PROXY_PATH="/v1/image/s3.sneak.cloud/sneak-public/2021/2021-04-18.untitled.a7r4.07723.jpg/400x300.jpeg"
|
PROXY_PATH="/v1/image/s3.sneak.cloud/sneak-public/2021/2021-04-18.untitled.a7r4.07723.jpg/400x300.jpeg"
|
||||||
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" "$BASE_URL$PROXY_PATH")
|
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" "$BASE_URL$PROXY_PATH")
|
||||||
@@ -121,10 +121,10 @@ fi
|
|||||||
# Test 9: Generate short-TTL URL and verify expiration
|
# Test 9: Generate short-TTL URL and verify expiration
|
||||||
echo "--- Test 9: Expired URL returns 410 ---"
|
echo "--- Test 9: Expired URL returns 410 ---"
|
||||||
# Login again
|
# Login again
|
||||||
curl -sf -X POST "$BASE_URL/" -d "password=$SIGNING_KEY" -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L -o /dev/null
|
curl -sf -X POST "$BASE_URL/" -d "key=$SIGNING_KEY" -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L -o /dev/null
|
||||||
# Generate URL with 1 second TTL
|
# Generate URL with 1 second TTL
|
||||||
GEN_RESULT=$(curl -sf -X POST "$BASE_URL/generate" -b "$COOKIE_JAR" \
|
GEN_RESULT=$(curl -sf -X POST "$BASE_URL/generate" -b "$COOKIE_JAR" \
|
||||||
-d "source_url=$TEST_IMAGE_URL" \
|
-d "url=$TEST_IMAGE_URL" \
|
||||||
-d "width=100" \
|
-d "width=100" \
|
||||||
-d "height=100" \
|
-d "height=100" \
|
||||||
-d "format=jpeg" \
|
-d "format=jpeg" \
|
||||||
|
|||||||
Reference in New Issue
Block a user