Admin role added, dev side has hot reload again, characters AND groups tied to accounts now.

This commit is contained in:
2026-01-28 23:43:02 +00:00
parent 8e92c28272
commit 3cec7abee9
48 changed files with 6139 additions and 45 deletions

5
.gitignore vendored
View File

@@ -61,3 +61,8 @@ group-ironmen-master/*.*
group-ironmen-tracker-master/*.*
tasks-tracker-plugin-master/*.*
os-league-tools-master/build.tar.gz
node_modules/
.vscode/
.gitea/
.claude/

45
ecosystem.config.js Normal file
View File

@@ -0,0 +1,45 @@
module.exports = {
apps: [
{
name: 'api-prod',
cwd: './server',
script: 'npx',
args: 'tsx src/index.ts',
env: {
NODE_ENV: 'production',
PORT: 3002,
DATABASE_URL: 'file:./data.db',
SESSION_SECRET: 'K7xP2mN9qR4sT6vW8yB1cD3fG5hJ0kL2nO4pQ6rS8tU0wX2z',
BACKEND_SECRET: 'A1bC3dE5fG7hI9jK1lM3nO5pQ7rS9tU1vW3xY5zA7bC9dE1f',
CORS_ORIGINS: 'https://leagues.tools,https://www.leagues.tools,http://localhost:3002',
FRONTEND_BUILD_PATH: '../os-league-tools-master/build',
},
},
{
name: 'api-dev',
cwd: './server',
script: 'npx',
args: 'tsx watch src/index.ts',
env: {
NODE_ENV: 'development',
PORT: 3003,
DATABASE_URL: 'file:./data-dev.db',
SESSION_SECRET: 'K7xP2mN9qR4sT6vW8yB1cD3fG5hJ0kL2nO4pQ6rS8tU0wX2z',
BACKEND_SECRET: 'A1bC3dE5fG7hI9jK1lM3nO5pQ7rS9tU1vW3xY5zA7bC9dE1f',
CORS_ORIGINS: 'http://localhost:3000,http://localhost:3001,https://dev.leagues.tools',
FRONTEND_BUILD_PATH: '../os-league-tools-master/build',
},
},
{
name: 'frontend-dev',
cwd: './os-league-tools-master',
script: 'npm',
args: 'run dev',
env: {
BROWSER: 'none',
PORT: 3000,
WDS_SOCKET_PORT: 443, // Use nginx's HTTPS port for WebSocket
},
},
],
};

View File

@@ -0,0 +1,7 @@
# Development environment variables
# API server - use same domain, nginx proxies /api to backend
REACT_APP_RELDO_URL=
# Google Analytics (optional - leave empty for dev)
REACT_APP_GA_MID=

View File

@@ -0,0 +1,5 @@
# API server URL
REACT_APP_RELDO_URL=http://localhost:3001
# Google Analytics tracking ID (optional)
REACT_APP_GA_MID=

View File

@@ -0,0 +1,7 @@
# Production environment variables
# Production API server (same origin - served by Node.js)
REACT_APP_RELDO_URL=
# Google Analytics
REACT_APP_GA_MID=

View File

@@ -0,0 +1,19 @@
[Unit]
Description=Leagues Tools Frontend Dev Server (Hot Reload)
After=network.target leagues-tools-dev.service
Wants=leagues-tools-dev.service
[Service]
Type=simple
User=sonder
WorkingDirectory=/home/sonder/leagues-tools-dev/os-league-tools-master
ExecStart=/usr/bin/npm run dev
Restart=on-failure
RestartSec=10
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=leagues-tools-frontend-dev
Environment=NODE_ENV=development
[Install]
WantedBy=multi-user.target

View File

@@ -96,6 +96,7 @@
},
"devDependencies": {
"gh-pages": "^6.2.0",
"http-proxy-middleware": "^3.0.5",
"husky": "^7.0.4",
"jest": "^27.4.7",
"lint-staged": "^12.1.5",
@@ -10254,26 +10255,21 @@
}
},
"node_modules/http-proxy-middleware": {
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz",
"integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-proxy": "^1.17.8",
"@types/http-proxy": "^1.17.15",
"debug": "^4.3.6",
"http-proxy": "^1.18.1",
"is-glob": "^4.0.1",
"is-plain-obj": "^3.0.0",
"micromatch": "^4.0.2"
"is-glob": "^4.0.3",
"is-plain-object": "^5.0.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"@types/express": "^4.17.13"
},
"peerDependenciesMeta": {
"@types/express": {
"optional": true
}
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/https-proxy-agent": {
@@ -10858,6 +10854,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-port-reachable": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-port-reachable/-/is-port-reachable-4.0.0.tgz",
@@ -19831,6 +19837,30 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/webpack-dev-server/node_modules/http-proxy-middleware": {
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
"license": "MIT",
"dependencies": {
"@types/http-proxy": "^1.17.8",
"http-proxy": "^1.18.1",
"is-glob": "^4.0.1",
"is-plain-obj": "^3.0.0",
"micromatch": "^4.0.2"
},
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"@types/express": "^4.17.13"
},
"peerDependenciesMeta": {
"@types/express": {
"optional": true
}
}
},
"node_modules/webpack-dev-server/node_modules/open": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz",

View File

@@ -108,6 +108,7 @@
},
"devDependencies": {
"gh-pages": "^6.2.0",
"http-proxy-middleware": "^3.0.5",
"husky": "^7.0.4",
"jest": "^27.4.7",
"lint-staged": "^12.1.5",

View File

@@ -19,6 +19,7 @@ import Faq from './pages/Faq';
import ViewCharacter from './pages/ViewCharacter';
import Groups from './pages/Groups';
import Planner from './pages/Planner';
import Admin from './pages/Admin';
import { submitRenderError } from './client/feedback-client';
import { ErrorPage } from './components/common/util/ErrorBoundary';
@@ -45,6 +46,7 @@ history.listen(() => {
const isDevEnvironment = () =>
window.location.hostname.startsWith('dev.') ||
window.location.hostname === 'localhost' ||
window.location.port === '3000' ||
window.location.port === '3001';
export default function App() {
@@ -111,6 +113,7 @@ export default function App() {
<Route path='about' element={<About />} />
<Route path='settings' element={<Settings />} />
<Route path='faq' element={<Faq />} />
<Route path='admin' element={<Admin />} />
</Routes>
</ErrorBoundary>
</Auth0Provider>

View File

@@ -0,0 +1,95 @@
// 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',
};
function handleResponse(response) {
if (!response.ok) {
return response.json().then(data => ({
success: false,
error: data.message || data.error || 'An error occurred',
}));
}
return response.json().then(data => ({
success: true,
value: data,
}));
}
function handleError(error) {
console.warn(error);
return { success: false, error: error.message || 'Network error' };
}
export function getAdminStats() {
return fetch(`${BASE_URL}/api/admin/stats`, {
method: 'GET',
headers: DEFAULT_HEADERS,
credentials: 'include',
})
.then(handleResponse)
.catch(handleError);
}
export function getUsers() {
return fetch(`${BASE_URL}/api/admin/users`, {
method: 'GET',
headers: DEFAULT_HEADERS,
credentials: 'include',
})
.then(handleResponse)
.catch(handleError);
}
export function getUser(id) {
return fetch(`${BASE_URL}/api/admin/users/${id}`, {
method: 'GET',
headers: DEFAULT_HEADERS,
credentials: 'include',
})
.then(handleResponse)
.catch(handleError);
}
export function updateUser(id, data) {
return fetch(`${BASE_URL}/api/admin/users/${id}`, {
method: 'PATCH',
headers: DEFAULT_HEADERS,
credentials: 'include',
body: JSON.stringify(data),
})
.then(handleResponse)
.catch(handleError);
}
export function resetUserPassword(id, password) {
return fetch(`${BASE_URL}/api/admin/users/${id}/password`, {
method: 'PATCH',
headers: DEFAULT_HEADERS,
credentials: 'include',
body: JSON.stringify({ password }),
})
.then(handleResponse)
.catch(handleError);
}
export function deleteUser(id) {
return fetch(`${BASE_URL}/api/admin/users/${id}`, {
method: 'DELETE',
headers: DEFAULT_HEADERS,
credentials: 'include',
})
.then(handleResponse)
.catch(handleError);
}
export function invalidateUserSessions(id) {
return fetch(`${BASE_URL}/api/admin/users/${id}/sessions`, {
method: 'DELETE',
headers: DEFAULT_HEADERS,
credentials: 'include',
})
.then(handleResponse)
.catch(handleError);
}

View File

@@ -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',
};

View File

@@ -0,0 +1,98 @@
// 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',
};
function handleResponse(response) {
if (!response.ok) {
return response.json().then(data => ({
success: false,
error: data.message || data.error || 'An error occurred',
}));
}
return response.json().then(data => ({
success: true,
value: data,
}));
}
function handleError(error) {
console.warn(error);
return { success: false, error: error.message || 'Network error' };
}
/**
* Get all characters for the authenticated user
*/
export function getCharacters() {
return fetch(`${BASE_URL}/api/characters`, {
method: 'GET',
headers: DEFAULT_HEADERS,
credentials: 'include',
})
.then(handleResponse)
.catch(handleError);
}
/**
* Create a new character
* @param {string} rsn - RuneScape Name
* @param {boolean} setActive - Whether to set this character as active
*/
export function createCharacter(rsn, setActive = false) {
return fetch(`${BASE_URL}/api/characters`, {
method: 'POST',
headers: DEFAULT_HEADERS,
credentials: 'include',
body: JSON.stringify({ rsn, setActive }),
})
.then(handleResponse)
.catch(handleError);
}
/**
* Update a character (rename, set active, or sync data)
* @param {number} id - Character ID
* @param {object} updates - Fields to update (rsn, isActive, tasksData, unlocksData, notesData)
*/
export function updateCharacter(id, updates) {
return fetch(`${BASE_URL}/api/characters/${id}`, {
method: 'PATCH',
headers: DEFAULT_HEADERS,
credentials: 'include',
body: JSON.stringify(updates),
})
.then(handleResponse)
.catch(handleError);
}
/**
* Delete a character
* @param {number} id - Character ID
*/
export function deleteCharacter(id) {
return fetch(`${BASE_URL}/api/characters/${id}`, {
method: 'DELETE',
headers: DEFAULT_HEADERS,
credentials: 'include',
})
.then(handleResponse)
.catch(handleError);
}
/**
* Bulk sync characters (used on login to merge local data with server)
* @param {Array} characters - Array of character objects or RSN strings
* @param {number} activeIndex - Index of the active character
*/
export function syncCharacters(characters, activeIndex) {
return fetch(`${BASE_URL}/api/characters/sync`, {
method: 'POST',
headers: DEFAULT_HEADERS,
credentials: 'include',
body: JSON.stringify({ characters, activeIndex }),
})
.then(handleResponse)
.catch(handleError);
}

