Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51d3a5fbdf | ||
|
|
c11538755b | ||
|
|
fc9cd27ab0 |
@@ -100,13 +100,6 @@ than once, is refused with 400.
|
|||||||
- `<format>`: one of `orig`, `png`, `jpeg`, `webp`
|
- `<format>`: one of `orig`, `png`, `jpeg`, `webp`
|
||||||
- `<size>`: `orig` or `<width>x<height>` (e.g. `800x600`)
|
- `<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
|
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
|
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
|
refused with 429 and a `Retry-After` header. Behind a reverse proxy the client
|
||||||
|
|||||||
@@ -30,23 +30,15 @@ exhaustion
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 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 cache stats report real numbers (closes #56): `Cache.Stats`
|
- 2026-09-28 cache stats report real numbers (closes #56): `Cache.Stats`
|
||||||
counts the cached source images and processed variants (`source_content`
|
counts the cached source images and processed variants (`source_content`
|
||||||
plus `variant_content`) and takes their size from `Cache.UsageBytes`,
|
plus `variant_content`) and takes their size from `Cache.UsageBytes`,
|
||||||
instead of reading `request_cache` and `output_content`, which nothing
|
instead of reading `request_cache` and `output_content`, which nothing
|
||||||
writes; those two tables are left in the schema. A miss is counted after
|
writes; those two tables are left in the schema. A miss is counted after
|
||||||
it is served or fails, even when the request context has ended by then,
|
it is served or fails, with the bytes it fetched from upstream, so
|
||||||
with the bytes it read from upstream, so `upstream_fetch_count` and
|
`upstream_fetch_count` and `upstream_fetch_bytes` move, including for a
|
||||||
`upstream_fetch_bytes` move, including for an upstream body that fails
|
fetched source that then fails the magic byte check; `transform_count`
|
||||||
partway or a fetched source that then fails the magic byte check;
|
counts each image the image processor transcodes.
|
||||||
`transform_count` counts each image the image processor transcodes.
|
|
||||||
- 2026-09-28 strip metadata from processed images (closes #82): every output is
|
- 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
|
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
|
profile; the image is first turned upright with `AutoRotate` (before sizes are
|
||||||
|
|||||||
@@ -103,8 +103,7 @@ func (g *Generator) Parse(token string) (*Payload, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ToImageRequest converts the payload to an ImageRequest.
|
// ToImageRequest converts the payload to an ImageRequest.
|
||||||
// Applies default values for omitted optional fields. An ExpiresAt of 0, a URL
|
// Applies default values for omitted optional fields.
|
||||||
// that never expires, gives the zero Expires.
|
|
||||||
func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
|
func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
|
||||||
format := p.Format
|
format := p.Format
|
||||||
if format == "" {
|
if format == "" {
|
||||||
@@ -121,11 +120,6 @@ func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
|
|||||||
fitMode = DefaultFitMode
|
fitMode = DefaultFitMode
|
||||||
}
|
}
|
||||||
|
|
||||||
var expires time.Time
|
|
||||||
if p.ExpiresAt != 0 {
|
|
||||||
expires = time.Unix(p.ExpiresAt, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &imgcache.ImageRequest{
|
return &imgcache.ImageRequest{
|
||||||
SourceHost: p.SourceHost,
|
SourceHost: p.SourceHost,
|
||||||
SourcePath: p.SourcePath,
|
SourcePath: p.SourcePath,
|
||||||
@@ -137,7 +131,6 @@ func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
|
|||||||
Format: format,
|
Format: format,
|
||||||
Quality: quality,
|
Quality: quality,
|
||||||
FitMode: fitMode,
|
FitMode: fitMode,
|
||||||
Expires: expires,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -220,24 +220,6 @@ func (s *Handlers) respondImageError(
|
|||||||
s.respondError(w, "internal error", http.StatusInternalServerError)
|
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,
|
// writeImageResponse writes headers and streams the image content,
|
||||||
// handling conditional and HEAD requests.
|
// handling conditional and HEAD requests.
|
||||||
func (s *Handlers) writeImageResponse(
|
func (s *Handlers) writeImageResponse(
|
||||||
@@ -253,7 +235,7 @@ func (s *Handlers) writeImageResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cache control headers
|
// Cache control headers
|
||||||
w.Header().Set("Cache-Control", cacheControl(req.Expires))
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
||||||
|
|
||||||
if resp.ETag != "" {
|
if resp.ETag != "" {
|
||||||
|
|||||||
@@ -1,203 +0,0 @@
|
|||||||
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
|
// signedHost is not on the allowlist setupTestHandler builds, so a request
|
||||||
// for it needs a valid signature. setupTestHandler serves no image for it: a
|
// for it needs a valid signature. No image is served for it: a request that
|
||||||
// request that passes the signature check gets 502 from the failed fetch, and
|
// passes the signature check gets 502 from the failed fetch, and one that
|
||||||
// one that fails the check gets 401.
|
// fails the check gets 401.
|
||||||
const signedHost = "signed.example.com"
|
const signedHost = "signed.example.com"
|
||||||
|
|
||||||
// getImage sends a GET for target to the image route of fix and returns the
|
// 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))
|
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache headers: max-age ends at the URL's expiry
|
// Cache headers - encrypted URLs can be cached since they're immutable
|
||||||
w.Header().Set("Cache-Control", cacheControl(req.Expires))
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
||||||
|
|
||||||
// Stream the response
|
// Stream the response
|
||||||
|
|||||||
@@ -95,8 +95,7 @@ type ImageRequest struct {
|
|||||||
FitMode FitMode
|
FitMode FitMode
|
||||||
// Signature is the HMAC signature for non-allowlisted hosts
|
// Signature is the HMAC signature for non-allowlisted hosts
|
||||||
Signature string
|
Signature string
|
||||||
// Expires is when the URL expires: the exp of a signed URL, or the expiry
|
// Expires is the signature expiration timestamp
|
||||||
// of an encrypted URL; the zero time if it has none
|
|
||||||
Expires time.Time
|
Expires time.Time
|
||||||
// AllowHTTP indicates whether HTTP (non-TLS) is allowed for this request
|
// AllowHTTP indicates whether HTTP (non-TLS) is allowed for this request
|
||||||
AllowHTTP bool
|
AllowHTTP bool
|
||||||
|
|||||||
@@ -156,13 +156,12 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cache miss - process the cached source or fetch it, then count the
|
// Cache miss - process the cached source or fetch it, then count the
|
||||||
// miss with the bytes it fetched from upstream, also when it failed or
|
// miss with the bytes it fetched from upstream, also when it failed
|
||||||
// the request context has ended meanwhile
|
|
||||||
cacheKey := CacheKey(req)
|
cacheKey := CacheKey(req)
|
||||||
|
|
||||||
response, fetchedBytes, err := s.processFromSourceOrFetch(ctx, req, cacheKey)
|
response, fetchedBytes, err := s.processFromSourceOrFetch(ctx, req, cacheKey)
|
||||||
|
|
||||||
s.cache.IncrementStats(context.WithoutCancel(ctx), false, fetchedBytes)
|
s.cache.IncrementStats(ctx, false, fetchedBytes)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -284,7 +283,10 @@ func (s *Service) processFromSourceOrFetch(
|
|||||||
s.log.Warn("source lookup failed", "error", err)
|
s.log.Warn("source lookup failed", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var sourceData []byte
|
var (
|
||||||
|
sourceData []byte
|
||||||
|
fetchBytes int64
|
||||||
|
)
|
||||||
|
|
||||||
if contentHash != "" {
|
if contentHash != "" {
|
||||||
s.log.Debug("using cached source", "hash", contentHash)
|
s.log.Debug("using cached source", "hash", contentHash)
|
||||||
@@ -297,16 +299,16 @@ func (s *Service) processFromSourceOrFetch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Process using cached source; nothing was fetched from upstream
|
// Process using cached source; nothing was fetched from upstream
|
||||||
resp, err := s.processAndStore(
|
fetchBytes = int64(len(sourceData))
|
||||||
ctx, req, cacheKey, sourceData, int64(len(sourceData)),
|
|
||||||
)
|
resp, err := s.processAndStore(ctx, req, cacheKey, sourceData, fetchBytes)
|
||||||
|
|
||||||
return resp, 0, err
|
return resp, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchAndProcess fetches from upstream, processes, and caches the result.
|
// fetchAndProcess fetches from upstream, processes, and caches the result.
|
||||||
// It also returns the number of bytes read from upstream, including when
|
// It also returns the number of bytes fetched from upstream once the
|
||||||
// reading the response or a later step fails.
|
// response has been read, including when a later step fails.
|
||||||
func (s *Service) fetchAndProcess(
|
func (s *Service) fetchAndProcess(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req *ImageRequest,
|
req *ImageRequest,
|
||||||
@@ -337,13 +339,13 @@ func (s *Service) fetchAndProcess(
|
|||||||
|
|
||||||
// Read and validate the source content
|
// Read and validate the source content
|
||||||
sourceData, err := io.ReadAll(fetchResult.Content)
|
sourceData, err := io.ReadAll(fetchResult.Content)
|
||||||
fetchBytes := int64(len(sourceData))
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fetchBytes, fmt.Errorf("failed to read upstream response: %w", err)
|
return nil, 0, fmt.Errorf("failed to read upstream response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate download bitrate
|
// Calculate download bitrate
|
||||||
|
fetchBytes := int64(len(sourceData))
|
||||||
|
|
||||||
var downloadRate string
|
var downloadRate string
|
||||||
|
|
||||||
if fetchResult.FetchDurationMs > 0 {
|
if fetchResult.FetchDurationMs > 0 {
|
||||||
@@ -408,8 +410,7 @@ func (s *Service) processAndStore(
|
|||||||
|
|
||||||
processDuration := time.Since(processStart)
|
processDuration := time.Since(processStart)
|
||||||
|
|
||||||
// Counted also when the request context has ended meanwhile
|
s.cache.IncrementTransformCount(ctx)
|
||||||
s.cache.IncrementTransformCount(context.WithoutCancel(ctx))
|
|
||||||
|
|
||||||
// Read processed content
|
// Read processed content
|
||||||
processedData, err := io.ReadAll(processResult.Content)
|
processedData, err := io.ReadAll(processResult.Content)
|
||||||
|
|||||||
Reference in New Issue
Block a user