Repository: unjs/h3 (ts)

Issue:
getRequestFingerprint
### Describe the feature

I have made this helper function (taken from laravel) https://github.com/unjs/nitro/issues/1705#issuecomment-1711497734a that creates a hash based on the request. This can be useful to create some sort of identification of the request. which in turn can be used for numerous things (in my example it was to create a afterResponse hook).

```ts
import type { H3Event } from 'h3';
import crypto from 'crypto';

export default function getRequestFingerprint(event: H3Event): string {
    return crypto.createHash('sha1')
        .update(`${getRequestIP(event)}-${event.method}-${getRequestURL(event).toString()}`)
        .digest('hex');
}
```

### Additional information

- [X] Would you be willing to help implement this feature?

Pull request:
feat: add `getRequestFingerprint` util 
closes #536
continuation of https://github.com/unjs/h3/pull/548


Patch:
diff --git a/src/utils/fingerprint.ts b/src/utils/fingerprint.ts
new file mode 100644
index 0000000..7cbd11d
--- /dev/null
+++ b/src/utils/fingerprint.ts
@@ -0,0 +1,70 @@
+import crypto from "uncrypto";
+import type { H3Event } from "../event";
+import { getRequestIP, getRequestHeader } from "./request";
+
+export interface RequestFingerprintOptions {
+  /** @default SHA-1 */
+  hash?: false | "SHA-1";
+
+  /** @default `true` */
+  ip?: boolean;
+
+  /** @default `false` */
+  xForwardedFor?: boolean;
+
+  /** @default `false` */
+  method?: boolean;
+
+  /** @default `false` */
+  path?: boolean;
+
+  /** @default `false` */
+  userAgent?: boolean;
+}
+
+/** @experimental Behavior of this utility might change in the future versions */
+export async function getRequestFingerprint(
+  event: H3Event,
+  opts: RequestFingerprintOptions = {},
+): Promise<string | null> {
+  const fingerprint: unknown[] = [];
+
+  if (opts.ip !== false) {
+    fingerprint.push(
+      getRequestIP(event, { xForwardedFor: opts.xForwardedFor }),
+    );
+  }
+
+  if (opts.method === true) {
+    fingerprint.push(event.method);
+  }
+
+  if (opts.path === true) {
+    fingerprint.push(event.path);
+  }
+
+  if (opts.userAgent === true) {
+    fingerprint.push(getRequestHeader(event, "user-agent"));
+  }
+
+  const fingerprintString = fingerprint.filter(Boolean).join("|");
+
+  if (!fingerprintString) {
+    return null;
+  }
+
+  if (opts.hash === false) {
+    return fingerprintString;
+  }
+
+  const buffer = await crypto.subtle.digest(
+    opts.hash || "SHA-1",
+    new TextEncoder().encode(fingerprintString),
+  );
+
+  const hash = [...new Uint8Array(buffer)]
+    .map((b) => b.toString(16).padStart(2, "0"))
+    .join("");
+
+  return hash;
+}
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 4404d42..f24486b 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -4,6 +4,7 @@ export * from "./cache";
 export * from "./consts";
 export * from "./cors";
 export * from "./cookie";
+export * from "./fingerprint";
 export * from "./proxy";
 export * from "./request";
 export * from "./response";


Test patch:
diff --git a/test/utils.test.ts b/test/utils.test.ts
index bee4749..1528906 100644
--- a/test/utils.test.ts
+++ b/test/utils.test.ts
@@ -12,6 +12,7 @@ import {
   getRequestURL,
   readFormData,
   getRequestIP,
+  getRequestFingerprint,
 } from "../src";
 
 describe("", () => {
@@ -187,6 +188,103 @@ describe("", () => {
     });
   });
 
+  describe("getRequestFingerprint", () => {
+    it("returns an hash", async () => {
+      app.use(eventHandler((event) => getRequestFingerprint(event)));
+
+      const req = request.get("/");
+
+      // sha1 is 40 chars long
+      expect((await req).text).toHaveLength(40);
+
+      // and only uses hex chars
+      expect((await req).text).toMatch(/^[\dA-Fa-f]+$/);
+    });
+
+    it("returns the same hash every time for same request", async () => {
+      app.use(
+        eventHandler((event) => getRequestFingerprint(event, { hash: false })),
+      );
+
+      const req = request.get("/");
+      expect((await req).text).toMatchInlineSnapshot('"::ffff:127.0.0.1"');
+      expect((await req).text).toMatchInlineSnapshot('"::ffff:127.0.0.1"');
+    });
+
+    it("returns null when all detections impossible", async () => {
+      app.use(
+        eventHandler((event) =>
+          getRequestFingerprint(event, { hash: false, ip: false }),
+        ),
+      );
+      const f1 = (await request.get("/")).text;
+      expect(f1).toBe("");
+    });
+
+    it("can use path/method", async () => {
+      app.use(
+        eventHandler((event) =>
+          getRequestFingerprint(event, {
+            hash: false,
+            ip: false,
+            path: true,
+            method: true,
+          }),
+        ),
+      );
+
+      const req = request.post("/foo");
+
+      expect((await req).text).toMatchInlineSnapshot('"POST|/foo"');
+    });
+
+    it("uses user agent when available", async () => {
+      app.use(
+        eventHandler((event) =>
+          getRequestFingerprint(event, { hash: false, userAgent: true }),
+        ),
+      );
+
+      const req = request.get("/");
+      req.set("user-agent", "test-user-agent");
+
+      expect((await req).text).toMatchInlineSnapshot(
+        '"::ffff:127.0.0.1|test-user-agent"',
+      );
+    });
+
+    it("uses x-forwarded-for ip when header set", async () => {
+      app.use(
+        eventHandler((event) =>
+          getRequestFingerprint(event, { hash: false, xForwardedFor: true }),
+        ),
+      );
+
+      const req = request.get("/");
+      req.set("x-forwarded-for", "x-forwarded-for");
+
+      expect((await req).text).toMatchInlineSnapshot('"x-forwarded-for"');
+    });
+
+    it("uses the request ip when no x-forwarded-for header set", async () => {
+      app.use(
+        eventHandler((event) => getRequestFingerprint(event, { hash: false })),
+      );
+
+      app.options.onRequest = (e) => {
+        Object.defineProperty(e.node.req.socket, "remoteAddress", {
+          get(): any {
+            return "0.0.0.0";
+          },
+        });
+      };
+
+      const req = request.get("/");
+
+      expect((await req).text).toMatchInlineSnapshot('"0.0.0.0"');
+    });
+  });
+
   describe("assertMethod", () => {
     it("only allow head and post", async () => {
       app.use(