From 03752323ea8244ba93b562efe530eec029d25bcb Mon Sep 17 00:00:00 2001 From: Nicholas O'Donnell Date: Sun, 27 Aug 2023 15:45:28 -0400 Subject: [PATCH] Implement stream tokens (#2) * Implement stream tokens * Remove yarn lock --- README.md | 19 +- next.config.js | 6 +- package-lock.json | 168 +- package.json | 9 +- src/actions/getStreamOnline.ts | 21 + src/actions/setGlobalHeaders.ts | 15 + src/actions/setStreamToken.ts | 18 + src/app/api/live/route.ts | 25 +- src/app/api/segment/route.ts | 10 +- src/app/error.tsx | 4 +- src/app/page.tsx | 23 +- src/components/player.tsx | 34 +- src/constants.ts | 4 + src/lib/jwt.ts | 29 + src/middleware.ts | 25 +- yarn.lock | 2705 ------------------------------- 16 files changed, 261 insertions(+), 2854 deletions(-) create mode 100644 src/actions/getStreamOnline.ts create mode 100644 src/actions/setGlobalHeaders.ts create mode 100644 src/actions/setStreamToken.ts create mode 100644 src/constants.ts create mode 100644 src/lib/jwt.ts delete mode 100644 yarn.lock diff --git a/README.md b/README.md index c1b1a9c..4f50213 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,11 @@ -ODO Stream is a custom web-based live stream player powered by [Restreamer](https://datarhei.github.io/restreamer/) with proxy support for streaming live `H.264` video to multiple devices. Keep your Restreamer instance private while allowing a publicly accessible HTML player. +ODO Stream is a web-based live stream player powered by [Restreamer](https://datarhei.github.io/restreamer/) for streaming live `H.264` video to multiple devices. Features include: +- **Proxy Support** - Proxy HLS playlist and segments to keep your restreamer server private +- **Stream Authentication** - Generate signed tokens to validate stream access +- **CORS support** - Prevent your stream from being embedded on other sites +- **Stream Status** - View stream status and statistics (cooming soon) [![CD](https://github.com/nicholasodonnell/odo-stream/actions/workflows/cd.yml/badge.svg)](https://github.com/nicholasodonnell/odo-stream/actions/workflows/cd.yml) @@ -23,12 +27,13 @@ ODO Stream is a custom web-based live stream player powered by [Restreamer](http ## ENV Options -| Option | Description | Default | -| ------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------ | -| `TZ` | Timezone set to the [TZ database name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) of your time zone. | `UTC` | -| `RS_URL` | Restreamer URL (used for proxing HLS segments and fetching stream status). | `http://restreamer:8080` | -| `RS_USERNAME` | Username for the Restreamer backend. |   | -| `RS_PASSWORD` | Password for the Restreamer backend. |   | +| Option | Description | Default | +| ---------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------ | +| `TZ` | Timezone set to the [TZ database name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) of your time zone. | `UTC` | +| `RS_URL` | Restreamer URL (used for proxing HLS segments and fetching stream status). | `http://restreamer:8080` | +| `RS_USERNAME` | Username for the Restreamer backend. |   | +| `RS_PASSWORD` | Password for the Restreamer backend. |   | +| `SIGNING_SECRET` | Secret used to sign stream tokens (used to validate stream access). | *random uuid* | See [Restreamer API Docs](https://datarhei.github.io/restreamer/docs/references-environment-vars.html) for more options. diff --git a/next.config.js b/next.config.js index 8dadfad..65682d5 100644 --- a/next.config.js +++ b/next.config.js @@ -1,7 +1,12 @@ /* eslint-disable prettier/prettier */ +const { v4: uuid } = require('uuid') + /** @type {import('next').NextConfig} */ const nextConfig = { + env: { + SIGNING_SECRET: process.env.SIGNING_SECRET ?? uuid(), + }, async rewrites() { return [ { @@ -14,7 +19,6 @@ const nextConfig = { }, ] }, - swcMinify: false, } diff --git a/package-lock.json b/package-lock.json index 49f9134..1c58881 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,25 +1,29 @@ { "name": "odo-stream", - "version": "1.1.0", + "version": "1.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "odo-stream", - "version": "1.1.0", + "version": "1.2.1", "dependencies": { "@gumlet/react-hls-player": "^1.0.1", - "axios": "^1.4.0", "classnames": "^2.3.2", + "http-errors": "^2.0.0", + "jose": "^4.14.4", "next": "13.4.12", "react": "18.2.0", "react-dom": "18.2.0", - "tailwindcss": "3.3.3" + "tailwindcss": "3.3.3", + "uuid": "^9.0.0" }, "devDependencies": { + "@types/http-errors": "^2.0.1", "@types/node": "20.4.5", "@types/react": "18.2.17", "@types/react-dom": "18.2.7", + "@types/uuid": "^9.0.2", "autoprefixer": "10.4.14", "eslint": "8.46.0", "eslint-config-next": "13.4.12", @@ -429,6 +433,12 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-/K3ds8TRAfBvi5vfjuz8y6+GiAYBZ0x4tXv1Av6CWBWn0IlADc+ZX9pMq7oU0fNQPnBwIZl3rmeLp6SBApbxSQ==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.12", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz", @@ -485,6 +495,12 @@ "integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==", "dev": true }, + "node_modules/@types/uuid": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.2.tgz", + "integrity": "sha512-kNnC1GFBLuhImSnV7w4njQkUiJi0ZXUycu1rUaouPqiKlXkh77JKgdRnTAp1x5eBwcIwbtI+3otwzuIDEuDoxQ==", + "dev": true + }, "node_modules/@typescript-eslint/parser": { "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", @@ -867,11 +883,6 @@ "integrity": "sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag==", "dev": true }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, "node_modules/autoprefixer": { "version": "10.4.14", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.14.tgz", @@ -926,16 +937,6 @@ "node": ">=4" } }, - "node_modules/axios": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", - "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", - "dependencies": { - "follow-redirects": "^1.15.0", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, "node_modules/axobject-query": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz", @@ -1187,17 +1188,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -1333,12 +1323,12 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "engines": { - "node": ">=0.4.0" + "node": ">= 0.8" } }, "node_modules/dequal": { @@ -2223,25 +2213,6 @@ "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", "dev": true }, - "node_modules/follow-redirects": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", - "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -2251,19 +2222,6 @@ "is-callable": "^1.1.3" } }, - "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fraction.js": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz", @@ -2576,6 +2534,21 @@ "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.4.10.tgz", "integrity": "sha512-wAVSj4Fm2MqOHy5+BlYnlKxXvJlv5IuZHjlzHu18QmjRzSDFQiUDWdHs5+NsFMQrgKEBwuWDcyvaMC9dUzJ5Uw==" }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/human-signals": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz", @@ -2978,6 +2951,14 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "4.14.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.14.4.tgz", + "integrity": "sha512-j8GhLiKmUAh+dsFXlX1aJCbt5KMibuKb+d7j1JaOJG6s2UjX1PQlW+OKB/sD4a/5ZYF4RcmYmLSndOoU3Lt/3g==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3145,25 +3126,6 @@ "node": ">=8.6" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/mimic-fn": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", @@ -3906,11 +3868,6 @@ "react-is": "^16.13.1" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" - }, "node_modules/punycode": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", @@ -4249,6 +4206,11 @@ "node": ">=10" } }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4307,6 +4269,14 @@ "node": ">=0.10.0" } }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -4618,6 +4588,14 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -4831,6 +4809,14 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "node_modules/uuid": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", diff --git a/package.json b/package.json index cd471a2..9bb3812 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "odo-stream", - "version": "1.2.1", + "version": "1.3.0", "scripts": { "dev": "next dev", "build": "next build", @@ -10,15 +10,20 @@ "dependencies": { "@gumlet/react-hls-player": "^1.0.1", "classnames": "^2.3.2", + "http-errors": "^2.0.0", + "jose": "^4.14.4", "next": "13.4.12", "react": "18.2.0", "react-dom": "18.2.0", - "tailwindcss": "3.3.3" + "tailwindcss": "3.3.3", + "uuid": "^9.0.0" }, "devDependencies": { + "@types/http-errors": "^2.0.1", "@types/node": "20.4.5", "@types/react": "18.2.17", "@types/react-dom": "18.2.7", + "@types/uuid": "^9.0.2", "autoprefixer": "10.4.14", "eslint": "8.46.0", "eslint-config-next": "13.4.12", diff --git a/src/actions/getStreamOnline.ts b/src/actions/getStreamOnline.ts new file mode 100644 index 0000000..1ab03cf --- /dev/null +++ b/src/actions/getStreamOnline.ts @@ -0,0 +1,21 @@ +import { PHASE_PRODUCTION_BUILD } from 'next/constants' + +import { NEXT_PHASE, RS_URL } from '../constants' + +export async function getStreamOnline(): Promise { + // skip during production build - restreamer is not guaranteed to be ready during build + if (NEXT_PHASE === PHASE_PRODUCTION_BUILD) { + return false + } + + try { + const res: Response = await fetch(`${RS_URL}/v1/states`) + const data = await res.json() + + return data?.repeat_to_local_nginx?.type === 'connected' + } catch (e: any) { + throw new Error(`Failed to fetch stream state: ${e.message}`, { + cause: e, + }) + } +} diff --git a/src/actions/setGlobalHeaders.ts b/src/actions/setGlobalHeaders.ts new file mode 100644 index 0000000..f86d374 --- /dev/null +++ b/src/actions/setGlobalHeaders.ts @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server' + +export function setGlobalHeaders( + response: NextResponse, + params: { origin: string }, +) { + // CORS + response.headers.set('Access-Control-Allow-Credentials', 'true') + response.headers.set('Access-Control-Allow-Origin', params.origin) + response.headers.set('Access-Control-Allow-Methods', 'GET') + + // Cache + response.headers.set('Cache-Control', 'no-cache, no-store, must-revalidate') + response.headers.set('Pragma', 'no-cache') +} diff --git a/src/actions/setStreamToken.ts b/src/actions/setStreamToken.ts new file mode 100644 index 0000000..5d04b5a --- /dev/null +++ b/src/actions/setStreamToken.ts @@ -0,0 +1,18 @@ +import { NextResponse } from 'next/server' +import { v4 as uuid } from 'uuid' + +import { STREAM_TOKEN_COOKIE_NAME } from '../constants' +import { sign } from '../lib/jwt' + +export async function setStreamToken(response: NextResponse): Promise { + const token = await sign({ + viewerId: uuid(), + }) + + response.cookies.set({ + httpOnly: true, + name: STREAM_TOKEN_COOKIE_NAME, + path: '/', + value: token, + }) +} diff --git a/src/app/api/live/route.ts b/src/app/api/live/route.ts index c317da1..914a4e5 100644 --- a/src/app/api/live/route.ts +++ b/src/app/api/live/route.ts @@ -1,14 +1,23 @@ -import { NextResponse } from 'next/server' +import HttpError from 'http-errors' +import { NextRequest, NextResponse } from 'next/server' + +import { RS_URL, STREAM_TOKEN_COOKIE_NAME } from '../../../constants' +import { verify } from '../../../lib/jwt' export const dynamic = 'force-dynamic' -export const revalidate = 0 export const fetchCache = 'force-no-store' +export const revalidate = 0 -export async function GET(): Promise { +export async function GET(request: NextRequest): Promise { try { - const response: Response = await fetch( - `${process.env.RS_URL}/hls/live.stream.m3u8`, - ) + const token = request.cookies.get(STREAM_TOKEN_COOKIE_NAME) + const claims = token ? await verify(token.value) : undefined + + if (!claims) { + throw HttpError.Unauthorized('Invalid token') + } + + const response: Response = await fetch(`${RS_URL}/hls/live.stream.m3u8`) return new NextResponse(response.body, { headers: { @@ -18,8 +27,10 @@ export async function GET(): Promise { }, }) } catch (e: any) { - throw new Error(`Failed to fetch playlist: ${e.message}`, { + const error = new Error(`Failed to fetch playlist: ${e.message}`, { cause: e, }) + + return new NextResponse(error.message) } } diff --git a/src/app/api/segment/route.ts b/src/app/api/segment/route.ts index 1449a7c..1dfce73 100644 --- a/src/app/api/segment/route.ts +++ b/src/app/api/segment/route.ts @@ -1,15 +1,17 @@ import { NextRequest, NextResponse } from 'next/server' +import { RS_URL } from '../../../constants' + export const dynamic = 'force-dynamic' -export const revalidate = 0 export const fetchCache = 'force-no-store' +export const revalidate = 0 export async function GET(request: NextRequest): Promise { const requestUrl = new URL(request.url) try { const response: Response = await fetch( - `${process.env.RS_URL}/hls${requestUrl.pathname}`, + `${RS_URL}/hls${requestUrl.pathname}`, ) return new NextResponse(response.body, { @@ -19,8 +21,10 @@ export async function GET(request: NextRequest): Promise { }, }) } catch (e: any) { - throw new Error(`Failed to fetch segment: ${e.message}`, { + const error = new Error(`Failed to fetch segment: ${e.message}`, { cause: e, }) + + return new NextResponse(error.message) } } diff --git a/src/app/error.tsx b/src/app/error.tsx index 6df233e..87fa268 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -9,7 +9,9 @@ export default function Error({ error, reset }: ErrorProps): React.ReactNode { return (

Something went wrong

-
{error.message}
+
+        {error.message}
+