Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Local Netlify folder
.netlify
34 changes: 15 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,46 +1,42 @@
Required Connections
-
## Required Connections

- TMDB
- Google Client
- MongoDB


```
# server/.env
GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET
JWT_SECRET=<come up with one>
SESSION_SECRET=<come up with one>
BE_BASE_URL=http://localhost:3000 (default)
FE_BASE_URL=http://localhost:5173 (default)
GOOGLE_REDIRECT_URI=http://localhost:3000/api/auth/google/callback (default)
JWT_SECRET
SESSION_SECRET
BE_BASE_URL
FE_BASE_URL
GOOGLE_REDIRECT_URI
MONGODB_URI
TMDB_API_KEY
```

```
# watchlist/.env
VITE_API_ACCESS_TOKEN
VITE_BE_BASE_URL=http://localhost:3000 (default)
```
Current Features
-

## Current Features

- Google login support
- Search for movies
- Add movie to watchlist
- Remove from watchlist
- Add movie to watched list

Currently Working On
-
## Currently Working On

- Track rewatches
- Local storage alternative for guest sessions
- Timestamps

Screenshots
-
## Screenshots

<img width="1905" height="929" alt="image" src="https://github.com/user-attachments/assets/387be216-f3da-4d3a-9be3-d46bbb4c0c25" />
<img width="1904" height="928" alt="image" src="https://github.com/user-attachments/assets/d6b87eda-1380-49f6-a4a5-208fb17258d5" />




37 changes: 37 additions & 0 deletions server/netlify.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
[build]
command = "npm install"
functions = "netlify/functions"

[functions]
node_bundler = "esbuild"
external_node_modules = ["mongoose"]

[[redirects]]
from = "/auth/google"
to = "/.netlify/functions/auth-google"
status = 200

[[redirects]]
from = "/api/auth/google/callback"
to = "/.netlify/functions/auth-google-callback"
status = 200

[[redirects]]
from = "/api/auth/token"
to = "/.netlify/functions/auth-token"
status = 200

[[redirects]]
from = "/api/watchlist"
to = "/.netlify/functions/watchlist"
status = 200

[[redirects]]
from = "/api/watched"
to = "/.netlify/functions/watched"
status = 200

[[redirects]]
from = "/api/tmdb/*"
to = "/.netlify/functions/tmdb/:splat"
status = 200
68 changes: 46 additions & 22 deletions server/src/app.ts → server/netlify/functions/app.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
import express from 'express';
import express, { Request } from 'express';
import session from 'express-session';
import { OAuth2Client } from 'googleapis-common';
import dotenv from 'dotenv';
import { google } from 'googleapis';
import jwt from 'jsonwebtoken';
import mongoose from 'mongoose';
import cors from 'cors';
import { authenticateToken } from './middleware/auth';
import { User } from './models/User';
import { corsHeaders } from './cors';
import { authenticateToken } from '../../src/middleware/auth';
import { User } from '../../src/models/User';
import serverless from 'serverless-http';

dotenv.config();

declare global {
namespace Express {
interface Request {
user?: { id: string; email: string; name: string; picture: string };
}
}
}

const app = express();
const PORT = process.env.PORT || 3000;

Expand All @@ -25,9 +35,21 @@ app.use(
}
},
credentials: true,
})
}),
);

// Ensure all responses include the same CORS headers and respond to preflight
app.use((req, res, next) => {
const headers = corsHeaders(req.headers.origin as string);
// Set headers on the response
Object.entries(headers).forEach(([k, v]) => res.setHeader(k, v));

if (req.method === 'OPTIONS') {
return res.status(204).end();
}
next();
});

// Connect to MongoDB
const clientOptions = {
serverApi: { version: '1' as '1', strict: true, deprecationErrors: true },
Expand All @@ -43,7 +65,7 @@ app.use(
secret: process.env.SESSION_SECRET as string,
resave: false,
saveUninitialized: true,
})
}),
);

