diff --git a/backend/package.json b/backend/package.json index 9d4d903f..68598134 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,10 +21,14 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.645.0", + "@remix-run/node": "^2.14.0", "@smithy/node-http-handler": "^3.2.3", + "@types/express-session": "^1.18.1", "@types/underscore": "^1.11.15", + "cookie-parser": "^1.4.7", "dotenv": "^16.4.5", "express": "^4.19.2", + "express-session": "^1.18.1", "he": "^1.2.0", "module-alias": "^2.2.3", "sanitize-html": "^2.14.0", diff --git a/backend/src/controllers/tools.ts b/backend/src/controllers/tools.ts index 7ed10c5b..b9370e7b 100644 --- a/backend/src/controllers/tools.ts +++ b/backend/src/controllers/tools.ts @@ -16,6 +16,7 @@ import { SanitizedFields, SaveUserConfigRequest } from './types.js' +import { getSession } from '@/services/session.js' export const getDefault = async (_: Request, res: Response) => { try { @@ -33,15 +34,24 @@ export const createUserConfig = async (req: Request, res: Response) => { const data: CreateConfigRequest = req.body const tag = data.version || data.tag - if (!data.walletAddress) { + if (!data?.walletAddress) { throw 'Wallet address is required' } + const walletAddress = decodeURIComponent(`https://${data.walletAddress}`) + + const cookieHeader = req.headers.cookie + const session = await getSession(cookieHeader) + + const validForWallet = session?.get('validForWallet') + + if (!session || validForWallet !== walletAddress) { + throw 'Grant confirmation is required' + } + const defaultData = await getDefaultData() const defaultDataContent: ConfigVersions['default'] = JSON.parse(defaultData).default - defaultDataContent.walletAddress = decodeURIComponent( - `https://${data.walletAddress}` - ) + defaultDataContent.walletAddress = walletAddress sanitizeConfigFields({ ...defaultDataContent, tag }) @@ -51,6 +61,7 @@ export const createUserConfig = async (req: Request, res: Response) => { try { // existing config const s3data = await s3.send(new GetObjectCommand(params)) + // Convert the file stream to a string fileContentString = await streamToString( s3data.Body as NodeJS.ReadableStream @@ -97,10 +108,17 @@ export const createUserConfig = async (req: Request, res: Response) => { export const saveUserConfig = async (req: Request, res: Response) => { try { const data: SaveUserConfigRequest = req.body + const cookieHeader = req.headers.cookie + const session = await getSession(cookieHeader) + + const validForWallet = session?.get('validForWallet') if (!data.walletAddress) { throw 'Wallet address is required' } + if (!session || validForWallet !== data.walletAddress) { + throw 'Grant confirmation is required' + } const { s3, params } = getS3AndParams(data.walletAddress) @@ -132,11 +150,14 @@ export const getUserConfig = async (req: Request, res: Response) => { if (!id) { throw new S3FileNotFoundError('Wallet address is required') } + const walletAddress = decodeURIComponent(`https://${id}`) // ensure we have all keys w default values, user config will overwrite values that exist in saved json const defaultData = await getDefaultData() + const parsedDefaultData = JSON.parse(defaultData) + parsedDefaultData.default.walletAddress = walletAddress - const { s3, params } = getS3AndParams(id) + const { s3, params } = getS3AndParams(walletAddress) const data = await s3.send(new GetObjectCommand(params)) // Convert the file stream to a string const fileContentString = await streamToString( @@ -144,7 +165,7 @@ export const getUserConfig = async (req: Request, res: Response) => { ) let fileContent = Object.assign( - JSON.parse(defaultData), + parsedDefaultData, ...[JSON.parse(fileContentString)] ) fileContent = filterDeepProperties(fileContent) diff --git a/backend/src/server.ts b/backend/src/server.ts index 7997a460..f1df1845 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -2,7 +2,9 @@ import https from 'https' import http from 'http' import fs from 'fs' import express, { Express } from 'express' +import session from 'express-session' import routes from './routes/index.js' +import { SESSION_COOKIE_SECRET_KEY } from './services/session.js' const router: Express = express() @@ -21,10 +23,24 @@ if (isDevelopment) { router.use(express.urlencoded({ extended: true })) router.use(express.json()) +// Session middleware +router.use( + session({ + secret: SESSION_COOKIE_SECRET_KEY, + resave: false, + saveUninitialized: true, // Only save the session if it is modified + cookie: { + httpOnly: true, + secure: true, + sameSite: 'none' + } + }) +) + router.use((req, res, next) => { // set the CORS policy res.header('Access-Control-Allow-Origin', '*') - // set the CORS headers + res.header('Access-Control-Allow-Credentials', 'true') res.header( 'Access-Control-Allow-Headers', 'origin,X-Requested-With,Content-Type,Accept,Authorization' diff --git a/backend/src/services/session.ts b/backend/src/services/session.ts new file mode 100644 index 00000000..21fa81fc --- /dev/null +++ b/backend/src/services/session.ts @@ -0,0 +1,17 @@ +import { createCookieSessionStorage } from '@remix-run/node' + +export const SESSION_COOKIE_SECRET_KEY = + process.env.SESSION_COOKIE_SECRET_KEY || 'supersecretilpaystring' + +const { getSession, commitSession, destroySession } = + createCookieSessionStorage({ + cookie: { + name: 'wmtools-session', + httpOnly: true, + secure: true, + sameSite: 'none', + secrets: [SESSION_COOKIE_SECRET_KEY] + } + }) + +export { getSession, commitSession, destroySession } diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts new file mode 100644 index 00000000..dba3b9a5 --- /dev/null +++ b/backend/src/types/index.ts @@ -0,0 +1,7 @@ +import 'express-session' + +declare module 'express-session' { + interface Session { + validForWallet?: string + } +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json index bef7e01c..8be378c1 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -10,5 +10,6 @@ } }, "include": ["src/**/*", "tests/**/*"], - "exclude": ["node_modules", "**/node_modules/*"] + "exclude": ["node_modules", "**/node_modules/*"], + "files": ["src/types/index.ts"] } diff --git a/docker/dev/.env.example b/docker/dev/.env.example index 476f6a2a..dee7dc6d 100644 --- a/docker/dev/.env.example +++ b/docker/dev/.env.example @@ -5,6 +5,11 @@ SCRIPT_FRONTEND_URL=https://localhost:5100/ SCRIPT_ILPAY_URL=https://interledgerpay.com/extension/ SCRIPT_EMBED_URL=https://localhost:5100/ +OP_KEY_ID= +OP_PRIVATE_KEY= +OP_WALLET_ADDRESS= +OP_REDIRECT_URL= + # BACKEND AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= diff --git a/docker/dev/docker-compose.yml b/docker/dev/docker-compose.yml index 77483e4d..30a94aa5 100644 --- a/docker/dev/docker-compose.yml +++ b/docker/dev/docker-compose.yml @@ -50,6 +50,10 @@ services: ILPAY_URL: ${SCRIPT_ILPAY_URL} FRONTEND_URL: ${SCRIPT_FRONTEND_URL} INIT_SCRIPT_URL: ${SCRIPT_EMBED_URL} + OP_KEY_ID: ${OP_KEY_ID} + OP_PRIVATE_KEY: ${OP_PRIVATE_KEY} + OP_WALLET_ADDRESS: ${OP_WALLET_ADDRESS} + OP_REDIRECT_URL: ${OP_REDIRECT_URL} networks: - wm-tools command: pnpm run dev diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 394d9c18..bee84e82 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -44,10 +44,12 @@ RUN pnpm --filter frontend build ARG VITE_SCRIPT_API_URL ARG VITE_SCRIPT_FRONTEND_URL ARG VITE_SCRIPT_ILPAY_URL +ARG VITE_INIT_SCRIPT_URL ENV VITE_SCRIPT_API_URL=$VITE_SCRIPT_API_URL \ VITE_SCRIPT_FRONTEND_URL=$VITE_SCRIPT_FRONTEND_URL \ - VITE_SCRIPT_ILPAY_URL=$VITE_SCRIPT_ILPAY_URL + VITE_SCRIPT_ILPAY_URL=$VITE_SCRIPT_ILPAY_URL \ + VITE_INIT_SCRIPT_URL=$VITE_INIT_SCRIPT_URL RUN pnpm --filter frontend build:init diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev index c9356f3a..5680bfe8 100644 --- a/frontend/Dockerfile.dev +++ b/frontend/Dockerfile.dev @@ -31,10 +31,12 @@ EXPOSE 5100 ARG VITE_SCRIPT_API_URL ARG VITE_SCRIPT_FRONTEND_URL ARG VITE_SCRIPT_ILPAY_URL +ARG VITE_INIT_SCRIPT_URL ENV VITE_SCRIPT_API_URL=$VITE_SCRIPT_API_URL \ VITE_SCRIPT_FRONTEND_URL=$VITE_SCRIPT_FRONTEND_URL \ - VITE_SCRIPT_ILPAY_URL=$VITE_SCRIPT_ILPAY_URL + VITE_SCRIPT_ILPAY_URL=$VITE_SCRIPT_ILPAY_URL \ + VITE_INIT_SCRIPT_URL=$VITE_INIT_SCRIPT_URL WORKDIR /app/frontend diff --git a/frontend/app/components/Snackbar.tsx b/frontend/app/components/Snackbar.tsx index 49187caf..709d7028 100644 --- a/frontend/app/components/Snackbar.tsx +++ b/frontend/app/components/Snackbar.tsx @@ -2,7 +2,7 @@ import { Transition } from '@headlessui/react' import { cx } from 'class-variance-authority' import type { FC } from 'react' import { Fragment, useEffect } from 'react' -import { type Message } from '~/lib/message.server' +import { type Message } from '~/lib/server/message.server.js' import { CheckCircleSolid, XIcon, XCircleSolid } from '../components/icons.js' interface SnackbarProps { diff --git a/frontend/app/components/modals/Confirm.tsx b/frontend/app/components/modals/Confirm.tsx index 5e3069ea..47c84436 100644 --- a/frontend/app/components/modals/Confirm.tsx +++ b/frontend/app/components/modals/Confirm.tsx @@ -3,9 +3,11 @@ import { Form } from '@remix-run/react' import { ElementErrors } from '~/lib/types.js' import { XIcon } from '~/components/icons.js' import { Button } from '~/components/index.js' +import { ReactElement } from 'react' type ConfirmModalProps = { - title: string + title: string | ReactElement + description: string | ReactElement isOpen: boolean errors?: ElementErrors onClose: () => void @@ -14,6 +16,7 @@ type ConfirmModalProps = { export const ConfirmModal = ({ title, + description, isOpen, onClose, onConfirm @@ -44,13 +47,16 @@ export const ConfirmModal = ({