diff --git a/docs/content/docs/api/configuration/upload-config.mdx b/docs/content/docs/api/configuration/upload-config.mdx index ff6c683c..77cb327f 100644 --- a/docs/content/docs/api/configuration/upload-config.mdx +++ b/docs/content/docs/api/configuration/upload-config.mdx @@ -55,6 +55,18 @@ createUploadConfig().provider("cloudflareR2",{ // [!code highlight] }) ``` +**Public R2 bucket with custom domain:** + +```typescript title="R2 Public Bucket" +createUploadConfig().provider("cloudflareR2", { + // ...credentials + customDomain: 'https://cdn.yourdomain.com', // [!code highlight] + visibility: 'public', // [!code highlight] +}) +``` + +When `visibility: 'public'` is set, pushduck returns the custom domain URL directly after upload instead of a presigned URL. For R2, `customDomain` is **required** when `visibility: 'public'` — R2 presigned URLs cannot be used with custom domains (TypeScript will enforce this). + ### AWS S3 ```typescript title="AWS S3 Configuration" @@ -112,6 +124,35 @@ createUploadConfig().provider("s3Compatible",{ }) ``` +## Bucket Visibility + +The `visibility` option controls what kind of download URL pushduck returns after a file is uploaded. + +```typescript +createUploadConfig().provider("aws", { + // ... + visibility: 'private', // default +}) +``` + +| Value | Download URL returned | When to use | +|-------|----------------------|-------------| +| `'private'` (default) | Presigned GET URL (time-limited, signed against S3 API endpoint) | Private buckets — most setups | +| `'public'` | Plain URL (`customDomain/key` or S3 URL) | Publicly accessible buckets | + +**Important:** `visibility` must match your actual bucket configuration. Setting `visibility: 'public'` on a private bucket will return URLs that result in 403 errors. pushduck does not verify your bucket's access settings. + +| Bucket type | `customDomain` | `visibility` | URL returned | +|------------|---------------|-------------|-------------| +| Private | — | `'private'` | Presigned GET (S3 API endpoint) | +| Private | CDN | `'private'` | Presigned GET (S3 API endpoint — CDN bypassed) | +| Public | — | `'public'` | Plain S3 URL | +| Public | CDN | `'public'` | `https://cdn.yourdomain.com/key` | + +> **R2 note:** `visibility: 'public'` requires `customDomain` to be set. R2 presigned URLs cannot be used with custom domains — TypeScript will show an error if you omit `customDomain` when setting `visibility: 'public'` on an R2 provider. + +`storage.download.presignedUrl(key, expiresIn?)` always generates a presigned URL regardless of `visibility` — useful when you explicitly need a time-limited signed URL for a specific file. + ## Configuration Methods ### .defaults() diff --git a/docs/content/docs/providers/aws-s3.mdx b/docs/content/docs/providers/aws-s3.mdx index a36ca47f..f5c1f743 100644 --- a/docs/content/docs/providers/aws-s3.mdx +++ b/docs/content/docs/providers/aws-s3.mdx @@ -300,30 +300,41 @@ If your bucket is public (not recommended for production): Set up pushduck with your AWS S3 configuration: +**Private bucket (default — recommended):** + ```typescript // lib/upload.ts import { createUploadConfig } from "pushduck/server"; -export const { s3, config } = createUploadConfig() +export const { s3 } = createUploadConfig() .provider("aws", { accessKeyId: process.env.AWS_ACCESS_KEY_ID!, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, region: process.env.AWS_REGION!, bucket: process.env.AWS_S3_BUCKET_NAME!, - // Optional: Custom domain for public files - customDomain: process.env.S3_CUSTOM_DOMAIN, - }) - .defaults({ - maxFileSize: "10MB", - acl: "private", // Use 'public-read' for public files }) + .defaults({ maxFileSize: "10MB" }) .build(); +``` -export { s3 }; +**Public bucket with CloudFront CDN:** + +```typescript +export const { s3 } = createUploadConfig() + .provider("aws", { + accessKeyId: process.env.AWS_ACCESS_KEY_ID!, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + region: process.env.AWS_REGION!, + bucket: process.env.AWS_S3_BUCKET_NAME!, + customDomain: process.env.S3_CUSTOM_DOMAIN, // e.g. https://cdn.yourdomain.com // [!code highlight] + visibility: 'public', // returns CDN URLs after upload instead of presigned URLs // [!code highlight] + }) + .defaults({ maxFileSize: "10MB" }) + .build(); ``` - **Custom Domain**: When `customDomain` is configured, public URLs will use your custom domain instead of the S3 URL. Internal operations (upload, delete) still use S3 endpoints. + **`visibility`**: Controls what download URL pushduck returns after upload. `'private'` (default) generates a presigned GET URL valid for 1 hour. `'public'` returns the plain CDN/S3 URL — use this only when your bucket or objects are publicly accessible. Upload signing always uses the S3 API endpoint regardless of this setting. diff --git a/docs/content/docs/providers/cloudflare-r2.mdx b/docs/content/docs/providers/cloudflare-r2.mdx index 81e5a467..a852de3c 100644 --- a/docs/content/docs/providers/cloudflare-r2.mdx +++ b/docs/content/docs/providers/cloudflare-r2.mdx @@ -85,8 +85,7 @@ Add to your `.env.local`: # Cloudflare R2 Configuration AWS_ACCESS_KEY_ID=your_r2_access_key_id AWS_SECRET_ACCESS_KEY=your_r2_secret_access_key -AWS_ENDPOINT_URL=https://account-id.r2.cloudflarestorage.com -AWS_REGION=auto +R2_ACCOUNT_ID=your_cloudflare_account_id S3_BUCKET_NAME=your-bucket-name # Optional: Custom domain for public files @@ -123,26 +122,41 @@ For better performance and branding, you can use a custom domain: ## 6. Update Your Upload Configuration +**Private bucket (default):** + ```typescript // lib/upload.ts import { createUploadConfig } from "pushduck/server"; -export const { s3, } = createUploadConfig() - .provider("cloudflareR2",{ +export const { s3 } = createUploadConfig() + .provider("cloudflareR2", { accessKeyId: process.env.AWS_ACCESS_KEY_ID!, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, - accountId: process.env.R2_ACCOUNT_ID!, // Found in R2 dashboard + accountId: process.env.R2_ACCOUNT_ID!, bucket: process.env.S3_BUCKET_NAME!, - // Optional: Custom domain for faster access - customDomain: process.env.CLOUDFLARE_R2_CUSTOM_DOMAIN, }) - .defaults({ - maxFileSize: "10MB", - acl: "public-read", // For public access + .defaults({ maxFileSize: "10MB" }) + .build(); +``` + +**Public bucket with custom domain:** + +```typescript +export const { s3 } = createUploadConfig() + .provider("cloudflareR2", { + accessKeyId: process.env.AWS_ACCESS_KEY_ID!, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + accountId: process.env.R2_ACCOUNT_ID!, + bucket: process.env.S3_BUCKET_NAME!, + customDomain: process.env.CLOUDFLARE_R2_CUSTOM_DOMAIN!, // e.g. https://cdn.yourdomain.com // [!code highlight] + visibility: 'public', // returns CDN URLs after upload instead of presigned URLs // [!code highlight] }) + .defaults({ maxFileSize: "10MB" }) .build(); ``` +> **Note:** For R2, `visibility: 'public'` requires `customDomain` to be set. R2 presigned URLs cannot be used with custom domains — TypeScript enforces this at compile time. + ## 7. Test Your Setup ```bash diff --git a/packages/pushduck/src/__tests__/download-url-visibility.test.ts b/packages/pushduck/src/__tests__/download-url-visibility.test.ts new file mode 100644 index 00000000..8efe31b5 --- /dev/null +++ b/packages/pushduck/src/__tests__/download-url-visibility.test.ts @@ -0,0 +1,213 @@ +/** + * Tests for download URL generation with visibility config + * + * Covers: + * - Private bucket (default): presigned GET signed against S3 API endpoint + * - Private bucket + custom domain: presigned GET still against S3 API, not CDN + * - Public bucket + no custom domain: plain S3 URL, no signing + * - Public bucket + custom domain: custom domain URL, no signing + * - R2 private: presigned GET against R2 API endpoint + * - R2 public + custom domain: custom domain URL, no signing + * - storage.download.presignedUrl(): always presigns regardless of visibility + */ + +import { describe, expect, it } from "vitest"; +import { createUploadConfig } from "../core/config/upload-config"; +import { + generateDownloadUrl, + generatePresignedDownloadUrl, +} from "../core/storage/client"; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +type AWSOverrides = { + visibility?: "public" | "private"; + customDomain?: string; +}; + +function makeAWS(overrides: AWSOverrides = {}) { + return createUploadConfig() + .provider("aws", { + accessKeyId: "test-key", + secretAccessKey: "test-secret", + region: "us-east-1", + bucket: "test-bucket", + ...overrides, + }) + .build().config; +} + +/** Private R2 bucket — customDomain is optional */ +function makeR2Private(customDomain?: string) { + return createUploadConfig() + .provider("cloudflareR2", { + accessKeyId: "test-key", + secretAccessKey: "test-secret", + accountId: "abc123", + bucket: "test-bucket", + customDomain, + }) + .build().config; +} + +/** Public R2 bucket — customDomain is required */ +function makeR2Public(customDomain: string) { + return createUploadConfig() + .provider("cloudflareR2", { + accessKeyId: "test-key", + secretAccessKey: "test-secret", + accountId: "abc123", + bucket: "test-bucket", + visibility: "public", + customDomain, + }) + .build().config; +} + +const KEY = "uploads/photo.jpg"; +const S3_URL = `https://test-bucket.s3.us-east-1.amazonaws.com/${KEY}`; +const R2_API_URL = `https://abc123.r2.cloudflarestorage.com/test-bucket/${KEY}`; +// customDomain must include the protocol — buildPublicUrl uses the value as-is +const CUSTOM_DOMAIN = "https://cdn.example.com"; +const CDN_URL = `${CUSTOM_DOMAIN}/${KEY}`; + +// ─── generateDownloadUrl — private (default) ──────────────────────────────── + +describe("generateDownloadUrl — private bucket (default)", () => { + it("returns a presigned URL signed against the S3 API endpoint", async () => { + const config = makeAWS(); + const url = await generateDownloadUrl(config, KEY); + expect(url).toContain(S3_URL); + expect(url).toContain("X-Amz-Signature"); + expect(url).toContain("X-Amz-Expires=3600"); + }); + + it("respects custom expiresIn", async () => { + const config = makeAWS(); + const url = await generateDownloadUrl(config, KEY, 300); + expect(url).toContain("X-Amz-Expires=300"); + }); + + it("signs against S3 API endpoint even when customDomain is set", async () => { + const config = makeAWS({ customDomain: CUSTOM_DOMAIN }); + const url = await generateDownloadUrl(config, KEY); + // Must use the S3 API endpoint for signing, NOT the custom domain + expect(url).toContain("s3.us-east-1.amazonaws.com"); + expect(url).not.toContain("cdn.example.com"); + expect(url).toContain("X-Amz-Signature"); + }); + + it("explicit visibility: 'private' behaves the same as default", async () => { + const config = makeAWS({ visibility: "private" }); + const url = await generateDownloadUrl(config, KEY); + expect(url).toContain(S3_URL); + expect(url).toContain("X-Amz-Signature"); + }); +}); + +// ─── generateDownloadUrl — public bucket ──────────────────────────────────── + +describe("generateDownloadUrl — public bucket", () => { + it("returns plain S3 URL with no signing when no custom domain", async () => { + const config = makeAWS({ visibility: "public" }); + const url = await generateDownloadUrl(config, KEY); + expect(url).toBe(S3_URL); + expect(url).not.toContain("X-Amz-Signature"); + expect(url).not.toContain("X-Amz-Expires"); + }); + + it("returns custom domain URL with no signing when customDomain is set", async () => { + const config = makeAWS({ visibility: "public", customDomain: CUSTOM_DOMAIN }); + const url = await generateDownloadUrl(config, KEY); + expect(url).toBe(CDN_URL); + expect(url).not.toContain("X-Amz-Signature"); + expect(url).not.toContain("amazonaws.com"); + }); + + it("strips trailing slash from custom domain", async () => { + const config = makeAWS({ visibility: "public", customDomain: CUSTOM_DOMAIN + "/" }); + const url = await generateDownloadUrl(config, KEY); + expect(url).toBe(CDN_URL); + }); +}); + +// ─── generateDownloadUrl — R2 ─────────────────────────────────────────────── + +describe("generateDownloadUrl — Cloudflare R2", () => { + it("private (default): presigned GET against R2 API endpoint", async () => { + const config = makeR2Private(); + const url = await generateDownloadUrl(config, KEY); + expect(url).toContain(R2_API_URL); + expect(url).toContain("X-Amz-Signature"); + }); + + it("private + custom domain: still signs against R2 API, not custom domain", async () => { + const config = makeR2Private(CUSTOM_DOMAIN); + const url = await generateDownloadUrl(config, KEY); + // R2 presigned URLs must use the API endpoint, not custom domain + expect(url).toContain("r2.cloudflarestorage.com"); + expect(url).not.toContain("cdn.example.com"); + expect(url).toContain("X-Amz-Signature"); + }); + + it("public + custom domain: returns custom domain URL with no signing", async () => { + const config = makeR2Public(CUSTOM_DOMAIN); + const url = await generateDownloadUrl(config, KEY); + expect(url).toBe(CDN_URL); + expect(url).not.toContain("X-Amz-Signature"); + expect(url).not.toContain("r2.cloudflarestorage.com"); + }); +}); + +// ─── generatePresignedDownloadUrl — always presigns ───────────────────────── + +describe("generatePresignedDownloadUrl — always presigns (ignores visibility)", () => { + it("presigns for private bucket", async () => { + const config = makeAWS({ visibility: "private" }); + const url = await generatePresignedDownloadUrl(config, KEY); + expect(url).toContain("X-Amz-Signature"); + expect(url).toContain(S3_URL); + }); + + it("presigns even when visibility is 'public'", async () => { + const config = makeAWS({ visibility: "public", customDomain: CUSTOM_DOMAIN }); + const url = await generatePresignedDownloadUrl(config, KEY); + // Ignores visibility — always presigns against S3 API endpoint + expect(url).toContain("X-Amz-Signature"); + expect(url).toContain("s3.us-east-1.amazonaws.com"); + expect(url).not.toContain("cdn.example.com"); + }); + + it("respects custom expiresIn", async () => { + const config = makeAWS(); + const url = await generatePresignedDownloadUrl(config, KEY, 900); + expect(url).toContain("X-Amz-Expires=900"); + }); + + it("defaults to 3600s expiry", async () => { + const config = makeAWS(); + const url = await generatePresignedDownloadUrl(config, KEY); + expect(url).toContain("X-Amz-Expires=3600"); + }); +}); + +// ─── R2 TypeScript discriminated union ─────────────────────────────────────── + +describe("R2 visibility: 'public' TypeScript constraint", () => { + it("R2 private without customDomain is valid", () => { + expect(() => makeR2Private()).not.toThrow(); + }); + + it("R2 public with customDomain is valid", () => { + expect(() => makeR2Public(CUSTOM_DOMAIN)).not.toThrow(); + }); + + it("R2 public without customDomain throws at runtime", () => { + expect(() => + createUploadConfig() + // @ts-expect-error: visibility: 'public' without customDomain is a type error + .provider("cloudflareR2", { accessKeyId: "k", secretAccessKey: "s", accountId: "id", bucket: "b", visibility: "public" }) + .build() + ).toThrow("R2 requires customDomain when visibility is 'public'"); + }); +}); diff --git a/packages/pushduck/src/__tests__/s3-fallback.test.ts b/packages/pushduck/src/__tests__/s3-fallback.test.ts index f752e656..79db5645 100644 --- a/packages/pushduck/src/__tests__/s3-fallback.test.ts +++ b/packages/pushduck/src/__tests__/s3-fallback.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { createUploadConfig } from "../core/config/upload-config"; import { - generatePresignedDownloadUrl, + generateDownloadUrl, generatePresignedUploadUrl, getFileUrl, } from "../core/storage/client"; @@ -66,7 +66,7 @@ describe("S3 Fallback Behavior", () => { }) .build(); - const presignedUrl = await generatePresignedDownloadUrl( + const presignedUrl = await generateDownloadUrl( config, "uploads/test-image.jpg", 3600 diff --git a/packages/pushduck/src/core/providers/providers.ts b/packages/pushduck/src/core/providers/providers.ts index 9de8d5cb..fe85187b 100644 --- a/packages/pushduck/src/core/providers/providers.ts +++ b/packages/pushduck/src/core/providers/providers.ts @@ -91,6 +91,24 @@ export interface BaseProviderConfig { customDomain?: string; /** Force path-style URLs instead of virtual-hosted style */ forcePathStyle?: boolean; + /** + * Controls what kind of download URL the library returns after a file is uploaded. + * + * - `'private'` (default) — generates a presigned GET URL signed against the + * provider's S3 API endpoint. Works for private buckets. The URL expires + * after `expiresIn` seconds (default 3600). + * - `'public'` — returns the plain public URL (custom domain if configured, + * otherwise the provider's public URL). No signing. Use this when your + * bucket/objects are already publicly accessible. + * + * **Important:** This setting must match your actual bucket configuration. + * Setting `visibility: 'public'` on a private bucket will result in 403 errors + * when clients try to access the returned URLs. pushduck does not verify or + * enforce your bucket's access settings. + * + * @default 'private' + */ + visibility?: "public" | "private"; } // ======================================== @@ -140,14 +158,34 @@ export interface AWSProviderConfig extends BaseProviderConfig { sessionToken?: string; } +/** + * Base fields for Cloudflare R2 object storage (without visibility/customDomain). + * Use the exported `CloudflareR2Config` type for the full discriminated union. + */ +export interface CloudflareR2BaseConfig extends Omit { + provider: "cloudflare-r2"; + /** Cloudflare Account ID */ + accountId: string; + /** R2 Access Key ID */ + accessKeyId: string; + /** R2 Secret Access Key */ + secretAccessKey: string; + /** Region (typically 'auto' for R2) */ + region?: "auto"; + /** Custom endpoint (auto-generated from accountId if not provided) */ + endpoint?: string; +} + /** * Configuration for Cloudflare R2 object storage. * S3-compatible storage with zero egress fees and global distribution. * - * @interface CloudflareR2Config - * @extends BaseProviderConfig + * R2 presigned URLs only work with the R2 S3 API endpoint — they cannot be + * used with custom domains. Therefore `visibility: 'public'` requires a + * `customDomain` to be set (R2 public access requires a custom domain or + * the r2.dev subdomain; the API endpoint does not serve public content). * - * @example Basic Configuration + * @example Basic Configuration (private bucket) * ```typescript * const r2Config: CloudflareR2Config = { * provider: 'cloudflare-r2', @@ -158,7 +196,7 @@ export interface AWSProviderConfig extends BaseProviderConfig { * }; * ``` * - * @example With Custom Domain + * @example Public bucket with custom domain * ```typescript * const r2WithDomain: CloudflareR2Config = { * provider: 'cloudflare-r2', @@ -166,23 +204,16 @@ export interface AWSProviderConfig extends BaseProviderConfig { * accountId: 'abc123', * accessKeyId: 'key123', * secretAccessKey: 'secret123', - * customDomain: 'assets.myapp.com', + * customDomain: 'https://assets.myapp.com', // required when visibility is 'public' + * visibility: 'public', * }; * ``` */ -export interface CloudflareR2Config extends BaseProviderConfig { - provider: "cloudflare-r2"; - /** Cloudflare Account ID */ - accountId: string; - /** R2 Access Key ID */ - accessKeyId: string; - /** R2 Secret Access Key */ - secretAccessKey: string; - /** Region (typically 'auto' for R2) */ - region?: "auto"; - /** Custom endpoint (auto-generated from accountId if not provided) */ - endpoint?: string; -} +export type CloudflareR2Config = CloudflareR2BaseConfig & + ( + | { visibility: "public"; customDomain: string } + | { visibility?: "private"; customDomain?: string } + ); /** * Configuration for DigitalOcean Spaces object storage. @@ -399,46 +430,67 @@ export type ProviderConfig = // DRY Provider Factory System // ======================================== -interface ConfigKeyMapping { - readonly [key: string]: readonly string[]; -} - interface ProviderSpec { readonly provider: string; - readonly configKeys: ConfigKeyMapping; - readonly defaults: Record; - readonly customLogic?: (config: any, computed: any) => any; + readonly configKeys: { readonly [key: string]: readonly string[] }; + readonly defaults: { readonly [key: string]: unknown }; + readonly customLogic?: ( + config: Record, + computed: Record + ) => Record; } /** - * Generic provider configuration builder - * Validates required configuration fields + * Generic provider configuration builder. + * + * The function signature is fully typed — `T` flows from the provider config + * type so callers retain type information. Internally, we widen `config` to + * `Record` only for dynamic key-based access (the spec keys + * are runtime strings, not statically known). The single `as unknown as T` + * cast at the return boundary is the only escape hatch; it is justified + * because `validateProviderConfig` runs immediately before, ensuring the + * object has all required fields for T. */ function createProviderBuilder( spec: ProviderSpec -): (config?: Partial) => T { - return (config: Partial = {}): T => { - const result: any = { provider: spec.provider }; +): (config?: Partial>) => T { + return (config: Partial> = {} as Partial>): T => { + // Widen to Record for dynamic key access — widening only, no data loss + const configRecord = config as Record; + const result: Record = { provider: spec.provider }; + + // Apply spec-declared keys with config values or fallback defaults + for (const key of Object.keys(spec.configKeys)) { + result[key] = configRecord[key] ?? spec.defaults[key] ?? ""; + } - // Only use explicit config and defaults (no env vars) - for (const [key] of Object.entries(spec.configKeys)) { - result[key] = config[key as keyof T] || spec.defaults[key] || ""; + // Pass through any config keys not covered by spec.configKeys + // (e.g. visibility, forcePathStyle, future BaseProviderConfig fields) + for (const [key, value] of Object.entries(configRecord)) { + if (!(key in result) && value !== undefined) { + result[key] = value; + } } - // Apply custom logic if provided + // Apply provider-specific derived fields (e.g. auto-compute endpoint) if (spec.customLogic) { - Object.assign(result, spec.customLogic(config, result)); + Object.assign(result, spec.customLogic(configRecord, result)); } - // Validate the final configuration - const validation = validateProviderConfig(result); + // Single boundary cast: dynamic Record → typed T. + // unknown intermediate required because Record and the + // ProviderConfig union don't share enough structure for a direct assertion. + const built = result as unknown as T; + + // Validate before returning — throws if required fields are missing + const validation = validateProviderConfig(built); if (!validation.valid) { throw new Error( `Provider validation failed: ${validation.errors.join(", ")}` ); } - return result as T; + return built; }; } @@ -483,7 +535,7 @@ const PROVIDER_SPECS = { region: "auto", acl: "private", }, - customLogic: (config: any, computed: any) => ({ + customLogic: (config, computed) => ({ endpoint: computed.endpoint || (computed.accountId @@ -513,7 +565,7 @@ const PROVIDER_SPECS = { region: "nyc3", acl: "private", }, - customLogic: (config: any, computed: any) => ({ + customLogic: (config, computed) => ({ endpoint: computed.endpoint || `https://${computed.region}.digitaloceanspaces.com`, @@ -536,7 +588,7 @@ const PROVIDER_SPECS = { region: "us-east-1", acl: "private", }, - customLogic: (config: any, computed: any) => ({ + customLogic: (config, computed) => ({ useSSL: config.useSSL ?? false, port: config.port ? Number(config.port) : undefined, }), @@ -556,7 +608,7 @@ const PROVIDER_SPECS = { region: "us-central1", acl: "private", }, - customLogic: (config: any) => ({ + customLogic: (config, _computed) => ({ credentials: config.credentials, }), }, @@ -578,7 +630,7 @@ const PROVIDER_SPECS = { forcePathStyle: true, // Most S3-compatible providers need path-style access }, }, -} as const; +} as const satisfies Record; // ======================================== // Generic Provider Creator (New API) @@ -593,12 +645,33 @@ export type ProviderType = keyof ProviderSpecsType; // ======================================== /** - * Maps each provider type to its corresponding configuration interface - * This enables type-safe provider configuration in createUploadConfig().provider() + * Maps each provider key to its fully-resolved output config type. + * Used to constrain createProviderBuilder so the generic T is the + * concrete ProviderConfig subtype, not the loose ProviderConfig union. + */ +export type ProviderOutputMap = { + aws: AWSProviderConfig; + cloudflareR2: CloudflareR2Config; + digitalOceanSpaces: DigitalOceanSpacesConfig; + minio: MinIOConfig; + gcs: GoogleCloudStorageConfig; + s3Compatible: S3CompatibleConfig; +}; + +/** + * Maps each provider type to its user-facing input configuration. + * Partial so callers only supply the fields they know; missing values are + * filled from environment variables or defaults inside createProviderBuilder. + * R2 carries its discriminated union so TypeScript enforces the + * visibility: 'public' → customDomain required constraint at the call site. */ export type ProviderConfigMap = { aws: Partial>; - cloudflareR2: Partial>; + cloudflareR2: Partial> & + ( + | { visibility: "public"; customDomain: string } + | { visibility?: "private"; customDomain?: string } + ); digitalOceanSpaces: Partial>; minio: Partial>; gcs: Partial>; @@ -661,14 +734,20 @@ export type ProviderConfigMap = { */ export function createProvider( type: T, - config: ProviderConfigMap[T] = {} as ProviderConfigMap[T] -): ProviderConfig { + config?: ProviderConfigMap[T] +): ProviderOutputMap[T] { const spec = PROVIDER_SPECS[type]; if (!spec) { throw new Error(`Unknown provider type: ${type}`); } - return createProviderBuilder(spec)(config as any); + // ProviderConfigMap[T] is always a structural subtype of + // Partial> for each concrete T — + // the only difference is the Partial wrapping and R2's discriminated union. + // The cast narrows from the user-facing input shape to the builder's param. + return createProviderBuilder(spec)( + config as Partial> + ); } // ======================================== @@ -758,6 +837,10 @@ export function validateProviderConfig(config: ProviderConfig): { if (!config.accessKeyId) errors.push("R2 Access Key ID is required"); if (!config.secretAccessKey) errors.push("R2 Secret Access Key is required"); + if (config.visibility === "public" && !config.customDomain) + errors.push( + "R2 requires customDomain when visibility is 'public' — R2 presigned URLs cannot be used with custom domains" + ); break; case "digitalocean-spaces": diff --git a/packages/pushduck/src/core/router/router-v2.ts b/packages/pushduck/src/core/router/router-v2.ts index f0ffdc49..866ed6db 100644 --- a/packages/pushduck/src/core/router/router-v2.ts +++ b/packages/pushduck/src/core/router/router-v2.ts @@ -61,7 +61,7 @@ import { createUniversalHandler } from "../handler/universal-handler"; import { InferS3Input, InferS3Output, S3Schema } from "../schema"; import { generateFileKey, - generatePresignedDownloadUrl, + generateDownloadUrl, generatePresignedUploadUrl, getFileUrl, } from "../storage/client"; @@ -986,8 +986,10 @@ export class S3Router { // Get file URL const url = getFileUrl(this.config, completion.key); - // Generate presigned download URL (expires in 1 hour by default) - const presignedUrl = await generatePresignedDownloadUrl( + // Generate download URL — respects provider visibility setting. + // Returns a presigned GET URL for private buckets, or a plain/CDN URL + // for public buckets (visibility: 'public'). + const downloadUrl = await generateDownloadUrl( this.config, completion.key, 3600 @@ -1007,7 +1009,7 @@ export class S3Router { success: true, key: completion.key, url, - presignedUrl, + presignedUrl: downloadUrl, file: completion.file, }); } catch (error) { @@ -1060,7 +1062,7 @@ export interface CompletionResponse { success: boolean; key: string; url?: string; - presignedUrl?: string; // Temporary download URL (expires in 1 hour) + presignedUrl?: string; // Download URL — presigned for private buckets, plain/CDN URL for public buckets file?: S3FileMetadata; error?: string; } diff --git a/packages/pushduck/src/core/storage/client.ts b/packages/pushduck/src/core/storage/client.ts index d006ffd7..809837f1 100644 --- a/packages/pushduck/src/core/storage/client.ts +++ b/packages/pushduck/src/core/storage/client.ts @@ -89,6 +89,8 @@ interface S3CompatibleConfig { customDomain?: string; /** Enable debug logging */ debug?: boolean; + /** Whether the bucket is public or private (controls download URL format) */ + visibility?: "public" | "private"; } /** @@ -131,6 +133,7 @@ function getS3CompatibleConfig( acl: config.acl, customDomain: config.customDomain, forcePathStyle: config.forcePathStyle, + visibility: config.visibility, debug: options.debug ?? false, }; @@ -605,7 +608,16 @@ export interface PresignedUrlResult { } /** - * Generates a presigned URL for downloading/viewing a file from S3 + * Always generates a presigned GET URL for a file, regardless of the provider's + * `visibility` setting. Signs against the S3 API endpoint (never the custom domain). + * + * Use this when the caller explicitly wants a time-limited signed URL — for example, + * `storage.download.presignedUrl(key)` where the user is explicitly requesting a + * presigned URL even if the bucket is public. + * + * Note: The `host` header is part of the SigV4 canonical request and must match + * the endpoint being called. Custom domains (CDNs, CloudFront, R2 custom domains) + * cannot be used as the signing base for presigned URLs. */ export async function generatePresignedDownloadUrl( uploadConfig: UploadConfig, @@ -616,27 +628,19 @@ export async function generatePresignedDownloadUrl( const config = getS3CompatibleConfig(uploadConfig.provider); try { - const s3Url = buildPublicUrl(key, config); + // Always sign against the S3 API endpoint, never the custom domain. + const s3Url = buildS3Url(key, config); const url = new URL(s3Url); - // Add expiration as query parameter url.searchParams.set("X-Amz-Expires", expiresIn.toString()); - // Create a signed request for GET operation (download/view) const signedRequest = await awsClient.sign( - new Request(url.toString(), { - method: "GET", - }), - { - aws: { signQuery: true }, - } + new Request(url.toString(), { method: "GET" }), + { aws: { signQuery: true } } ); if (config.debug) { - logger.presignedUrl(key, { - signedUrl: signedRequest.url, - expiresIn, - }); + logger.presignedUrl(key, { signedUrl: signedRequest.url, expiresIn }); } return signedRequest.url; @@ -658,6 +662,35 @@ export async function generatePresignedDownloadUrl( } } +/** + * Generates a download URL for a file, respecting the provider's `visibility` setting. + * + * - `visibility: 'public'` — returns the plain public URL (custom domain if configured, + * otherwise the S3 URL). No signing. Bucket/objects must already be publicly accessible. + * - `visibility: 'private'` (default) — returns a presigned GET URL signed against the + * S3 API endpoint. Expires after `expiresIn` seconds (default 3600). + * + * Used internally by the router to generate the download URL returned after upload. + * For explicit presigned URL generation regardless of visibility, use + * `generatePresignedDownloadUrl` instead. + */ +export async function generateDownloadUrl( + uploadConfig: UploadConfig, + key: string, + expiresIn: number = 3600 +): Promise { + const config = getS3CompatibleConfig(uploadConfig.provider); + + // Public bucket: return the plain URL (custom domain if set, otherwise S3 URL). + // No signing needed — objects are already publicly accessible. + if (config.visibility === "public") { + return buildPublicUrl(key, config); + } + + // Private bucket (default): generate a presigned GET URL. + return generatePresignedDownloadUrl(uploadConfig, key, expiresIn); +} + /** * Generates a presigned URL for uploading a file to S3 */ diff --git a/packages/pushduck/src/core/storage/storage-api.ts b/packages/pushduck/src/core/storage/storage-api.ts index a88eb07f..3bcf1303 100644 --- a/packages/pushduck/src/core/storage/storage-api.ts +++ b/packages/pushduck/src/core/storage/storage-api.ts @@ -101,6 +101,8 @@ export class StorageInstance { // Download operations - grouped under 'download' namespace download = { + // Always generates a presigned GET URL regardless of the provider's + // visibility setting — the caller is explicitly asking for a presigned URL. presignedUrl: (key: string, expiresIn?: number) => client.generatePresignedDownloadUrl(this.config, key, expiresIn),