Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
6 changes: 6 additions & 0 deletions .changeset/three-bees-melt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@uploadthing/shared": patch
"uploadthing": patch
---

feat: add optional hashFn to generateKey for wider key entropy
11 changes: 7 additions & 4 deletions packages/shared/src/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as Redacted from "effect/Redacted";
import SQIds, { defaultOptions } from "sqids";

import { UploadThingError } from "./error";
import type { ExtractHashPartsFn, FileProperties, Time } from "./types";
import type { ExtractHashPartsFn, FileProperties, HashFn, Time } from "./types";
import { parseTimeToSeconds } from "./utils";

const signaturePrefix = "hmac-sha256=";
Expand Down Expand Up @@ -90,6 +90,7 @@ export const generateKey = (
file: FileProperties,
appId: string,
getHashParts?: ExtractHashPartsFn,
hashFn?: HashFn,
) =>
Micro.sync(() => {
// Get the parts of which we should hash to constuct the key
Expand All @@ -108,9 +109,11 @@ export const generateKey = (

// Hash and Encode the parts and appId as sqids
const alphabet = shuffle(defaultOptions.alphabet, appId);
const encodedFileSeed = new SQIds({ alphabet, minLength: 36 }).encode([
Math.abs(Hash.string(hashParts)),
]);
const rawHash = hashFn ? hashFn(hashParts) : Hash.string(hashParts);
const hashArray = Array.isArray(rawHash) ? rawHash : [rawHash];
const encodedFileSeed = new SQIds({ alphabet, minLength: 36 }).encode(
hashArray.map((n) => Math.abs(n)),
);
const encodedAppId = new SQIds({ alphabet, minLength: 12 }).encode([
Math.abs(Hash.string(appId)),
]);
Expand Down
19 changes: 19 additions & 0 deletions packages/shared/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ export type ExtractHashPartsFn = (
file: FileProperties,
) => (string | number | undefined | null | boolean)[];

/**
* Custom hash function for the file seed portion of the key.
* By default, Effect's `Hash.string` (32-bit) is used. Provide a function
* that returns a wider hash to increase entropy and reduce collision probability.
* The function receives the JSON-stringified hash parts and should return
* a number or an array of numbers (for wider entropy via multiple SQIds inputs).
* @default Hash.string (32-bit)
*/
export type HashFn = (input: string) => number | number[];

/**
* A subset of the standard RequestInit properties needed by UploadThing internally.
* @see RequestInit from lib.dom.d.ts
Expand Down Expand Up @@ -199,6 +209,15 @@ export type RouteOptions = {
* @default (file) => [file.name, file.size, file.type, file.lastModified, Date.now()]
*/
getFileHashParts?: ExtractHashPartsFn;
/**
* Custom hash function for the file seed portion of the key.
* By default, Effect's `Hash.string` (32-bit) is used. Provide a function
* that returns a wider hash to increase entropy and reduce collision probability.
* The function receives the JSON-stringified hash parts and should return
* a number or an array of numbers (for wider entropy via multiple SQIds inputs).
* @default Hash.string (32-bit)
*/
hashFn?: HashFn;
};

export type FileRouterInputKey = AllowedFileType | MimeType;
Expand Down
65 changes: 65 additions & 0 deletions packages/shared/test/crypto.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
verifyKey,
verifySignature,
} from "../src/crypto";
import type { ExtractHashPartsFn } from "../src/types";

describe("crypto sign / verify", () => {
it.effect("signs and verifies a payload", () =>
Expand Down Expand Up @@ -198,4 +199,68 @@ describe("key gen", () => {
expect(verified).toBe(true);
}),
);

it.effect(
"custom hashFn returning a single number generates a valid key",
() =>
Effect.gen(function* () {
const appI = "foo-123";
const key = yield* generateKey(
{
name: "foo.txt",
size: 123,
type: "text/plain",
lastModified: 1000,
},
appI,
(file) => [file.name],
() => 42,
);

expect(key).toBeTruthy();
const verified = yield* verifyKey(key, appI);
expect(verified).toBe(true);
}),
);

it.effect(
"custom hashFn returning an array of numbers generates a valid key",
() =>
Effect.gen(function* () {
const appI = "foo-123";
const key = yield* generateKey(
{
name: "foo.txt",
size: 123,
type: "text/plain",
lastModified: 1000,
},
appI,
(file) => [file.name],
() => [111, 222, 333, 444],
);

expect(key).toBeTruthy();
const verified = yield* verifyKey(key, appI);
expect(verified).toBe(true);
}),
);

it.effect("different hashFn outputs produce different keys", () =>
Effect.gen(function* () {
const appI = "foo-123";
const file = {
name: "foo.txt",
size: 123,
type: "text/plain",
lastModified: 1000,
};
const hashParts: ExtractHashPartsFn = (f) => [f.name];

const key1 = yield* generateKey(file, appI, hashParts, () => 100);
const key2 = yield* generateKey(file, appI, hashParts, () => 999);

expect(key1).not.toBe(key2);
}),
);
});
1 change: 1 addition & 0 deletions packages/uploadthing/src/_internal/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,7 @@ const handleUploadAction = (opts: {
file,
appId,
routeOptions.getFileHashParts,
routeOptions.hashFn,
);

const url = yield* generateSignedURL(`${ingestUrl}/${key}`, apiKey, {
Expand Down
Loading