From 8afe720c18774bfe26d90dbc04bdec71e5357e43 Mon Sep 17 00:00:00 2001 From: Ramphy Rojas Date: Tue, 4 Aug 2026 16:56:23 -0400 Subject: [PATCH] feat(truocloud): add TruoCloud provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TruoCloud serves images from `https://img.truo.cloud/i//`, with the transformation as query parameters. Two modes share the path: a path mapped onto the tenant's own origin, and `/fetch/` to proxy a third-party one — the same shape as imgix's Web Folder and Web Proxy. Detection is by domain rather than path: `/i/` is far too generic for the shared path registry and would claim URLs that are not ours. A custom domain therefore needs an explicit provider, as with the other providers that allow them. Two details that are not obvious from the URL shape: - parameters are emitted sorted by name. The service accepts any order, but every builder of this contract sorts them, and two orderings of one request are two CDN cache entries for the same image. - commas stay literal. The transformation engine does not decode `%2C`, so an escaped `crop=60,30,0,0` is ignored and the image comes back uncropped, with a 200. `extract` drops `s` and `exp` instead of returning them as operations: they are an HMAC over that exact path and query, so carrying them through `generate` would produce a URL whose signature no longer covers it. --- .changeset/afraid-pandas-shake.md | 5 ++ data/domains.json | 1 + demo/src/examples.json | 4 + deno.jsonc | 1 + src/async.ts | 1 + src/extract.ts | 2 + src/providers/truocloud.test.ts | 77 ++++++++++++++++++ src/providers/truocloud.ts | 128 ++++++++++++++++++++++++++++++ src/providers/types.ts | 3 + src/transform.ts | 2 + src/types.ts | 2 + 11 files changed, 226 insertions(+) create mode 100644 .changeset/afraid-pandas-shake.md create mode 100644 src/providers/truocloud.test.ts create mode 100644 src/providers/truocloud.ts diff --git a/.changeset/afraid-pandas-shake.md b/.changeset/afraid-pandas-shake.md new file mode 100644 index 0000000..1e8e3d0 --- /dev/null +++ b/.changeset/afraid-pandas-shake.md @@ -0,0 +1,5 @@ +--- +"unpic": minor +--- + +feat(truocloud): add TruoCloud provider diff --git a/data/domains.json b/data/domains.json index f13205f..119ae15 100644 --- a/data/domains.json +++ b/data/domains.json @@ -2,6 +2,7 @@ "images.ctfassets.net": "contentful", "cdn.builder.io": "builder.io", "images.prismic.io": "imgix", + "img.truo.cloud": "truocloud", "www.datocms-assets.com": "imgix", "cdn.sanity.io": "imgix", "images.unsplash.com": "imgix", diff --git a/demo/src/examples.json b/demo/src/examples.json index bffd3d8..63fdf98 100644 --- a/demo/src/examples.json +++ b/demo/src/examples.json @@ -80,6 +80,10 @@ "Uploadcare", "https://ucarecdn.com/661bd414-064c-477a-b50f-8ffd8f66aa49/" ], + "truocloud": [ + "TruoCloud", + "https://img.truo.cloud/i/demo/wikipedia/commons/3/3f/Fronalpstock_big.jpg" + ], "supabase": [ "Supabase", "https://enlyjtqaeutqbhqgkadn.supabase.co/storage/v1/object/public/sample-public-bucket/alexander-shatov-PHH_0uw9-Qw-unsplash.jpg" diff --git a/deno.jsonc b/deno.jsonc index 88c341a..5e4e371 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -27,6 +27,7 @@ "./providers/shopify": "./src/providers/shopify.ts", "./providers/storyblok": "./src/providers/storyblok.ts", "./providers/supabase": "./src/providers/supabase.ts", + "./providers/truocloud": "./src/providers/truocloud.ts", "./providers/uploadcare": "./src/providers/uploadcare.ts", "./providers/vercel": "./src/providers/vercel.ts", "./providers/wordpress": "./src/providers/wordpress.ts", diff --git a/src/async.ts b/src/async.ts index bb77294..80869a9 100644 --- a/src/async.ts +++ b/src/async.ts @@ -37,6 +37,7 @@ const asyncProviderMap: AsyncProviderMap = { shopify: () => import("./providers/shopify.ts"), storyblok: () => import("./providers/storyblok.ts"), supabase: () => import("./providers/supabase.ts"), + truocloud: () => import("./providers/truocloud.ts"), uploadcare: () => import("./providers/uploadcare.ts"), vercel: () => import("./providers/vercel.ts"), wordpress: () => import("./providers/wordpress.ts"), diff --git a/src/extract.ts b/src/extract.ts index 2ec907a..7741148 100644 --- a/src/extract.ts +++ b/src/extract.ts @@ -30,6 +30,7 @@ import { extract as scene7 } from "./providers/scene7.ts"; import { extract as shopify } from "./providers/shopify.ts"; import { extract as storyblok } from "./providers/storyblok.ts"; import { extract as supabase } from "./providers/supabase.ts"; +import { extract as truocloud } from "./providers/truocloud.ts"; import { extract as uploadcare } from "./providers/uploadcare.ts"; import { extract as vercel } from "./providers/vercel.ts"; import { extract as wordpress } from "./providers/wordpress.ts"; @@ -60,6 +61,7 @@ export const parsers: URLExtractorMap = { shopify, storyblok, supabase, + truocloud, uploadcare, vercel, wordpress, diff --git a/src/providers/truocloud.test.ts b/src/providers/truocloud.test.ts new file mode 100644 index 0000000..8cef623 --- /dev/null +++ b/src/providers/truocloud.test.ts @@ -0,0 +1,77 @@ +import { assertEquals } from "jsr:@std/assert"; +import { extract, generate, transform } from "./truocloud.ts"; +import { assertEqualIgnoringQueryOrder } from "../test-utils.ts"; + +const img = "https://img.truo.cloud/i/demo/uploads/photo.jpg"; + +Deno.test("truocloud extract", async (t) => { + await t.step("should parse a delivery URL", () => { + const result = extract(`${img}?f=auto&w=800`); + assertEquals(result?.src, img); + assertEquals(result?.operations.width, 800); + assertEquals(result?.operations.format, "auto"); + }); + + await t.step("should return null for a URL it does not own", () => { + // The delivery path is `/i//…`; anything else on the host is not an + // image, and claiming it would rewrite somebody else's URL. + assertEquals(extract("https://img.truo.cloud/health"), null); + assertEquals(extract("https://images.example.com/a.jpg?w=100"), null); + }); + + await t.step("should not return the signature as an operation", () => { + // `s` is an HMAC over this exact path and query. Returning it would let a + // caller regenerate a URL carrying a signature that no longer covers it. + const result = extract(`${img}?w=800&s=abc123`); + assertEquals(result?.operations.s, undefined); + }); +}); + +Deno.test("truocloud generate", async (t) => { + await t.step("should format a URL with width and height", () => { + const result = generate(img, { width: 300, height: 200 }); + assertEqualIgnoringQueryOrder(result, `${img}?h=200&w=300`); + }); + + await t.step("should map jpeg to jpg", () => { + // The service answers `jpg` and ignores a format it does not know, so an + // unmapped `jpeg` would silently return the source format. + const result = generate(img, { format: "jpeg" }); + assertEqualIgnoringQueryOrder(result, `${img}?f=jpg`); + }); + + await t.step("should emit parameters in sorted order", () => { + // Every builder of this contract sorts them; two orderings of one request + // are two CDN cache entries for the same image. + const result = generate(img, { quality: 70, width: 800, format: "auto" }); + assertEquals(result, `${img}?f=auto&q=70&w=800`); + }); + + await t.step("should keep commas literal", () => { + // The transformation engine does not decode `%2C`: with the comma escaped + // the crop is ignored and the image comes back uncropped, with a 200. + const result = generate(img, { crop: "60,30,0,0" }); + assertEquals(result, `${img}?crop=60,30,0,0`); + }); +}); + +Deno.test("truocloud transform", async (t) => { + await t.step("should change one operation and leave the rest", () => { + const result = transform(`${img}?f=auto&q=70&w=800`, { width: 400 }); + assertEqualIgnoringQueryOrder(result, `${img}?f=auto&q=70&w=400`); + }); + + await t.step("should round non-integer params", () => { + const result = transform(img, { width: 200.6, height: 100.2 }); + assertEqualIgnoringQueryOrder(result, `${img}?h=100&w=201`); + }); + + await t.step("should survive a proxied source intact", () => { + // `/fetch/` proxies a third-party origin. Its slashes are + // percent-encoded so the whole URL stays a single path segment. + const proxied = + "https://img.truo.cloud/i/demo/fetch/https%3A%2F%2Fexample.com%2Fa.jpg"; + const result = transform(`${proxied}?w=400`, { width: 800 }); + assertEqualIgnoringQueryOrder(result, `${proxied}?w=800`); + }); +}); diff --git a/src/providers/truocloud.ts b/src/providers/truocloud.ts new file mode 100644 index 0000000..5e6c9c2 --- /dev/null +++ b/src/providers/truocloud.ts @@ -0,0 +1,128 @@ +import type { + ImageFormat, + Operations, + URLExtractor, + URLGenerator, + URLTransformer, +} from "../types.ts"; +import { + createExtractAndGenerate, + createOperationsHandlers, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; + +/** + * `auto` is a first-class value, not a convenience: the service picks avif or + * webp from the browser's `Accept` header and answers `Vary: Accept`. + */ +export type TruoCloudFormats = + | ImageFormat + | "gif" + | "tiff" + | "jxl" + | "json" + | "auto"; + +export interface TruoCloudOperations extends Operations { + /** Width in pixels. */ + w?: number; + /** Height in pixels. */ + h?: number; + /** Quality, 1-100. Defaults to 82 server-side when a transform is present. */ + q?: number; + /** Output format. */ + f?: TruoCloudFormats; + /** + * Resize behaviour. The service also accepts the imgix and ImageKit + * vocabularies (`crop`, `clip`, `pad`, `scale`…) and maps them itself; those + * reach it through the index signature below. + */ + fit?: "contain" | "cover" | "fill" | "inside" | "outside"; + /** Device pixel ratio, up to 3. */ + dpr?: number; + /** Blur radius, up to 100. */ + blur?: number; + /** Gravity for `fit=cover`. */ + a?: string; + /** Crop rectangle, as `width,height,x,y`. */ + crop?: `${number},${number},${number},${number}`; + /** Rotation in degrees. */ + ro?: number; + /** Background colour, as hex without `#` or a CSS name. */ + bg?: string; + [key: string]: string | number | boolean | undefined; +} + +const { operationsGenerator, operationsParser } = createOperationsHandlers< + TruoCloudOperations +>({ + keyMap: { + width: "w", + height: "h", + format: "f", + quality: "q", + }, + // The service answers `jpg`, not `jpeg`, and silently ignores a format it + // does not know — so an unmapped `jpeg` would return the source format with + // a 200 and no way to tell why. + formatMap: { jpeg: "jpg" }, +}); + +/** + * `/i//…` is the only shape this provider handles. The pid pattern is the + * service's own (3-64 lowercase alphanumerics and dashes); matching it rather + * than just `/i/` keeps the provider from claiming an unrelated URL on a custom + * domain that happens to have an `/i/` directory. + */ +const DELIVERY_PATH = /^\/i\/[a-z0-9][a-z0-9-]{2,63}\/.+/; + +/** + * Parameters come out sorted by name. + * + * The service accepts any order, but every other builder of this contract emits + * them sorted, and two orderings of one request are two CDN cache entries for + * the same image. + */ +function sortSearch(url: URL): void { + const sorted = [...url.searchParams.entries()].sort( + ([a], [b]) => (a < b ? -1 : a > b ? 1 : 0), + ); + url.search = ""; + for (const [key, value] of sorted) { + url.searchParams.append(key, value); + } + // The transformation engine does not decode `%2C`, so `crop=60,30,0,0` has + // to keep its commas literal or the crop is ignored and the image comes + // back uncropped, with a 200. + url.search = url.search.replace(/%2C/g, ","); +} + +export const extract: URLExtractor<"truocloud"> = (url) => { + const src = toUrl(url); + if (!DELIVERY_PATH.test(src.pathname)) { + return null; + } + + const operations = operationsParser(url); + // `s` and `exp` are an HMAC over this exact path and query. Returning them + // as operations would let a caller regenerate a URL carrying a signature + // that no longer covers it. + delete operations.s; + delete operations.exp; + + src.search = ""; + return { src: toCanonicalUrlString(src), operations }; +}; + +export const generate: URLGenerator<"truocloud"> = (src, operations) => { + const url = toUrl(src); + url.search = operationsGenerator(operations); + sortSearch(url); + return toCanonicalUrlString(url); +}; + +export const transform: URLTransformer<"truocloud"> = createExtractAndGenerate( + extract, + generate, +); diff --git a/src/providers/types.ts b/src/providers/types.ts index 3166818..3fcc78c 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -34,6 +34,7 @@ import type { Scene7Operations } from "./scene7.ts"; import type { ShopifyOperations } from "./shopify.ts"; import type { StoryblokOperations } from "./storyblok.ts"; import type { SupabaseOperations } from "./supabase.ts"; +import type { TruoCloudOperations } from "./truocloud.ts"; import type { UploadcareOperations, UploadcareOptions } from "./uploadcare.ts"; import type { VercelOperations, VercelOptions } from "./vercel.ts"; import type { WordPressOperations } from "./wordpress.ts"; @@ -64,6 +65,7 @@ export interface ProviderOperations { shopify: ShopifyOperations; storyblok: StoryblokOperations; supabase: SupabaseOperations; + truocloud: TruoCloudOperations; uploadcare: UploadcareOperations; vercel: VercelOperations; wordpress: WordPressOperations; @@ -95,6 +97,7 @@ export interface ProviderOptions { shopify: undefined; storyblok: undefined; supabase: undefined; + truocloud: undefined; uploadcare: UploadcareOptions; vercel: VercelOptions; wordpress: undefined; diff --git a/src/transform.ts b/src/transform.ts index d9bc03b..70ad708 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -23,6 +23,7 @@ import { transform as scene7 } from "./providers/scene7.ts"; import { transform as shopify } from "./providers/shopify.ts"; import { transform as storyblok } from "./providers/storyblok.ts"; import { transform as supabase } from "./providers/supabase.ts"; +import { transform as truocloud } from "./providers/truocloud.ts"; import { transform as uploadcare } from "./providers/uploadcare.ts"; import { transform as vercel } from "./providers/vercel.ts"; import { transform as wordpress } from "./providers/wordpress.ts"; @@ -63,6 +64,7 @@ const transformerMap: URLTransformerMap = { shopify, storyblok, supabase, + truocloud, uploadcare, vercel, wordpress, diff --git a/src/types.ts b/src/types.ts index 3492606..c4306a0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -58,6 +58,7 @@ export type ImageCdn = | "imagekit" | "uploadcare" | "supabase" + | "truocloud" | "hygraph" | "appwrite" | "wsrv"; @@ -87,6 +88,7 @@ export const SupportedProviders: Record = { shopify: "Shopify", storyblok: "Storyblok", supabase: "Supabase", + truocloud: "TruoCloud", uploadcare: "Uploadcare", vercel: "Vercel", wordpress: "WordPress",