Admin role added, dev side has hot reload again, characters AND groups tied to accounts now.
This commit is contained in:
23
server/.env
Normal file
23
server/.env
Normal 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
23
server/.env.development
Normal 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
28
server/.gitignore
vendored
Normal 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/
|
||||
18
server/leagues-tools-dev.service
Normal file
18
server/leagues-tools-dev.service
Normal 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
|
||||
18
server/leagues-tools-prod.service
Normal file
18
server/leagues-tools-prod.service
Normal 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
1708
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
server/package.json
Normal file
35
server/package.json
Normal 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
215
server/prisma/schema.prisma
Normal 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
58
server/src/app.ts
Normal 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
17
server/src/db.ts
Normal 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
39
server/src/index.ts
Normal 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);
|
||||
});
|
||||
52
server/src/middleware/groupAuth.ts
Normal file
52
server/src/middleware/groupAuth.ts
Normal 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();
|
||||
}
|
||||
129
server/src/middleware/session.ts
Normal file
129
server/src/middleware/session.ts
Normal 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
221
server/src/routes/admin.ts
Normal 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
135
server/src/routes/auth.ts
Normal 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;
|
||||
334
server/src/routes/characters.ts
Normal file
334
server/src/routes/characters.ts
Normal 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
124
server/src/routes/groups.ts
Normal 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;
|
||||
211
server/src/routes/members.ts
Normal file
211
server/src/routes/members.ts
Normal 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
121
server/src/routes/public.ts
Normal 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;
|
||||
35
server/src/utils/blake2.ts
Normal file
35
server/src/utils/blake2.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
11
server/src/utils/password.ts
Normal file
11
server/src/utils/password.ts
Normal 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
18
server/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user