From 95063d406608170870d0db89a176fb290f680bdf Mon Sep 17 00:00:00 2001 From: Sonderau Date: Tue, 3 Feb 2026 23:37:47 +0000 Subject: [PATCH] feat: add password reset functionality and email notifications - Implemented forgot password and reset password routes in the backend. - Added email sending capabilities using Nodemailer for password reset requests. - Created ResetPassword page in the frontend for users to reset their passwords. - Updated user model to include reset token and expiry fields. - Integrated hiscores API with caching mechanism for improved performance. - Enhanced authentication modal to include forgot password option. - Updated environment configuration for SMTP settings. --- CLAUDE.md | 142 ++++++++++++ ecosystem.config.js | 18 ++ os-league-tools-master/src/App.js | 2 + .../src/client/auth-client.js | 22 ++ .../src/client/feedback-client.js | 3 +- .../src/client/group-ironmen-client.js | 3 +- .../src/client/hiscores-client.js | 2 +- .../src/client/user-data-client.js | 3 +- .../src/components/AuthModal.js | 97 ++++++++- .../src/pages/ResetPassword.js | 184 ++++++++++++++++ server/package-lock.json | 21 ++ server/package.json | 4 +- server/prisma/schema.prisma | 35 ++- server/src/app.ts | 4 + server/src/routes/auth.ts | 98 +++++++++ server/src/routes/hiscores.ts | 205 ++++++++++++++++++ server/src/utils/email.ts | 123 +++++++++++ 17 files changed, 945 insertions(+), 21 deletions(-) create mode 100644 CLAUDE.md create mode 100644 os-league-tools-master/src/pages/ResetPassword.js create mode 100644 server/src/routes/hiscores.ts create mode 100644 server/src/utils/email.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..57d9c1c5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,142 @@ +# Leagues Tools + +OSRS (Old School RuneScape) League tracker and planning application. Allows users to track League tasks, unlocks, planning, and group ironman progress via RuneLite plugin integration. + +## Tech Stack + +### Frontend (`os-league-tools-master/`) +- **React 18.3** with React Router DOM 6.28 +- **Redux Toolkit** for state management +- **TailwindCSS 3.0** for styling +- **Webpack 5** for bundling + +### Backend (`server/`) +- **Hono** - lightweight web framework on Node.js +- **TypeScript** (ES2022 target) +- **Prisma 6.2** ORM with **SQLite** database +- **bcrypt** for password hashing +- **Nodemailer** for emails +- **Blake2b** for token hashing + +## Project Structure + +``` +leagues-tools-dev/ +├── os-league-tools-master/ # React frontend +│ ├── src/ +│ │ ├── App.js # Main entry, routing +│ │ ├── client/ # API client modules +│ │ ├── components/ # React components +│ │ ├── pages/ # Page components +│ │ ├── store/ # Redux slices +│ │ └── hooks/ # Custom hooks +│ └── build/ # Production build output +│ +├── server/ # Hono backend +│ ├── src/ +│ │ ├── index.ts # Server entry point +│ │ ├── app.ts # Hono app setup +│ │ ├── db.ts # Prisma client +│ │ ├── routes/ # API route handlers +│ │ ├── middleware/ # Auth middleware +│ │ └── utils/ # Helpers (email, password, blake2) +│ └── prisma/schema.prisma # Database schema +│ +└── ecosystem.config.js # PM2 deployment config +``` + +## Commands + +### Frontend +```bash +cd os-league-tools-master +npm run dev # Start dev server (port 3000) +npm run build # Production build +``` + +### Backend +```bash +cd server +npm run dev # Start with hot reload (tsx watch) +npm run build # Compile TypeScript +npm run db:push # Push schema to database +npm run db:migrate # Run migrations +npm run db:generate # Generate Prisma client +``` + +### PM2 (Root) +```bash +npm run start # Start all PM2 apps +npm run stop # Stop all apps +npm run logs # View logs +``` + +## Database Schema (Key Models) + +- **User** - Auth accounts with role (USER/ADMIN), sessions, characters +- **Character** - User's OSRS characters with RSN, stores tasks/unlocks/notes as JSON +- **Session** - Cookie-based sessions (7-day TTL) +- **Group** - Group Ironmen tracking with Blake2b hashed token +- **Member** - Group member data (stats, inventory, equipment, bank, quests) +- **HiscoresCache** - Cached OSRS hiscores (5-min TTL) + +## API Routes + +All routes prefixed with `/api`: + +| Route | Purpose | +|-------|---------| +| `/register`, `/login`, `/logout` | Auth | +| `/me`, `/auth/status` | Current user | +| `/forgot-password`, `/reset-password` | Password reset | +| `/characters` | User character CRUD | +| `/group/:name/*` | Group data (RuneLite plugin) | +| `/hiscores/:rsn` | OSRS hiscores with caching | +| `/admin/*` | Admin user management | +| `/create-group`, `/ge-prices` | Public endpoints | + +## Authentication + +- **Session-based**: HTTP-only secure cookies, 7-day TTL +- **Group tokens**: Blake2b-256 hashed, used by RuneLite plugin +- **Roles**: USER (default), ADMIN (access to `/api/admin/*`) + +Middleware in `server/src/middleware/`: +- `session.ts` - requireAuth, requireAdmin +- `groupAuth.ts` - RuneLite token validation + +## Environment Variables + +Backend expects (via `.env` or ecosystem.config.js): +- `PORT` - Server port (3001 default) +- `DATABASE_URL` - SQLite path (`file:./data.db`) +- `CORS_ORIGINS` - Allowed origins (comma-separated) +- `SMTP_HOST`, `SMTP_PORT`, `SMTP_FROM` - Email config +- `FRONTEND_BUILD_PATH` - Path to React build + +Frontend uses: +- `REACT_APP_RELDO_URL` - Override API endpoint +- `REACT_APP_GA_MID` - Google Analytics ID + +## Key Patterns + +1. **JSON Storage**: Complex data (tasks, unlocks) stored as JSON strings in SQLite +2. **Graceful Fallbacks**: Hiscores returns stale cache if OSRS API fails +3. **Character Active State**: Only one character active per user +4. **Bulk Sync**: Merges local client data with server on login +5. **SPA Routing**: Backend serves `index.html` for non-API routes + +## Ports + +| Service | Dev | Prod | +|---------|-----|------| +| Frontend | 3000 | (served by backend) | +| Backend | 3003 | 3002 | + +## Important Files + +- `server/src/app.ts` - All route mounting, CORS, middleware +- `server/prisma/schema.prisma` - Full database schema +- `os-league-tools-master/src/App.js` - Frontend routing +- `os-league-tools-master/src/client/` - API client functions +- `ecosystem.config.js` - PM2 deployment configuration diff --git a/ecosystem.config.js b/ecosystem.config.js index d9adf309..fb1ac05f 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -13,6 +13,14 @@ module.exports = { BACKEND_SECRET: 'A1bC3dE5fG7hI9jK1lM3nO5pQ7rS9tU1vW3xY5zA7bC9dE1f', CORS_ORIGINS: 'https://leagues.tools,https://www.leagues.tools,http://localhost:3002', FRONTEND_BUILD_PATH: '../os-league-tools-master/build', + APP_URL: 'https://leagues.tools', + // SMTP Configuration - fill in your server details + SMTP_HOST: 'yeahnah-net.mail.protection.outlook.com', // e.g., 'smtp.gmail.com' or 'mail.yourserver.com' + SMTP_PORT: '25', // 587 for TLS, 465 for SSL + SMTP_SECURE: 'false', // 'true' for port 465, 'false' for 587 + SMTP_USER: 'bailey@yeahnah.net', // SMTP username/email + SMTP_PASS: 'Howaboutno123!', // SMTP password or app password + SMTP_FROM: 'noreply@leagues.tools', // From address for emails }, }, { @@ -28,6 +36,14 @@ module.exports = { BACKEND_SECRET: 'A1bC3dE5fG7hI9jK1lM3nO5pQ7rS9tU1vW3xY5zA7bC9dE1f', CORS_ORIGINS: 'http://localhost:3000,http://localhost:3001,https://dev.leagues.tools', FRONTEND_BUILD_PATH: '../os-league-tools-master/build', + APP_URL: 'https://dev.leagues.tools', + // SMTP Configuration - same as prod or leave empty for console logging + SMTP_HOST: 'yeahnah-net.mail.protection.outlook.com', + SMTP_PORT: '25', + SMTP_SECURE: 'false', + SMTP_USER: 'bailey@yeahnah.net', + SMTP_PASS: 'Howaboutno123!', + SMTP_FROM: 'noreply@leagues.tools', }, }, { @@ -43,3 +59,5 @@ module.exports = { }, ], }; + + diff --git a/os-league-tools-master/src/App.js b/os-league-tools-master/src/App.js index 39c7f08c..e6846b70 100644 --- a/os-league-tools-master/src/App.js +++ b/os-league-tools-master/src/App.js @@ -20,6 +20,7 @@ import ViewCharacter from './pages/ViewCharacter'; import Groups from './pages/Groups'; import Planner from './pages/Planner'; import Admin from './pages/Admin'; +import ResetPassword from './pages/ResetPassword'; import { submitRenderError } from './client/feedback-client'; import { ErrorPage } from './components/common/util/ErrorBoundary'; @@ -114,6 +115,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/os-league-tools-master/src/client/auth-client.js b/os-league-tools-master/src/client/auth-client.js index b92f8367..b2d5ef43 100644 --- a/os-league-tools-master/src/client/auth-client.js +++ b/os-league-tools-master/src/client/auth-client.js @@ -73,3 +73,25 @@ export function getCurrentUser() { .then(handleResponse) .catch(handleError); } + +export function forgotPassword(email) { + return fetch(`${BASE_URL}/api/forgot-password`, { + method: 'POST', + headers: DEFAULT_HEADERS, + credentials: 'include', + body: JSON.stringify({ email }), + }) + .then(handleResponse) + .catch(handleError); +} + +export function resetPassword(token, password) { + return fetch(`${BASE_URL}/api/reset-password`, { + method: 'POST', + headers: DEFAULT_HEADERS, + credentials: 'include', + body: JSON.stringify({ token, password }), + }) + .then(handleResponse) + .catch(handleError); +} diff --git a/os-league-tools-master/src/client/feedback-client.js b/os-league-tools-master/src/client/feedback-client.js index 717de3cf..d4f818c5 100644 --- a/os-league-tools-master/src/client/feedback-client.js +++ b/os-league-tools-master/src/client/feedback-client.js @@ -1,4 +1,5 @@ -const BASE_URL = process.env.REACT_APP_RELDO_URL || 'http://localhost:8080'; +// Use relative URLs when REACT_APP_RELDO_URL is not set (same-origin via nginx) +const BASE_URL = process.env.REACT_APP_RELDO_URL || ''; export function submitBug(formData) { return submitFeedback(formData, '/bug'); diff --git a/os-league-tools-master/src/client/group-ironmen-client.js b/os-league-tools-master/src/client/group-ironmen-client.js index 47b9e735..48d8e2fd 100644 --- a/os-league-tools-master/src/client/group-ironmen-client.js +++ b/os-league-tools-master/src/client/group-ironmen-client.js @@ -3,7 +3,8 @@ * Communicates with the Java/Spring Boot backend at spring-backend/ */ -const BASE_URL = process.env.REACT_APP_GROUP_IRONMEN_URL || 'http://localhost:8080'; +// Use relative URLs when REACT_APP_GROUP_IRONMEN_URL is not set (same-origin via nginx) +const BASE_URL = process.env.REACT_APP_GROUP_IRONMEN_URL || ''; const DEFAULT_HEADERS = { 'Content-type': 'application/json', }; diff --git a/os-league-tools-master/src/client/hiscores-client.js b/os-league-tools-master/src/client/hiscores-client.js index 93c56236..015f46aa 100644 --- a/os-league-tools-master/src/client/hiscores-client.js +++ b/os-league-tools-master/src/client/hiscores-client.js @@ -9,7 +9,7 @@ export default async function getHiscores(rsn, handleResultCallback) { }); } - const url = `${BASE_URL}/hiscores/${rsn}`; + const url = `${BASE_URL}/api/hiscores/${rsn}`; await fetch(url) .then(res => res.json()) .then( diff --git a/os-league-tools-master/src/client/user-data-client.js b/os-league-tools-master/src/client/user-data-client.js index 1e8fed62..3dae6266 100644 --- a/os-league-tools-master/src/client/user-data-client.js +++ b/os-league-tools-master/src/client/user-data-client.js @@ -1,4 +1,5 @@ -const BASE_URL = process.env.REACT_APP_RELDO_URL || 'http://localhost:8080'; +// Use relative URLs when REACT_APP_RELDO_URL is not set (same-origin via nginx) +const BASE_URL = process.env.REACT_APP_RELDO_URL || ''; const DEFAULT_HEADERS = { 'Content-type': 'application/json', }; diff --git a/os-league-tools-master/src/components/AuthModal.js b/os-league-tools-master/src/components/AuthModal.js index a74700a2..fbe9a6b3 100644 --- a/os-league-tools-master/src/components/AuthModal.js +++ b/os-league-tools-master/src/components/AuthModal.js @@ -1,11 +1,12 @@ import React, { useState } from 'react'; import Modal from './Modal'; import Spinner from './common/Spinner'; -import { login, register } from '../client/auth-client'; +import { login, register, forgotPassword } from '../client/auth-client'; const VIEW = { LOGIN: 'login', REGISTER: 'register', + FORGOT_PASSWORD: 'forgot_password', }; export default function AuthModal({ isOpen, setIsOpen, onAuthSuccess }) { @@ -21,6 +22,9 @@ export default function AuthModal({ isOpen, setIsOpen, onAuthSuccess }) { const [registerPassword, setRegisterPassword] = useState(''); const [registerConfirmPassword, setRegisterConfirmPassword] = useState(''); + const [forgotEmail, setForgotEmail] = useState(''); + const [successMessage, setSuccessMessage] = useState(''); + const resetForm = () => { setLoginUsername(''); setLoginPassword(''); @@ -28,7 +32,9 @@ export default function AuthModal({ isOpen, setIsOpen, onAuthSuccess }) { setRegisterEmail(''); setRegisterPassword(''); setRegisterConfirmPassword(''); + setForgotEmail(''); setError(''); + setSuccessMessage(''); }; const handleClose = () => { @@ -99,6 +105,28 @@ export default function AuthModal({ isOpen, setIsOpen, onAuthSuccess }) { } }; + const handleForgotPassword = async e => { + e.preventDefault(); + setError(''); + setSuccessMessage(''); + + if (!forgotEmail) { + setError('Please enter your email address'); + return; + } + + setIsLoading(true); + const result = await forgotPassword(forgotEmail); + setIsLoading(false); + + if (result.success) { + setSuccessMessage(result.value.message); + setForgotEmail(''); + } else { + setError(result.error || 'Failed to send reset email'); + } + }; + return ( - {view === VIEW.LOGIN ? 'Login' : 'Create Account'} + {view === VIEW.LOGIN && 'Login'} + {view === VIEW.REGISTER && 'Create Account'} + {view === VIEW.FORGOT_PASSWORD && 'Reset Password'} - {view === VIEW.LOGIN ? ( + {view === VIEW.LOGIN && (
- {view === VIEW.LOGIN ? ( + {view === VIEW.LOGIN && (

Don't have an account?{' '}

- ) : ( + )} + {view === VIEW.REGISTER && (

Already have an account?{' '}

)} + {view === VIEW.FORGOT_PASSWORD && ( +

+ Remember your password?{' '} + +

+ )}
); diff --git a/os-league-tools-master/src/pages/ResetPassword.js b/os-league-tools-master/src/pages/ResetPassword.js new file mode 100644 index 00000000..e65b3a98 --- /dev/null +++ b/os-league-tools-master/src/pages/ResetPassword.js @@ -0,0 +1,184 @@ +import React, { useState } from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import Card from '../components/common/Card'; +import PageWrapper from '../components/PageWrapper'; +import Spinner from '../components/common/Spinner'; +import { resetPassword } from '../client/auth-client'; + +export default function ResetPassword() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const token = searchParams.get('token'); + + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + + if (!password || !confirmPassword) { + setError('Please fill in all fields'); + return; + } + + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + + if (password.length < 8) { + setError('Password must be at least 8 characters'); + return; + } + + setIsLoading(true); + const result = await resetPassword(token, password); + setIsLoading(false); + + if (result.success) { + setSuccess(true); + } else { + setError(result.error || 'Failed to reset password'); + } + }; + + // No token provided + if (!token) { + return ( + +
+ + +

