Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d271d4617 |
@@ -473,9 +473,11 @@ nor change what it asks for.
|
||||
`jxl` for a form with no format.
|
||||
|
||||
The token holds the source's host, path and query and the size, format, quality,
|
||||
fit and expiry, encrypted with a key derived from `signing_key`. The source
|
||||
URL's scheme is not kept: the image is fetched like any other (see Routes), and
|
||||
the blocked networks still apply.
|
||||
fit and expiry, encrypted with a key derived from `signing_key`. The host is
|
||||
kept in lower case and an empty path as `/`, as for `/v1/image/`, so
|
||||
`https://CDN.Example.com` and `https://cdn.example.com/` are one source image,
|
||||
fetched and cached once. The source URL's scheme is not kept: the image is
|
||||
fetched like any other (see Routes), and the blocked networks still apply.
|
||||
|
||||
How long the URL lasts is chosen on the page, from 1 minute to 1 year, or never.
|
||||
The expiry is fixed in the token when the URL is made and cannot be changed or
|
||||
@@ -715,14 +717,11 @@ Key settings in more detail:
|
||||
- `signing_key` — HMAC secret for URL signatures
|
||||
- `db_url` — the SQLite database to open; omitted, it is
|
||||
`file:<state_dir>/state.sqlite3?_pragma=journal_mode(WAL)`, which keeps the
|
||||
database in WAL mode. `state_dir` is percent-encoded in it, so a `%`, `?` or
|
||||
`#` in the name is written as `%25`, `%3F` or `%23`. For a `state_dir` of `/`
|
||||
or one starting with `//` it starts `file:////`, so SQLite does not read the
|
||||
first name in the path as a host. pixa opens one connection to it, so its own
|
||||
reads and writes run one at a time. pixa adds `_pragma=busy_timeout(5000)` to
|
||||
any `db_url`, so a write that finds another program writing to the file waits
|
||||
up to five seconds for it instead of failing. WAL mode comes only from the
|
||||
URL: keep `_pragma=journal_mode(WAL)` in one you set
|
||||
database in WAL mode. pixa opens one connection to it, so its own reads and
|
||||
writes run one at a time. pixa adds `_pragma=busy_timeout(5000)` to any
|
||||
`db_url`, so a write that finds another program writing to the file waits up
|
||||
to five seconds for it instead of failing. WAL mode comes only from the URL:
|
||||
keep `_pragma=journal_mode(WAL)` in one you set
|
||||
- `cache_max_bytes` — disk cache size limit in bytes; `0` disables the disk
|
||||
cache entirely; omitted defaults to 75% of the sum of the free space on the
|
||||
filesystem containing `<state_dir>/cache/` and the bytes of source and
|
||||
|
||||
@@ -30,14 +30,11 @@ P2: security: per-origin rate limiting
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-16 a `state_dir` whose name holds `%`, `?` or `#` keeps its database
|
||||
at `state.sqlite3` in that directory (closes #290): the `db_url` derived from
|
||||
it put the name into a `file:` URL as it was, so SQLite read `%41` as `A`, a
|
||||
`?` or `#` ended the path early, and the journal mode parameter was lost. The
|
||||
derived `db_url` is now built with Go's `net/url`, which writes those
|
||||
characters as `%25`, `%3F` and `%23`. For a `state_dir` of `/` or one starting
|
||||
with `//` it starts `file:////`, so SQLite no longer reads the first name in
|
||||
the path as a host.
|
||||
- 2026-10-16 the generator page lower-cases the source host and writes an empty
|
||||
source path as `/` before it makes the encrypted URL, as `/v1/image/` does
|
||||
(closes #293). A host written in upper and in lower case, or a source URL with
|
||||
no path and with the path `/`, made two cache keys and two upstream requests
|
||||
for one image.
|
||||
- 2026-10-16 the source URL a request fetches is built with `net/url` from the
|
||||
source host, path and query, not joined as text (closes #288). The source
|
||||
path, decoded, is fetched percent-encoded again, so `100%25.png` fetches
|
||||
|
||||
@@ -338,7 +338,8 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
||||
settingName(keyDBURL), errValueEmpty)
|
||||
}
|
||||
|
||||
c.DBURL = defaultDBURL(c.StateDir)
|
||||
// The driver sets the journal mode only through a _pragma parameter.
|
||||
c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_pragma=journal_mode(WAL)", c.StateDir)
|
||||
}
|
||||
|
||||
if loader.err != nil {
|
||||
@@ -353,30 +354,6 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// defaultDBURL returns the db_url used when db_url is omitted: the file
|
||||
// state.sqlite3 in stateDir, in WAL mode, which the driver sets only
|
||||
// through a _pragma parameter. String() percent-encodes any %, ? or # in
|
||||
// the directory name, which SQLite decodes back, so the URL names exactly
|
||||
// this file. OmitHost writes file:/var/lib/pixa/... instead of
|
||||
// file:///var/lib/pixa/..., and keeps a relative stateDir from being read
|
||||
// as a host name. The path written into the URL is stateDir followed by
|
||||
// /state.sqlite3; when it starts with //, as it does for a stateDir of /
|
||||
// or one starting with //, the empty host is kept, so the URL starts
|
||||
// file://// and SQLite does not read the first name in the path as a
|
||||
// host.
|
||||
func defaultDBURL(stateDir string) string {
|
||||
dbPath := stateDir + "/state.sqlite3"
|
||||
|
||||
dbURL := url.URL{
|
||||
Scheme: "file",
|
||||
OmitHost: !strings.HasPrefix(dbPath, "//"),
|
||||
Path: dbPath,
|
||||
RawQuery: "_pragma=journal_mode(WAL)",
|
||||
}
|
||||
|
||||
return dbURL.String()
|
||||
}
|
||||
|
||||
// validateKnownKeys rejects configuration files containing keys the
|
||||
// application does not understand, so typos fail at startup instead of
|
||||
// being silently ignored, and rejects keys that are explicitly set to
|
||||
|
||||
@@ -135,104 +135,6 @@ func TestDefaultDBURLOpensTheDatabaseInWALMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDerivedDBURLOpensStateDirHoldingURLCharacters opens the db_url
|
||||
// derived from a state_dir whose name holds %41, ? and #, and checks that
|
||||
// the database is state.sqlite3 in that directory, in WAL mode. Put into
|
||||
// a file: URL as it is, the name would lose its meaning: %41 is read as
|
||||
// A, and the path ends at the ?, which also loses the journal mode.
|
||||
func TestDerivedDBURLOpensStateDirHoldingURLCharacters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stateDir := filepath.Join(t.TempDir(), "a%41b?c#d")
|
||||
|
||||
err := os.Mkdir(stateDir, 0o750)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create %q: %v", stateDir, err)
|
||||
}
|
||||
|
||||
// Quoted so YAML takes the whole name as it is.
|
||||
c, err := configFromYAML(t, signingKeyLine+"state_dir: '"+stateDir+"'\n")
|
||||
if err != nil {
|
||||
t.Fatalf("config with state_dir %q should be valid, got: %v", stateDir, err)
|
||||
}
|
||||
|
||||
t.Logf("state_dir %q gives db_url %q", stateDir, c.DBURL)
|
||||
|
||||
checkDBURLOpensFileInWALMode(t, c.DBURL, filepath.Join(stateDir, "state.sqlite3"))
|
||||
}
|
||||
|
||||
// TestDerivedDBURLOpensStateDirStartingWithTwoSlashes opens the db_url
|
||||
// derived from a state_dir that starts with //, and checks that the
|
||||
// database is state.sqlite3 in that directory, in WAL mode. Written right
|
||||
// after file:, such a name gives a URL starting file://, and SQLite reads
|
||||
// the first directory name as a host and refuses to open it.
|
||||
func TestDerivedDBURLOpensStateDirStartingWithTwoSlashes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
stateDir := "/" + dir
|
||||
|
||||
c, err := configFromYAML(t, signingKeyLine+"state_dir: "+stateDir+"\n")
|
||||
if err != nil {
|
||||
t.Fatalf("config with state_dir %q should be valid, got: %v", stateDir, err)
|
||||
}
|
||||
|
||||
t.Logf("state_dir %q gives db_url %q", stateDir, c.DBURL)
|
||||
|
||||
checkDBURLOpensFileInWALMode(t, c.DBURL, filepath.Join(dir, "state.sqlite3"))
|
||||
}
|
||||
|
||||
// TestDerivedDBURLForRootStateDir checks the db_url derived from a
|
||||
// state_dir of /. The path written into the URL is then
|
||||
// //state.sqlite3, and written right after file: it gives a URL starting
|
||||
// file://, where SQLite reads state.sqlite3 as a host and refuses to open
|
||||
// it. The test only checks the URL, since it cannot write to /.
|
||||
func TestDerivedDBURLForRootStateDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, err := configFromYAML(t, signingKeyLine+"state_dir: /\n")
|
||||
if err != nil {
|
||||
t.Fatalf("config with state_dir / should be valid, got: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("state_dir / gives db_url %q", c.DBURL)
|
||||
|
||||
wantDBURL := "file:////state.sqlite3?_pragma=journal_mode(WAL)"
|
||||
if c.DBURL != wantDBURL {
|
||||
t.Errorf("DBURL = %q, want %q", c.DBURL, wantDBURL)
|
||||
}
|
||||
}
|
||||
|
||||
// checkDBURLOpensFileInWALMode opens dbURL with the SQLite driver pixad
|
||||
// uses, and checks that the database is in WAL mode and is the file
|
||||
// dbFile.
|
||||
func checkDBURLOpensFileInWALMode(t *testing.T, dbURL, dbFile string) {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite", dbURL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open %q: %v", dbURL, err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
var journalMode string
|
||||
|
||||
err = db.QueryRowContext(t.Context(), "PRAGMA journal_mode").Scan(&journalMode)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read the journal mode of %q: %v", dbURL, err)
|
||||
}
|
||||
|
||||
if journalMode != "wal" {
|
||||
t.Errorf("journal mode of %q = %q, want wal", dbURL, journalMode)
|
||||
}
|
||||
|
||||
_, err = os.Stat(dbFile)
|
||||
if err != nil {
|
||||
t.Errorf("opening %q did not create %q: %v", dbURL, dbFile, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitValidValuesAreUsed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
||||
sourceURL := r.FormValue("url")
|
||||
|
||||
// Validate source URL
|
||||
parsed, err := url.Parse(sourceURL)
|
||||
parsed, err := parseSourceURL(sourceURL)
|
||||
if err != nil || parsed.Host == "" {
|
||||
s.renderGeneratorWithForm(w, r, "Invalid source URL", r.Form,
|
||||
http.StatusBadRequest)
|
||||
@@ -198,6 +198,25 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// parseSourceURL parses the source URL entered on the generator page with its
|
||||
// host lower-cased and an empty path written as "/", as /v1/image/ reads a
|
||||
// source URL, so that each spelling of one source URL makes one cache key and
|
||||
// one upstream request.
|
||||
func parseSourceURL(sourceURL string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(sourceURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parsed.Host = strings.ToLower(parsed.Host)
|
||||
|
||||
if parsed.Path == "" {
|
||||
parsed.Path = "/"
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// fetchedAsWritten reports whether fetchURL, the URL pixa fetches for an
|
||||
// image URL made from the source URL source, is source as it was written,
|
||||
// apart from the scheme, which the token does not keep. It is not when source
|
||||
|
||||
@@ -308,6 +308,70 @@ func TestGeneratedURLFetchesTheSourceURLAsEntered(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeneratorMakesOneImageURLForTwoSpellingsOfASourceURL enters two
|
||||
// spellings of one source URL on the generator page, requests the encrypted
|
||||
// URL made for each, and checks that the upstream receives one request and
|
||||
// that the second encrypted URL is served from the cache. One pair writes the
|
||||
// host in upper and in lower case, which /v1/image/ lower-cases. The host is
|
||||
// an IPv6 address, whose hex digits have letter case, because the fetcher
|
||||
// would look a host name up in DNS. It has a port, as the fetcher refuses an
|
||||
// IPv6 address written without one. The other pair is a source URL with no
|
||||
// path and with the path "/", both of which fetch "/".
|
||||
func TestGeneratorMakesOneImageURLForTwoSpellingsOfASourceURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
// sourceURLs are the two spellings, after "https://"
|
||||
sourceURLs []string
|
||||
// wantRequest is the path the upstream must receive
|
||||
wantRequest string
|
||||
}{
|
||||
{"host in upper and in lower case",
|
||||
[]string{"[2001:DB8::A]:8443/a.png", "[2001:db8::a]:8443/a.png"},
|
||||
"/a.png"},
|
||||
{"no path and the path written as a slash",
|
||||
[]string{upstreamHost, upstreamHost + "/"}, "/"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
upstream := startRecordingUpstream(t)
|
||||
s, _, _ := startImageProxy(t, upstream.Server)
|
||||
|
||||
var imageURLs []string
|
||||
|
||||
for _, sourceURL := range tt.sourceURLs {
|
||||
rec := postGenerate(t, s, "https://"+sourceURL)
|
||||
|
||||
match := generatedURLPattern.FindStringSubmatch(rec.Body.String())
|
||||
if rec.Code != http.StatusOK || match == nil {
|
||||
t.Fatalf("POST /generate: %d with no encrypted URL; body %s",
|
||||
rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
imageURLs = append(imageURLs, match[1])
|
||||
}
|
||||
|
||||
getImage(t, s, imageURLs[0])
|
||||
|
||||
second := getImage(t, s, imageURLs[1])
|
||||
if got := second.Header().Get("X-Pixa-Cache"); got != string(imgcache.CacheHit) {
|
||||
t.Errorf("second X-Pixa-Cache = %q, want HIT", got)
|
||||
}
|
||||
|
||||
got := upstream.requestTargets()
|
||||
t.Logf("upstream received %q", got)
|
||||
|
||||
if !slices.Equal(got, []string{tt.wantRequest}) {
|
||||
t.Errorf("upstream received %q, want [%q]", got, tt.wantRequest)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeneratorRefusesSourceURLItCannotFetchAsWritten submits source URLs the
|
||||
// generator page must make no encrypted URL for, and checks that it answers
|
||||
// 400 and shows why. pixa keeps the source path decoded and fetches it
|
||||
|
||||
Reference in New Issue
Block a user