diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66967908..3b404987 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,6 @@ name: CI on: - push: - branches: ["main"] pull_request: types: [opened, synchronize] @@ -14,6 +12,13 @@ jobs: env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ vars.TURBO_TEAM }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + POSTGRES_URL: ${{ secrets.POSTGRES_URL }} + NEXTAUTH_URL: ${{ secrets.NEXTAUTH_URL }} + NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} steps: - name: 🏗 Check out code @@ -34,8 +39,8 @@ jobs: - name: 👷 Install dependencies run: pnpm install - - name: 📀 Install Playwright Browsers - run: npx playwright install --with-deps + # - name: 📀 Install Playwright Browsers + # run: npx playwright install --with-deps - name: 💅 Lint run: pnpm lint diff --git a/.github/workflows/dev-db-migrate.yml b/.github/workflows/dev-db-migrate.yml new file mode 100644 index 00000000..c8f10224 --- /dev/null +++ b/.github/workflows/dev-db-migrate.yml @@ -0,0 +1,47 @@ +name: Run Development Database Migrations + +on: + push: + branches: ["dev"] + +jobs: + build: + name: Build and Run Migrations + timeout-minutes: 15 + runs-on: ubuntu-latest + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} + DATABASE_URL: ${{ secrets.DEV_DATABASE_URL }} + POSTGRES_URL: ${{ secrets.POSTGRES_URL }} + NEXTAUTH_URL: ${{ secrets.NEXTAUTH_URL }} + NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} + + steps: + - name: 🏗 Check out code + uses: actions/checkout@v3 + with: + fetch-depth: 2 + + - uses: pnpm/action-setup@v2.0.1 + with: + version: 6.32.2 + + - name: 🏗 Setup Node.js environment + uses: actions/setup-node@v3 + with: + node-version: 18 + cache: "pnpm" + + - name: 👷 Install dependencies + run: pnpm install + + # - name: 📀 Install Playwright Browsers + # run: npx playwright install --with-deps + + - name: 🥾 Run db migrations + working-directory: "./packages/database" + run: pnpm db:migrate:run diff --git a/.github/workflows/prod-db-migrate.yml b/.github/workflows/prod-db-migrate.yml new file mode 100644 index 00000000..fcfc2f00 --- /dev/null +++ b/.github/workflows/prod-db-migrate.yml @@ -0,0 +1,44 @@ +name: Run Production Database Migrations + +on: + push: + branches: ["main"] + +jobs: + build: + name: Build and Run Migrations + timeout-minutes: 15 + runs-on: ubuntu-latest + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + POSTGRES_URL: ${{ secrets.POSTGRES_URL }} + NEXTAUTH_URL: ${{ secrets.NEXTAUTH_URL }} + NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} + + steps: + - name: 🏗 Check out code + uses: actions/checkout@v3 + with: + fetch-depth: 2 + + - uses: pnpm/action-setup@v2.0.1 + with: + version: 6.32.2 + + - name: 🏗 Setup Node.js environment + uses: actions/setup-node@v3 + with: + node-version: 18 + cache: "pnpm" + + - name: 👷 Install dependencies + run: pnpm install + + - name: 🥾 Run db migrations + working-directory: "./packages/database" + run: pnpm db:migrate:run diff --git a/README.md b/README.md index 4dff691b..021c9ca3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
diff --git a/apps/client/next-auth.d.ts b/apps/client/next-auth.d.ts new file mode 100644 index 00000000..ef79b49e --- /dev/null +++ b/apps/client/next-auth.d.ts @@ -0,0 +1,21 @@ +import "next-auth"; + +declare module "next-auth" { + interface User { + id: string; + name: string | null; + nickname: string; + email: string | null; + emailVerified: Date | null; + image: string | null; + createdAt: Date; + } + + /** + * Returned by `useSession`, `getSession` and received as a prop on the `Provider` React Context + */ + interface Session { + user: User; + expires: string; + } +} diff --git a/apps/client/next.config.js b/apps/client/next.config.js index 47f1558e..501732d6 100644 --- a/apps/client/next.config.js +++ b/apps/client/next.config.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires const { withSentryConfig } = require("@sentry/nextjs"); /** @type {import('next').NextConfig} */ @@ -19,6 +20,9 @@ const nextConfig = { { hostname: "api.dicebear.com", }, + { + hostname: "lh3.googleusercontent.com", + }, ], }, }; diff --git a/apps/client/package.json b/apps/client/package.json index 694d951d..ee9ea32f 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -1,5 +1,5 @@ { - "name": "game-ai", + "name": "client", "version": "0.1.0", "private": true, "scripts": { @@ -7,41 +7,45 @@ "build": "next build", "start": "next start", "lint": "next lint", - "test": "playwright test" + "test:e2e": "playwright test" }, "dependencies": { - "@sentry/nextjs": "^7.65.0", - "@tanstack/react-query": "^4.33.0", - "@upstash/ratelimit": "^0.4.3", + "@auth/drizzle-adapter": "^0.3.2", + "@sentry/nextjs": "^7.68.0", + "@tanstack/react-query": "^4.35.0", + "@upstash/ratelimit": "^0.4.4", "@vercel/analytics": "^1.0.2", "@vercel/kv": "^0.2.2", "@xstate/react": "^3.2.2", "autoprefixer": "10.4.15", "clsx": "^2.0.0", - "framer-motion": "^10.16.1", + "database": "workspace:*", + "drizzle-orm": "^0.28.6", + "framer-motion": "^10.16.4", "next": "^13.4.19", - "openai": "^4.3.1", + "next-auth": "^4.23.1", + "openai": "^4.6.0", "postcss": "8.4.29", "react": "^18.2.0", "react-dom": "^18.2.0", "react-hot-toast": "^2.4.1", - "react-icons": "^4.10.1", + "react-icons": "^4.11.0", "sharp": "^0.32.5", "socket.io-client": "^4.7.2", "tailwind-merge": "^1.14.0", "tailwindcss": "^3.3.3", "typescript": "5.2.2", - "xstate": "^4.38.2", - "zustand": "^4.4.1" + "xstate": "^4.38.2" }, "devDependencies": { "@playwright/test": "^1.37.1", - "@tailwindcss/typography": "^0.5.9", - "@types/node": "^20.5.7", + "@tailwindcss/forms": "^0.5.6", + "@tailwindcss/typography": "^0.5.10", + "@types/node": "^20.6.0", "@types/react": "^18.2.21", "@types/react-dom": "^18.2.7", "eslint-config-custom": "workspace:*", - "prettier-plugin-tailwindcss": "^0.5.3", + "prettier-plugin-tailwindcss": "^0.5.4", "tsconfig": "workspace:*" }, "engines": { diff --git a/apps/client/src/app/api/generate/route.ts b/apps/client/src/app/api/generate/route.ts index cdf078c9..0540c252 100644 --- a/apps/client/src/app/api/generate/route.ts +++ b/apps/client/src/app/api/generate/route.ts @@ -1,41 +1,20 @@ +import { getServerSession } from "next-auth"; import { NextResponse } from "next/server"; import OpenAI from "openai"; -import { kv } from "@vercel/kv"; -import { Ratelimit } from "@upstash/ratelimit"; -export const runtime = "edge"; +import { authOptions } from "@ai/pages/api/auth/[...nextauth]"; const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY ?? "", }); export async function POST(req: Request) { - if (process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN) { - const ip = req.headers.get("x-forwarded-for"); - const ratelimit = new Ratelimit({ - redis: kv, - // rate limit to 5 requests per 10 seconds - limiter: Ratelimit.slidingWindow(5, "10s"), - }); - - const { success, limit, reset, remaining } = await ratelimit.limit( - `ratelimit_${ip}`, - ); + const session = await getServerSession(authOptions(req)); - if (!success) { - return new Response("You have reached your request limit for the day.", { - status: 429, - headers: { - "X-RateLimit-Limit": limit.toString(), - "X-RateLimit-Remaining": remaining.toString(), - "X-RateLimit-Reset": reset.toString(), - }, - }); - } - } else { - console.log( - "KV_REST_API_URL and KV_REST_API_TOKEN env vars not found, not rate limiting...", - ); + if (!session) { + return new Response("Unauthorized", { + status: 401, + }); } const body = await req.json(); @@ -57,7 +36,7 @@ export async function POST(req: Request) { const images = await openai.images.generate({ prompt, n: 2, - size: "1024x1024", + size: "512x512", }); return NextResponse.json({ result: images.data }); diff --git a/apps/client/src/app/api/host/route.ts b/apps/client/src/app/api/host/route.ts new file mode 100644 index 00000000..23c88f0b --- /dev/null +++ b/apps/client/src/app/api/host/route.ts @@ -0,0 +1,32 @@ +import { getServerSession } from "next-auth"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; + +import { existingHost } from "@ai/app/server-actions"; +import { authOptions } from "@ai/pages/api/auth/[...nextauth]"; + +export async function GET(req: Request) { + const session = await getServerSession(authOptions(req)); + + const sessionToken = cookies().get("next-auth.session-token"); + + if (!session || !sessionToken) { + redirect("/"); + } + + const searchParams = new URL(req.url).searchParams; + + const nickname = searchParams.get("nickname"); + + if (!nickname) { + redirect("/"); + } + + const roomForExistingUser = await existingHost({ + userId: session.user.id, + nickname, + sessionToken: sessionToken.value, + }); + + redirect(`/room/${roomForExistingUser.room.code}`); +} diff --git a/apps/client/src/app/api/join/route.ts b/apps/client/src/app/api/join/route.ts new file mode 100644 index 00000000..d312d843 --- /dev/null +++ b/apps/client/src/app/api/join/route.ts @@ -0,0 +1,34 @@ +import { redirect } from "next/navigation"; +import { getServerSession } from "next-auth"; +import { cookies } from "next/headers"; + +import { joinRoom } from "@ai/app/server-actions"; +import { authOptions } from "@ai/pages/api/auth/[...nextauth]"; + +export async function GET(req: Request) { + const session = await getServerSession(authOptions(req)); + + const sessionToken = cookies().get("next-auth.session-token"); + + if (!session || !sessionToken) { + redirect("/"); + } + + const searchParams = new URL(req.url).searchParams; + + const nickname = searchParams.get("nickname"); + const roomCode = searchParams.get("code"); + + if (!roomCode || !nickname) { + redirect("/"); + } + + await joinRoom({ + userId: session.user.id, + nickname, + code: roomCode, + sessionToken: sessionToken.value, + }); + + redirect(`/room/${roomCode}`); +} diff --git a/apps/client/src/app/api/replicate/route.ts b/apps/client/src/app/api/replicate/route.ts index bf8b876b..bf80ab57 100644 --- a/apps/client/src/app/api/replicate/route.ts +++ b/apps/client/src/app/api/replicate/route.ts @@ -1,36 +1,15 @@ -import { Ratelimit } from "@upstash/ratelimit"; -import { kv } from "@vercel/kv"; +import { getServerSession } from "next-auth"; import { NextResponse } from "next/server"; -export const runtime = "edge"; +import { authOptions } from "@ai/pages/api/auth/[...nextauth]"; export async function POST(req: Request) { - if (process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN) { - const ip = req.headers.get("x-forwarded-for"); - const ratelimit = new Ratelimit({ - redis: kv, - // rate limit to 5 requests per 10 seconds - limiter: Ratelimit.slidingWindow(5, "10s"), - }); - - const { success, limit, reset, remaining } = await ratelimit.limit( - `ratelimit_${ip}`, - ); + const session = await getServerSession(authOptions(req)); - if (!success) { - return new Response("You have reached your request limit for the day.", { - status: 429, - headers: { - "X-RateLimit-Limit": limit.toString(), - "X-RateLimit-Remaining": remaining.toString(), - "X-RateLimit-Reset": reset.toString(), - }, - }); - } - } else { - console.log( - "KV_REST_API_URL and KV_REST_API_TOKEN env vars not found, not rate limiting...", - ); + if (!session) { + return new Response("Unauthorized", { + status: 401, + }); } const body = await req.json(); @@ -55,10 +34,10 @@ export async function POST(req: Request) { // Pinned to a specific version of Stable Diffusion // See https://replicate.com/stability-ai/sdxl version: - "2b017d9b67edd2ee1401238df49d75da53c523f36e363881e057f5dc3ed3c5b2", + "8beff3369e81422112d93b89ca01426147de542cd4684c244b673b105188fe5f", // This is the text prompt that will be submitted by a form on the frontend - input: { prompt, num_outputs: 2 }, + input: { prompt, num_outputs: 2, width: 768, height: 768 }, }), }, ); diff --git a/apps/client/src/app/error.tsx b/apps/client/src/app/error.tsx new file mode 100644 index 00000000..696e5f42 --- /dev/null +++ b/apps/client/src/app/error.tsx @@ -0,0 +1,19 @@ +"use client"; // Error components must be Client Components + +import { useEffect } from "react"; +import * as Sentry from "@sentry/nextjs"; + +import ErrorScreen from "@ai/components/error-screen"; + +export default function Error({ error }: { error: Error; reset: () => void }) { + useEffect(() => { + console.error(error); + Sentry.captureException(error); + }, [error]); + + return ( +