+ Invalid Reset Link +

+
+ +

+ This password reset link is invalid or has expired. + Please request a new password reset from the login page. +

+
+ +
+
+
+
+
+ ); + } + + // Success state + if (success) { + return ( + +
+ + +

+ Password Reset +

+
+ +
+

+ Your password has been reset successfully! +

+

+ You can now log in with your new password. +

+ +
+
+
+
+
+ ); + } + + // Reset form + return ( + +
+ + +

+ Reset Password +

+
+ +
+

+ Enter your new password below. +

+ + + + + + {error && ( +
{error}
+ )} + + +
+
+
+
+
+ ); +} diff --git a/server/package-lock.json b/server/package-lock.json index ef661db9..5f6eb435 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -13,11 +13,13 @@ "bcrypt": "^5.1.1", "blakejs": "^1.2.1", "hono": "^4.6.16", + "nodemailer": "^7.0.13", "uuid": "^11.0.5" }, "devDependencies": { "@types/bcrypt": "^5.0.2", "@types/node": "^22.10.5", + "@types/nodemailer": "^7.0.9", "@types/uuid": "^10.0.0", "prisma": "^6.2.1", "tsx": "^4.19.2", @@ -610,6 +612,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz", + "integrity": "sha512-vI8oF1M+8JvQhsId0Pc38BdUP2evenIIys7c7p+9OZXSPOH5c1dyINP1jT8xQ2xPuBUXmIC87s+91IZMDjH8Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -1273,6 +1285,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", diff --git a/server/package.json b/server/package.json index dcdefa5f..7f653d5d 100644 --- a/server/package.json +++ b/server/package.json @@ -19,14 +19,16 @@ "dependencies": { "@hono/node-server": "^1.13.7", "@prisma/client": "^6.2.1", - "blakejs": "^1.2.1", "bcrypt": "^5.1.1", + "blakejs": "^1.2.1", "hono": "^4.6.16", + "nodemailer": "^7.0.13", "uuid": "^11.0.5" }, "devDependencies": { "@types/bcrypt": "^5.0.2", "@types/node": "^22.10.5", + "@types/nodemailer": "^7.0.9", "@types/uuid": "^10.0.0", "prisma": "^6.2.1", "tsx": "^4.19.2", diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 5443b44a..a238674b 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -15,16 +15,18 @@ enum Role { // User authentication model User { - id Int @id @default(autoincrement()) - username String @unique - email String @unique - passwordHash String - role Role @default(USER) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - sessions Session[] - characters Character[] - ownedGroups Group[] @relation("GroupOwner") + id Int @id @default(autoincrement()) + username String @unique + email String @unique + passwordHash String + role Role @default(USER) + resetToken String? // Password reset token + resetTokenExpiry DateTime? // Token expiration time + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + sessions Session[] + characters Character[] + ownedGroups Group[] @relation("GroupOwner") } // User's OSRS characters (for task/unlock tracking) @@ -213,3 +215,16 @@ model CollectionLogNew { @@id([memberId, pageId]) } + +// Hiscores cache - stores fetched hiscores data +model HiscoresCache { + rsn String @id // RuneScape Name (lowercase for lookup) + displayRsn String // Original case RSN for display + skills String // JSON - skill data + clues String // JSON - clue scroll data + activities String // JSON - activities/bosses data + leaguePoints Int @default(0) + fetchedAt DateTime @default(now()) + + @@index([fetchedAt]) +} diff --git a/server/src/app.ts b/server/src/app.ts index 160d3c84..aae359b4 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -9,6 +9,7 @@ import groupRoutes from './routes/groups'; import memberRoutes from './routes/members'; import adminRoutes from './routes/admin'; import characterRoutes from './routes/characters'; +import hiscoresRoutes from './routes/hiscores'; export function createApp() { const app = new Hono(); @@ -46,6 +47,9 @@ export function createApp() { // Health check app.get('/api/health', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString() })); + // Hiscores proxy - under /api so nginx routes correctly + app.route('/api/hiscores', hiscoresRoutes); + // Serve static files from React build const frontendPath = process.env.FRONTEND_BUILD_PATH || '../os-league-tools-master/build'; diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 5f02f03e..9d466c0b 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -1,7 +1,9 @@ import { Hono } from 'hono'; +import { randomBytes } from 'crypto'; import { prisma } from '../db'; import { hashPassword, verifyPassword } from '../utils/password'; import { createSession, destroySession, requireAuth } from '../middleware/session'; +import { sendPasswordResetEmail, isEmailConfigured } from '../utils/email'; const auth = new Hono(); @@ -132,4 +134,100 @@ auth.get('/me', requireAuth, (c) => { }); }); +/** + * POST /api/forgot-password + * Request a password reset + */ +auth.post('/forgot-password', async (c) => { + const body = await c.req.json(); + const { email } = body; + + if (!email) { + return c.json({ error: 'Email is required' }, 400); + } + + // Find user by email + const user = await prisma.user.findUnique({ + where: { email }, + }); + + // Always return success to prevent email enumeration + if (!user) { + return c.json({ message: 'If an account with that email exists, a reset link has been sent.' }); + } + + // Generate secure token + const resetToken = randomBytes(32).toString('hex'); + const resetTokenExpiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now + + // Store token in database + await prisma.user.update({ + where: { id: user.id }, + data: { + resetToken, + resetTokenExpiry, + }, + }); + + // Send email with reset link + const baseUrl = process.env.APP_URL || 'https://leagues.tools'; + + if (isEmailConfigured()) { + const emailResult = await sendPasswordResetEmail(email, resetToken, baseUrl); + if (!emailResult.success) { + console.error(`Failed to send password reset email to ${email}:`, emailResult.error); + } + } else { + // Log token in development when email is not configured + console.log(`Password reset requested for ${email}. Token: ${resetToken}`); + console.log(`Reset URL: ${baseUrl}/reset-password?token=${resetToken}`); + } + + return c.json({ message: 'If an account with that email exists, a reset link has been sent.' }); +}); + +/** + * POST /api/reset-password + * Reset password using token + */ +auth.post('/reset-password', async (c) => { + const body = await c.req.json(); + const { token, password } = body; + + if (!token || !password) { + return c.json({ error: 'Token and password are required' }, 400); + } + + if (password.length < 8) { + return c.json({ error: 'Password must be at least 8 characters' }, 400); + } + + // Find user with this token + const user = await prisma.user.findFirst({ + where: { + resetToken: token, + resetTokenExpiry: { + gt: new Date(), // Token must not be expired + }, + }, + }); + + if (!user) { + return c.json({ error: 'Invalid or expired reset token' }, 400); + } + + // Update password and clear token + const passwordHash = await hashPassword(password); + await prisma.user.update({ + where: { id: user.id }, + data: { + passwordHash, + resetToken: null, + resetTokenExpiry: null, + }, + }); + + return c.json({ message: 'Password has been reset successfully. You can now log in.' }); +}); + export default auth; diff --git a/server/src/routes/hiscores.ts b/server/src/routes/hiscores.ts new file mode 100644 index 00000000..86383f36 --- /dev/null +++ b/server/src/routes/hiscores.ts @@ -0,0 +1,205 @@ +import { Hono } from 'hono'; +import { prisma } from '../db'; + +const hiscores = new Hono(); + +// Cache TTL in milliseconds (5 minutes) +const CACHE_TTL_MS = 5 * 60 * 1000; + +// Skill names in order returned by the hiscores API +const SKILL_NAMES = [ + 'overall', + 'attack', + 'defence', + 'strength', + 'hitpoints', + 'ranged', + 'prayer', + 'magic', + 'cooking', + 'woodcutting', + 'fletching', + 'fishing', + 'firemaking', + 'crafting', + 'smithing', + 'mining', + 'herblore', + 'agility', + 'thieving', + 'slayer', + 'farming', + 'runecraft', + 'hunter', + 'construction', +]; + +// Activity/minigame names (after skills in the CSV) +const ACTIVITY_NAMES = [ + 'league_points', + 'deadman_points', + 'bounty_hunter_hunter', + 'bounty_hunter_rogue', + 'bounty_hunter_hunter_legacy', + 'bounty_hunter_rogue_legacy', + 'clue_scrolls_all', + 'clue_scrolls_beginner', + 'clue_scrolls_easy', + 'clue_scrolls_medium', + 'clue_scrolls_hard', + 'clue_scrolls_elite', + 'clue_scrolls_master', + 'lms_rank', + 'pvp_arena_rank', + 'soul_wars_zeal', + 'rifts_closed', + 'colosseum_glory', +]; + +/** + * GET /api/hiscores/:rsn + * Fetch hiscores from cache or Jagex seasonal (Leagues) hiscores + */ +hiscores.get('/:rsn', async (c) => { + const rsn = c.req.param('rsn'); + + if (!rsn) { + return c.json({ error: 'RSN is required' }, 400); + } + + const rsnLower = rsn.toLowerCase(); + + // Check cache first + const cached = await prisma.hiscoresCache.findUnique({ + where: { rsn: rsnLower }, + }); + + const now = new Date(); + if (cached && (now.getTime() - cached.fetchedAt.getTime()) < CACHE_TTL_MS) { + // Return cached data + return c.json({ + rsn: cached.displayRsn, + skills: JSON.parse(cached.skills), + clues: JSON.parse(cached.clues), + activities: JSON.parse(cached.activities), + leaguePoints: cached.leaguePoints, + cached: true, + cachedAt: cached.fetchedAt, + }); + } + + // Fetch from Jagex API + const url = `https://secure.runescape.com/m=hiscore_oldschool_seasonal/index_lite.ws?player=${encodeURIComponent(rsn)}`; + + try { + const response = await fetch(url, { + headers: { + 'User-Agent': 'LeaguesTools/1.0', + }, + }); + + if (response.status === 404) { + return c.json({ status: 404, error: 'Player not found' }, 404); + } + + if (!response.ok) { + // If fetch fails but we have stale cache, return it + if (cached) { + return c.json({ + rsn: cached.displayRsn, + skills: JSON.parse(cached.skills), + clues: JSON.parse(cached.clues), + activities: JSON.parse(cached.activities), + leaguePoints: cached.leaguePoints, + cached: true, + stale: true, + cachedAt: cached.fetchedAt, + }); + } + console.error(`Hiscores API error: ${response.status}`); + return c.json({ error: 'Failed to fetch hiscores' }, 502); + } + + const text = await response.text(); + const lines = text.trim().split('\n'); + + // Parse skills (first 24 lines) + const skills: Record = {}; + for (let i = 0; i < SKILL_NAMES.length && i < lines.length; i++) { + const [rank, level, xp] = lines[i].split(',').map(Number); + skills[SKILL_NAMES[i]] = { rank, level, xp }; + } + + // Parse activities/minigames (remaining lines) + const activities: Record = {}; + for (let i = SKILL_NAMES.length; i < lines.length; i++) { + const activityIndex = i - SKILL_NAMES.length; + if (activityIndex < ACTIVITY_NAMES.length) { + const [rank, score] = lines[i].split(',').map(Number); + activities[ACTIVITY_NAMES[activityIndex]] = { rank, score }; + } + } + + // Build clues object for frontend compatibility + const clues = { + all: { rank: activities.clue_scrolls_all?.rank ?? -1, score: activities.clue_scrolls_all?.score ?? -1 }, + beginner: { rank: activities.clue_scrolls_beginner?.rank ?? -1, score: activities.clue_scrolls_beginner?.score ?? -1 }, + easy: { rank: activities.clue_scrolls_easy?.rank ?? -1, score: activities.clue_scrolls_easy?.score ?? -1 }, + medium: { rank: activities.clue_scrolls_medium?.rank ?? -1, score: activities.clue_scrolls_medium?.score ?? -1 }, + hard: { rank: activities.clue_scrolls_hard?.rank ?? -1, score: activities.clue_scrolls_hard?.score ?? -1 }, + elite: { rank: activities.clue_scrolls_elite?.rank ?? -1, score: activities.clue_scrolls_elite?.score ?? -1 }, + master: { rank: activities.clue_scrolls_master?.rank ?? -1, score: activities.clue_scrolls_master?.score ?? -1 }, + }; + + const leaguePoints = activities.league_points?.score || 0; + + // Cache the result + await prisma.hiscoresCache.upsert({ + where: { rsn: rsnLower }, + update: { + displayRsn: rsn, + skills: JSON.stringify(skills), + clues: JSON.stringify(clues), + activities: JSON.stringify(activities), + leaguePoints, + fetchedAt: now, + }, + create: { + rsn: rsnLower, + displayRsn: rsn, + skills: JSON.stringify(skills), + clues: JSON.stringify(clues), + activities: JSON.stringify(activities), + leaguePoints, + fetchedAt: now, + }, + }); + + return c.json({ + rsn, + skills, + clues, + activities, + leaguePoints, + cached: false, + }); + } catch (error) { + // If fetch fails but we have stale cache, return it + if (cached) { + return c.json({ + rsn: cached.displayRsn, + skills: JSON.parse(cached.skills), + clues: JSON.parse(cached.clues), + activities: JSON.parse(cached.activities), + leaguePoints: cached.leaguePoints, + cached: true, + stale: true, + cachedAt: cached.fetchedAt, + }); + } + console.error('Hiscores fetch error:', error); + return c.json({ error: 'Failed to fetch hiscores' }, 500); + } +}); + +export default hiscores; diff --git a/server/src/utils/email.ts b/server/src/utils/email.ts new file mode 100644 index 00000000..d689cd49 --- /dev/null +++ b/server/src/utils/email.ts @@ -0,0 +1,123 @@ +import nodemailer from 'nodemailer'; + +// SMTP configuration from environment variables +const smtpConfig = { + host: process.env.SMTP_HOST || 'localhost', + port: parseInt(process.env.SMTP_PORT || '587', 10), + secure: process.env.SMTP_SECURE === 'true', // true for 465, false for other ports + auth: { + user: process.env.SMTP_USER || '', + pass: process.env.SMTP_PASS || '', + }, +}; + +// Default sender +const defaultFrom = process.env.SMTP_FROM || 'noreply@leagues.tools'; + +// Create reusable transporter +let transporter: nodemailer.Transporter | null = null; + +function getTransporter(): nodemailer.Transporter { + if (!transporter) { + transporter = nodemailer.createTransport(smtpConfig); + } + return transporter; +} + +// Check if email is configured +export function isEmailConfigured(): boolean { + return !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS); +} + +// Send email +export async function sendEmail(options: { + to: string; + subject: string; + text?: string; + html?: string; +}): Promise<{ success: boolean; messageId?: string; error?: string }> { + if (!isEmailConfigured()) { + console.warn('Email not configured. Set SMTP_HOST, SMTP_USER, and SMTP_PASS environment variables.'); + return { success: false, error: 'Email service not configured' }; + } + + try { + const info = await getTransporter().sendMail({ + from: defaultFrom, + to: options.to, + subject: options.subject, + text: options.text, + html: options.html, + }); + + console.log('Email sent:', info.messageId); + return { success: true, messageId: info.messageId }; + } catch (error) { + console.error('Failed to send email:', error); + return { success: false, error: error instanceof Error ? error.message : 'Failed to send email' }; + } +} + +// Send password reset email +export async function sendPasswordResetEmail( + email: string, + resetToken: string, + baseUrl: string +): Promise<{ success: boolean; error?: string }> { + const resetUrl = `${baseUrl}/reset-password?token=${resetToken}`; + + const result = await sendEmail({ + to: email, + subject: 'Password Reset Request - Leagues Tools', + text: ` +You requested a password reset for your Leagues Tools account. + +Click the link below to reset your password: +${resetUrl} + +This link will expire in 1 hour. + +If you didn't request this, you can safely ignore this email. + `.trim(), + html: ` + + + + + + + +
+
+

Leagues Tools

+
+
+

Password Reset Request

+

You requested a password reset for your Leagues Tools account.

+

Click the button below to reset your password:

+

+ Reset Password +

+

Or copy and paste this link into your browser:

+

${resetUrl}

+

This link will expire in 1 hour.

+

If you didn't request this, you can safely ignore this email.

+
+ +
+ + + `.trim(), + }); + + return result; +}