fix: resolve multiple runtime and build issues

- Fix network binding to allow external access (0.0.0.0)
- Upgrade to Node.js 20.x for Next.js 16 compatibility
- Fix next-auth v4 configuration and session handling
- Add Steam client secret for Steam OAuth provider
- Fix Prisma schema unique constraint syntax
- Fix database creation script for automated deployment
- Fix game search API to use new IStoreService endpoint
- Fix session auth in API routes for Steam linking
- Add TypeScript types for next-auth session
This commit is contained in:
2026-02-21 22:51:45 +00:00
parent 14890b6875
commit 70726c50dc
13 changed files with 1064 additions and 112 deletions

19
deploy.sh Normal file → Executable file
View File

@@ -3,6 +3,8 @@ set -e
DOMAIN="${1:-192.168.2.175:3000}" DOMAIN="${1:-192.168.2.175:3000}"
export PATH="$HOME/.local/node/bin:$PATH"
echo "=== FairReview Deployment Script ===" echo "=== FairReview Deployment Script ==="
echo "Target domain: $DOMAIN" echo "Target domain: $DOMAIN"
echo "" echo ""
@@ -22,6 +24,9 @@ fi
source .postgres-env source .postgres-env
# Export database credentials for Node.js scripts
export DB_HOST DB_PORT DB_USER DB_PASS DB_NAME
# Validate credentials # Validate credentials
if [ -z "$DB_PASS" ] || [ "$DB_PASS" = "your_password_here" ]; then if [ -z "$DB_PASS" ] || [ "$DB_PASS" = "your_password_here" ]; then
echo "Error: Please update .postgres-env with your actual database password." echo "Error: Please update .postgres-env with your actual database password."
@@ -47,7 +52,7 @@ fi
echo "" echo ""
echo "=== Installing required packages ===" echo "=== Installing required packages ==="
sudo apt update sudo apt update
sudo apt install -y curl build-essential git sudo apt install -y curl build-essential git postgresql-client
# Install PM2 globally # Install PM2 globally
echo "" echo ""
@@ -82,10 +87,18 @@ echo " Database: ${DB_HOST}:${DB_PORT}/${DB_NAME}"
echo "" echo ""
echo "IMPORTANT: Update STEAM_API_KEY in .env.local with your Steam Web API key!" echo "IMPORTANT: Update STEAM_API_KEY in .env.local with your Steam Web API key!"
# Generate Prisma client and push schema # Generate Prisma client
echo "" echo ""
echo "=== Setting up database ===" echo "=== Setting up database ==="
npx prisma generate npx prisma generate
# Create database if it doesn't exist
echo ""
echo "=== Creating database if not exists ==="
node scripts/create-db.js
# Push schema to database
export DATABASE_URL="postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=require"
npx prisma db push npx prisma db push
# Build for production # Build for production
@@ -98,7 +111,7 @@ echo ""
echo "=== Starting application with PM2 ===" echo "=== Starting application with PM2 ==="
pm2 stop fairreview 2>/dev/null || true pm2 stop fairreview 2>/dev/null || true
pm2 delete fairreview 2>/dev/null || true pm2 delete fairreview 2>/dev/null || true
pm2 start npm --name "fairreview" -- run start pm2 start npm --name "fairreview" -- start -- -H 0.0.0.0
# Save PM2 config for restart on reboot # Save PM2 config for restart on reboot
pm2 save pm2 save

932
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,17 +9,24 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^6.19.2",
"bcryptjs": "^3.0.3",
"next": "16.1.6", "next": "16.1.6",
"next-auth": "^4.24.13",
"next-auth-steam": "^0.4.0",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.6", "eslint-config-next": "16.1.6",
"pg": "^8.18.0",
"prisma": "^6.19.2",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
} }

View File

@@ -67,6 +67,6 @@ model Vote {
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
review Review @relation(fields: [reviewId], references: [id]) review Review @relation(fields: [reviewId], references: [id])
@@unique([userId, reviewId]) @map("user_review_unique") @@unique([userId, reviewId], name: "user_review_unique")
@@map("votes") @@map("votes")
} }

48
scripts/create-db.js Normal file
View File

@@ -0,0 +1,48 @@
const { Client } = require('pg');
async function createDatabase() {
const dbHost = process.env.DB_HOST || '192.168.2.175';
const dbPort = process.env.DB_PORT || 5432;
const dbUser = process.env.DB_USER || 'zhuma';
const dbPass = process.env.DB_PASS;
const dbName = process.env.DB_NAME || 'fairreview';
if (!dbPass) {
console.error('Error: DB_PASS environment variable not set');
process.exit(1);
}
const client = new Client({
host: dbHost,
port: parseInt(dbPort),
user: dbUser,
password: dbPass,
database: 'postgres',
ssl: {
rejectUnauthorized: false
}
});
try {
await client.connect();
const result = await client.query(
"SELECT 1 FROM pg_database WHERE datname = $1",
[dbName]
);
if (result.rows.length === 0) {
await client.query(`CREATE DATABASE ${dbName}`);
console.log(`Database ${dbName} created successfully!`);
} else {
console.log(`Database ${dbName} already exists.`);
}
} catch (error) {
console.error('Error:', error.message);
process.exit(1);
} finally {
await client.end();
}
}
createDatabase();

View File

@@ -1,3 +1,19 @@
import { handlers } from '@/lib/auth' import NextAuth from 'next-auth'
import { authOptions } from '@/lib/auth'
import SteamProvider from 'next-auth-steam'
import { NextRequest } from 'next/server'
export const { GET, POST } = handlers async function auth(req: NextRequest, ctx: { params: Promise<{ nextauth: string[] }> }) {
const resolvedParams = await ctx.params
return NextAuth(req, { params: resolvedParams }, {
...authOptions,
providers: [
...authOptions.providers,
SteamProvider(req, {
clientSecret: process.env.STEAM_CLIENT_SECRET || ''
})
]
})
}
export { auth as GET, auth as POST }

View File

@@ -14,7 +14,7 @@ export async function GET(
} }
let game = await prisma.game.findUnique({ let game = await prisma.game.findUnique({
where: { app_id: appId } where: { appId: appId }
}) })
if (!game) { if (!game) {
@@ -26,13 +26,13 @@ export async function GET(
game = await prisma.game.create({ game = await prisma.game.create({
data: { data: {
app_id: appId, appId: appId,
name: steamData.name, name: steamData.name,
description: steamData.short_description, description: steamData.short_description,
header_image: steamData.header_image, headerImage: steamData.header_image,
capsule_image: steamData.capsule_image, capsuleImage: steamData.capsule_image,
background_image: steamData.background, backgroundImage: steamData.background,
release_date: steamData.release_date?.date, releaseDate: steamData.release_date?.date,
developers: steamData.developers || [], developers: steamData.developers || [],
publishers: steamData.publishers || [], publishers: steamData.publishers || [],
genres: steamData.genres?.map(g => g.description) || [] genres: steamData.genres?.map(g => g.description) || []
@@ -41,12 +41,12 @@ export async function GET(
} }
return NextResponse.json({ return NextResponse.json({
app_id: game.app_id, app_id: game.appId,
name: game.name, name: game.name,
description: game.description, description: game.description,
header_image: game.header_image, header_image: game.headerImage,
background_image: game.background_image, background_image: game.backgroundImage,
release_date: game.release_date, release_date: game.releaseDate,
developers: game.developers, developers: game.developers,
publishers: game.publishers, publishers: game.publishers,
genres: game.genres genres: game.genres

View File

@@ -18,15 +18,15 @@ export async function GET(request: NextRequest) {
} }
}, },
select: { select: {
app_id: true, appId: true,
name: true, name: true,
header_image: true headerImage: true
}, },
take: 10 take: 10
}) })
if (cachedGames.length > 0) { if (cachedGames.length > 0) {
return NextResponse.json(cachedGames) return NextResponse.json(cachedGames.map((g: any) => ({ app_id: g.appId, name: g.name, header_image: g.headerImage })))
} }
const steamGames = await searchSteamGames(query) const steamGames = await searchSteamGames(query)

View File

@@ -30,29 +30,29 @@ export async function POST(
const existingVote = await prisma.vote.findUnique({ const existingVote = await prisma.vote.findUnique({
where: { where: {
user_review_unique: { user_review_unique: {
user_id: session.user.id, userId: session.user.id,
review_id: resolvedParams.id reviewId: resolvedParams.id
} }
} }
}) })
if (existingVote) { if (existingVote) {
if (existingVote.vote_type === voteType) { if (existingVote.voteType === voteType) {
await prisma.vote.delete({ await prisma.vote.delete({
where: { id: existingVote.id } where: { id: existingVote.id }
}) })
} else { } else {
await prisma.vote.update({ await prisma.vote.update({
where: { id: existingVote.id }, where: { id: existingVote.id },
data: { vote_type: voteType } data: { voteType: voteType }
}) })
} }
} else { } else {
await prisma.vote.create({ await prisma.vote.create({
data: { data: {
user_id: session.user.id, userId: session.user.id,
review_id: resolvedParams.id, reviewId: resolvedParams.id,
vote_type: voteType voteType: voteType
} }
}) })
} }

View File

@@ -14,7 +14,7 @@ export async function GET(request: NextRequest) {
const where: Record<string, unknown> = {} const where: Record<string, unknown> = {}
if (appId) { if (appId) {
where.app_id = parseInt(appId) where.appId = parseInt(appId)
} }
const reviews = await prisma.review.findMany({ const reviews = await prisma.review.findMany({
@@ -34,16 +34,16 @@ export async function GET(request: NextRequest) {
} }
}, },
orderBy: { orderBy: {
created_at: 'desc' createdAt: 'desc'
}, },
take: limit take: limit
}) })
const formattedReviews = reviews.map(review => { const formattedReviews = reviews.map((review: any) => {
const upvotes = review.votes.filter(v => v.vote_type === 1).length const upvotes = review.votes.filter((v: any) => v.voteType === 1).length
const downvotes = review.votes.filter(v => v.vote_type === -1).length const downvotes = review.votes.filter((v: any) => v.voteType === -1).length
const userVote = userId const userVote = userId
? review.votes.find(v => v.user_id === userId)?.vote_type || null ? review.votes.find((v: any) => v.userId === userId)?.voteType || null
: null : null
return { return {
@@ -52,11 +52,11 @@ export async function GET(request: NextRequest) {
avatar_url: review.user.steamAvatar, avatar_url: review.user.steamAvatar,
content: review.content, content: review.content,
rating: review.rating, rating: review.rating,
playtime_hours: review.playtime_hours, playtime_hours: review.playtimeHours,
upvotes, upvotes,
downvotes, downvotes,
user_vote: userVote, user_vote: userVote,
created_at: review.created_at.toISOString(), created_at: review.createdAt.toISOString(),
game_name: review.game?.name game_name: review.game?.name
} }
}) })
@@ -89,8 +89,8 @@ export async function POST(request: NextRequest) {
const existingReview = await prisma.review.findFirst({ const existingReview = await prisma.review.findFirst({
where: { where: {
user_id: session.user.id, userId: session.user.id,
app_id: appId appId: appId
} }
}) })
@@ -102,16 +102,16 @@ export async function POST(request: NextRequest) {
} }
const game = await prisma.game.findUnique({ const game = await prisma.game.findUnique({
where: { app_id: appId } where: { appId: appId }
}) })
const review = await prisma.review.create({ const review = await prisma.review.create({
data: { data: {
user_id: session.user.id, userId: session.user.id,
app_id: appId, appId: appId,
content, content,
rating, rating,
playtime_hours: playtimeHours || null playtimeHours: playtimeHours || null
} }
}) })

View File

@@ -1,17 +1,15 @@
import NextAuth from 'next-auth' import NextAuth, { NextAuthOptions, getServerSession } from 'next-auth'
import Credentials from 'next-auth/providers/credentials' import CredentialsProvider from 'next-auth/providers/credentials'
import Steam from 'next-auth/providers/steam'
import { compare } from 'bcryptjs' import { compare } from 'bcryptjs'
import { prisma } from './db' import { prisma } from './db'
import { headers } from 'next/headers'
export const { handlers, signIn, signOut, auth } = NextAuth({ export { getServerSession }
export const authOptions: NextAuthOptions = {
secret: process.env.NEXTAUTH_SECRET,
providers: [ providers: [
Steam({ CredentialsProvider({
clientId: process.env.STEAM_CLIENT_ID,
clientSecret: process.env.STEAM_CLIENT_SECRET,
allowDangerousEmailAccountLinking: true
}),
Credentials({
name: 'credentials', name: 'credentials',
credentials: { credentials: {
email: { label: 'Email', type: 'email' }, email: { label: 'Email', type: 'email' },
@@ -54,18 +52,18 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
async jwt({ token, user }) { async jwt({ token, user }) {
if (user) { if (user) {
token.id = user.id token.id = user.id
token.steamId = user.steamId token.steamId = (user as any).steamId
token.steamPersonaname = user.steamPersonaname token.steamPersonaname = (user as any).steamPersonaname
token.steamAvatar = user.steamAvatar token.steamAvatar = (user as any).steamAvatar
} }
return token return token
}, },
async session({ session, token }) { async session({ session, token }) {
if (token) { if (session.user) {
session.user.id = token.id as string (session.user as any).id = token.id
session.user.steamId = token.steamId as string | null ;(session.user as any).steamId = token.steamId
session.user.steamPersonaname = token.steamPersonaname as string | null ;(session.user as any).steamPersonaname = token.steamPersonaname
session.user.steamAvatar = token.steamAvatar as string | null ;(session.user as any).steamAvatar = token.steamAvatar
} }
return session return session
} }
@@ -76,23 +74,10 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
session: { session: {
strategy: 'jwt' strategy: 'jwt'
} }
})
declare module 'next-auth' {
interface User {
steamId?: string | null
steamPersonaname?: string | null
steamAvatar?: string | null
} }
interface Session { export async function auth() {
user: { const headersList = await headers()
id: string const session = await getServerSession(authOptions)
email: string return session
name: string
steamId?: string | null
steamPersonaname?: string | null
steamAvatar?: string | null
}
}
} }

View File

@@ -9,7 +9,8 @@ export interface SteamGame {
export interface SteamGameDetails { export interface SteamGameDetails {
[appId: string]: { [appId: string]: {
data: { success: boolean
data?: {
name: string name: string
short_description: string short_description: string
header_image: string header_image: string
@@ -43,14 +44,14 @@ export interface SteamPlayerStats {
export async function searchSteamGames(query: string): Promise<SteamGame[]> { export async function searchSteamGames(query: string): Promise<SteamGame[]> {
try { try {
const response = await fetch( const response = await fetch(
`${STEAM_API_BASE}/ISteamGames/GetAppList/v0002/?format=json` `https://api.steampowered.com/IStoreService/GetAppList/v1/?key=${STEAM_API_KEY}`
) )
const data = await response.json() const data = await response.json()
const apps = data.applist?.apps?.app || [] const apps = data.response?.apps || []
const filtered = apps const filtered = apps
.filter((app: { appid: number; name: string }) => .filter((app: { appid: number; name: string }) =>
app.name.toLowerCase().includes(query.toLowerCase()) app.name && app.name.toLowerCase().includes(query.toLowerCase())
) )
.slice(0, 20) .slice(0, 20)

12
src/types/next-auth.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
import { DefaultSession } from 'next-auth'
declare module 'next-auth' {
interface Session {
user: {
id: string
steamId?: string | null
steamPersonaname?: string | null
steamAvatar?: string | null
} & DefaultSession['user']
}
}