feat: add Hono API with JWT authentication
- Implement REST API using Hono framework with token-based auth - Add JWT middleware for scope-based access control - Create version endpoints: launcher, downloader, patches, server - Add account lookup endpoints (by UUID or username) - Fetch server software with signed URLs and full version data - Add token signing utility script for testing - Support configurable account UUID for game session creation - Integrate API server with existing module system Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
317c886529
commit
65f900169b
11 changed files with 489 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -6,6 +6,7 @@ state.json
|
|||
.claude
|
||||
tokens/*
|
||||
!tokens/.gitkeep
|
||||
hytale-endpoints.md
|
||||
|
||||
### Linux ###
|
||||
*~
|
||||
|
|
|
|||
|
|
@ -12,8 +12,12 @@
|
|||
"start": "tsx src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"discord.js-selfbot-v13": "^3.7.1",
|
||||
"env-var": "^7.5.0",
|
||||
"hono": "^4.6.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"tsx": "^4.20.3",
|
||||
"wrappergram": "^1.3.0"
|
||||
},
|
||||
|
|
|
|||
122
pnpm-lock.yaml
generated
122
pnpm-lock.yaml
generated
|
|
@ -8,12 +8,24 @@ importers:
|
|||
|
||||
.:
|
||||
dependencies:
|
||||
'@hono/node-server':
|
||||
specifier: ^1.19.9
|
||||
version: 1.19.9(hono@4.11.4)
|
||||
'@types/jsonwebtoken':
|
||||
specifier: ^9.0.10
|
||||
version: 9.0.10
|
||||
discord.js-selfbot-v13:
|
||||
specifier: ^3.7.1
|
||||
version: 3.7.1
|
||||
env-var:
|
||||
specifier: ^7.5.0
|
||||
version: 7.5.0
|
||||
hono:
|
||||
specifier: ^4.6.0
|
||||
version: 4.11.4
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.2
|
||||
version: 9.0.3
|
||||
tsx:
|
||||
specifier: ^4.20.3
|
||||
version: 4.20.3
|
||||
|
|
@ -366,6 +378,12 @@ packages:
|
|||
'@gramio/types@9.3.0':
|
||||
resolution: {integrity: sha512-gZGZFuxEjcV1pi2kjc5Nn2Js+sTJFLbPhuoCDYLY4awVjgcGRfrXK07Ah1zm4jsScT4xoVCAEf63dQvKzwooLQ==}
|
||||
|
||||
'@hono/node-server@1.19.9':
|
||||
resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==}
|
||||
engines: {node: '>=18.14.1'}
|
||||
peerDependencies:
|
||||
hono: ^4
|
||||
|
||||
'@humanfs/core@0.19.1':
|
||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
|
|
@ -449,6 +467,9 @@ packages:
|
|||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
'@types/jsonwebtoken@9.0.10':
|
||||
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
||||
|
||||
|
|
@ -606,6 +627,9 @@ packages:
|
|||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
|
||||
buffer-equal-constant-time@1.0.1:
|
||||
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||
|
||||
buffer@6.0.3:
|
||||
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||
|
||||
|
|
@ -727,6 +751,9 @@ packages:
|
|||
engines: {node: '>=20.18'}
|
||||
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||
|
||||
electron-to-chromium@1.5.182:
|
||||
resolution: {integrity: sha512-Lv65Btwv9W4J9pyODI6EWpdnhfvrve/us5h1WspW8B2Fb0366REPtY3hX7ounk1CkV/TBjWCEvCBBbYbmV0qCA==}
|
||||
|
||||
|
|
@ -1073,6 +1100,10 @@ packages:
|
|||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
hono@4.11.4:
|
||||
resolution: {integrity: sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
|
|
@ -1150,6 +1181,16 @@ packages:
|
|||
resolution: {integrity: sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
|
||||
jsonwebtoken@9.0.3:
|
||||
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
|
||||
jwa@2.0.1:
|
||||
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
|
||||
|
||||
jws@4.0.1:
|
||||
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
|
|
@ -1169,9 +1210,30 @@ packages:
|
|||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
lodash.includes@4.3.0:
|
||||
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||
|
||||
lodash.isboolean@3.0.3:
|
||||
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
|
||||
|
||||
lodash.isinteger@4.0.4:
|
||||
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
|
||||
|
||||
lodash.isnumber@3.0.3:
|
||||
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
|
||||
|
||||
lodash.isplainobject@4.0.6:
|
||||
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||
|
||||
lodash.isstring@4.0.1:
|
||||
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
lodash.once@4.1.1:
|
||||
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||
|
||||
lodash@4.17.21:
|
||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||
|
||||
|
|
@ -1521,6 +1583,9 @@ packages:
|
|||
run-parallel@1.2.0:
|
||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
|
||||
safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
scslre@0.3.0:
|
||||
resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==}
|
||||
engines: {node: ^14.0.0 || >=16.0.0}
|
||||
|
|
@ -2047,6 +2112,10 @@ snapshots:
|
|||
|
||||
'@gramio/types@9.3.0': {}
|
||||
|
||||
'@hono/node-server@1.19.9(hono@4.11.4)':
|
||||
dependencies:
|
||||
hono: 4.11.4
|
||||
|
||||
'@humanfs/core@0.19.1': {}
|
||||
|
||||
'@humanfs/node@0.16.6':
|
||||
|
|
@ -2128,6 +2197,11 @@ snapshots:
|
|||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/jsonwebtoken@9.0.10':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
'@types/node': 22.16.3
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
|
@ -2326,6 +2400,8 @@ snapshots:
|
|||
node-releases: 2.0.19
|
||||
update-browserslist-db: 1.1.3(browserslist@4.25.1)
|
||||
|
||||
buffer-equal-constant-time@1.0.1: {}
|
||||
|
||||
buffer@6.0.3:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
|
|
@ -2441,6 +2517,10 @@ snapshots:
|
|||
- opusscript
|
||||
- utf-8-validate
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
electron-to-chromium@1.5.182: {}
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
|
|
@ -2862,6 +2942,8 @@ snapshots:
|
|||
|
||||
has-flag@4.0.0: {}
|
||||
|
||||
hono@4.11.4: {}
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
|
@ -2916,6 +2998,30 @@ snapshots:
|
|||
espree: 9.6.1
|
||||
semver: 7.7.2
|
||||
|
||||
jsonwebtoken@9.0.3:
|
||||
dependencies:
|
||||
jws: 4.0.1
|
||||
lodash.includes: 4.3.0
|
||||
lodash.isboolean: 3.0.3
|
||||
lodash.isinteger: 4.0.4
|
||||
lodash.isnumber: 3.0.3
|
||||
lodash.isplainobject: 4.0.6
|
||||
lodash.isstring: 4.0.1
|
||||
lodash.once: 4.1.1
|
||||
ms: 2.1.3
|
||||
semver: 7.7.2
|
||||
|
||||
jwa@2.0.1:
|
||||
dependencies:
|
||||
buffer-equal-constant-time: 1.0.1
|
||||
ecdsa-sig-formatter: 1.0.11
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
jws@4.0.1:
|
||||
dependencies:
|
||||
jwa: 2.0.1
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
keyv@4.5.4:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
|
|
@ -2939,8 +3045,22 @@ snapshots:
|
|||
dependencies:
|
||||
p-locate: 5.0.0
|
||||
|
||||
lodash.includes@4.3.0: {}
|
||||
|
||||
lodash.isboolean@3.0.3: {}
|
||||
|
||||
lodash.isinteger@4.0.4: {}
|
||||
|
||||
lodash.isnumber@3.0.3: {}
|
||||
|
||||
lodash.isplainobject@4.0.6: {}
|
||||
|
||||
lodash.isstring@4.0.1: {}
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
lodash.once@4.1.1: {}
|
||||
|
||||
lodash@4.17.21: {}
|
||||
|
||||
loglevel@1.9.2: {}
|
||||
|
|
@ -3439,6 +3559,8 @@ snapshots:
|
|||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
|
||||
safe-buffer@5.2.1: {}
|
||||
|
||||
scslre@0.3.0:
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.1
|
||||
|
|
|
|||
34
scripts/sign-token.ts
Normal file
34
scripts/sign-token.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import jwt from 'jsonwebtoken'
|
||||
import process from 'node:process'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
let scopes = 'admin'
|
||||
let expirationHours = 24
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--scope' && args[i + 1]) {
|
||||
scopes = args[i + 1]
|
||||
i++
|
||||
}
|
||||
else if (args[i] === '--exp' && args[i + 1]) {
|
||||
const expStr = args[i + 1]
|
||||
const match = expStr.match(/^(\d+)h?$/)
|
||||
if (match) {
|
||||
expirationHours = parseInt(match[1], 10)
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
const jwtSecret = process.env.JWT_SECRET || 'your-secret-key'
|
||||
|
||||
const payload = {
|
||||
scope: scopes,
|
||||
sub: 'test-client',
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(Date.now() / 1000) + (expirationHours * 3600),
|
||||
}
|
||||
|
||||
const token = jwt.sign(payload, jwtSecret, { algorithm: 'HS256' })
|
||||
|
||||
console.log(token)
|
||||
47
src/api/index.ts
Normal file
47
src/api/index.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { Hono } from 'hono'
|
||||
import { serve } from '@hono/node-server'
|
||||
import type { StateStore } from '../core/state-store.js'
|
||||
import type { TokenManager } from '../core/token-manager.js'
|
||||
import type { ApiContext } from '../types.js'
|
||||
import { createJwtMiddleware, requireScope } from './middleware.js'
|
||||
import { createVersionRoutes } from './routes/versions.js'
|
||||
import { createAccountRoutes } from './routes/accounts.js'
|
||||
|
||||
export interface ApiServerOptions {
|
||||
port: number
|
||||
jwtSecret: string
|
||||
stateStore: StateStore
|
||||
tokenManager: TokenManager
|
||||
accountUuid?: string
|
||||
}
|
||||
|
||||
export async function startApiServer(options: ApiServerOptions): Promise<void> {
|
||||
const app = new Hono<{ Variables: { apiContext: ApiContext } }>()
|
||||
|
||||
// Global JWT middleware
|
||||
app.use('*', createJwtMiddleware(options.jwtSecret))
|
||||
|
||||
// Version routes (require launcher or downloader scope)
|
||||
const versionRoutes = createVersionRoutes(options.stateStore, options.tokenManager)
|
||||
app.use('/api/versions/launcher', requireScope('launcher'))
|
||||
app.use('/api/versions/downloader', requireScope('downloader'))
|
||||
app.use('/api/versions/patches', requireScope('launcher'))
|
||||
app.use('/api/versions/server', requireScope('downloader'))
|
||||
app.route('/api/versions', versionRoutes)
|
||||
|
||||
// Account routes (require accounts scope)
|
||||
const accountRoutes = createAccountRoutes(options.tokenManager, options.accountUuid)
|
||||
app.use('/api/accounts/*', requireScope('accounts'))
|
||||
app.route('/api/accounts', accountRoutes)
|
||||
|
||||
// Health check (no auth required, but we need to handle it before JWT middleware)
|
||||
app.get('/health', (c) => {
|
||||
return c.json({ status: 'ok' })
|
||||
})
|
||||
|
||||
console.log(`[API] Starting server on port ${options.port}`)
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
port: options.port,
|
||||
})
|
||||
}
|
||||
53
src/api/middleware.ts
Normal file
53
src/api/middleware.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { createMiddleware } from 'hono/factory'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import type { JwtPayload, ApiContext } from '../types.js'
|
||||
|
||||
export function createJwtMiddleware(jwtSecret: string) {
|
||||
return createMiddleware<{ Variables: { apiContext: ApiContext } }>(async (c, next) => {
|
||||
const authHeader = c.req.header('Authorization')
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return c.json({ error: 'Missing or invalid Authorization header' }, 401)
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7)
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(token, jwtSecret) as JwtPayload
|
||||
|
||||
if (!payload.scope) {
|
||||
return c.json({ error: 'Token missing scope claim' }, 401)
|
||||
}
|
||||
|
||||
c.set('apiContext', {
|
||||
scope: payload.scope,
|
||||
sub: payload.sub,
|
||||
})
|
||||
|
||||
await next()
|
||||
} catch (error) {
|
||||
return c.json({ error: 'Invalid token' }, 401)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function requireScope(...requiredScopes: string[]) {
|
||||
return createMiddleware<{ Variables: { apiContext: ApiContext } }>(async (c, next) => {
|
||||
const apiContext = c.get('apiContext')
|
||||
|
||||
if (!apiContext) {
|
||||
return c.json({ error: 'No API context' }, 401)
|
||||
}
|
||||
|
||||
const tokenScopes = apiContext.scope.split(' ')
|
||||
const hasRequiredScope = requiredScopes.some(
|
||||
scope => tokenScopes.includes(scope) || tokenScopes.includes('admin'),
|
||||
)
|
||||
|
||||
if (!hasRequiredScope) {
|
||||
return c.json({ error: 'Insufficient permissions' }, 403)
|
||||
}
|
||||
|
||||
await next()
|
||||
})
|
||||
}
|
||||
88
src/api/routes/accounts.ts
Normal file
88
src/api/routes/accounts.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { Hono } from 'hono'
|
||||
import type { TokenManager } from '../../core/token-manager.js'
|
||||
import type { ApiContext } from '../../types.js'
|
||||
|
||||
interface GameSessionResponse {
|
||||
expiresAt: string
|
||||
identityToken: string
|
||||
sessionToken: string
|
||||
}
|
||||
|
||||
interface ProfileResponse {
|
||||
username: string
|
||||
uuid: string
|
||||
skin: string
|
||||
entitlements?: string[]
|
||||
}
|
||||
|
||||
export function createAccountRoutes(tokenManager: TokenManager, accountUuid?: string) {
|
||||
const router = new Hono<{ Variables: { apiContext: ApiContext } }>()
|
||||
|
||||
router.get('/profile/:identifier', async (c) => {
|
||||
try {
|
||||
if (!accountUuid) {
|
||||
return c.json({ error: 'Account UUID not configured' }, 500)
|
||||
}
|
||||
|
||||
const identifier = c.req.param('identifier')
|
||||
|
||||
// Parse identifier format: "uuid:UUID" or "username:USERNAME"
|
||||
const [type, value] = identifier.split(':')
|
||||
|
||||
if (!type || !value) {
|
||||
return c.json({ error: 'Invalid identifier format. Use uuid:UUID or username:USERNAME' }, 400)
|
||||
}
|
||||
|
||||
if (type !== 'uuid' && type !== 'username') {
|
||||
return c.json({ error: 'Invalid identifier type. Use uuid or username' }, 400)
|
||||
}
|
||||
|
||||
// Get launcher token for creating game session
|
||||
const launcherToken = await tokenManager.getAccessToken('launcher')
|
||||
|
||||
// Create game session using the configured account UUID
|
||||
const sessionResponse = await fetch('https://sessions.hytale.com/game-session/new', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${launcherToken}`,
|
||||
},
|
||||
body: JSON.stringify({ uuid: accountUuid }),
|
||||
})
|
||||
|
||||
if (!sessionResponse.ok) {
|
||||
console.warn(`[API] Failed to create game session: ${sessionResponse.statusText}`)
|
||||
return c.json({ error: 'Failed to create game session' }, 500)
|
||||
}
|
||||
|
||||
const sessionData = await sessionResponse.json() as GameSessionResponse
|
||||
const sessionToken = sessionData.sessionToken
|
||||
|
||||
// Fetch profile using session token
|
||||
const profileUrl = type === 'uuid'
|
||||
? `https://account-data.hytale.com/profile/uuid/${value}`
|
||||
: `https://account-data.hytale.com/profile/username/${value}`
|
||||
|
||||
const profileResponse = await fetch(profileUrl, {
|
||||
headers: { Authorization: `Bearer ${sessionToken}` },
|
||||
})
|
||||
|
||||
if (!profileResponse.ok) {
|
||||
if (profileResponse.status === 404) {
|
||||
return c.json({ error: 'Profile not found' }, 404)
|
||||
}
|
||||
console.warn(`[API] Failed to fetch profile: ${profileResponse.statusText}`)
|
||||
return c.json({ error: 'Failed to fetch profile' }, 500)
|
||||
}
|
||||
|
||||
const profile = await profileResponse.json() as ProfileResponse
|
||||
return c.json(profile)
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[API] Error in /profile endpoint:', err)
|
||||
return c.json({ error: 'Internal server error' }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
98
src/api/routes/versions.ts
Normal file
98
src/api/routes/versions.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { Hono } from 'hono'
|
||||
import type { StateStore } from '../../core/state-store.js'
|
||||
import type { TokenManager } from '../../core/token-manager.js'
|
||||
import type { ApiContext } from '../../types.js'
|
||||
|
||||
export function createVersionRoutes(stateStore: StateStore, tokenManager: TokenManager) {
|
||||
const router = new Hono<{ Variables: { apiContext: ApiContext } }>()
|
||||
|
||||
router.get('/launcher', async (c) => {
|
||||
const data = stateStore.get('hytale-launcher')
|
||||
if (!data) {
|
||||
return c.json({ error: 'No launcher data available' }, 404)
|
||||
}
|
||||
return c.json(data)
|
||||
})
|
||||
|
||||
router.get('/downloader', async (c) => {
|
||||
const data = stateStore.get('hytale-downloader')
|
||||
if (!data) {
|
||||
return c.json({ error: 'No downloader data available' }, 404)
|
||||
}
|
||||
return c.json(data)
|
||||
})
|
||||
|
||||
router.get('/patches', async (c) => {
|
||||
const data = stateStore.get('hytale-patches')
|
||||
if (!data) {
|
||||
return c.json({ error: 'No patches data available' }, 404)
|
||||
}
|
||||
return c.json(data)
|
||||
})
|
||||
|
||||
router.get('/server', async (c) => {
|
||||
try {
|
||||
const serverData = stateStore.get('hytale-server')
|
||||
if (!serverData) {
|
||||
return c.json({ error: 'No server data available' }, 404)
|
||||
}
|
||||
|
||||
const serverObj = serverData as Record<string, { version: string; lastCheck: number }>
|
||||
const result: Record<string, { version: string; download_url: string; sha256?: string; url: string }> = {}
|
||||
|
||||
const token = await tokenManager.getAccessToken('downloader')
|
||||
|
||||
for (const [patchline, data] of Object.entries(serverObj)) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://account-data.hytale.com/game-assets/version/${patchline}.json`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`[API] Failed to fetch signed URL for ${patchline}: ${response.statusText}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const signedUrlData = await response.json() as { url: string }
|
||||
|
||||
// Fetch version data from signed URL
|
||||
let versionData: { version?: string; download_url?: string; sha256?: string } = {}
|
||||
try {
|
||||
const versionResponse = await fetch(signedUrlData.url)
|
||||
if (versionResponse.ok) {
|
||||
versionData = await versionResponse.json()
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.warn(`[API] Error fetching version data from signed URL for ${patchline}:`, err)
|
||||
}
|
||||
|
||||
result[patchline] = {
|
||||
version: versionData.version || data.version,
|
||||
download_url: versionData.download_url || '',
|
||||
sha256: versionData.sha256,
|
||||
url: signedUrlData.url,
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.warn(`[API] Error fetching signed URL for ${patchline}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(result).length === 0) {
|
||||
return c.json({ error: 'Failed to fetch signed URLs' }, 500)
|
||||
}
|
||||
|
||||
return c.json(result)
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[API] Error in /server endpoint:', err)
|
||||
return c.json({ error: 'Internal server error' }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
23
src/index.ts
23
src/index.ts
|
|
@ -4,6 +4,7 @@ import { config } from './config.js'
|
|||
import { StateStore } from './core/state-store.js'
|
||||
import { TelegramClient } from './core/telegram-client.js'
|
||||
import { TokenManager } from './core/token-manager.js'
|
||||
import { startApiServer } from './api/index.js'
|
||||
|
||||
// Module factories
|
||||
import { discordForwarderFactory } from './modules/discord-forwarder/index.js'
|
||||
|
|
@ -85,6 +86,28 @@ async function main() {
|
|||
}
|
||||
}
|
||||
|
||||
// Start API server if enabled
|
||||
const apiConfig = config.modules.api as any
|
||||
if (apiConfig?.enabled) {
|
||||
const jwtSecret = process.env.JWT_SECRET || apiConfig.jwtSecret || 'your-secret-key'
|
||||
const port = process.env.API_PORT ? parseInt(process.env.API_PORT, 10) : (apiConfig.port || 3000)
|
||||
const accountUuid = process.env.ACCOUNT_UUID || apiConfig.accountUuid
|
||||
|
||||
try {
|
||||
await startApiServer({
|
||||
port,
|
||||
jwtSecret,
|
||||
stateStore,
|
||||
tokenManager,
|
||||
accountUuid,
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to start API server:', err)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = async () => {
|
||||
console.log('Shutting down...')
|
||||
|
|
|
|||
19
src/types.ts
19
src/types.ts
|
|
@ -82,3 +82,22 @@ export interface HytalePatchesConfig extends HytaleTrackerConfig {
|
|||
export interface HytaleServerConfig extends HytaleTrackerConfig {
|
||||
patchlines: string[]
|
||||
}
|
||||
|
||||
export interface ApiConfig extends ModuleConfig {
|
||||
enabled: boolean
|
||||
port: number
|
||||
jwtSecret: string
|
||||
accountUuid?: string
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
scope: string
|
||||
sub?: string
|
||||
iat?: number
|
||||
exp?: number
|
||||
}
|
||||
|
||||
export interface ApiContext {
|
||||
scope: string
|
||||
sub?: string
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue