Skip to content

Commit fa036c7

Browse files
committed
chore: security hardening (RBAC, validation, ESLint headers, scripts)
1 parent b34b655 commit fa036c7

7 files changed

Lines changed: 210 additions & 211 deletions

File tree

app/api/categories/route.ts

Lines changed: 9 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { NextResponse, NextRequest } from "next/server";
22
import { db } from "@/lib/db";
33
import { categories, categoryOptionValues, hostelOptions } from "@/lib/schema";
4-
import { sql, eq } from "drizzle-orm";
4+
import { eq } from "drizzle-orm";
5+
import { requireAdmin } from "@/lib/auth/server";
6+
import { CreateCategorySchema, UpdateCategorySchema } from "@/lib/validation";
57

6-
export async function GET(req: NextRequest) {
8+
export async function GET() {
79
try {
810
const allCategories = await db.select().from(categories);
911

@@ -23,99 +25,14 @@ export async function GET(req: NextRequest) {
2325
return NextResponse.json(categoriesWithOptions);
2426
} catch (error) {
2527
console.error('Error fetching categories:', error);
26-
27-
// Return sample categories for testing when database is not connected
28-
if (error instanceof Error && error.message.includes('DATABASE_URL')) {
29-
console.log('Database not connected, returning sample categories for testing');
30-
return NextResponse.json([
31-
{
32-
categoryId: 1,
33-
categoryName: "Amenities",
34-
options: [
35-
{ optionId: 1, optionName: "Free WiFi" },
36-
{ optionId: 2, optionName: "Kitchen" },
37-
{ optionId: 3, optionName: "Laundry" },
38-
{ optionId: 4, optionName: "Common Room" },
39-
{ optionId: 5, optionName: "Garden/Terrace" },
40-
{ optionId: 6, optionName: "Bar" },
41-
{ optionId: 7, optionName: "Breakfast Included" },
42-
{ optionId: 8, optionName: "Air Conditioning" },
43-
{ optionId: 9, optionName: "Heating" },
44-
{ optionId: 10, optionName: "Luggage Storage" },
45-
{ optionId: 11, optionName: "24/7 Reception" },
46-
{ optionId: 12, optionName: "Security Lockers" },
47-
{ optionId: 13, optionName: "Bicycle Rental" },
48-
{ optionId: 14, optionName: "Tour Desk" },
49-
{ optionId: 15, optionName: "BBQ Area" }
50-
]
51-
},
52-
{
53-
categoryId: 2,
54-
categoryName: "Room Type",
55-
options: [
56-
{ optionId: 16, optionName: "Dormitory" },
57-
{ optionId: 17, optionName: "Private Room" },
58-
{ optionId: 18, optionName: "Double Room" },
59-
{ optionId: 19, optionName: "Twin Room" },
60-
{ optionId: 20, optionName: "Single Room" },
61-
{ optionId: 21, optionName: "Family Room" },
62-
{ optionId: 22, optionName: "Female Only Dorm" },
63-
{ optionId: 23, optionName: "Male Only Dorm" },
64-
{ optionId: 24, optionName: "Mixed Dorm" }
65-
]
66-
},
67-
{
68-
categoryId: 3,
69-
categoryName: "Location Type",
70-
options: [
71-
{ optionId: 25, optionName: "City Center" },
72-
{ optionId: 26, optionName: "Near Train Station" },
73-
{ optionId: 27, optionName: "Near Airport" },
74-
{ optionId: 28, optionName: "Beachfront" },
75-
{ optionId: 29, optionName: "Mountain View" },
76-
{ optionId: 30, optionName: "Rural Area" },
77-
{ optionId: 31, optionName: "University District" },
78-
{ optionId: 32, optionName: "Shopping District" },
79-
{ optionId: 33, optionName: "Historic District" },
80-
{ optionId: 34, optionName: "Business District" }
81-
]
82-
},
83-
{
84-
categoryId: 4,
85-
categoryName: "Price Range",
86-
options: [
87-
{ optionId: 35, optionName: "Budget ($10-25)" },
88-
{ optionId: 36, optionName: "Economy ($25-50)" },
89-
{ optionId: 37, optionName: "Mid-range ($50-100)" },
90-
{ optionId: 38, optionName: "Premium ($100-200)" },
91-
{ optionId: 39, optionName: "Luxury ($200+)" }
92-
]
93-
},
94-
{
95-
categoryId: 5,
96-
categoryName: "Atmosphere",
97-
options: [
98-
{ optionId: 40, optionName: "Party/Social" },
99-
{ optionId: 41, optionName: "Quiet/Relaxed" },
100-
{ optionId: 42, optionName: "Family Friendly" },
101-
{ optionId: 43, optionName: "Backpacker" },
102-
{ optionId: 44, optionName: "Digital Nomad" },
103-
{ optionId: 45, optionName: "Student" },
104-
{ optionId: 46, optionName: "Eco-friendly" },
105-
{ optionId: 47, optionName: "Boutique" },
106-
{ optionId: 48, optionName: "Traditional" }
107-
]
108-
}
109-
]);
110-
}
111-
11228
return NextResponse.json({ message: 'Failed to fetch categories.' }, { status: 500 });
11329
}
11430
}
11531

11632
export async function POST(req: NextRequest) {
11733
try {
118-
const { categoryName, options } = await req.json();
34+
await requireAdmin(req);
35+
const { categoryName, options } = CreateCategorySchema.parse(await req.json());
11936

12037
const [newCategory] = await db.insert(categories).values({
12138
category: categoryName
@@ -139,7 +56,8 @@ export async function POST(req: NextRequest) {
13956

14057
export async function PUT(req: NextRequest) {
14158
try {
142-
const { categoryId, categoryName, options } = await req.json();
59+
await requireAdmin(req);
60+
const { categoryId, categoryName, options } = UpdateCategorySchema.parse(await req.json());
14361

14462
console.log('PUT /api/categories - Incoming data:', { categoryId, categoryName, options });
14563

@@ -208,6 +126,7 @@ export async function PUT(req: NextRequest) {
208126

209127
export async function DELETE(req: NextRequest) {
210128
try {
129+
await requireAdmin(req);
211130
const { categoryId } = await req.json();
212131

213132
if (!categoryId) {

app/api/hostels/route.ts

Lines changed: 10 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { NextRequest, NextResponse } from 'next/server';
2-
import { db } from '../../../lib/db';
3-
import { hostels, hostelImages, ratings, comments, hostelOptions, categories, categoryOptionValues } from '../../../lib/schema';
2+
import { db } from '@/lib/db';
3+
import { hostels, hostelImages, ratings, comments, hostelOptions, categories, categoryOptionValues } from '@/lib/schema';
44
import { eq, and, sql, avg, count } from 'drizzle-orm';
5+
import { requireAdmin } from '@/lib/auth/server';
6+
import { CreateHostelSchema, UpdateHostelSchema } from '@/lib/validation';
57

6-
export async function GET(req: NextRequest) {
8+
export async function GET() {
79
try {
810
const allHostels = await db.select().from(hostels).where(eq(hostels.isActive, true));
911

@@ -62,103 +64,14 @@ export async function GET(req: NextRequest) {
6264
return NextResponse.json(finalHostels);
6365
} catch (error) {
6466
console.error('Error fetching hostels:', error);
65-
66-
// Return sample data for testing when database is not connected
67-
if (error instanceof Error && error.message.includes('DATABASE_URL')) {
68-
console.log('Database not connected, returning sample data for testing');
69-
return NextResponse.json([
70-
{
71-
hostelId: 1,
72-
hostelName: "Sample Hostel - Backpacker's Paradise",
73-
hostelDescription: "A vibrant hostel in the heart of the city, perfect for budget travelers looking to meet fellow adventurers. Features a lively common room, free breakfast, and organized tours.",
74-
location: "https://maps.google.com/?q=123+Main+St+City+Center",
75-
address: "123 Main Street, City Center, 12345",
76-
phoneNumber: "+1 555 123 4567",
77-
email: "info@backpackersparadise.com",
78-
website: "https://www.backpackersparadise.com",
79-
priceRange: "$25-50",
80-
createdAt: new Date().toISOString(),
81-
isActive: true,
82-
categories: [
83-
{ categoryName: "Amenities", optionName: "Free WiFi" },
84-
{ categoryName: "Room Type", optionName: "Dormitory" },
85-
{ categoryName: "Location Type", optionName: "City Center" },
86-
{ categoryName: "Price Range", optionName: "Economy ($25-50)" },
87-
{ categoryName: "Atmosphere", optionName: "Party/Social" }
88-
],
89-
images: [
90-
{
91-
imageId: 1,
92-
imageUrl: "https://images.unsplash.com/photo-1566073771259-6a8506099945?w=800",
93-
imageType: "general",
94-
isPrimary: true,
95-
uploadedAt: new Date().toISOString()
96-
}
97-
],
98-
averageRating: 4.5,
99-
totalRatings: 127,
100-
comments: [
101-
{
102-
commentId: 1,
103-
commentText: "Great atmosphere and friendly staff! Perfect for meeting other travelers.",
104-
userName: "Sarah M.",
105-
userEmail: "sarah@example.com",
106-
isVerified: true,
107-
createdAt: new Date().toISOString()
108-
}
109-
]
110-
},
111-
{
112-
hostelId: 2,
113-
hostelName: "Mountain View Lodge",
114-
hostelDescription: "Peaceful hostel with stunning mountain views. Perfect for nature lovers and those seeking a quiet retreat. Features hiking trails, garden, and cozy common areas.",
115-
location: "https://maps.google.com/?q=456+Mountain+Rd+Scenic+View",
116-
address: "456 Mountain Road, Scenic View, 67890",
117-
phoneNumber: "+1 555 987 6543",
118-
email: "hello@mountainviewlodge.com",
119-
website: "https://www.mountainviewlodge.com",
120-
priceRange: "$50-100",
121-
createdAt: new Date().toISOString(),
122-
isActive: true,
123-
categories: [
124-
{ categoryName: "Amenities", optionName: "Garden/Terrace" },
125-
{ categoryName: "Room Type", optionName: "Private Room" },
126-
{ categoryName: "Location Type", optionName: "Mountain View" },
127-
{ categoryName: "Price Range", optionName: "Mid-range ($50-100)" },
128-
{ categoryName: "Atmosphere", optionName: "Quiet/Relaxed" }
129-
],
130-
images: [
131-
{
132-
imageId: 2,
133-
imageUrl: "https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=800",
134-
imageType: "general",
135-
isPrimary: true,
136-
uploadedAt: new Date().toISOString()
137-
}
138-
],
139-
averageRating: 4.8,
140-
totalRatings: 89,
141-
comments: [
142-
{
143-
commentId: 2,
144-
commentText: "Absolutely beautiful location and very peaceful. Perfect for a relaxing getaway.",
145-
userName: "Mike R.",
146-
userEmail: "mike@example.com",
147-
isVerified: true,
148-
createdAt: new Date().toISOString()
149-
}
150-
]
151-
}
152-
]);
153-
}
154-
15567
return NextResponse.json({ message: 'Failed to fetch hostels.' }, { status: 500 });
15668
}
15769
}
15870

15971
export async function POST(req: NextRequest) {
16072
try {
161-
const { hostelName, hostelDescription, location, address, phoneNumber, email, website, priceRange, hostelCategoryOptions } = await req.json();
73+
await requireAdmin(req);
74+
const { hostelName, hostelDescription, location, address, phoneNumber, email, website, priceRange, hostelCategoryOptions } = CreateHostelSchema.parse(await req.json());
16275

16376
console.log('POST /api/hostels - Incoming hostel data:', { hostelName, hostelDescription, hostelCategoryOptions });
16477

@@ -226,7 +139,8 @@ export async function POST(req: NextRequest) {
226139

227140
export async function PUT(req: NextRequest) {
228141
try {
229-
const { hostelId, hostelName, hostelDescription, location, address, phoneNumber, email, website, priceRange, hostelCategoryOptions } = await req.json();
142+
await requireAdmin(req);
143+
const { hostelId, hostelName, hostelDescription, location, address, phoneNumber, email, website, priceRange, hostelCategoryOptions } = UpdateHostelSchema.parse(await req.json());
230144

231145
console.log('PUT /api/hostels - Incoming hostel data:', { hostelId, hostelName, hostelDescription, hostelCategoryOptions });
232146

@@ -291,6 +205,7 @@ export async function PUT(req: NextRequest) {
291205

292206
export async function DELETE(req: NextRequest) {
293207
try {
208+
await requireAdmin(req);
294209
const { searchParams } = new URL(req.url);
295210
const hostelId = searchParams.get('hostelId');
296211

lib/auth/server.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { NextRequest } from 'next/server';
2+
import { adminAuth } from '@/lib/firebase/firebaseadmin';
3+
import { db } from '@/lib/db';
4+
import { users } from '@/lib/schema';
5+
import { eq } from 'drizzle-orm';
6+
7+
export type AuthContext = {
8+
firebaseUid: string;
9+
userId: number; // DB users.uid
10+
userRole: string | null;
11+
displayName?: string | null;
12+
};
13+
14+
function getBearerToken(req: NextRequest): string | null {
15+
const authHeader = req.headers.get('authorization') || req.headers.get('Authorization');
16+
if (!authHeader) return null;
17+
const [type, token] = authHeader.split(' ');
18+
if (type?.toLowerCase() !== 'bearer' || !token) return null;
19+
return token;
20+
}
21+
22+
export async function verifyRequest(req: NextRequest): Promise<AuthContext | null> {
23+
const token = getBearerToken(req);
24+
if (!token) return null;
25+
26+
try {
27+
const decoded = await adminAuth.verifyIdToken(token);
28+
const firebaseUid = decoded.uid;
29+
const name = decoded.name ?? null;
30+
31+
// Lookup or create DB user mapping
32+
let [dbUser] = await db.select().from(users).where(eq(users.firebaseUid, firebaseUid));
33+
if (!dbUser) {
34+
const inserted = await db
35+
.insert(users)
36+
.values({ firebaseUid, userRole: 'user', displayName: name ?? undefined })
37+
.returning();
38+
dbUser = inserted[0];
39+
}
40+
41+
return {
42+
firebaseUid,
43+
userId: dbUser.uid,
44+
userRole: dbUser.userRole ?? null,
45+
displayName: dbUser.displayName ?? null,
46+
};
47+
} catch {
48+
return null;
49+
}
50+
}
51+
52+
export async function requireUser(req: NextRequest): Promise<AuthContext> {
53+
const ctx = await verifyRequest(req);
54+
if (!ctx) {
55+
throw new Response('Unauthorized', { status: 401 });
56+
}
57+
return ctx;
58+
}
59+
60+
export async function requireAdmin(req: NextRequest): Promise<AuthContext> {
61+
const ctx = await requireUser(req);
62+
const role = (ctx.userRole || '').toLowerCase();
63+
if (!(role === 'admin' || role === 'superadmin' || role === 'super_admin')) {
64+
throw new Response('Forbidden', { status: 403 });
65+
}
66+
return ctx;
67+
}

lib/db.ts

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
1-
import * as dotenv from "dotenv";
2-
dotenv.config({ path: ".env" });
1+
import { drizzle } from "drizzle-orm/node-postgres";
2+
import { Pool } from "pg";
3+
import * as schema from "./schema"; // adjust path
34

4-
import { drizzle } from "drizzle-orm/node-postgres";
5-
import { Pool } from "pg";
6-
import * as schema from "./schema"; // adjust path
5+
// Rely on Next.js to load env vars (e.g., from .env.local) and injected runtime vars in production.
6+
const databaseUrl = process.env.DATABASE_URL;
7+
if (!databaseUrl) {
8+
throw new Error("DATABASE_URL is not set. Please define it in your environment (e.g., .env.local).");
9+
}
710

8-
if (!process.env.DATABASE_URL) {
9-
throw new Error("❌ DATABASE_URL not set in .env.local");
10-
}
11+
// Configure SSL only when explicitly requested (e.g., Neon/Render provide sslmode=require)
12+
const sslMode = process.env.PGSSLMODE || process.env.SSLMODE || "";
13+
const databaseSsl = process.env.DATABASE_SSL || "";
14+
const useSsl = [sslMode.toLowerCase(), databaseSsl.toLowerCase()].some((v) => v === "require" || v === "true");
1115

12-
export const pool = new Pool({
13-
connectionString: process.env.DATABASE_URL,
14-
ssl: { rejectUnauthorized: false }, // Needed for Neon
15-
});
16+
export const pool = new Pool({
17+
connectionString: databaseUrl,
18+
ssl: useSsl ? { rejectUnauthorized: false } : undefined,
19+
});
1620

17-
export const db = drizzle(pool, { schema });
21+
export const db = drizzle(pool, { schema });

0 commit comments

Comments
 (0)