View File

@@ -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 default async function getHiscores(rsn, handleResultCallback) {
if (!rsn) {

View File

@@ -20,11 +20,11 @@ export default function PageWrapper({ children }) {
const [manageDataModalType, setManageDataModalType] = useQueryString('open');
const navItems = [
new NavItem('Stats', 'primary', 0, 0).withRouterLink('/stats').withIconFont('query_stats'),
new NavItem('Trackers', 'primary', 0, 1).withRouterLink('/tracker').withIconFont('checklist_rtl'),
new NavItem('Calculators', 'primary', 0, 2).withRouterLink('/calculators').withIconFont('calculate'),
new NavItem('Groups', 'primary', 0, 3).withRouterLink('/groups').withIconFont('groups'),
new NavItem('Planner', 'primary', 0, 4).withRouterLink('/planner').withIconFont('event_note'),
new NavItem('WIP-Stats', 'primary', 0, 0).withRouterLink('/stats').withIconFont('query_stats'),
new NavItem('WIP-Trackers', 'primary', 0, 1).withRouterLink('/tracker').withIconFont('checklist_rtl'),
new NavItem('WIP-Calculators', 'primary', 0, 2).withRouterLink('/calculators').withIconFont('calculate'),
new NavItem('WIP-Groups', 'primary', 0, 3).withRouterLink('/groups').withIconFont('groups'),
new NavItem('WIP-Planner', 'primary', 0, 4).withRouterLink('/planner').withIconFont('event_note'),
new NavItem('Settings', 'overflow', 3, 1).withRouterLink('/settings').withIconFont('settings'),
new NavItem('WIP-FAQ', 'overflow', 3, 2).withRouterLink('/faq').withIconFont('help_outline'),
new NavItem('WIP-About', 'overflow', 3, 3).withRouterLink('/about').withIconFont('info'),

View File

@@ -1,4 +1,5 @@
import React, { useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import Spinner from '../common/Spinner';
import Dropdown from '../common/Dropdown';
import useAccount from '../../hooks/useAccount';
@@ -13,6 +14,7 @@ function NavBarItem() {
isAuthenticating,
username,
userEmail,
isAdmin,
openAuthModal,
logout,
authModalOpen,
@@ -70,6 +72,13 @@ function NavBarItem() {
{userEmail && <p className='text-xs text-secondary font-normal'>{userEmail}</p>}
</Dropdown.Text>
<Dropdown.Separator />
{isAdmin && (
<Link to='/admin' onClick={() => setExpanded(false)}>
<Dropdown.Button className='text-left' icon='admin_panel_settings'>
Admin
</Dropdown.Button>
</Link>
)}
<Dropdown.Button
className='text-left'
icon='logout'
@@ -141,6 +150,7 @@ function SideBarItem({ isCollapsed, onNavigate }) {
isAuthenticating,
username,
userEmail,
isAdmin,
openAuthModal,
logout,
authModalOpen,
@@ -205,6 +215,21 @@ function SideBarItem({ isCollapsed, onNavigate }) {
<span className='sidebar-nav-link-label text-xs'>{userEmail}</span>
</div>
)}
{isAdmin && (
<Link
to='/admin'
className='sidebar-nav-link w-full text-left text-sm'
onClick={() => {
setExpanded(false);
if (onNavigate) {
onNavigate();
}
}}
>
<span className='sidebar-nav-link-icon icon-base text-primary-alt'>admin_panel_settings</span>
<span className='sidebar-nav-link-label'>Admin</span>
</Link>
)}
<button
className='sidebar-nav-link w-full text-left text-sm'
onClick={() => {

View File

@@ -1,15 +1,22 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { resetState } from '../store/common';
import { setLoggedIn, setLoggedOut } from '../store/user/account';
import { syncFromServer } from '../store/user/character';
import { getAuthStatus, getCurrentUser, logout as logoutApi } from '../client/auth-client';
import { syncCharacters, getCharacters } from '../client/character-client';
export default function useAccount() {
const [authModalOpen, setAuthModalOpen] = useState(false);
const hasSyncedRef = useRef(false);
const isLoggedIn = useSelector(state => state.account.accountCache.isLoggedIn);
const isChecking = useSelector(state => state.account.accountCache.isChecking);
const username = useSelector(state => state.account.accountCache.username);
const userEmail = useSelector(state => state.account.accountCache.userEmail);
const userRole = useSelector(state => state.account.accountCache.userRole);
const isAdmin = userRole === 'ADMIN';
const localCharacters = useSelector(state => state.character.characters);
const localActiveCharacter = useSelector(state => state.character.activeCharacter);
const dispatch = useDispatch();
useEffect(() => {
@@ -20,6 +27,7 @@ export default function useAccount() {
dispatch(setLoggedIn({
username: userResult.value.username,
email: userResult.value.email,
role: userResult.value.role,
}));
} else {
dispatch(setLoggedOut());
@@ -31,11 +39,52 @@ export default function useAccount() {
});
}, [dispatch]);
// Sync characters once when user logs in
useEffect(() => {
if (isLoggedIn && !hasSyncedRef.current) {
hasSyncedRef.current = true;
const doSync = async () => {
if (localCharacters.length > 0) {
const result = await syncCharacters(
localCharacters.map(rsn => ({ rsn })),
localActiveCharacter
);
if (result.success) {
const activeIndex = result.value.characters.findIndex(c => c.isActive);
dispatch(syncFromServer({
characters: result.value.characters,
activeIndex: activeIndex >= 0 ? activeIndex : 0,
}));
}
} else {
const result = await getCharacters();
if (result.success && result.value.characters.length > 0) {
const activeIndex = result.value.characters.findIndex(c => c.isActive);
dispatch(syncFromServer({
characters: result.value.characters,
activeIndex: activeIndex >= 0 ? activeIndex : 0,
}));
}
}
};
doSync();
}
// Reset sync flag on logout
if (!isLoggedIn) {
hasSyncedRef.current = false;
}
}, [isLoggedIn, localCharacters, localActiveCharacter, dispatch]);
const handleAuthSuccess = userData => {
dispatch(setLoggedIn({
username: userData.username,
email: userData.email,
role: userData.role,
}));
// Character sync will be triggered by the isLoggedIn effect
};
const openAuthModal = () => {
@@ -59,6 +108,8 @@ export default function useAccount() {
isAuthenticating: isChecking,
username,
userEmail,
userRole,
isAdmin,
authModalOpen,
openAuthModal,
closeAuthModal,

View File

@@ -0,0 +1,334 @@
import React, { useEffect, useState } from 'react';
import { Navigate } from 'react-router-dom';
import PageWrapper from '../components/PageWrapper';
import useAccount from '../hooks/useAccount';
import {
getAdminStats,
getUsers,
updateUser,
resetUserPassword,
deleteUser,
invalidateUserSessions,
} from '../client/admin-client';
function StatCard({ label, value, icon }) {
return (
<div className='bg-secondary p-4 rounded-lg'>
<div className='flex items-center gap-3'>
<span className='icon-lg text-primary-alt'>{icon}</span>
<div>
<p className='text-2xl font-bold text-accent'>{value}</p>
<p className='text-sm text-secondary'>{label}</p>
</div>
</div>
</div>
);
}
function UserRow({ user, onUpdate, onDelete }) {
const [isEditing, setIsEditing] = useState(false);
const [editRole, setEditRole] = useState(user.role);
const [showPasswordReset, setShowPasswordReset] = useState(false);
const [newPassword, setNewPassword] = useState('');
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState(null);
const handleRoleChange = async () => {
setLoading(true);
const result = await updateUser(user.id, { role: editRole });
setLoading(false);
if (result.success) {
onUpdate();
setIsEditing(false);
setMessage({ type: 'success', text: 'Role updated' });
} else {
setMessage({ type: 'error', text: result.error });
}
setTimeout(() => setMessage(null), 3000);
};
const handlePasswordReset = async () => {
if (newPassword.length < 8) {
setMessage({ type: 'error', text: 'Password must be at least 8 characters' });
return;
}
setLoading(true);
const result = await resetUserPassword(user.id, newPassword);
setLoading(false);
if (result.success) {
setShowPasswordReset(false);
setNewPassword('');
setMessage({ type: 'success', text: 'Password reset and sessions invalidated' });
} else {
setMessage({ type: 'error', text: result.error });
}
setTimeout(() => setMessage(null), 3000);
};
const handleInvalidateSessions = async () => {
setLoading(true);
const result = await invalidateUserSessions(user.id);
setLoading(false);
if (result.success) {
setMessage({ type: 'success', text: result.value.message });
onUpdate();
} else {
setMessage({ type: 'error', text: result.error });
}
setTimeout(() => setMessage(null), 3000);
};
const handleDelete = async () => {
if (!window.confirm(`Are you sure you want to delete user "${user.username}"?`)) {
return;
}
setLoading(true);
const result = await deleteUser(user.id);
setLoading(false);
if (result.success) {
onDelete();
} else {
setMessage({ type: 'error', text: result.error });
setTimeout(() => setMessage(null), 3000);
}
};
return (
<tr className='border-b border-primary'>
<td className='py-3 px-4'>
<div className='font-medium text-accent'>{user.username}</div>
<div className='text-xs text-secondary'>{user.email}</div>
</td>
<td className='py-3 px-4'>
{isEditing ? (
<select
value={editRole}
onChange={e => setEditRole(e.target.value)}
className='input-primary text-sm'
>
<option value='USER'>USER</option>
<option value='ADMIN'>ADMIN</option>
</select>
) : (
<span
className={`px-2 py-1 rounded text-xs font-medium ${
user.role === 'ADMIN' ? 'bg-accent text-black' : 'bg-secondary text-primary'
}`}
>
{user.role}
</span>
)}
</td>
<td className='py-3 px-4 text-sm text-secondary'>{user.sessionCount}</td>
<td className='py-3 px-4 text-sm text-secondary'>
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className='py-3 px-4'>
<div className='flex gap-2 flex-wrap'>
{isEditing ? (
<>
<button
type='button'
onClick={handleRoleChange}
disabled={loading}
className='btn-primary text-xs px-2 py-1'
>
Save
</button>
<button
type='button'
onClick={() => {
setIsEditing(false);
setEditRole(user.role);
}}
className='btn-secondary text-xs px-2 py-1'
>
Cancel
</button>
</>
) : (
<>
<button
type='button'
onClick={() => setIsEditing(true)}
className='btn-secondary text-xs px-2 py-1'
title='Edit role'
>
<span className='icon-sm'>edit</span>
</button>
<button
type='button'
onClick={() => setShowPasswordReset(!showPasswordReset)}
className='btn-secondary text-xs px-2 py-1'
title='Reset password'
>
<span className='icon-sm'>key</span>
</button>
<button
type='button'
onClick={handleInvalidateSessions}
disabled={loading}
className='btn-secondary text-xs px-2 py-1'
title='Invalidate sessions'
>
<span className='icon-sm'>logout</span>
</button>
<button
type='button'
onClick={handleDelete}
disabled={loading}
className='btn-danger text-xs px-2 py-1'
title='Delete user'
>
<span className='icon-sm'>delete</span>
</button>
</>
)}
</div>
{showPasswordReset && (
<div className='mt-2 flex gap-2'>
<input
type='password'
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
placeholder='New password'
className='input-primary text-sm flex-1'
/>
<button
type='button'
onClick={handlePasswordReset}
disabled={loading}
className='btn-primary text-xs px-2 py-1'
>
Reset
</button>
</div>
)}
{message && (
<div
className={`mt-2 text-xs ${message.type === 'error' ? 'text-error' : 'text-success'}`}
>
{message.text}
</div>
)}
</td>
</tr>
);
}
export default function Admin() {
const { isLoggedIn, isAuthenticating, isAdmin } = useAccount();
const [stats, setStats] = useState(null);
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchData = async () => {
setLoading(true);
const [statsResult, usersResult] = await Promise.all([getAdminStats(), getUsers()]);
if (statsResult.success) {
setStats(statsResult.value);
} else {
setError(statsResult.error);
}
if (usersResult.success) {
setUsers(usersResult.value.users);
} else {
setError(usersResult.error);
}
setLoading(false);
};
useEffect(() => {
if (isAdmin) {
fetchData();
}
}, [isAdmin]);
// Redirect non-admins
if (!isAuthenticating && (!isLoggedIn || !isAdmin)) {
return <Navigate to='/' replace />;
}
return (
<PageWrapper>
<div className='max-w-6xl mx-auto p-4'>
<h1 className='text-2xl font-bold text-accent mb-6 flex items-center gap-2'>
<span className='icon-lg'>admin_panel_settings</span>
Admin Dashboard
</h1>
{loading && (
<div className='text-center py-8'>
<span className='icon-lg animate-spin'>sync</span>
<p className='text-secondary mt-2'>Loading...</p>
</div>
)}
{error && (
<div className='bg-error/20 border border-error rounded p-4 mb-6'>
<p className='text-error'>{error}</p>
</div>
)}
{!loading && stats && (
<>
{/* Stats Cards */}
<div className='grid grid-cols-2 md:grid-cols-4 gap-4 mb-8'>
<StatCard label='Users' value={stats.users} icon='people' />
<StatCard label='Active Sessions' value={stats.activeSessions} icon='key' />
<StatCard label='Groups' value={stats.groups} icon='groups' />
<StatCard label='Members' value={stats.members} icon='person' />
</div>
{/* Users Table */}
<div className='bg-secondary rounded-lg overflow-hidden'>
<div className='p-4 border-b border-primary'>
<h2 className='text-lg font-semibold text-primary flex items-center gap-2'>
<span className='icon-base'>people</span>
Users ({users.length})
</h2>
</div>
<div className='overflow-x-auto'>
<table className='w-full'>
<thead className='bg-secondary-alt'>
<tr>
<th className='text-left py-3 px-4 text-sm font-medium text-secondary'>
User
</th>
<th className='text-left py-3 px-4 text-sm font-medium text-secondary'>
Role
</th>
<th className='text-left py-3 px-4 text-sm font-medium text-secondary'>
Sessions
</th>
<th className='text-left py-3 px-4 text-sm font-medium text-secondary'>
Created
</th>
<th className='text-left py-3 px-4 text-sm font-medium text-secondary'>
Actions
</th>
</tr>
</thead>
<tbody>
{users.map(user => (
<UserRow
key={user.id}
user={user}
onUpdate={fetchData}
onDelete={fetchData}
/>
))}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
</PageWrapper>
);
}

View File

@@ -0,0 +1,15 @@
const { createProxyMiddleware } = require('http-proxy-middleware');
module.exports = function(app) {
// Proxy API requests to the Node.js backend
app.use(
'/api',
createProxyMiddleware({
target: process.env.REACT_APP_RELDO_URL || 'http://localhost:3003',
changeOrigin: true,
secure: false,
// Pass cookies for session auth
cookieDomainRewrite: 'localhost',
})
);
};

View File

@@ -1,25 +1,49 @@
/* eslint-disable no-unused-vars */
import { LOCALSTORAGE_KEYS } from '../client/localstorage-client';
import { updateCharacter } from '../client/character-client';
// Debounce server sync to avoid too many requests
let syncTimeout = null;
const SYNC_DEBOUNCE_MS = 2000;
// eslint-disable-next-line no-unused-vars
export default function updateWithUserDataStorage(wrappedDispatchFn, wrappedFnProps, localstorageKey, stateKey) {
return async (dispatch, getState) => {
await dispatch(wrappedDispatchFn(wrappedFnProps));
// TODO re-enable user login
// const dataKey = (() => {
// switch (localstorageKey) {
// case LOCALSTORAGE_KEYS.UNLOCKS:
// case LOCALSTORAGE_KEYS.TASKS: {
// const state = getState();
// return `${localstorageKey}_${state.character.characters[state.character.activeCharacter] ?? 'DEFAULT'}`;
// }
// default: {
// return localstorageKey;
// }
// }
// })();
const state = getState();
const { isLoggedIn } = state.account.accountCache;
// const { isLoggedIn, userEmail, accessToken } = getState().account.accountCache;
// if (isLoggedIn && !wrappedFnProps.skipDbUpdate) {
// putUserData(userEmail, dataKey, getState()[stateKey], accessToken);
// }
// Sync to server if logged in and not skipping DB update
if (isLoggedIn && !wrappedFnProps?.skipDbUpdate) {
// Only sync tasks and unlocks data
if (localstorageKey === LOCALSTORAGE_KEYS.TASKS || localstorageKey === LOCALSTORAGE_KEYS.UNLOCKS) {
const activeRsn = state.character.characters[state.character.activeCharacter];
const characterId = state.character.characterIds[activeRsn];
if (characterId) {
// Debounce the sync to avoid too many requests
if (syncTimeout) {
clearTimeout(syncTimeout);
}
syncTimeout = setTimeout(async () => {
const currentState = getState();
const updateData = {};
if (localstorageKey === LOCALSTORAGE_KEYS.TASKS) {
updateData.tasksData = currentState.tasks;
} else if (localstorageKey === LOCALSTORAGE_KEYS.UNLOCKS) {
updateData.unlocksData = currentState.unlocks;
}
try {
await updateCharacter(characterId, updateData);
} catch (error) {
console.warn('Failed to sync data to server:', error);
}
}, SYNC_DEBOUNCE_MS);
}
}
}
};
}

View File

@@ -2,7 +2,7 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
export const CURRENT_VERSION = 4;
export const CURRENT_VERSION = 5;
const INITIAL_STATE = {
version: CURRENT_VERSION,
@@ -11,6 +11,7 @@ const INITIAL_STATE = {
isChecking: true,
username: undefined,
userEmail: undefined,
userRole: undefined,
},
};
@@ -26,12 +27,14 @@ export const accountSlice = createSlice({
state.accountCache.isChecking = false;
state.accountCache.username = action.payload.username;
state.accountCache.userEmail = action.payload.email;
state.accountCache.userRole = action.payload.role;
},
setLoggedOut: state => {
state.accountCache.isLoggedIn = false;
state.accountCache.isChecking = false;
state.accountCache.username = undefined;
state.accountCache.userEmail = undefined;
state.accountCache.userRole = undefined;
},
reset: () => INITIAL_STATE,
},

View File

@@ -16,11 +16,18 @@ export const characterSlice = createSlice({
},
addCharacter: (state, action) => {
state.characters.push(action.payload.rsn);
if (action.payload.id) {
state.characterIds[action.payload.rsn] = action.payload.id;
}
if (action.payload.setActive) {
state.activeCharacter = state.characters.length - 1;
}
},
deleteCharacter: (state, action) => {
const rsn = state.characters[action.payload];
if (rsn && state.characterIds[rsn]) {
delete state.characterIds[rsn];
}
state.characters.splice(action.payload, 1);
if (state.activeCharacter === action.payload) {
state.activeCharacter = 0;
@@ -29,8 +36,28 @@ export const characterSlice = createSlice({
}
},
renameCharacter: (state, action) => {
const oldRsn = state.characters[action.payload.index];
const serverId = state.characterIds[oldRsn];
if (serverId) {
delete state.characterIds[oldRsn];
state.characterIds[action.payload.rsn] = serverId;
}
state.characters[action.payload.index] = action.payload.rsn;
},
// Sync characters from server (replaces local state)
syncFromServer: (state, action) => {
const { characters, activeIndex } = action.payload;
state.characters = characters.map(c => c.rsn);
state.characterIds = {};
characters.forEach(c => {
state.characterIds[c.rsn] = c.id;
});
state.activeCharacter = activeIndex ?? 0;
},
// Set server ID for a character
setCharacterId: (state, action) => {
state.characterIds[action.payload.rsn] = action.payload.id;
},
updateHiscores: (state, action) => {
switch (action.payload.type) {
case 'LOADING':
@@ -128,6 +155,6 @@ export function reset(props) {
return updateWithUserDataStorage(innerReset, props, LOCALSTORAGE_KEYS.CHARACTER, 'character');
}
export const { updateHiscores } = characterSlice.actions;
export const { updateHiscores, syncFromServer, setCharacterId } = characterSlice.actions;
export default characterSlice.reducer;

View File

@@ -1,4 +1,4 @@
export const CURRENT_VERSION = 2;
export const CURRENT_VERSION = 3;
export const HISCORES_TTL = 1800000; // 30 min in ms
@@ -13,5 +13,7 @@ export const INITIAL_STATE = {
version: CURRENT_VERSION,
activeCharacter: 0,
characters: [],
// Map of RSN -> server character ID (for sync)
characterIds: {},
hiscoresCache: INITIAL_HISCORES_STATE,
};

View File

@@ -2,6 +2,7 @@ import { CURRENT_VERSION } from './constants';
const versionUpdaters = {
2: updateToV2,
3: updateToV3,
};
export default function updateCharacterVersion(state) {
@@ -27,3 +28,12 @@ function updateToV2(prevState) {
characters: [prevState.username],
};
}
function updateToV3(prevState) {
// V3 adds characterIds map for server sync
return {
...prevState,
version: 3,
characterIds: {},
};
}

View File

@@ -947,6 +947,16 @@ body:is(.dark *) {
border-color: rgb(113 113 122 / var(--tw-border-opacity, 1));
}
.border-error {
--tw-border-opacity: 1;
border-color: rgb(220 38 38 / var(--tw-border-opacity, 1));
}
.border-error:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(239 68 68 / var(--tw-border-opacity, 1));
}
.shadow-primary {
--tw-shadow-color: rgb(0 0 0 / 0.1);
--tw-shadow: var(--tw-shadow-colored);
@@ -2742,6 +2752,9 @@ select[multiple]:focus option:checked {
.mb-6 {
margin-bottom: 1.5rem;
}
.mb-8 {
margin-bottom: 2rem;
}
.mb-auto {
margin-bottom: auto;
}
@@ -2976,6 +2989,9 @@ select[multiple]:focus option:checked {
.max-w-5xl {
max-width: 64rem;
}
.max-w-6xl {
max-width: 72rem;
}
.max-w-\[3\.5rem\] {
max-width: 3.5rem;
}
@@ -3185,6 +3201,9 @@ select[multiple]:focus option:checked {
.overflow-hidden {
overflow: hidden;
}
.overflow-x-auto {
overflow-x: auto;
}
.overflow-y-auto {
overflow-y: auto;
}
@@ -3561,6 +3580,10 @@ select[multiple]:focus option:checked {
.tracking-widest {
letter-spacing: 0.1em;
}
.text-black {
--tw-text-opacity: 1;
color: rgb(0 0 0 / var(--tw-text-opacity, 1));
}
.text-blue-400 {
--tw-text-opacity: 1;
color: rgb(96 165 250 / var(--tw-text-opacity, 1));

1672
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "leagues-tools-dev",
"private": true,
"scripts": {
"start": "npx pm2 start ecosystem.config.js",
"stop": "npx pm2 stop all",
"restart": "npx pm2 restart all",
"status": "npx pm2 status",
"logs": "npx pm2 logs",
"logs:prod": "npx pm2 logs api-prod",
"logs:dev": "npx pm2 logs api-dev",
"logs:frontend": "npx pm2 logs frontend-dev",
"kill": "npx pm2 kill"
},
"devDependencies": {
"pm2": "^6.0.14"
}
}

23
server/.env Normal file
View File

@@ -0,0 +1,23 @@
# Development environment
NODE_ENV=development
# Database (separate dev database)
DATABASE_URL="file:./data-dev.db"
# Server
PORT=3003
# Security
SESSION_SECRET="K7xP2mN9qR4sT6vW8yB1cD3fG5hJ0kL2nO4pQ6rS8tU0wX2z"
BACKEND_SECRET="A1bC3dE5fG7hI9jK1lM3nO5pQ7rS9tU1vW3xY5zA7bC9dE1f"
# CORS - allow React dev server
CORS_ORIGINS="http://localhost:3000,http://localhost:3001,https://dev.leagues.tools"
# Captcha (disabled for dev)
CAPTCHA_ENABLED=false
CAPTCHA_SITEKEY=""
CAPTCHA_SECRET=""
# Frontend build path (not used in dev - React dev server handles it)
FRONTEND_BUILD_PATH="../os-league-tools-master/build"

23
server/.env.development Normal file
View File

@@ -0,0 +1,23 @@
# Development environment
NODE_ENV=development
# Database (separate dev database)
DATABASE_URL="file:./data-dev.db"
# Server
PORT=3003
# Security
SESSION_SECRET="K7xP2mN9qR4sT6vW8yB1cD3fG5hJ0kL2nO4pQ6rS8tU0wX2z"
BACKEND_SECRET="A1bC3dE5fG7hI9jK1lM3nO5pQ7rS9tU1vW3xY5zA7bC9dE1f"
# CORS - allow React dev server
CORS_ORIGINS="http://localhost:3000,http://localhost:3001,https://dev.leagues.tools"
# Captcha (disabled for dev)
CAPTCHA_ENABLED=false
CAPTCHA_SITEKEY=""
CAPTCHA_SECRET=""
# Frontend build path (not used in dev - React dev server handles it)
FRONTEND_BUILD_PATH="../os-league-tools-master/build"

28
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Dependencies
node_modules/
# Build output
dist/
# Database
*.db
*.db-journal
# Environment
.env.local
.env.production
# IDE
.idea/
.vscode/
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Prisma
prisma/migrations/

View File

@@ -0,0 +1,18 @@
[Unit]
Description=Leagues Tools Development Server
After=network.target
[Service]
Type=simple
User=sonder
WorkingDirectory=/home/sonder/leagues-tools-dev/server
ExecStart=/usr/bin/npm run dev
Restart=on-failure
RestartSec=10
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=leagues-tools-dev
Environment=NODE_ENV=development
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,18 @@
[Unit]
Description=Leagues Tools Production Server
After=network.target
[Service]
Type=simple
User=sonder
WorkingDirectory=/home/sonder/leagues-tools-dev/server
ExecStart=/usr/bin/npm run prod
Restart=on-failure
RestartSec=10
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=leagues-tools-prod
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.target

1708
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
server/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "leagues-tools-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "cp .env.development .env && tsx watch src/index.ts",
"prod": "cp .env.production .env && tsx src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"start:dev": "cp .env.development .env && node dist/index.js",
"start:prod": "cp .env.production .env && node dist/index.js",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:push:dev": "cp .env.development .env && prisma db push",
"db:push:prod": "cp .env.production .env && prisma db push",
"db:migrate": "prisma migrate dev",
"db:studio": "prisma studio"
},
"dependencies": {
"@hono/node-server": "^1.13.7",
"@prisma/client": "^6.2.1",
"blakejs": "^1.2.1",
"bcrypt": "^5.1.1",
"hono": "^4.6.16",
"uuid": "^11.0.5"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/node": "^22.10.5",
"@types/uuid": "^10.0.0",
"prisma": "^6.2.1",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}

215
server/prisma/schema.prisma Normal file
View File

@@ -0,0 +1,215 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
// User roles
enum Role {
USER
ADMIN
}
// 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")
}
// User's OSRS characters (for task/unlock tracking)
model Character {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
rsn String // RuneScape Name
isActive Boolean @default(false)
// Synced data (stored as JSON)
tasksData String? // JSON - task completion status
unlocksData String? // JSON - unlock status
notesData String? // JSON - user notes/planner data
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, rsn])
@@index([userId])
}
model Session {
id String @id @default(uuid())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime
createdAt DateTime @default(now())
@@index([userId])
}
// Group tracking (for RuneLite plugin)
model Group {
id Int @id @default(autoincrement())
name String
tokenHash String // Blake2b-256 hash of token
version Int @default(1)
ownerId Int? // Optional - user who owns/manages this group
owner User? @relation("GroupOwner", fields: [ownerId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
members Member[]
@@unique([name, tokenHash])
@@index([tokenHash])
@@index([ownerId])
}
model Member {
id Int @id @default(autoincrement())
groupId Int
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
name String
// Stats (HP, Prayer, Energy, World, etc.) - JSON array of 7 integers
stats String? // JSON
statsLastUpdate DateTime?
// Coordinates (x, y, plane) - JSON array of 3 integers
coordinates String? // JSON
coordinatesLastUpdate DateTime?
// Skills (24 skills) - JSON array of 24 integers
skills String? // JSON
skillsLastUpdate DateTime?
// Quests - binary blob
quests Bytes?
questsLastUpdate DateTime?
// Inventory (56 items) - JSON array of 56 integers
inventory String? // JSON
inventoryLastUpdate DateTime?
// Equipment (28 slots) - JSON array of 28 integers
equipment String? // JSON
equipmentLastUpdate DateTime?
// Rune pouch (8 runes) - JSON array of 8 integers
runePouch String? // JSON
runePouchLastUpdate DateTime?
// Bank - JSON array (variable length)
bank String? // JSON
bankLastUpdate DateTime?
// Seed vault - JSON array (variable length)
seedVault String? // JSON
seedVaultLastUpdate DateTime?
// Interacting NPC
interacting String?
interactingLastUpdate DateTime?
// Diary vars (62 integers) - JSON array
diaryVars String? // JSON
diaryVarsLastUpdate DateTime?
// Overall last update
lastUpdated DateTime?
// Skills aggregation
skillsDay SkillsDay[]
skillsMonth SkillsMonth[]
skillsYear SkillsYear[]
// Collection log
collectionLogs CollectionLog[]
collectionLogsNew CollectionLogNew[]
@@unique([groupId, name])
@@index([groupId])
}
// Skills aggregation tables
model SkillsDay {
memberId Int
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
time DateTime
skills String // JSON array of 24 integers
@@id([memberId, time])
}
model SkillsMonth {
memberId Int
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
time DateTime
skills String // JSON array of 24 integers
@@id([memberId, time])
}
model SkillsYear {
memberId Int
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
time DateTime
skills String // JSON array of 24 integers
@@id([memberId, time])
}
// Aggregation tracking
model AggregationInfo {
type String @id
lastAggregation DateTime @default(dbgenerated("'2000-01-01 00:00:00'"))
}
// Collection log tables
model CollectionTab {
id Int @id @default(autoincrement())
name String
pages CollectionPage[]
}
model CollectionPage {
id Int @id @default(autoincrement())
tabId Int
tab CollectionTab @relation(fields: [tabId], references: [id], onDelete: Cascade)
pageName String
collectionLogs CollectionLog[]
collectionLogsNew CollectionLogNew[]
@@unique([tabId, pageName])
}
model CollectionLog {
memberId Int
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
pageId Int
page CollectionPage @relation(fields: [pageId], references: [id], onDelete: Cascade)
items String? // JSON array of item IDs
counts String? // JSON array of completion counts
lastUpdated DateTime?
@@id([memberId, pageId])
}
model CollectionLogNew {
memberId Int
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
pageId Int
page CollectionPage @relation(fields: [pageId], references: [id], onDelete: Cascade)
newItems String? // JSON array of new item IDs
lastUpdated DateTime?
@@id([memberId, pageId])
}

58
server/src/app.ts Normal file
View File

@@ -0,0 +1,58 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { serveStatic } from '@hono/node-server/serve-static';
import { sessionMiddleware } from './middleware/session';
import authRoutes from './routes/auth';
import publicRoutes from './routes/public';
import groupRoutes from './routes/groups';
import memberRoutes from './routes/members';
import adminRoutes from './routes/admin';
import characterRoutes from './routes/characters';
export function createApp() {
const app = new Hono();
// Middleware
app.use('*', logger());
// CORS - configured via CORS_ORIGINS env var
const corsOrigins = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',')
: ['http://localhost:3000', 'http://localhost:3001', 'http://localhost:4000'];
app.use(
'*',
cors({
origin: corsOrigins,
credentials: true,
allowHeaders: ['Content-Type', 'Authorization'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'],
maxAge: 3600,
})
);
// Session middleware for all routes
app.use('*', sessionMiddleware);
// API Routes
app.route('/api', authRoutes);
app.route('/api', publicRoutes);
app.route('/api/group', groupRoutes);
app.route('/api/group', memberRoutes);
app.route('/api/admin', adminRoutes);
app.route('/api/characters', characterRoutes);
// Health check
app.get('/api/health', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString() }));
// Serve static files from React build
const frontendPath = process.env.FRONTEND_BUILD_PATH || '../os-league-tools-master/build';
app.use('/*', serveStatic({ root: frontendPath }));
// SPA fallback - serve index.html for all non-API routes
app.get('*', serveStatic({ path: `${frontendPath}/index.html` }));
return app;
}

17
server/src/db.ts Normal file
View File

@@ -0,0 +1,17 @@
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();
export async function connectDatabase() {
try {
await prisma.$connect();
console.log('Database connected');
} catch (error) {
console.error('Database connection failed:', error);
process.exit(1);
}
}
export async function disconnectDatabase() {
await prisma.$disconnect();
}

39
server/src/index.ts Normal file
View File

@@ -0,0 +1,39 @@
import { serve } from '@hono/node-server';
import { createApp } from './app';
import { connectDatabase, disconnectDatabase } from './db';
const PORT = parseInt(process.env.PORT || '3001', 10);
async function main() {
// Connect to database
await connectDatabase();
// Create app
const app = createApp();
// Start server
console.log(`Server starting on port ${PORT}...`);
serve({
fetch: app.fetch,
port: PORT,
});
console.log(`Server running at http://localhost:${PORT}`);
console.log(`API available at http://localhost:${PORT}/api`);
// Graceful shutdown
const shutdown = async () => {
console.log('\nShutting down...');
await disconnectDatabase();
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
main().catch((error) => {
console.error('Failed to start server:', error);
process.exit(1);
});

View File

@@ -0,0 +1,52 @@
import { Context, Next } from 'hono';
import { prisma } from '../db';
import { hashToken } from '../utils/blake2';
// Extend Hono context with group data
declare module 'hono' {
interface ContextVariableMap {
groupId: number | null;
groupName: string | null;
}
}
/**
* Group token authentication middleware.
* Validates the Authorization header token against the group.
*
* Expected header format: Authorization: {token}
* Group name is extracted from the URL path parameter.
*/
export async function groupAuthMiddleware(c: Context, next: Next) {
const groupName = c.req.param('group_name');
const token = c.req.header('Authorization');
if (!groupName) {
return c.json({ error: 'Group name required' }, 400);
}
if (!token) {
return c.json({ error: 'Authorization token required' }, 401);
}
// Hash the token with the group name as salt
const tokenHash = hashToken(token, groupName);
// Find the group with matching name and token hash
const group = await prisma.group.findFirst({
where: {
name: groupName,
tokenHash: tokenHash,
},
});
if (!group) {
return c.json({ error: 'Invalid token or group not found' }, 401);
}
// Set group info in context
c.set('groupId', group.id);
c.set('groupName', group.name);
await next();
}

View File

@@ -0,0 +1,129 @@
import { Context, Next } from 'hono';
import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
import { Role } from '@prisma/client';
import { prisma } from '../db';
const SESSION_COOKIE_NAME = 'session_id';
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
export interface SessionUser {
id: number;
username: string;
email: string;
role: Role;
}
// Extend Hono context with session data
declare module 'hono' {
interface ContextVariableMap {
user: SessionUser | null;
sessionId: string | null;
}
}
/**
* Session middleware - validates session cookie and sets user in context
*/
export async function sessionMiddleware(c: Context, next: Next) {
const sessionId = getCookie(c, SESSION_COOKIE_NAME);
if (sessionId) {
const session = await prisma.session.findUnique({
where: { id: sessionId },
include: { user: true },
});
if (session && session.expiresAt > new Date()) {
c.set('user', {
id: session.user.id,
username: session.user.username,
email: session.user.email,
role: session.user.role,
});
c.set('sessionId', sessionId);
} else if (session) {
// Session expired, clean it up
await prisma.session.delete({ where: { id: sessionId } });
deleteCookie(c, SESSION_COOKIE_NAME);
c.set('user', null);
c.set('sessionId', null);
} else {
c.set('user', null);
c.set('sessionId', null);
}
} else {
c.set('user', null);
c.set('sessionId', null);
}
await next();
}
/**
* Create a new session for a user
*/
export async function createSession(c: Context, userId: number): Promise<string> {
const expiresAt = new Date(Date.now() + SESSION_MAX_AGE);
const session = await prisma.session.create({
data: {
userId,
expiresAt,
},
});
setCookie(c, SESSION_COOKIE_NAME, session.id, {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax',
maxAge: SESSION_MAX_AGE / 1000,
});
return session.id;
}
/**
* Destroy the current session
*/
export async function destroySession(c: Context): Promise<void> {
const sessionId = c.get('sessionId');
if (sessionId) {
await prisma.session.delete({ where: { id: sessionId } }).catch(() => {});
}
deleteCookie(c, SESSION_COOKIE_NAME);
c.set('user', null);
c.set('sessionId', null);
}
/**
* Middleware to require authentication
*/
export async function requireAuth(c: Context, next: Next) {
const user = c.get('user');
if (!user) {
return c.json({ error: 'Unauthorized' }, 401);
}
await next();
}
/**
* Middleware to require admin role
*/
export async function requireAdmin(c: Context, next: Next) {
const user = c.get('user');
if (!user) {
return c.json({ error: 'Unauthorized' }, 401);
}
if (user.role !== 'ADMIN') {
return c.json({ error: 'Forbidden: Admin access required' }, 403);
}
await next();
}

221
server/src/routes/admin.ts Normal file
View File

@@ -0,0 +1,221 @@
import { Hono } from 'hono';
import { prisma } from '../db';
import { requireAdmin } from '../middleware/session';
import { hashPassword } from '../utils/password';
const admin = new Hono();
// All admin routes require admin role
admin.use('/*', requireAdmin);
/**
* GET /api/admin/users
* List all users
*/
admin.get('/users', async (c) => {
const users = await prisma.user.findMany({
select: {
id: true,
username: true,
email: true,
role: true,
createdAt: true,
updatedAt: true,
_count: {
select: { sessions: true },
},
},
orderBy: { createdAt: 'desc' },
});
return c.json({
users: users.map((u) => ({
...u,
sessionCount: u._count.sessions,
_count: undefined,
})),
});
});
/**
* GET /api/admin/users/:id
* Get single user details
*/
admin.get('/users/:id', async (c) => {
const id = parseInt(c.req.param('id'), 10);
const user = await prisma.user.findUnique({
where: { id },
select: {
id: true,
username: true,
email: true,
role: true,
createdAt: true,
updatedAt: true,
sessions: {
select: {
id: true,
createdAt: true,
expiresAt: true,
},
},
},
});
if (!user) {
return c.json({ error: 'User not found' }, 404);
}
return c.json({ user });
});
/**
* PATCH /api/admin/users/:id
* Update user (role, email, etc.)
*/
admin.patch('/users/:id', async (c) => {
const id = parseInt(c.req.param('id'), 10);
const body = await c.req.json();
const { role, email, username } = body;
const user = await prisma.user.findUnique({ where: { id } });
if (!user) {
return c.json({ error: 'User not found' }, 404);
}
const updateData: Record<string, unknown> = {};
if (role && ['USER', 'ADMIN'].includes(role)) {
updateData.role = role;
}
if (email) {
// Check if email is already taken
const existing = await prisma.user.findFirst({
where: { email, NOT: { id } },
});
if (existing) {
return c.json({ error: 'Email already in use' }, 400);
}
updateData.email = email;
}
if (username) {
// Check if username is already taken
const existing = await prisma.user.findFirst({
where: { username, NOT: { id } },
});
if (existing) {
return c.json({ error: 'Username already in use' }, 400);
}
updateData.username = username;
}
const updated = await prisma.user.update({
where: { id },
data: updateData,
select: {
id: true,
username: true,
email: true,
role: true,
updatedAt: true,
},
});
return c.json({ user: updated });
});
/**
* PATCH /api/admin/users/:id/password
* Reset user password
*/
admin.patch('/users/:id/password', async (c) => {
const id = parseInt(c.req.param('id'), 10);
const body = await c.req.json();
const { password } = body;
if (!password || password.length < 8) {
return c.json({ error: 'Password must be at least 8 characters' }, 400);
}
const user = await prisma.user.findUnique({ where: { id } });
if (!user) {
return c.json({ error: 'User not found' }, 404);
}
const passwordHash = await hashPassword(password);
await prisma.user.update({
where: { id },
data: { passwordHash },
});
// Invalidate all sessions for this user
await prisma.session.deleteMany({ where: { userId: id } });
return c.json({ success: true, message: 'Password reset and sessions invalidated' });
});
/**
* DELETE /api/admin/users/:id
* Delete user
*/
admin.delete('/users/:id', async (c) => {
const id = parseInt(c.req.param('id'), 10);
const currentUser = c.get('user')!;
// Prevent self-deletion
if (currentUser.id === id) {
return c.json({ error: 'Cannot delete your own account' }, 400);
}
const user = await prisma.user.findUnique({ where: { id } });
if (!user) {
return c.json({ error: 'User not found' }, 404);
}
await prisma.user.delete({ where: { id } });
return c.json({ success: true, message: 'User deleted' });
});
/**
* DELETE /api/admin/users/:id/sessions
* Invalidate all sessions for a user
*/
admin.delete('/users/:id/sessions', async (c) => {
const id = parseInt(c.req.param('id'), 10);
const user = await prisma.user.findUnique({ where: { id } });
if (!user) {
return c.json({ error: 'User not found' }, 404);
}
const result = await prisma.session.deleteMany({ where: { userId: id } });
return c.json({ success: true, message: `${result.count} sessions invalidated` });
});
/**
* GET /api/admin/stats
* Get admin dashboard stats
*/
admin.get('/stats', async (c) => {
const [userCount, sessionCount, groupCount, memberCount] = await Promise.all([
prisma.user.count(),
prisma.session.count(),
prisma.group.count(),
prisma.member.count(),
]);
return c.json({
users: userCount,
activeSessions: sessionCount,
groups: groupCount,
members: memberCount,
});
});
export default admin;

135
server/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,135 @@
import { Hono } from 'hono';
import { prisma } from '../db';
import { hashPassword, verifyPassword } from '../utils/password';
import { createSession, destroySession, requireAuth } from '../middleware/session';
const auth = new Hono();
/**
* POST /api/register
* Create a new user account
*/
auth.post('/register', async (c) => {
const body = await c.req.json();
const { username, email, password } = body;
// Validation
if (!username || !email || !password) {
return c.json({ error: 'Username, email, and password are required' }, 400);
}
if (username.length < 3 || username.length > 30) {
return c.json({ error: 'Username must be 3-30 characters' }, 400);
}
if (password.length < 8) {
return c.json({ error: 'Password must be at least 8 characters' }, 400);
}
// Check if username or email already exists
const existingUser = await prisma.user.findFirst({
where: {
OR: [{ username }, { email }],
},
});
if (existingUser) {
if (existingUser.username === username) {
return c.json({ error: 'Username already taken' }, 400);
}
return c.json({ error: 'Email already registered' }, 400);
}
// Create user
const passwordHash = await hashPassword(password);
const user = await prisma.user.create({
data: {
username,
email,
passwordHash,
},
});
// Create session
await createSession(c, user.id);
return c.json({
username: user.username,
email: user.email,
role: user.role,
});
});
/**
* POST /api/login
* Authenticate and create session
*/
auth.post('/login', async (c) => {
const body = await c.req.json();
const { username, password } = body;
if (!username || !password) {
return c.json({ error: 'Username and password are required' }, 400);
}
// Find user by username or email
const user = await prisma.user.findFirst({
where: {
OR: [{ username }, { email: username }],
},
});
if (!user) {
return c.json({ error: 'Invalid username or password' }, 401);
}
// Verify password
const isValid = await verifyPassword(password, user.passwordHash);
if (!isValid) {
return c.json({ error: 'Invalid username or password' }, 401);
}
// Create session
await createSession(c, user.id);
return c.json({
username: user.username,
email: user.email,
role: user.role,
});
});
/**
* POST /api/logout
* Destroy session
*/
auth.post('/logout', async (c) => {
await destroySession(c);
return c.json({ success: true });
});
/**
* GET /api/auth/status
* Check if user is authenticated
*/
auth.get('/auth/status', (c) => {
const user = c.get('user');
return c.json({
authenticated: !!user,
});
});
/**
* GET /api/me
* Get current user info
*/
auth.get('/me', requireAuth, (c) => {
const user = c.get('user');
return c.json({
username: user!.username,
email: user!.email,
role: user!.role,
});
});
export default auth;

View File

@@ -0,0 +1,334 @@
import { Hono } from 'hono';
import { prisma } from '../db';
import { requireAuth } from '../middleware/session';
const characters = new Hono();
// All character routes require authentication
characters.use('/*', requireAuth);
/**
* GET /api/characters
* Get all characters for the authenticated user
*/
characters.get('/', async (c) => {
const user = c.get('user')!;
const userCharacters = await prisma.character.findMany({
where: { userId: user.id },
orderBy: { createdAt: 'asc' },
});
return c.json({
characters: userCharacters.map((char) => ({
id: char.id,
rsn: char.rsn,
isActive: char.isActive,
tasksData: char.tasksData ? JSON.parse(char.tasksData) : null,
unlocksData: char.unlocksData ? JSON.parse(char.unlocksData) : null,
notesData: char.notesData ? JSON.parse(char.notesData) : null,
createdAt: char.createdAt,
updatedAt: char.updatedAt,
})),
});
});
/**
* POST /api/characters
* Create a new character
*/
characters.post('/', async (c) => {
const user = c.get('user')!;
const body = await c.req.json();
const { rsn, setActive } = body;
if (!rsn || typeof rsn !== 'string') {
return c.json({ error: 'RSN is required' }, 400);
}
const trimmedRsn = rsn.trim();
if (trimmedRsn.length < 1 || trimmedRsn.length > 12) {
return c.json({ error: 'RSN must be 1-12 characters' }, 400);
}
// Check if character already exists for this user
const existing = await prisma.character.findUnique({
where: { userId_rsn: { userId: user.id, rsn: trimmedRsn } },
});
if (existing) {
return c.json({ error: 'Character already exists' }, 400);
}
// If setActive, deactivate all other characters first
if (setActive) {
await prisma.character.updateMany({
where: { userId: user.id },
data: { isActive: false },
});
}
const character = await prisma.character.create({
data: {
userId: user.id,
rsn: trimmedRsn,
isActive: setActive || false,
},
});
return c.json({
character: {
id: character.id,
rsn: character.rsn,
isActive: character.isActive,
tasksData: null,
unlocksData: null,
notesData: null,
createdAt: character.createdAt,
updatedAt: character.updatedAt,
},
});
});
/**
* PATCH /api/characters/:id
* Update a character (rename, set active, sync data)
*/
characters.patch('/:id', async (c) => {
const user = c.get('user')!;
const id = parseInt(c.req.param('id'), 10);
const body = await c.req.json();
const character = await prisma.character.findFirst({
where: { id, userId: user.id },
});
if (!character) {
return c.json({ error: 'Character not found' }, 404);
}
const updateData: Record<string, unknown> = {};
// Rename
if (body.rsn !== undefined) {
const trimmedRsn = body.rsn.trim();
if (trimmedRsn.length < 1 || trimmedRsn.length > 12) {
return c.json({ error: 'RSN must be 1-12 characters' }, 400);
}
// Check if new name already exists
const existing = await prisma.character.findFirst({
where: { userId: user.id, rsn: trimmedRsn, NOT: { id } },
});
if (existing) {
return c.json({ error: 'Character with that name already exists' }, 400);
}
updateData.rsn = trimmedRsn;
}
// Set active
if (body.isActive !== undefined) {
if (body.isActive) {
// Deactivate all other characters
await prisma.character.updateMany({
where: { userId: user.id, NOT: { id } },
data: { isActive: false },
});
}
updateData.isActive = body.isActive;
}
// Sync tasks data
if (body.tasksData !== undefined) {
updateData.tasksData = body.tasksData ? JSON.stringify(body.tasksData) : null;
}
// Sync unlocks data
if (body.unlocksData !== undefined) {
updateData.unlocksData = body.unlocksData ? JSON.stringify(body.unlocksData) : null;
}
// Sync notes data
if (body.notesData !== undefined) {
updateData.notesData = body.notesData ? JSON.stringify(body.notesData) : null;
}
const updated = await prisma.character.update({
where: { id },
data: updateData,
});
return c.json({
character: {
id: updated.id,
rsn: updated.rsn,
isActive: updated.isActive,
tasksData: updated.tasksData ? JSON.parse(updated.tasksData) : null,
unlocksData: updated.unlocksData ? JSON.parse(updated.unlocksData) : null,
notesData: updated.notesData ? JSON.parse(updated.notesData) : null,
createdAt: updated.createdAt,
updatedAt: updated.updatedAt,
},
});
});
/**
* DELETE /api/characters/:id
* Delete a character
*/
characters.delete('/:id', async (c) => {
const user = c.get('user')!;
const id = parseInt(c.req.param('id'), 10);
const character = await prisma.character.findFirst({
where: { id, userId: user.id },
});
if (!character) {
return c.json({ error: 'Character not found' }, 404);
}
await prisma.character.delete({ where: { id } });
// If deleted character was active, activate the first remaining character
if (character.isActive) {
const firstChar = await prisma.character.findFirst({
where: { userId: user.id },
orderBy: { createdAt: 'asc' },
});
if (firstChar) {
await prisma.character.update({
where: { id: firstChar.id },
data: { isActive: true },
});
}
}
return c.json({ success: true });
});
/**
* POST /api/characters/sync
* Bulk sync all characters (used on login to merge local data)
*/
characters.post('/sync', async (c) => {
const user = c.get('user')!;
const body = await c.req.json();
const { characters: localCharacters, activeIndex } = body;
if (!Array.isArray(localCharacters)) {
return c.json({ error: 'characters must be an array' }, 400);
}
// Get existing server characters
const serverCharacters = await prisma.character.findMany({
where: { userId: user.id },
});
const serverRsnMap = new Map(serverCharacters.map((c) => [c.rsn.toLowerCase(), c]));
const result: Array<{
id: number;
rsn: string;
isActive: boolean;
tasksData: unknown;
unlocksData: unknown;
notesData: unknown;
createdAt: Date;
updatedAt: Date;
}> = [];
// Process each local character
for (let i = 0; i < localCharacters.length; i++) {
const local = localCharacters[i];
const rsn = typeof local === 'string' ? local : local.rsn;
const tasksData = typeof local === 'object' ? local.tasksData : null;
const unlocksData = typeof local === 'object' ? local.unlocksData : null;
const notesData = typeof local === 'object' ? local.notesData : null;
const isActive = i === activeIndex;
const existing = serverRsnMap.get(rsn.toLowerCase());
if (existing) {
// Update existing character if local data is newer/present
const updateData: Record<string, unknown> = { isActive };
// Merge data - prefer local if it exists and server doesn't have it
if (tasksData && !existing.tasksData) {
updateData.tasksData = JSON.stringify(tasksData);
}
if (unlocksData && !existing.unlocksData) {
updateData.unlocksData = JSON.stringify(unlocksData);
}
if (notesData && !existing.notesData) {
updateData.notesData = JSON.stringify(notesData);
}
const updated = await prisma.character.update({
where: { id: existing.id },
data: updateData,
});
result.push({
id: updated.id,
rsn: updated.rsn,
isActive: updated.isActive,
tasksData: updated.tasksData ? JSON.parse(updated.tasksData) : null,
unlocksData: updated.unlocksData ? JSON.parse(updated.unlocksData) : null,
notesData: updated.notesData ? JSON.parse(updated.notesData) : null,
createdAt: updated.createdAt,
updatedAt: updated.updatedAt,
});
serverRsnMap.delete(rsn.toLowerCase());
} else {
// Create new character
const created = await prisma.character.create({
data: {
userId: user.id,
rsn,
isActive,
tasksData: tasksData ? JSON.stringify(tasksData) : null,
unlocksData: unlocksData ? JSON.stringify(unlocksData) : null,
notesData: notesData ? JSON.stringify(notesData) : null,
},
});
result.push({
id: created.id,
rsn: created.rsn,
isActive: created.isActive,
tasksData: tasksData,
unlocksData: unlocksData,
notesData: notesData,
createdAt: created.createdAt,
updatedAt: created.updatedAt,
});
}
}
// Add remaining server characters that weren't in local
for (const serverChar of serverRsnMap.values()) {
// Deactivate if there was an active local character
if (serverChar.isActive && activeIndex !== undefined) {
await prisma.character.update({
where: { id: serverChar.id },
data: { isActive: false },
});
serverChar.isActive = false;
}
result.push({
id: serverChar.id,
rsn: serverChar.rsn,
isActive: serverChar.isActive,
tasksData: serverChar.tasksData ? JSON.parse(serverChar.tasksData) : null,
unlocksData: serverChar.unlocksData ? JSON.parse(serverChar.unlocksData) : null,
notesData: serverChar.notesData ? JSON.parse(serverChar.notesData) : null,
createdAt: serverChar.createdAt,
updatedAt: serverChar.updatedAt,
});
}
return c.json({ characters: result });
});
export default characters;

124
server/src/routes/groups.ts Normal file
View File

@@ -0,0 +1,124 @@
import { Hono } from 'hono';
import { prisma } from '../db';
import { groupAuthMiddleware } from '../middleware/groupAuth';
const groups = new Hono();
// Apply group token auth to all routes
groups.use('/*', groupAuthMiddleware);
/**
* GET /api/group/:group_name/get-group-data
* Get all members with optional delta updates
*/
groups.get('/:group_name/get-group-data', async (c) => {
const groupId = c.get('groupId')!;
const fromTimeParam = c.req.query('from_time');
let fromTimestamp: Date | undefined;
if (fromTimeParam) {
// Try parsing as epoch milliseconds first, then ISO string
const epochMs = parseInt(fromTimeParam, 10);
if (!isNaN(epochMs)) {
fromTimestamp = new Date(epochMs);
} else {
fromTimestamp = new Date(fromTimeParam);
}
}
const members = await prisma.member.findMany({
where: {
groupId,
...(fromTimestamp && {
lastUpdated: { gt: fromTimestamp },
}),
},
});
// Transform to API response format
const response = members.map((member) => ({
name: member.name,
stats: member.stats ? JSON.parse(member.stats) : null,
statsLastUpdate: member.statsLastUpdate?.getTime() || null,
coordinates: member.coordinates ? JSON.parse(member.coordinates) : null,
coordinatesLastUpdate: member.coordinatesLastUpdate?.getTime() || null,
skills: member.skills ? JSON.parse(member.skills) : null,
skillsLastUpdate: member.skillsLastUpdate?.getTime() || null,
quests: member.quests ? Array.from(member.quests) : null,
questsLastUpdate: member.questsLastUpdate?.getTime() || null,
inventory: member.inventory ? JSON.parse(member.inventory) : null,
inventoryLastUpdate: member.inventoryLastUpdate?.getTime() || null,
equipment: member.equipment ? JSON.parse(member.equipment) : null,
equipmentLastUpdate: member.equipmentLastUpdate?.getTime() || null,
runePouch: member.runePouch ? JSON.parse(member.runePouch) : null,
runePouchLastUpdate: member.runePouchLastUpdate?.getTime() || null,
bank: member.bank ? JSON.parse(member.bank) : null,
bankLastUpdate: member.bankLastUpdate?.getTime() || null,
seedVault: member.seedVault ? JSON.parse(member.seedVault) : null,
seedVaultLastUpdate: member.seedVaultLastUpdate?.getTime() || null,
interacting: member.interacting,
interactingLastUpdate: member.interactingLastUpdate?.getTime() || null,
diaryVars: member.diaryVars ? JSON.parse(member.diaryVars) : null,
diaryVarsLastUpdate: member.diaryVarsLastUpdate?.getTime() || null,
lastUpdated: member.lastUpdated?.getTime() || null,
}));
return c.json(response);
});
/**
* GET /api/group/:group_name/am-i-logged-in
* Check if authenticated (if this is reached, auth succeeded)
*/
groups.get('/:group_name/am-i-logged-in', (c) => {
return c.json({ authenticated: true });
});
/**
* GET /api/group/:group_name/am-i-in-group
* Check if a member exists in the group
*/
groups.get('/:group_name/am-i-in-group', async (c) => {
const groupId = c.get('groupId')!;
const memberName = c.req.query('member_name');
if (!memberName) {
return c.json({ error: 'member_name is required' }, 400);
}
const member = await prisma.member.findFirst({
where: {
groupId,
name: memberName,
},
});
return c.json({ in_group: !!member });
});
/**
* GET /api/group/:group_name/get-skill-data
* Get skill aggregation data
* TODO: Implement skill aggregation
*/
groups.get('/:group_name/get-skill-data', async (c) => {
const groupId = c.get('groupId')!;
const period = c.req.query('period'); // day, month, year
// TODO: Implement skill aggregation service
return c.json([]);
});
/**
* GET /api/group/:group_name/collection-log
* Get collection log data
* TODO: Implement collection log
*/
groups.get('/:group_name/collection-log', async (c) => {
const groupId = c.get('groupId')!;
// TODO: Implement collection log service
return c.json([]);
});
export default groups;

View File

@@ -0,0 +1,211 @@
import { Hono } from 'hono';
import { prisma } from '../db';
import { groupAuthMiddleware } from '../middleware/groupAuth';
const members = new Hono();
// Apply group token auth to all routes
members.use('/*', groupAuthMiddleware);
/**
* POST /api/group/:group_name/update-group-member
* Update member data (main RuneLite plugin endpoint)
* Only non-null fields are updated
*/
members.post('/:group_name/update-group-member', async (c) => {
const groupId = c.get('groupId')!;
const body = await c.req.json();
const { name, ...data } = body;
if (!name) {
return c.json({ error: 'Member name is required' }, 400);
}
// Find or create member
let member = await prisma.member.findFirst({
where: { groupId, name },
});
if (!member) {
// Auto-create member if doesn't exist
member = await prisma.member.create({
data: { groupId, name },
});
}
// Build update object with only provided fields
const now = new Date();
const updateData: Record<string, unknown> = {
lastUpdated: now,
};
if (data.stats !== undefined) {
updateData.stats = JSON.stringify(data.stats);
updateData.statsLastUpdate = now;
}
if (data.coordinates !== undefined) {
updateData.coordinates = JSON.stringify(data.coordinates);
updateData.coordinatesLastUpdate = now;
}
if (data.skills !== undefined) {
updateData.skills = JSON.stringify(data.skills);
updateData.skillsLastUpdate = now;
}
if (data.quests !== undefined) {
updateData.quests = Buffer.from(data.quests);
updateData.questsLastUpdate = now;
}
if (data.inventory !== undefined) {
updateData.inventory = JSON.stringify(data.inventory);
updateData.inventoryLastUpdate = now;
}
if (data.equipment !== undefined) {
updateData.equipment = JSON.stringify(data.equipment);
updateData.equipmentLastUpdate = now;
}
if (data.runePouch !== undefined || data.rune_pouch !== undefined) {
updateData.runePouch = JSON.stringify(data.runePouch || data.rune_pouch);
updateData.runePouchLastUpdate = now;
}
if (data.bank !== undefined) {
updateData.bank = JSON.stringify(data.bank);
updateData.bankLastUpdate = now;
}
if (data.seedVault !== undefined || data.seed_vault !== undefined) {
updateData.seedVault = JSON.stringify(data.seedVault || data.seed_vault);
updateData.seedVaultLastUpdate = now;
}
if (data.interacting !== undefined) {
updateData.interacting = data.interacting;
updateData.interactingLastUpdate = now;
}
if (data.diaryVars !== undefined || data.diary_vars !== undefined) {
updateData.diaryVars = JSON.stringify(data.diaryVars || data.diary_vars);
updateData.diaryVarsLastUpdate = now;
}
// Update member
await prisma.member.update({
where: { id: member.id },
data: updateData,
});
return c.json({ status: 'success' });
});
/**
* POST /api/group/:group_name/add-group-member
* Add a new member to the group
*/
members.post('/:group_name/add-group-member', async (c) => {
const groupId = c.get('groupId')!;
const body = await c.req.json();
const { name } = body;
if (!name) {
return c.json({ error: 'Member name is required' }, 400);
}
// Check if member already exists
const existing = await prisma.member.findFirst({
where: { groupId, name },
});
if (existing) {
return c.json({ error: 'Member already exists in group' }, 400);
}
// Create member
await prisma.member.create({
data: { groupId, name },
});
console.log(`Added member '${name}' to group_id: ${groupId}`);
return c.json({ status: 'success' });
});
/**
* DELETE /api/group/:group_name/delete-group-member
* Delete a member from the group
*/
members.delete('/:group_name/delete-group-member', async (c) => {
const groupId = c.get('groupId')!;
const body = await c.req.json();
const { name } = body;
if (!name) {
return c.json({ error: 'Member name is required' }, 400);
}
const member = await prisma.member.findFirst({
where: { groupId, name },
});
if (!member) {
return c.json({ error: 'Member not found' }, 404);
}
await prisma.member.delete({
where: { id: member.id },
});
console.log(`Deleted member '${name}' from group_id: ${groupId}`);
return c.json({ status: 'success' });
});
/**
* PUT /api/group/:group_name/rename-group-member
* Rename a group member
*/
members.put('/:group_name/rename-group-member', async (c) => {
const groupId = c.get('groupId')!;
const body = await c.req.json();
const { originalName, newName, original_name, new_name } = body;
const oldName = originalName || original_name;
const targetName = newName || new_name;
if (!oldName || !targetName) {
return c.json({ error: 'originalName and newName are required' }, 400);
}
const member = await prisma.member.findFirst({
where: { groupId, name: oldName },
});
if (!member) {
return c.json({ error: 'Member not found' }, 404);
}
// Check if new name already exists
const existing = await prisma.member.findFirst({
where: { groupId, name: targetName },
});
if (existing) {
return c.json({ error: 'A member with that name already exists' }, 400);
}
await prisma.member.update({
where: { id: member.id },
data: { name: targetName },
});
console.log(`Renamed member '${oldName}' -> '${targetName}' in group_id: ${groupId}`);
return c.json({ status: 'success' });
});
export default members;

121
server/src/routes/public.ts Normal file
View File

@@ -0,0 +1,121 @@
import { Hono } from 'hono';
import { v4 as uuidv4 } from 'uuid';
import { prisma } from '../db';
import { hashToken } from '../utils/blake2';
const publicRoutes = new Hono();
// In-memory cache for GE prices
let gePricesCache: Record<string, number> = {};
let gePricesCacheTime = 0;
const GE_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const CAPTCHA_ENABLED = process.env.CAPTCHA_ENABLED === 'true';
const CAPTCHA_SITEKEY = process.env.CAPTCHA_SITEKEY || '';
/**
* POST /api/create-group
* Create a new group with a token
*/
publicRoutes.post('/create-group', async (c) => {
const body = await c.req.json();
const { name, captchaResponse } = body;
if (!name || typeof name !== 'string') {
return c.json({ error: 'Group name is required' }, 400);
}
const trimmedName = name.trim();
if (trimmedName.length < 1 || trimmedName.length > 100) {
return c.json({ error: 'Group name must be 1-100 characters' }, 400);
}
// TODO: Implement captcha validation if enabled
// if (CAPTCHA_ENABLED && !verifyCaptcha(captchaResponse)) {
// return c.json({ error: 'Invalid captcha' }, 400);
// }
// Generate token
const token = uuidv4();
const tokenHash = hashToken(token, trimmedName);
// Check if group name + token combination already exists
const existingGroup = await prisma.group.findFirst({
where: {
name: trimmedName,
tokenHash: tokenHash,
},
});
if (existingGroup) {
return c.json({ error: 'Group already exists' }, 400);
}
// Create group
const group = await prisma.group.create({
data: {
name: trimmedName,
tokenHash: tokenHash,
},
});
console.log(`Group created: ${group.name} with token (first 8 chars): ${token.substring(0, 8)}...`);
return c.json({
name: group.name,
token: token, // Return unhashed token to user
});
});
/**
* GET /api/ge-prices
* Get cached Grand Exchange prices
*/
publicRoutes.get('/ge-prices', async (c) => {
const now = Date.now();
// Refresh cache if expired
if (now - gePricesCacheTime > GE_CACHE_TTL) {
try {
const response = await fetch('https://prices.runescape.wiki/api/v1/osrs/latest');
if (response.ok) {
const data = await response.json() as { data: Record<string, { high?: number; low?: number }> };
// Transform to simple id -> price mapping
gePricesCache = {};
for (const [id, item] of Object.entries(data.data)) {
// Use high price if available, otherwise low
const price = item.high ?? item.low ?? 0;
gePricesCache[id] = price;
}
gePricesCacheTime = now;
}
} catch (error) {
console.error('Failed to fetch GE prices:', error);
}
}
return c.json({ prices: gePricesCache });
});
/**
* GET /api/captcha-enabled
* Get captcha configuration
*/
publicRoutes.get('/captcha-enabled', (c) => {
return c.json({
enabled: CAPTCHA_ENABLED,
sitekey: CAPTCHA_SITEKEY,
});
});
/**
* GET /api/collection-log-info
* Get collection log metadata
* TODO: Load from JSON file
*/
publicRoutes.get('/collection-log-info', (c) => {
// TODO: Load from collection_log_info.json
return c.json({});
});
export default publicRoutes;

View File

@@ -0,0 +1,35 @@
import blake2b from 'blakejs';
const BACKEND_SECRET = process.env.BACKEND_SECRET || 'changeme_secret_key_for_production';
/**
* Hash a token with Blake2b-256 (2 iterations).
* This must match the Spring/Rust implementation exactly:
* - Uses Blake2b-256 (32 bytes output)
* - 2 iterations of hashing
* - Combines token + secret + salt (group name)
* - Returns hex-encoded hash (64 characters)
*/
export function hashToken(token: string, salt: string): string {
// First iteration: hash(token + secret + salt)
const input1 = token + BACKEND_SECRET + salt;
const hash1 = blake2b.blake2b(input1, undefined, 32);
// Second iteration: hash(hash1)
const hash2 = blake2b.blake2b(hash1, undefined, 32);
// Return hex-encoded (lowercase)
return Buffer.from(hash2).toString('hex').toLowerCase();
}
/**
* Verify if a token matches the stored hash.
*/
export function verifyToken(token: string, salt: string, storedHash: string): boolean {
try {
const computedHash = hashToken(token, salt);
return computedHash === storedHash.toLowerCase();
} catch {
return false;
}
}

View File

@@ -0,0 +1,11 @@
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}

18
server/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}