From 90dad10f255751fc47b67a57759b88cf003ac87e Mon Sep 17 00:00:00 2001 From: Satyam Shubham <57027429+ItsFlash10@users.noreply.github.com> Date: Fri, 25 Oct 2024 02:35:52 +0530 Subject: [PATCH] feat: Added cms-mobile routes (#1303) * add cms-mobile login route * removed courses call while signing in * added cms-mobile auth middleware * feat: Added Courses API routes * updated gitignore * added search route * feat: added middleware for mobile routes * feat: corrected user Request type * refactored search route * added checks for user access for course collection and content * minor fix --------- Co-authored-by: rishavvajpayee --- .gitignore | 4 + .../[collectionId]/[contentId]/route.ts | 68 ++++++++++++++ .../[courseId]/[collectionId]/route.ts | 62 +++++++++++++ .../api/mobile/courses/[courseId]/route.ts | 52 +++++++++++ src/app/api/mobile/courses/route.ts | 33 +++++++ src/app/api/mobile/search/route.ts | 84 +++++++++++++++++ src/app/api/mobile/signin/route.ts | 90 +++++++++++++++++++ src/middleware.ts | 64 ++++++++++++- 8 files changed, 454 insertions(+), 3 deletions(-) create mode 100644 src/app/api/mobile/courses/[courseId]/[collectionId]/[contentId]/route.ts create mode 100644 src/app/api/mobile/courses/[courseId]/[collectionId]/route.ts create mode 100644 src/app/api/mobile/courses/[courseId]/route.ts create mode 100644 src/app/api/mobile/courses/route.ts create mode 100644 src/app/api/mobile/search/route.ts create mode 100644 src/app/api/mobile/signin/route.ts diff --git a/.gitignore b/.gitignore index d7e11e5e2..f00191240 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,10 @@ yarn-error.log* # vercel .vercel +#vs-code (debug config) +.vscode +launch.json + # typescript *.tsbuildinfo next-env.d.ts diff --git a/src/app/api/mobile/courses/[courseId]/[collectionId]/[contentId]/route.ts b/src/app/api/mobile/courses/[courseId]/[collectionId]/[contentId]/route.ts new file mode 100644 index 000000000..47e27e375 --- /dev/null +++ b/src/app/api/mobile/courses/[courseId]/[collectionId]/[contentId]/route.ts @@ -0,0 +1,68 @@ +import db from '@/db'; +import { NextRequest, NextResponse } from 'next/server'; + +async function checkUserContentAccess(userId: string, contentId: string) { + const userContent = await db.content.findFirst({ + where: { + id: parseInt(contentId, 10), + courses: { + some: { + course: { + purchasedBy: { + some: { + userId, + }, + }, + }, + }, + }, + }, + }); + return userContent !== null; +} + +export async function GET( + req: NextRequest, + { params }: { params: { contentId: string } }, +) { + try { + const { contentId } = params; + const user = JSON.parse(req.headers.get('g') || ''); + const userContentAccess = await checkUserContentAccess(user.id, contentId); + if (!userContentAccess) { + return NextResponse.json( + { message: 'User does not have access to this content' }, + { status: 403 }, + ); + } + const contents = await db.content.findUnique({ + where: { + id: parseInt(contentId, 10), + }, + select: { + id: true, + type: true, + title: true, + hidden: true, + description: true, + thumbnail: true, + parentId: true, + createdAt: true, + notionMetadataId: true, + commentsCount: true, + VideoMetadata: true, + NotionMetadata: true, + }, + }); + return NextResponse.json({ + message: 'Content fetched successfully', + data: contents, + }); + } catch (error) { + console.log(error); + return NextResponse.json( + { message: 'Error fetching Content', error }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/mobile/courses/[courseId]/[collectionId]/route.ts b/src/app/api/mobile/courses/[courseId]/[collectionId]/route.ts new file mode 100644 index 000000000..e6a8e715a --- /dev/null +++ b/src/app/api/mobile/courses/[courseId]/[collectionId]/route.ts @@ -0,0 +1,62 @@ +import db from '@/db'; +import { NextRequest, NextResponse } from 'next/server'; + +async function checkUserCollectionAccess(userId: string, collectionId: string) { + const userCollection = await db.content.findFirst({ + where: { + id: parseInt(collectionId, 10), + courses: { + some: { + course: { + purchasedBy: { + some: { + userId, + }, + }, + }, + }, + }, + }, + }); + + return userCollection !== null; +} + +export async function GET( + request: NextRequest, + { params }: { params: { collectionId: string } }, +) { + try { + const user = JSON.parse(request.headers.get('g') || ''); + if (!user) { + return NextResponse.json({ message: 'User not found' }, { status: 401 }); + } + + const { collectionId } = params; + const userHasCollectionAccess = await checkUserCollectionAccess( + user.id, + collectionId, + ); + if (!userHasCollectionAccess) { + return NextResponse.json( + { message: 'User does not have access to this collection' }, + { status: 403 }, + ); + } + const collectionData = await db.content.findMany({ + where: { + parentId: parseInt(collectionId, 10), + }, + }); + return NextResponse.json({ + message: 'Collection Data fetched successfully', + data: collectionData, + }); + } catch (error) { + console.log(error); + return NextResponse.json( + { message: 'Error fetching user courses', error }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/mobile/courses/[courseId]/route.ts b/src/app/api/mobile/courses/[courseId]/route.ts new file mode 100644 index 000000000..795f69f27 --- /dev/null +++ b/src/app/api/mobile/courses/[courseId]/route.ts @@ -0,0 +1,52 @@ +import db from '@/db'; +import { NextResponse, NextRequest } from 'next/server'; + +async function checkUserCourseAccess(userId: string, courseId: string) { + const userCourse = await db.course.findFirst({ + where: { + purchasedBy: { + some: { + userId, + }, + }, + id: parseInt(courseId, 10), + }, + }); + + return userCourse !== null; +} + +export async function GET( + request: NextRequest, + { params }: { params: { courseId: string } }, +) { + try { + const user: { id: string } = JSON.parse(request.headers.get('g') || ''); + const { courseId } = params; + + const userCourseAccess = await checkUserCourseAccess(user.id, courseId); + if (!userCourseAccess) { + return NextResponse.json( + { message: 'User does not have access to this course' }, + { status: 403 }, + ); + } + const folderContents = await db.content.findMany({ + where: { + id: parseInt(courseId, 10), + type: 'folder', + }, + }); + + return NextResponse.json({ + message: 'Courses Data fetched successfully', + data: folderContents, + }); + } catch (error) { + console.log(error); + return NextResponse.json( + { message: 'Error fetching user courses', error }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/mobile/courses/route.ts b/src/app/api/mobile/courses/route.ts new file mode 100644 index 000000000..c9e922f53 --- /dev/null +++ b/src/app/api/mobile/courses/route.ts @@ -0,0 +1,33 @@ +import db from '@/db'; +import { NextResponse, NextRequest } from 'next/server'; + +export async function GET(req: NextRequest) { + try { + const user = JSON.parse(req.headers.get('g') || ''); + if (!user) { + return NextResponse.json({ message: 'User Not Found' }, { status: 400 }); + } + const userCourses = await db.course.findMany({ + where: { + purchasedBy: { + some: { + user: { + email: user.email, + }, + }, + }, + }, + }); + + return NextResponse.json({ + message: 'User courses fetched successfully', + data: userCourses, + }); + } catch (error) { + console.log(error); + return NextResponse.json( + { message: 'Error fetching user courses', error }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/mobile/search/route.ts b/src/app/api/mobile/search/route.ts new file mode 100644 index 000000000..7b9d85286 --- /dev/null +++ b/src/app/api/mobile/search/route.ts @@ -0,0 +1,84 @@ +import { cache } from '@/db/Cache'; +import db from '@/db'; +import { CourseContent } from '@prisma/client'; +import Fuse from 'fuse.js'; +import { NextRequest, NextResponse } from 'next/server'; + +export type TSearchedVideos = { + id: number; + parentId: number | null; + title: string; +} & { + parent: { courses: CourseContent[] } | null; +}; + +const fuzzySearch = (videos: TSearchedVideos[], searchQuery: string) => { + const searchedVideos = new Fuse(videos, { + minMatchCharLength: 3, + keys: ['title'], + }).search(searchQuery); + + return searchedVideos.map((video) => video.item); +}; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const searchQuery = searchParams.get('q'); + const user = JSON.parse(request.headers.get('g') || ''); + + if (!user) { + return NextResponse.json({ message: 'User Not Found' }, { status: 400 }); + } + + if (searchQuery && searchQuery.length > 2) { + const value: TSearchedVideos[] = await cache.get( + 'getAllVideosForSearch', + [], + ); + + if (value) { + return NextResponse.json(fuzzySearch(value, searchQuery)); + } + + const allVideos = await db.content.findMany({ + where: { + type: 'video', + hidden: false, + parent: { + courses: { + some: { + course: { + purchasedBy: { + some: { + userId: user.id, + }, + }, + }, + }, + }, + }, + }, + select: { + id: true, + parentId: true, + title: true, + parent: { + select: { + courses: true, + }, + }, + }, + }); + + cache.set('getAllVideosForSearch', [], allVideos, 24 * 60 * 60); + + return NextResponse.json(fuzzySearch(allVideos, searchQuery)); + } + } catch (err) { + return NextResponse.json( + { message: 'Error fetching search results', err }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/mobile/signin/route.ts b/src/app/api/mobile/signin/route.ts new file mode 100644 index 000000000..7d12462e9 --- /dev/null +++ b/src/app/api/mobile/signin/route.ts @@ -0,0 +1,90 @@ +import db from '@/db'; +import { NextRequest, NextResponse } from 'next/server'; +import bcrypt from 'bcrypt'; +import { z } from 'zod'; +import { importJWK, JWTPayload, SignJWT } from 'jose'; + +const requestBodySchema = z.object({ + email: z.string().email(), + password: z.string(), +}); + +const generateJWT = async (payload: JWTPayload) => { + const secret = process.env.JWT_SECRET || ''; + + const jwk = await importJWK({ k: secret, alg: 'HS256', kty: 'oct' }); + + const jwt = await new SignJWT(payload) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('365d') // TODO: Confirm if this is OK or we want to introduce a refresh token mechanism [this is the current implementation in CMS] + .sign(jwk); + + return jwt; +}; + +export async function POST(req: NextRequest) { + const authKey = req.headers.get('Auth-Key'); + if (authKey !== process.env.EXTERNAL_LOGIN_AUTH_SECRET) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 403 }); + } + + try { + const body = await req.json(); + const parseResult = requestBodySchema.safeParse(body); + + if (!parseResult.success) { + return NextResponse.json( + { message: 'Invalid input', errors: parseResult.error.errors }, + { status: 400 }, + ); + } + + const { email, password } = parseResult.data; + + const user = await db.user.findFirst({ + where: { + email, + }, + select: { + id: true, + email: true, + name: true, + password: true, + }, + }); + + if (!user) { + return NextResponse.json({ message: 'User not found' }, { status: 404 }); + } + + if ( + user && + user.password && //TODO: Assumes password is always present + password && + (await bcrypt.compare(password, user.password)) + ) { + const jwt = await generateJWT({ + id: user.id, + email: user.email, + }); + + return NextResponse.json({ + message: 'User found', + data: { + user: { + id: user.id, + email: user.email, + name: user.name, + }, + token: jwt, + }, + }); + } + } catch (error) { + return NextResponse.json( + { message: `Error fetching user ${error}` }, + { status: 500 }, + ); + } +} diff --git a/src/middleware.ts b/src/middleware.ts index a1f6e5795..c7feccfa9 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,8 +1,58 @@ -import { withAuth } from 'next-auth/middleware'; -import { NextResponse } from 'next/server'; +import { NextRequestWithAuth, withAuth } from 'next-auth/middleware'; +import { NextResponse, NextRequest } from 'next/server'; +import { jwtVerify, importJWK, JWTPayload } from 'jose'; export const config = { - matcher: ['/courses/:path*'], + matcher: ['/courses/:path*', '/api/mobile/:path*'], +}; + +interface RequestWithUser extends NextRequest { + user?: { + id: string; + email: string; + }; +} + +export const verifyJWT = async (token: string): Promise => { + const secret = process.env.JWT_SECRET || ''; + + try { + const jwk = await importJWK({ k: secret, alg: 'HS256', kty: 'oct' }); + const { payload } = await jwtVerify(token, jwk); + + return payload; + } catch (error) { + console.error('Invalid token:', error); + return null; + } +}; + +export const withMobileAuth = async (req: RequestWithUser) => { + if (req.headers.get('Auth-Key')) { + return NextResponse.next(); + } + const token = req.headers.get('Authorization'); + + if (!token) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 403 }); + } + const payload = await verifyJWT(token); + if (!payload) { + return NextResponse.json({ message: 'Unauthorized' }, { status: 403 }); + } + const newHeaders = new Headers(req.headers); + + /** + * Add a global object 'g' + * it holds the request claims and other keys + * easily pass around this key as request context + */ + newHeaders.set('g', JSON.stringify(payload)); + return NextResponse.next({ + request: { + headers: newHeaders, + }, + }); }; export default withAuth(async (req) => { @@ -20,3 +70,11 @@ export default withAuth(async (req) => { return NextResponse.redirect(new URL('/invalidsession', req.url)); } }); + +export function middleware(req: NextRequestWithAuth) { + const { pathname } = req.nextUrl; + if (pathname.startsWith('/api/mobile')) { + return withMobileAuth(req); + } + return withAuth(req); +}