Compare commits
3
Commits
main
...
cb8c885061
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb8c885061 | ||
|
|
ed3f8770e6 | ||
|
|
2afe61e301 |
+6
-2
@@ -68,8 +68,12 @@ RUN apk add --no-cache \
|
||||
COPY --from=builder /pixad /usr/local/bin/pixad
|
||||
COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Create non-root user, config directory, and data directory
|
||||
RUN adduser -D -H -s /sbin/nologin pixad && \
|
||||
# Create non-root user, config directory, and data directory. pixad
|
||||
# gets uid and gid 65532, which host login and system accounts do not
|
||||
# use: a bind-mounted /var/lib/pixa is given to pixad, and on the host
|
||||
# it must not belong to a person's account.
|
||||
RUN addgroup -g 65532 pixad && \
|
||||
adduser -D -H -s /sbin/nologin -u 65532 -G pixad pixad && \
|
||||
mkdir -p /var/lib/pixa /etc/pixa && \
|
||||
chown pixad:pixad /var/lib/pixa
|
||||
|
||||
|
||||
@@ -58,8 +58,10 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for pixa needs:
|
||||
`healthy`. The probe uses the port from `PORT` (default `8080`), so a
|
||||
port changed only in a mounted config file is not seen by it: change
|
||||
the port with `PORT`.
|
||||
- **First run:** create the host directory. It may be owned by root: the
|
||||
container gives it to its `pixad` user when it starts.
|
||||
- **First run:** create the host directory, owned by root or by uid
|
||||
`65532` and gid `65532`. The server runs as the container's `pixad`
|
||||
user, which has that uid and gid, and the container gives the
|
||||
directory to `pixad` when it starts.
|
||||
|
||||
## Rationale
|
||||
|
||||
@@ -100,16 +102,28 @@ than once, is refused with 400.
|
||||
- `<format>`: one of `orig`, `png`, `jpeg`, `webp`
|
||||
- `<size>`: `orig` or `<width>x<height>` (e.g. `800x600`)
|
||||
|
||||
An image is served with `Cache-Control: public, max-age=<seconds>, immutable`.
|
||||
When the URL has an expiry (an `exp`, or the TTL of an encrypted URL),
|
||||
`max-age` is the whole seconds left until then, at most one year, so no browser
|
||||
or proxy cache keeps the image after pixa would refuse the URL. A URL with no
|
||||
expiry gets one year. `immutable` only stops a client revalidating while its
|
||||
copy is fresh.
|
||||
|
||||
The login form (`POST /`) is limited to 5 attempts per minute per client
|
||||
address, counting an IPv6 client by its /64; an attempt over the limit is
|
||||
refused with 429 and a `Retry-After` header. Behind a reverse proxy the client
|
||||
address comes from `X-Forwarded-For` only when the proxy's address is in
|
||||
`trusted_proxies`; otherwise all users behind the proxy are counted as one
|
||||
client. With the default `trusted_proxies` (the RFC 1918 ranges), a client
|
||||
with a private address can choose the address it is counted by through its own
|
||||
`X-Forwarded-For`, whether it connects directly or through the proxy, because
|
||||
its own address is trusted too. Setting `trusted_proxies` to the proxy's own
|
||||
address closes this.
|
||||
address comes from `X-Forwarded-For` only when the address pixa sees for
|
||||
requests that come through the proxy is in `trusted_proxies`; otherwise all
|
||||
users behind the proxy are counted as one client. That address is not always
|
||||
the proxy's own: for a proxy on the Docker host it is the gateway of the
|
||||
container's Docker network, such as `172.17.0.1` on the default bridge. pixa's
|
||||
request log shows it as `remoteIP` while it is not in `trusted_proxies` (see
|
||||
`trusted_proxies` under Configuration). With the default `trusted_proxies`
|
||||
(the RFC 1918 ranges), a client with a private address can choose the address
|
||||
it is counted by through its own `X-Forwarded-For`, whether it connects
|
||||
directly or through the proxy, because its own address is trusted too. Setting
|
||||
`trusted_proxies` to only the address pixa sees for requests that come through
|
||||
the proxy closes this.
|
||||
|
||||
### Image Metadata
|
||||
|
||||
@@ -163,19 +177,27 @@ Where:
|
||||
outside), or `cover` when the URL has no `fit`; a request whose `fit` is
|
||||
anything else, an empty `fit=` included, is refused with 400
|
||||
|
||||
**Example:** resize `https://cdn.example.com/photos/cat.jpg` to 800x600
|
||||
WebP with expiration 1704067200, default quality and fit:
|
||||
The URL's `sig` is the HMAC-SHA256 result in base64url (the URL-safe alphabet
|
||||
of RFC 4648) with the trailing `=` padding kept, 44 characters in all. pixa
|
||||
compares it exactly, so a signature encoded without padding, as Node's
|
||||
`base64url` and Go's `base64.RawURLEncoding` do, is refused with 401.
|
||||
|
||||
**Example:** with the signing key `example-signing-key-for-documentation`,
|
||||
resize `https://cdn.example.com/photos/cat.jpg` to 800x600 WebP with
|
||||
expiration 1704067200, default quality and fit:
|
||||
|
||||
1. Build input:
|
||||
`cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover`
|
||||
2. Compute HMAC-SHA256 with your secret key
|
||||
3. Base64URL-encode the result
|
||||
2. Compute HMAC-SHA256 of it with the signing key
|
||||
3. Base64URL-encode the result, keeping the `=` padding:
|
||||
`-ay7KHpfqmtIGbibDGbUuBDkymi-Ymdn0NkC6j5EJag=`
|
||||
4. URL:
|
||||
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=<base64url>&exp=1704067200`
|
||||
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=-ay7KHpfqmtIGbibDGbUuBDkymi-Ymdn0NkC6j5EJag=&exp=1704067200`
|
||||
|
||||
For the same image at quality 40 with fit `contain`, the input ends in
|
||||
`:40:contain` and the URL is
|
||||
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=<base64url>&exp=1704067200&q=40&fit=contain`.
|
||||
`:40:contain`, the signature is `5IwXUx6vf7yefhaUvFzgXZvG2o0Df4RJxPTK3pKq5VU=`,
|
||||
and the URL is
|
||||
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=5IwXUx6vf7yefhaUvFzgXZvG2o0Df4RJxPTK3pKq5VU=&exp=1704067200&q=40&fit=contain`.
|
||||
|
||||
**Allowlist patterns:**
|
||||
|
||||
@@ -237,8 +259,14 @@ Key settings in more detail:
|
||||
`172.16.0.0/12`, `192.168.0.0/16`), since pixa is deployed behind a
|
||||
proxy on a private network; an explicitly empty list (`[]`) trusts no
|
||||
one, and an explicit list replaces the default. An invalid CIDR aborts
|
||||
startup. Set this to your proxy's address range if it is not already
|
||||
covered by the defaults
|
||||
startup. Set this to the address pixa sees for requests that come through
|
||||
your proxy, such as `172.17.0.1/32`, when the defaults do not cover it, or
|
||||
to trust nothing else (see the login limit under Routes). For a proxy on
|
||||
the Docker host that address is the gateway of the container's Docker
|
||||
network (`172.17.0.1` on the default bridge), not the proxy's own address.
|
||||
To find it, set this to `[]` (or `PIXA_TRUSTED_PROXIES` to empty), send a
|
||||
request through the proxy, and read `remoteIP` in pixa's request log line
|
||||
for it
|
||||
- `upstream_fetch_timeout` — timeout for origin requests
|
||||
- `upstream_max_response_size` — max origin response size
|
||||
- `downstream_timeout` — client response timeout
|
||||
|
||||
@@ -30,6 +30,26 @@ exhaustion
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-09-29 `trusted_proxies` advice and signature padding in `README.md`
|
||||
(closes #150): the login-limit paragraph, the `trusted_proxies` entry and
|
||||
`config.example.yml` say to set `trusted_proxies` to the address pixa sees for
|
||||
requests that come through the proxy, not the proxy's own address; for a proxy
|
||||
on the Docker host that is the Docker network's gateway, which the request log
|
||||
shows as `remoteIP` while it is not trusted; the signature section says `sig`
|
||||
is base64url with the `=` padding kept, and gives the example's `sig` for a
|
||||
stated signing key.
|
||||
- 2026-09-29 fixed uid and gid for `pixad` (closes #151): the image creates the
|
||||
`pixad` group with gid 65532 and the `pixad` user with uid 65532, instead of
|
||||
the first free uid 1000, so a bind-mounted `/var/lib/pixa` given to `pixad`
|
||||
is not owned on the host by a person's login account; the first-run step of
|
||||
"Running under upaas" in `README.md` names the uid and gid.
|
||||
- 2026-09-29 `max-age` never outlives an expiring URL (closes #63): both image
|
||||
routes build `Cache-Control` from the request's `Expires`, which an encrypted
|
||||
URL's expiry now fills too; `max-age` is one year, or the whole seconds left
|
||||
until the `exp` of a `/v1/image/` URL or the expiry of an encrypted URL when
|
||||
that is sooner, never negative; an allowlisted host's URL that has an `exp`
|
||||
follows it too; `immutable` stays, as freshness now ends at the expiry;
|
||||
documented in `README.md`.
|
||||
- 2026-09-28 strip metadata from processed images (closes #82): every output is
|
||||
exported with govips' `StripMetadata`, so it carries no EXIF, XMP, IPTC or ICC
|
||||
profile; the image is first turned upright with `AutoRotate` (before sizes are
|
||||
|
||||
+6
-1
@@ -50,7 +50,12 @@ allowlist_hosts:
|
||||
# 172.16.0.0/12, 192.168.0.0/16), since pixa is deployed behind a proxy on
|
||||
# a private network. An explicitly empty list ([]) trusts no one; an
|
||||
# explicit list replaces the default. An invalid CIDR aborts startup.
|
||||
# Uncomment to override the defaults with your proxy's address range.
|
||||
# Uncomment to override the defaults with the address pixa sees for
|
||||
# requests that come through your proxy. That is not always the proxy's
|
||||
# own address: for a proxy on the Docker host it is the gateway of the
|
||||
# container's Docker network (172.17.0.1 on the default bridge). The
|
||||
# trusted_proxies entry in README.md says how to find it in the request
|
||||
# log.
|
||||
# trusted_proxies:
|
||||
# - 10.0.0.0/8
|
||||
# - 2001:db8::/32
|
||||
|
||||
@@ -103,7 +103,8 @@ func (g *Generator) Parse(token string) (*Payload, error) {
|
||||
}
|
||||
|
||||
// ToImageRequest converts the payload to an ImageRequest.
|
||||
// Applies default values for omitted optional fields.
|
||||
// Applies default values for omitted optional fields. An ExpiresAt of 0, a URL
|
||||
// that never expires, gives the zero Expires.
|
||||
func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
|
||||
format := p.Format
|
||||
if format == "" {
|
||||
@@ -120,6 +121,11 @@ func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
|
||||
fitMode = DefaultFitMode
|
||||
}
|
||||
|
||||
var expires time.Time
|
||||
if p.ExpiresAt != 0 {
|
||||
expires = time.Unix(p.ExpiresAt, 0)
|
||||
}
|
||||
|
||||
return &imgcache.ImageRequest{
|
||||
SourceHost: p.SourceHost,
|
||||
SourcePath: p.SourcePath,
|
||||
@@ -131,6 +137,7 @@ func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
|
||||
Format: format,
|
||||
Quality: quality,
|
||||
FitMode: fitMode,
|
||||
Expires: expires,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -220,6 +220,24 @@ func (s *Handlers) respondImageError(
|
||||
s.respondError(w, "internal error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// cacheControl returns the Cache-Control header for an image served through a
|
||||
// URL that expires at expires, or never when expires is the zero time. A cache
|
||||
// may keep the image for a year, but not past the URL's expiry, after which
|
||||
// pixa refuses the URL. The seconds left are rounded down and never negative.
|
||||
// immutable only stops revalidation while the image is fresh, so it also ends
|
||||
// at the expiry.
|
||||
func cacheControl(expires time.Time) string {
|
||||
const oneYear = 365 * 24 * time.Hour
|
||||
|
||||
maxAge := oneYear
|
||||
|
||||
if !expires.IsZero() {
|
||||
maxAge = min(maxAge, max(time.Until(expires), 0))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("public, max-age=%d, immutable", int64(maxAge/time.Second))
|
||||
}
|
||||
|
||||
// writeImageResponse writes headers and streams the image content,
|
||||
// handling conditional and HEAD requests.
|
||||
func (s *Handlers) writeImageResponse(
|
||||
@@ -235,7 +253,7 @@ func (s *Handlers) writeImageResponse(
|
||||
}
|
||||
|
||||
// Cache control headers
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
w.Header().Set("Cache-Control", cacheControl(req.Expires))
|
||||
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
||||
|
||||
if resp.ETag != "" {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/encurl"
|
||||
"sneak.berlin/go/pixa/internal/imgcache"
|
||||
)
|
||||
|
||||
// photoPath is the path of the JPEG that newSignedHostServer serves.
|
||||
const photoPath = "/images/photo.jpg"
|
||||
|
||||
// newSignedHostServer returns a router for both image routes, and the Handlers
|
||||
// behind it, whose fetcher serves a JPEG at photoPath on signedHost. signedHost
|
||||
// is not on the allowlist, so a /v1/image/ URL for it is served only with a
|
||||
// valid signature.
|
||||
func newSignedHostServer(t *testing.T) (*Handlers, http.Handler) {
|
||||
t.Helper()
|
||||
|
||||
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
||||
StateDir: t.TempDir(),
|
||||
CacheTTL: time.Hour,
|
||||
NegativeTTL: 5 * time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("imgcache.NewCache() error = %v", err)
|
||||
}
|
||||
|
||||
jpegData := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255})
|
||||
|
||||
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
||||
Cache: cache,
|
||||
Fetcher: newMockFetcher(fstest.MapFS{
|
||||
signedHost + photoPath: &fstest.MapFile{Data: jpegData},
|
||||
}),
|
||||
SigningKey: testSigningKey,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("imgcache.NewService() error = %v", err)
|
||||
}
|
||||
|
||||
encGen, err := encurl.NewGenerator(testSigningKey)
|
||||
if err != nil {
|
||||
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
||||
}
|
||||
|
||||
h := &Handlers{
|
||||
log: slog.New(slog.DiscardHandler),
|
||||
imgSvc: svc,
|
||||
encGen: encGen,
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/v1/image/*", h.HandleImage())
|
||||
r.Get("/v1/e/{token}/*", h.HandleImageEnc())
|
||||
|
||||
return h, r
|
||||
}
|
||||
|
||||
// getMaxAge sends a GET for target to srv, requires a 200, and returns the
|
||||
// max-age of the response's Cache-Control header, which must read
|
||||
// "public, max-age=<seconds>, immutable".
|
||||
func getMaxAge(t *testing.T, srv http.Handler, target string) int {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.ServeHTTP(rec, req)
|
||||
|
||||
header := rec.Header().Get("Cache-Control")
|
||||
t.Logf("GET %s: %d, Cache-Control: %s", target, rec.Code, header)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
value, hasPrefix := strings.CutPrefix(header, "public, max-age=")
|
||||
value, hasSuffix := strings.CutSuffix(value, ", immutable")
|
||||
|
||||
maxAge, err := strconv.Atoi(value)
|
||||
if !hasPrefix || !hasSuffix || err != nil {
|
||||
t.Fatalf("Cache-Control = %q, want public, max-age=<seconds>, immutable",
|
||||
header)
|
||||
}
|
||||
|
||||
return maxAge
|
||||
}
|
||||
|
||||
// TestHandleImage_SignedURL_MaxAgeEndsAtExp verifies that an image served
|
||||
// through a signed URL expiring in 60 seconds may be cached for at most those
|
||||
// 60 seconds. The lower bound of 50 shows the max-age is the time left, not 0.
|
||||
func TestHandleImage_SignedURL_MaxAgeEndsAtExp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h, srv := newSignedHostServer(t)
|
||||
|
||||
signedURL, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
|
||||
SourceHost: signedHost,
|
||||
SourcePath: photoPath,
|
||||
Size: imgcache.Size{Width: 50, Height: 50},
|
||||
Format: imgcache.FormatJPEG,
|
||||
}, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSignedURL() error = %v", err)
|
||||
}
|
||||
|
||||
maxAge := getMaxAge(t, srv, signedURL)
|
||||
if maxAge < 50 || maxAge > 60 {
|
||||
t.Errorf("max-age = %d, want 50 to 60", maxAge)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleImage_AllowlistedHost_MaxAge verifies the max-age of an image from
|
||||
// an allowlisted host, which is served without checking sig or exp. A URL with
|
||||
// no exp may be cached for a year. A URL whose exp has passed is the one request
|
||||
// that reaches the header after its expiry, and must get 0, never less.
|
||||
func TestHandleImage_AllowlistedHost_MaxAge(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pastExp := strconv.FormatInt(time.Now().Add(-time.Hour).Unix(), 10)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
wantMaxAge int
|
||||
}{
|
||||
{"no exp", "", 31536000},
|
||||
{"exp already past", "?exp=" + pastExp, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fix := setupTestHandler(t)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/v1/image/*", fix.handler.HandleImage())
|
||||
|
||||
maxAge := getMaxAge(t, r,
|
||||
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg"+tt.query)
|
||||
if maxAge != tt.wantMaxAge {
|
||||
t.Errorf("max-age = %d, want %d", maxAge, tt.wantMaxAge)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleImageEnc_MaxAge verifies that an image served through an encrypted
|
||||
// URL with a 60 second TTL may be cached for at most those 60 seconds, that one
|
||||
// with a two-year TTL may be cached for a year, and that one made without a
|
||||
// TTL, which never expires, may be cached for a year.
|
||||
func TestHandleImageEnc_MaxAge(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expiresAt int64
|
||||
wantAtLeast int
|
||||
wantAtMost int
|
||||
}{
|
||||
{"60 second TTL", time.Now().Add(time.Minute).Unix(), 50, 60},
|
||||
{"two-year TTL", time.Now().Add(2 * 365 * 24 * time.Hour).Unix(), 31536000, 31536000},
|
||||
{"no TTL", 0, 31536000, 31536000},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h, srv := newSignedHostServer(t)
|
||||
|
||||
token, err := h.encGen.Generate(&encurl.Payload{
|
||||
SourceHost: signedHost,
|
||||
SourcePath: photoPath,
|
||||
Width: 50,
|
||||
Height: 50,
|
||||
Format: imgcache.FormatJPEG,
|
||||
ExpiresAt: tt.expiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate() error = %v", err)
|
||||
}
|
||||
|
||||
maxAge := getMaxAge(t, srv, "/v1/e/"+token+"/img.jpg")
|
||||
if maxAge < tt.wantAtLeast || maxAge > tt.wantAtMost {
|
||||
t.Errorf("max-age = %d, want %d to %d",
|
||||
maxAge, tt.wantAtLeast, tt.wantAtMost)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,9 @@ import (
|
||||
)
|
||||
|
||||
// signedHost is not on the allowlist setupTestHandler builds, so a request
|
||||
// for it needs a valid signature. No image is served for it: a request that
|
||||
// passes the signature check gets 502 from the failed fetch, and one that
|
||||
// fails the check gets 401.
|
||||
// for it needs a valid signature. setupTestHandler serves no image for it: a
|
||||
// request that passes the signature check gets 502 from the failed fetch, and
|
||||
// one that fails the check gets 401.
|
||||
const signedHost = "signed.example.com"
|
||||
|
||||
// getImage sends a GET for target to the image route of fix and returns the
|
||||
|
||||
@@ -89,8 +89,8 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
|
||||
}
|
||||
|
||||
// Cache headers - encrypted URLs can be cached since they're immutable
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
// Cache headers: max-age ends at the URL's expiry
|
||||
w.Header().Set("Cache-Control", cacheControl(req.Expires))
|
||||
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
||||
|
||||
// Stream the response
|
||||
|
||||
@@ -95,7 +95,8 @@ type ImageRequest struct {
|
||||
FitMode FitMode
|
||||
// Signature is the HMAC signature for non-allowlisted hosts
|
||||
Signature string
|
||||
// Expires is the signature expiration timestamp
|
||||
// Expires is when the URL expires: the exp of a signed URL, or the expiry
|
||||
// of an encrypted URL; the zero time if it has none
|
||||
Expires time.Time
|
||||
// AllowHTTP indicates whether HTTP (non-TLS) is allowed for this request
|
||||
AllowHTTP bool
|
||||
|
||||
Reference in New Issue
Block a user