app.use(express.json());
Expand All @@ -56,7 +78,7 @@ app.get('/', (req, res) => {
const oAuth2Client = new OAuth2Client(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
process.env.GOOGLE_REDIRECT_URI
process.env.GOOGLE_REDIRECT_URI,
);

app.get('/auth/google', (req, res) => {
Expand Down Expand Up @@ -115,7 +137,7 @@ app.get('/api/auth/google/callback', async (req, res) => {
picture: data?.picture,
},
process.env.JWT_SECRET || '',
{ expiresIn: '1h' }
{ expiresIn: '1h' },
);

const tempCode = Math.random().toString(36).substring(2);
Expand All @@ -126,7 +148,7 @@ app.get('/api/auth/google/callback', async (req, res) => {
console.error('Database error:', error);
res.status(500).send('Failed to create/update user');
}
}
},
);
} catch (error) {
console.error('Error:', error);
Expand All @@ -153,7 +175,7 @@ app.post('/api/auth/token', (req, res) => {
// Get user's watchlist
app.get('/api/watchlist', authenticateToken, async (req, res) => {
try {
const user = await User.findOne({ googleId: req.user.id });
const user = await User.findOne({ googleId: req.user?.id });
res.json(user?.watchlist || []);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch watchlist' });
Expand All @@ -164,9 +186,9 @@ app.get('/api/watchlist', authenticateToken, async (req, res) => {
app.post('/api/watchlist', authenticateToken, async (req, res) => {
try {
const user = await User.findOneAndUpdate(
{ googleId: req.user.id },
{ googleId: req.user?.id },
{ $addToSet: { watchlist: req.body } },
{ new: true }
{ new: true },
);
res.json(user?.watchlist);
} catch (error) {
Expand All @@ -179,9 +201,9 @@ app.delete('/api/watchlist/:movieId', authenticateToken, async (req, res) => {
try {
const movieId = Number(req.params.movieId);
const user = await User.findOneAndUpdate(
{ googleId: req.user.id },
{ googleId: req.user?.id },
{ $pull: { watchlist: { movieId: movieId } } },
{ new: true }
{ new: true },
);

if (!user) {
Expand All @@ -198,7 +220,7 @@ app.delete('/api/watchlist/:movieId', authenticateToken, async (req, res) => {
// Get user's watched list
app.get('/api/watched', authenticateToken, async (req, res) => {
try {
const user = await User.findOne({ googleId: req.user.id });
const user = await User.findOne({ googleId: req.user?.id });
res.json(user?.watched || []);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch watched list' });
Expand All @@ -209,12 +231,12 @@ app.get('/api/watched', authenticateToken, async (req, res) => {
app.post('/api/watched', authenticateToken, async (req, res) => {
try {
const user = await User.findOneAndUpdate(
{ googleId: req.user.id },
{ googleId: req.user?.id },
{
$addToSet: { watched: req.body },
$pull: { watchlist: { movieId: req.body.movieId } },
},
{ new: true }
{ new: true },
);
res.json(user?.watched);
} catch (error) {
Expand All @@ -226,23 +248,25 @@ app.put('/api/watched/:movieId', authenticateToken, async (req, res) => {
try {
const movieId = Number(req.params.movieId);
const user = await User.findOneAndUpdate(
{ googleId: req.user.id },
{ googleId: req.user?.id },
{
$set: { 'watched.$[elem]': req.body },
$pull: { watchlist: { movieId: req.body.movieId } },
},
{
arrayFilters: [{ 'elem.movieId': movieId }],
new: true,
}
},
);
res.json(user?.watched);
} catch (error) {
res.status(500).json({ error: 'Failed to update watched list' });
}
});

// Start the server
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
export const handler = serverless(app);

// // Start the server
// app.listen(PORT, () => {
// console.log(`Server is running on http://localhost:${PORT}`);
// });
80 changes: 80 additions & 0 deletions server/netlify/functions/auth-google-callback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { Handler } from '@netlify/functions';
import { google } from 'googleapis';
import jwt from 'jsonwebtoken';
import { connectToDatabase } from './dbConnect';
import { User } from './models/User';
import { corsHeaders } from './cors';

const oauth2Client = new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
process.env.GOOGLE_REDIRECT_URI,
);

export const handler: Handler = async (event) => {
const headers = corsHeaders(event?.headers?.origin);
if (event.httpMethod === 'OPTIONS') {
return { statusCode: 204, headers };
}

const code = event.queryStringParameters?.code;

if (!code) {
return {
statusCode: 400,
headers,
body: JSON.stringify({ error: 'Missing code' }),
};
}

try {
const redirectUri = process.env.GOOGLE_REDIRECT_URI;
console.debug('Exchanging code for tokens', { redirectUri });
const { tokens } = await oauth2Client.getToken({ code, redirect_uri: redirectUri } as any);
oauth2Client.setCredentials(tokens as any);

const oauth2 = google.oauth2('v2');
const resp = await oauth2.userinfo.get({ auth: oauth2Client as any });
const data = resp.data as any;

await connectToDatabase();

let user = await User.findOne({ googleId: data?.id });
if (!user) {
user = await User.create({
googleId: data?.id,
email: data?.email,
name: data?.name,
picture: data?.picture,
watchlist: [],
watched: [],
} as any);
}

const token = jwt.sign(
{
id: data?.id,
email: data?.email,
name: data?.name,
picture: data?.picture,
},
process.env.JWT_SECRET || '',
{ expiresIn: '1h' },
);

const redirectTo = `${process.env.FE_BASE_URL}/auth#token=${token}`;

return {
statusCode: 302,
headers: { ...headers, Location: redirectTo },
body: '',
};
} catch (err: any) {
console.error('Auth callback error', err);
return {
statusCode: 500,
headers,
body: JSON.stringify({ error: 'Authentication failed' }),
};
}
};
30 changes: 30 additions & 0 deletions server/netlify/functions/auth-google.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Handler } from '@netlify/functions';
import { google } from 'googleapis';
import { corsHeaders } from './cors';

const oauth2Client = new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
process.env.GOOGLE_REDIRECT_URI,
);

export const handler: Handler = async (event) => {
const headers = corsHeaders(event?.headers?.origin);
if (event.httpMethod === 'OPTIONS') {
return { statusCode: 204, headers };
}

const authUrl = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: [
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/userinfo.email',
],
});

return {
statusCode: 302,
headers: { ...headers, Location: authUrl },
body: '',
};
};
Loading