Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: UTApi.generateSignedURL #1146

Merged
merged 8 commits into from
Feb 11, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 46 additions & 3 deletions docs/src/app/(docs)/api-reference/ut-api/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -419,10 +419,53 @@ await utapi.renameFiles([

`object`

## `getSignedURL` {{ tag: 'method', since: '6.2'}}
## `generateSignedURL` {{ tag: 'method', since: '7.5'}}

Generate a presigned URL for a private file.

```ts
import { utapi } from "~/server/uploadthing.ts";

const fileKey = "2e0fdb64-9957-4262-8e45-f372ba903ac8_image.jpg";
const url = await utapi.generateSignedURL(fileKey, {
expiresIn: 60 * 60, // 1 hour
// expiresIn: '1 hour',
// expiresIn: '3d',
// expiresIn: '7 days',
});
```

Unlike [`getSignedURL`](#get-signed-url), this method does not make a fetch
request to the UploadThing API and is the recommended way to generate a
presigned URL for a private file.

### Parameters

<Properties>
<Property name="key" type="string" since="7.5" required>
The key of the file to get a signed URL for
</Property>
<Property name="options.expiresIn" type="number | TimeString" since="7.5">
Options for the signed URL. The parsed duration cannot exceed 7 days (604
800).
<Note>
`TimeString` refers to a human-readable string that can be parsed as a
number, followed by a unit of time. For example, `1s`, `1 second`, `2m`,
`2 minutes`, `7 days` etc. If no unit is specified, seconds are assumed.
</Note>
</Property>
</Properties>

### Returns

`{ ufsUrl: string }`

## `getSignedURL` {{ tag: 'method', since: '6.2' }}

Retrieve a [signed URL](/concepts/regions-acl#access-controls) for a private
file.
file. This method is no longer recommended as it makes a fetch request to the
UploadThing API which incurs redundant latency. Use
[`generateSignedURL`](#generate-signed-url) instead.

```ts
import { utapi } from "~/server/uploadthing.ts";
Expand Down Expand Up @@ -468,7 +511,7 @@ const url = await utapi.getSignedURL(fileKey, {

### Returns

`string`
`{ url: string, ufsUrl: string }`

## `updateACL` {{ tag: 'method', since: '6.8'}}

Expand Down
10 changes: 3 additions & 7 deletions docs/src/app/(docs)/concepts/regions-acl/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,6 @@ per-request basis.

### Using signed URLs

Signed URLs can be retrieved using the
[`getSignedURL`](/api-reference/ut-api#get-signed-url) method on the UTApi. It
accepts the expiration time in seconds as a parameter, which defaults to
whatever you've set in your app's settings. Overriding this value is only
allowed if you've enabled the
`Allow overriding this value on a per-request basis` setting on your app's
settings.
Signed URLs can be generated using the
[`generateSignedURL`](/api-reference/ut-api#generate-signed-url) method on the
UTApi. It accepts the expiration time in seconds as a parameter.
4 changes: 2 additions & 2 deletions docs/src/app/(docs)/working-with-files/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ export default {
If your files are protected with
[access controls](/concepts/regions-acl#access-controls), you can generate a
presigned URL using
[`UTApi.getSignedUrl`](/api-reference/ut-api#get-signed-url). Here's a reference
implementation using Node.js crypto:
[`UTApi.generateSignedURL`](/api-reference/ut-api#get-signed-url). Here's a
reference implementation using Node.js crypto:
Comment on lines +58 to +59
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix incorrect API reference link.

The link to the API reference points to #get-signed-url but should point to #generate-signed-url to match the new method name.

-[`UTApi.generateSignedURL`](/api-reference/ut-api#get-signed-url)
+[`UTApi.generateSignedURL`](/api-reference/ut-api#generate-signed-url)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
[`UTApi.generateSignedURL`](/api-reference/ut-api#get-signed-url). Here's a
reference implementation using Node.js crypto:
[`UTApi.generateSignedURL`](/api-reference/ut-api#generate-signed-url). Here's a
reference implementation using Node.js crypto:


```ts
import crypto from "node:crypto";
Expand Down
8 changes: 8 additions & 0 deletions packages/uploadthing/src/_internal/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,11 @@ export const IngestUrl = Effect.gen(function* () {
Config.map((url) => url.href.replace(/\/$/, "")),
);
});

export const UtfsHost = Config.string("utfsHost").pipe(
Config.withDefault("utfs.io"),
);

export const UfsHost = Config.string("ufsHost").pipe(
Config.withDefault("ufs.sh"),
);
81 changes: 78 additions & 3 deletions packages/uploadthing/src/sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,18 @@ import * as Redacted from "effect/Redacted";
import * as S from "effect/Schema";

import type { ACL, FetchEsque, MaybeUrl } from "@uploadthing/shared";
import { parseTimeToSeconds, UploadThingError } from "@uploadthing/shared";
import {
generateSignedURL,
parseTimeToSeconds,
UploadThingError,
} from "@uploadthing/shared";

import { ApiUrl, UPLOADTHING_VERSION, UTToken } from "../_internal/config";
import {
ApiUrl,
UfsHost,
UPLOADTHING_VERSION,
UTToken,
} from "../_internal/config";
import { logHttpClientError, logHttpClientResponse } from "../_internal/logger";
import { makeRuntime } from "../_internal/runtime";
import type {
Expand Down Expand Up @@ -376,7 +385,72 @@ export class UTApi {
);
};

/** Request a presigned url for a private file(s) */
/**
* Generate a presigned url for a private file
* Unlike {@link getSignedURL}, this method does not make a fetch request to the UploadThing API
* and is the recommended way to generate a presigned url for a private file.
**/
generateSignedURL = async (key: string, opts?: GetSignedURLOptions) => {
guardServerOnly();

const expiresIn = parseTimeToSeconds(opts?.expiresIn ?? "5 minutes");

if (opts?.expiresIn && isNaN(expiresIn)) {
throw new UploadThingError({
code: "BAD_REQUEST",
message:
"expiresIn must be a valid time string, for example '1d', '2 days', or a number of seconds.",
});
}
if (expiresIn > 86400 * 7) {
throw new UploadThingError({
code: "BAD_REQUEST",
message: "expiresIn must be less than 7 days (604800 seconds).",
});
}

const program = Effect.gen(function* () {
const { apiKey, appId } = yield* UTToken;
const ufsHost = yield* UfsHost;

const ufsUrl = yield* generateSignedURL(
`https://${appId}.${ufsHost}/f/${key}`,
apiKey,
{
ttlInSeconds: expiresIn,
},
);

return {
ufsUrl,
};
});

return await this.executeAsync(
program.pipe(
Effect.catchTag(
"ConfigError",
(e) =>
new UploadThingError({
code: "INVALID_SERVER_CONFIG",
message:
"There was an error with the server configuration. More info can be found on this error's `cause` property",
cause: e,
}),
),
Effect.withLogSpan("getSignedURL"),
),
);
};

/**
* Request a presigned url for a private file(s)
* @remarks This method is no longer recommended as it makes a fetch
* request to the UploadThing API which incurs redundant latency. It
* will be deprecated in UploadThing v8 and removed in UploadThing v9.
*
* @see {@link generateSignedURL} for a more efficient way to generate a presigned url
**/
getSignedURL = async (key: string, opts?: GetSignedURLOptions) => {
guardServerOnly();

Expand All @@ -403,6 +477,7 @@ export class UTApi {
"GetSignedUrlResponse",
)({
url: S.String,
ufsUrl: S.String,
}) {}

return await this.executeAsync(
Expand Down
9 changes: 9 additions & 0 deletions packages/uploadthing/src/sdk/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ type KeyRename = { fileKey: string; newName: string };
type CustomIdRename = { customId: string; newName: string };
export type RenameFileUpdate = KeyRename | CustomIdRename;

export interface GenerateSignedURLOptions {
/**
* How long the URL will be valid for.
* - Must be positive and less than 7 days (604800 seconds).
* @default 5min
*/
expiresIn?: Time;
}

export interface GetSignedURLOptions extends KeyTypeOptionsBase {
/**
* How long the URL will be valid for.
Expand Down
2 changes: 2 additions & 0 deletions packages/uploadthing/test/__test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ export const handlers = [
await callRequestSpy(request);
return HttpResponse.json({
url: `${UTFS_URL}/f/someFileKey?x-some-amz=query-param`,
ufsUrl:
"https://app-1.ufs.sh/f/someFileKey?signature=hmac-sha256%3Dsomesignature",
});
}),
http.post(`${API_URL}/v6/updateACL`, async ({ request }) => {
Expand Down
53 changes: 53 additions & 0 deletions packages/uploadthing/test/sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,59 @@ describe("uploadFilesFromUrl", () => {
});
});

describe("generateSignedURL", () => {
it("generates url without expiresIn", async () => {
const utapi = new UTApi({ token: testToken.encoded });
const result = await utapi.generateSignedURL("foo");

expect(result).toEqual({
ufsUrl: expect.stringMatching(ufsUrlPattern()),
});
expect(result.ufsUrl).toMatch(/[?&]signature=[A-Za-z0-9-_]+/);
});

it("generates url with valid expiresIn (1)", async () => {
const utapi = new UTApi({ token: testToken.encoded });
const result = await utapi.generateSignedURL("foo", { expiresIn: 86400 });

expect(result).toEqual({
ufsUrl: expect.stringMatching(ufsUrlPattern()),
});
expect(result.ufsUrl).toMatch(/[?&]signature=[A-Za-z0-9-_]+/);
});

it("generates url with valid expiresIn (2)", async () => {
const utapi = new UTApi({ token: testToken.encoded });
const result = await utapi.generateSignedURL("foo", {
expiresIn: "3 minutes",
});

expect(result).toEqual({
ufsUrl: expect.stringMatching(ufsUrlPattern()),
});
expect(result.ufsUrl).toMatch(/[?&]signature=[A-Za-z0-9-_]+/);
});

it("throws if expiresIn is invalid", async () => {
const utapi = new UTApi({ token: testToken.encoded });
await expect(() =>
// @ts-expect-error - intentionally passing invalid expiresIn
utapi.generateSignedURL("foo", { expiresIn: "something" }),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[UploadThingError: expiresIn must be a valid time string, for example '1d', '2 days', or a number of seconds.]`,
);
});

it("throws if expiresIn is longer than 7 days", async () => {
const utapi = new UTApi({ token: testToken.encoded });
await expect(() =>
utapi.generateSignedURL("foo", { expiresIn: "10 days" }),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[UploadThingError: expiresIn must be less than 7 days (604800 seconds).]`,
);
});
});

describe("getSignedURL", () => {
it("sends request without expiresIn", async () => {
const utapi = new UTApi({ token: testToken.encoded });
Expand Down
Loading