Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Support
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
S
srvpro2
Project overview
Project overview
Details
Activity
Releases
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Locked Files
Issues
0
Issues
0
List
Boards
Labels
Service Desk
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Security & Compliance
Security & Compliance
Dependency List
License Compliance
Packages
Packages
List
Container Registry
Analytics
Analytics
CI / CD
Code Review
Insights
Issues
Repository
Value Stream
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
nanahira
srvpro2
Commits
6fe7fe95
Commit
6fe7fe95
authored
Feb 18, 2026
by
nanahira
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
add dashboard
parent
b7363c75
Pipeline
#43299
passed with stages
in 2 minutes and 44 seconds
Changes
46
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
46 changed files
with
2280 additions
and
189 deletions
+2280
-189
config.example.yaml
config.example.yaml
+2
-0
src/app.ts
src/app.ts
+16
-0
src/client/transport-module.ts
src/client/transport-module.ts
+0
-2
src/client/transport/tcp/server.ts
src/client/transport/tcp/server.ts
+4
-1
src/client/transport/ws/server.ts
src/client/transport/ws/server.ts
+3
-2
src/config.ts
src/config.ts
+7
-1
src/constants/trans.ts
src/constants/trans.ts
+6
-0
src/feats/cloud-replay/cloud-replay-service.ts
src/feats/cloud-replay/cloud-replay-service.ts
+12
-0
src/feats/cloud-replay/duel-record-player.entity.ts
src/feats/cloud-replay/duel-record-player.entity.ts
+1
-2
src/feats/cloud-replay/duel-record.entity.ts
src/feats/cloud-replay/duel-record.entity.ts
+1
-2
src/feats/cloud-replay/utility/record-codec.ts
src/feats/cloud-replay/utility/record-codec.ts
+3
-1
src/feats/feats-module.ts
src/feats/feats-module.ts
+1
-2
src/feats/lock-deck/lock-deck-service.ts
src/feats/lock-deck/lock-deck-service.ts
+4
-1
src/feats/lp-low-hint-service.ts
src/feats/lp-low-hint-service.ts
+5
-1
src/feats/resource/base-resource-provider.ts
src/feats/resource/base-resource-provider.ts
+1
-2
src/feats/resource/file-resource-service.ts
src/feats/resource/file-resource-service.ts
+1
-129
src/feats/resource/module.ts
src/feats/resource/module.ts
+0
-2
src/feats/resource/resource-util.ts
src/feats/resource/resource-util.ts
+1
-15
src/feats/welcome.ts
src/feats/welcome.ts
+22
-4
src/file-resource/file-resource-service.ts
src/file-resource/file-resource-service.ts
+153
-0
src/file-resource/index.ts
src/file-resource/index.ts
+2
-0
src/file-resource/resource-util.ts
src/file-resource/resource-util.ts
+15
-0
src/join-handlers/join-roomlist.ts
src/join-handlers/join-roomlist.ts
+1
-1
src/legacy-api/index.ts
src/legacy-api/index.ts
+11
-0
src/legacy-api/legacy-api-deck-service.ts
src/legacy-api/legacy-api-deck-service.ts
+590
-0
src/legacy-api/legacy-api-module.ts
src/legacy-api/legacy-api-module.ts
+18
-0
src/legacy-api/legacy-api-record.entity.ts
src/legacy-api/legacy-api-record.entity.ts
+26
-0
src/legacy-api/legacy-api-replay-service.ts
src/legacy-api/legacy-api-replay-service.ts
+283
-0
src/legacy-api/legacy-api-service.ts
src/legacy-api/legacy-api-service.ts
+242
-0
src/legacy-api/legacy-ban-service.ts
src/legacy-api/legacy-ban-service.ts
+122
-0
src/legacy-api/legacy-ban.entity.ts
src/legacy-api/legacy-ban.entity.ts
+37
-0
src/legacy-api/legacy-deck.entity.ts
src/legacy-api/legacy-deck.entity.ts
+34
-0
src/legacy-api/legacy-room-id-service.ts
src/legacy-api/legacy-room-id-service.ts
+108
-0
src/legacy-api/legacy-stop-service.ts
src/legacy-api/legacy-stop-service.ts
+96
-0
src/legacy-api/legacy-welcome-service.ts
src/legacy-api/legacy-welcome-service.ts
+91
-0
src/legacy-api/utility/deck-name-match.ts
src/legacy-api/utility/deck-name-match.ts
+16
-0
src/legacy-api/utility/deck-name-query.ts
src/legacy-api/utility/deck-name-query.ts
+18
-0
src/room/duel-record.ts
src/room/duel-record.ts
+1
-1
src/room/room-manager.ts
src/room/room-manager.ts
+1
-1
src/room/room.ts
src/room/room.ts
+5
-0
src/services/koa-service.ts
src/services/koa-service.ts
+177
-0
src/services/legacy-api-auth-service.ts
src/services/legacy-api-auth-service.ts
+115
-0
src/services/ssl-finder.ts
src/services/ssl-finder.ts
+10
-17
src/services/typeorm.ts
src/services/typeorm.ts
+12
-1
src/utility/bigint-transformer.ts
src/utility/bigint-transformer.ts
+5
-1
src/utility/index.ts
src/utility/index.ts
+1
-0
No files found.
config.example.yaml
View file @
6fe7fe95
host
:
"
::"
port
:
7911
apiHost
:
"
"
apiPort
:
7922
dbHost
:
"
"
dbPort
:
5432
dbUser
:
postgres
...
...
src/app.ts
View file @
6fe7fe95
...
...
@@ -11,6 +11,11 @@ import { SqljsFactory, SqljsLoader } from './services/sqljs';
import
{
FeatsModule
}
from
'
./feats/feats-module
'
;
import
{
MiddlewareRx
}
from
'
./services/middleware-rx
'
;
import
{
TypeormFactory
,
TypeormLoader
}
from
'
./services/typeorm
'
;
import
{
SSLFinder
}
from
'
./services/ssl-finder
'
;
import
{
KoaService
}
from
'
./services/koa-service
'
;
import
{
FileResourceService
}
from
'
./file-resource
'
;
import
{
LegacyApiAuthService
}
from
'
./services/legacy-api-auth-service
'
;
import
{
LegacyApiModule
}
from
'
./legacy-api/legacy-api-module
'
;
const
core
=
createAppContext
()
.
provide
(
ConfigService
,
{
...
...
@@ -21,6 +26,16 @@ const core = createAppContext()
.
provide
(
MiddlewareRx
,
{
merge
:
[
'
event$
'
]
})
.
provide
(
HttpClient
,
{
merge
:
[
'
http
'
]
})
.
provide
(
AragamiService
,
{
merge
:
[
'
aragami
'
]
})
.
provide
(
SSLFinder
)
.
provide
(
FileResourceService
,
{
provide
:
'
fileResource
'
,
})
.
provide
(
LegacyApiAuthService
,
{
provide
:
'
legacyApiAuth
'
,
})
.
provide
(
KoaService
,
{
merge
:
[
'
router
'
,
'
koa
'
],
})
.
provide
(
SqljsLoader
,
{
useFactory
:
SqljsFactory
,
merge
:
[
'
SQL
'
],
...
...
@@ -37,6 +52,7 @@ export type ContextState = AppContextState<Context>;
export
const
app
=
core
.
use
(
TransportModule
)
.
use
(
FeatsModule
)
.
use
(
LegacyApiModule
)
.
use
(
RoomModule
)
.
use
(
JoinHandlerModule
)
.
define
();
src/client/transport-module.ts
View file @
6fe7fe95
...
...
@@ -6,10 +6,8 @@ import { ClientHandler } from './client-handler';
import
{
Chnroute
}
from
'
./chnroute
'
;
import
{
I18nService
}
from
'
./i18n
'
;
import
{
IpResolver
}
from
'
./ip-resolver
'
;
import
{
SSLFinder
}
from
'
./ssl-finder
'
;
export
const
TransportModule
=
createAppContext
<
ContextState
>
()
.
provide
(
SSLFinder
)
.
provide
(
IpResolver
)
.
provide
(
Chnroute
)
.
provide
(
I18nService
)
...
...
src/client/transport/tcp/server.ts
View file @
6fe7fe95
...
...
@@ -39,7 +39,10 @@ export class TcpServer {
socket
.
on
(
'
error
'
,
(
err
:
NodeJS
.
ErrnoException
)
=>
{
if
(
err
.
code
===
'
ECONNRESET
'
)
{
this
.
logger
.
debug
(
{
remoteAddress
:
socket
.
remoteAddress
,
remotePort
:
socket
.
remotePort
},
{
remoteAddress
:
socket
.
remoteAddress
,
remotePort
:
socket
.
remotePort
,
},
'
TCP socket reset by peer
'
,
);
return
;
...
...
src/client/transport/ws/server.ts
View file @
6fe7fe95
...
...
@@ -3,7 +3,7 @@ import { createServer as createHttpsServer } from 'node:https';
import
{
Server
as
WebSocketServer
}
from
'
ws
'
;
import
{
Context
}
from
'
../../../app
'
;
import
{
ClientHandler
}
from
'
../../client-handler
'
;
import
{
SSLFinder
}
from
'
../../ssl-finder
'
;
import
{
SSLFinder
}
from
'
../../
../services/
ssl-finder
'
;
import
{
WsClient
}
from
'
./client
'
;
import
{
WebSocket
}
from
'
ws
'
;
import
{
IpResolver
}
from
'
../../ip-resolver
'
;
...
...
@@ -26,7 +26,8 @@ export class WsServer {
return
;
}
const
host
=
this
.
ctx
.
config
.
getString
(
'
HOST
'
);
const
host
=
this
.
ctx
.
config
.
getString
(
'
WS_HOST
'
)
||
this
.
ctx
.
config
.
getString
(
'
HOST
'
);
// Try to get SSL configuration
const
sslFinder
=
this
.
ctx
.
get
(()
=>
SSLFinder
);
...
...
src/config.ts
View file @
6fe7fe95
...
...
@@ -12,6 +12,10 @@ export const defaultConfig = {
HOST
:
'
::
'
,
// Main server port for YGOPro clients. Format: integer string.
PORT
:
'
7911
'
,
// Legacy HTTP API bind address. Empty means fallback to HOST.
API_HOST
:
''
,
// Legacy HTTP API port. Format: integer string. '0' means disabled.
API_PORT
:
'
7922
'
,
// PostgreSQL host. Empty means database disabled.
DB_HOST
:
''
,
// PostgreSQL port. Format: integer string.
...
...
@@ -29,8 +33,10 @@ export const defaultConfig = {
REDIS_URL
:
''
,
// Log level. Format: lowercase string (e.g. info/debug/warn/error).
LOG_LEVEL
:
'
info
'
,
// WebSocket server bind host. Empty means fallback to HOST.
WS_HOST
:
''
,
// WebSocket port. Format: integer string. '0' means do not open a separate WS port.
WS_PORT
:
'
0
'
,
WS_PORT
:
'
7912
'
,
// Enable SSL for WebSocket server.
// Boolean parse rule (default false): ''/'0'/'false'/'null' => false, otherwise true.
ENABLE_SSL
:
'
0
'
,
...
...
src/constants/trans.ts
View file @
6fe7fe95
...
...
@@ -29,6 +29,8 @@ export const TRANSLATIONS = {
'
Error occurs, please create a new game and enter /ai to summon an AI.
'
,
create_room_failed
:
'
Game creation failed, please try again later.
'
,
invalid_password_not_found
:
'
Password invalid (Not Found)
'
,
banned_ip_login
:
'
You have been banned.
'
,
banned_user_login
:
'
You have been banned.
'
,
add_windbot_failed
:
'
AI addition failed, enter /ai again.
'
,
pre_reconnecting_to_room
:
'
You will be reconnected to your previous game. Please pick your previous deck.
'
,
...
...
@@ -170,6 +172,10 @@ export const TRANSLATIONS = {
windbot_name_too_long
:
'
AI房间名过长,请在建立房间后输入 /ai 来添加AI
'
,
create_room_failed
:
'
建立房间失败,请重试
'
,
invalid_password_not_found
:
'
主机密码不正确 (Not Found)
'
,
banned_ip_login
:
'
您的账号已被封禁。如果您没有进行违规操作且用的是流量网络,可能过几小时就好。是IP撞了。
'
,
banned_user_login
:
'
您的账号已被封禁。如果您没有进行违规操作且用的是流量网络,可能过几小时就好。是IP撞了。
'
,
add_windbot_failed
:
'
添加AI失败,可尝试输入 /ai 重新添加
'
,
pre_reconnecting_to_room
:
'
你有未完成的对局,即将重新连接,请选择你在本局决斗中使用的卡组并准备。
'
,
...
...
src/feats/cloud-replay/cloud-replay-service.ts
View file @
6fe7fe95
...
...
@@ -524,6 +524,18 @@ export class CloudReplayService {
});
}
buildReplayYrpPayload
(
replay
:
DuelRecordEntity
)
{
return
this
.
createReplayPacket
(
replay
).
replay
.
toYrp
();
}
async
getReplayYrpPayloadById
(
replayId
:
number
)
{
const
replay
=
await
this
.
findReplayById
(
replayId
);
if
(
!
replay
)
{
return
undefined
;
}
return
this
.
buildReplayYrpPayload
(
replay
);
}
private
restoreDuelRecord
(
replay
:
DuelRecordEntity
)
{
const
wasSwapped
=
this
.
resolveReplaySwappedByIsFirst
(
replay
);
const
seatCount
=
this
.
resolveSeatCount
(
replay
.
hostInfo
);
...
...
src/feats/cloud-replay/duel-record-player.entity.ts
View file @
6fe7fe95
...
...
@@ -7,8 +7,7 @@ import {
ManyToOne
,
PrimaryColumn
,
}
from
'
typeorm
'
;
import
{
BaseTimeEntity
}
from
'
../../utility
'
;
import
{
BigintTransformer
}
from
'
./bigint-transformer
'
;
import
{
BaseTimeEntity
,
BigintTransformer
}
from
'
../../utility
'
;
import
{
DuelRecordEntity
}
from
'
./duel-record.entity
'
;
@
Entity
(
'
duel_record_player
'
)
...
...
src/feats/cloud-replay/duel-record.entity.ts
View file @
6fe7fe95
...
...
@@ -7,8 +7,7 @@ import {
OneToMany
,
PrimaryColumn
,
}
from
'
typeorm
'
;
import
{
BaseTimeEntity
}
from
'
../../utility
'
;
import
{
BigintTransformer
}
from
'
./bigint-transformer
'
;
import
{
BaseTimeEntity
,
BigintTransformer
}
from
'
../../utility
'
;
import
{
DuelRecordPlayer
}
from
'
./duel-record-player.entity
'
;
@
Entity
(
'
duel_record
'
)
...
...
src/feats/cloud-replay/utility/record-codec.ts
View file @
6fe7fe95
...
...
@@ -144,7 +144,9 @@ function decodeLengthPrefixedResponses(payload: Buffer) {
}
export
function
decodeSeedBase64
(
seedBase64
:
string
)
{
const
decoded
=
seedBase64
?
Buffer
.
from
(
seedBase64
,
'
base64
'
)
:
Buffer
.
alloc
(
0
);
const
decoded
=
seedBase64
?
Buffer
.
from
(
seedBase64
,
'
base64
'
)
:
Buffer
.
alloc
(
0
);
const
raw
=
Buffer
.
alloc
(
32
,
0
);
decoded
.
copy
(
raw
,
0
,
0
,
Math
.
min
(
decoded
.
length
,
raw
.
length
));
const
seed
:
number
[]
=
[];
...
...
src/feats/feats-module.ts
View file @
6fe7fe95
import
{
createAppContext
}
from
'
nfkit
'
;
import
{
ClientVersionCheck
}
from
'
./client-version-check
'
;
import
{
ContextState
}
from
'
../app
'
;
import
{
Welcome
}
from
'
./welcome
'
;
import
{
PlayerStatusNotify
}
from
'
./player-status-notify
'
;
import
{
Reconnect
,
RefreshFieldService
}
from
'
./reconnect
'
;
...
...
@@ -19,7 +18,7 @@ import { LpLowHintService } from './lp-low-hint-service';
import
{
LockDeckService
}
from
'
./lock-deck
'
;
import
{
BlockReplay
}
from
'
./block-replay
'
;
export
const
FeatsModule
=
createAppContext
<
ContextState
>
()
export
const
FeatsModule
=
createAppContext
()
.
provide
(
ClientKeyProvider
)
.
provide
(
HidePlayerNameProvider
)
.
provide
(
KoishiContextService
)
...
...
src/feats/lock-deck/lock-deck-service.ts
View file @
6fe7fe95
import
{
YGOProLFListError
,
YGOProLFListErrorReason
}
from
'
ygopro-lflist-encode
'
;
import
{
YGOProLFListError
,
YGOProLFListErrorReason
,
}
from
'
ygopro-lflist-encode
'
;
import
{
ChatColor
}
from
'
ygopro-msg-encode
'
;
import
{
Context
}
from
'
../../app
'
;
import
{
RoomCheckDeck
}
from
'
../../room
'
;
...
...
src/feats/lp-low-hint-service.ts
View file @
6fe7fe95
import
{
ChatColor
,
YGOProMsgDamage
,
YGOProMsgPayLpCost
}
from
'
ygopro-msg-encode
'
;
import
{
ChatColor
,
YGOProMsgDamage
,
YGOProMsgPayLpCost
,
}
from
'
ygopro-msg-encode
'
;
import
{
Context
}
from
'
../app
'
;
import
{
Client
}
from
'
../client
'
;
import
{
RoomManager
}
from
'
../room
'
;
...
...
src/feats/resource/base-resource-provider.ts
View file @
6fe7fe95
import
{
Context
}
from
'
../../app
'
;
import
{
ValueContainer
}
from
'
../../utility/value-container
'
;
import
{
FileResourceService
}
from
'
./file-resource-service
'
;
import
{
cloneJson
}
from
'
./resource-util
'
;
type
AnyObject
=
Record
<
string
,
unknown
>
;
...
...
@@ -12,7 +11,7 @@ type RemoteEntry<T extends object> = {
export
abstract
class
BaseResourceProvider
<
T
extends
object
>
{
protected
logger
=
this
.
ctx
.
createLogger
(
this
.
constructor
.
name
);
protected
fileResourceService
=
this
.
ctx
.
get
(()
=>
FileResourceService
)
;
protected
fileResourceService
=
this
.
ctx
.
fileResource
;
protected
data
:
ValueContainer
<
T
>
;
public
resource
:
ValueContainer
<
T
>
;
...
...
src/feats/resource/file-resource-service.ts
View file @
6fe7fe95
import
*
as
fs
from
'
node:fs/promises
'
;
import
path
from
'
node:path
'
;
import
{
Context
}
from
'
../../app
'
;
import
{
cloneJson
,
isObjectRecord
}
from
'
./resource-util
'
;
export
class
FileResourceService
{
private
logger
=
this
.
ctx
.
createLogger
(
this
.
constructor
.
name
);
private
readonly
dataDir
=
path
.
resolve
(
process
.
cwd
(),
'
data
'
);
private
readonly
defaultDataPath
=
path
.
resolve
(
process
.
cwd
(),
'
resource
'
,
'
default_data.json
'
,
);
private
initialized
=
false
;
private
initTask
?:
Promise
<
void
>
;
private
dataByName
=
new
Map
<
string
,
Record
<
string
,
unknown
>>
();
private
dataPathByName
=
new
Map
<
string
,
string
>
();
constructor
(
private
ctx
:
Context
)
{}
async
init
()
{
await
this
.
ensureInitialized
();
}
async
ensureInitialized
()
{
if
(
this
.
initialized
)
{
return
;
}
if
(
!
this
.
initTask
)
{
this
.
initTask
=
this
.
doInit
();
}
await
this
.
initTask
;
}
getDataOrEmpty
<
T
extends
object
>
(
name
:
string
,
emptyData
:
T
):
T
{
if
(
!
this
.
initialized
)
{
return
cloneJson
(
emptyData
);
}
const
data
=
this
.
dataByName
.
get
(
name
);
if
(
!
data
)
{
return
cloneJson
(
emptyData
);
}
return
cloneJson
(
data
as
T
);
}
async
saveData
(
name
:
string
,
data
:
Record
<
string
,
unknown
>
)
{
await
this
.
ensureInitialized
();
const
dataPath
=
this
.
dataPathByName
.
get
(
name
);
if
(
!
dataPath
)
{
return
false
;
}
await
fs
.
writeFile
(
dataPath
,
JSON
.
stringify
(
data
,
null
,
2
),
'
utf-8
'
);
this
.
dataByName
.
set
(
name
,
cloneJson
(
data
));
return
true
;
}
private
async
doInit
()
{
await
fs
.
mkdir
(
this
.
dataDir
,
{
recursive
:
true
});
const
defaultData
=
await
this
.
readJsonFile
(
this
.
defaultDataPath
);
if
(
!
isObjectRecord
(
defaultData
))
{
this
.
logger
.
warn
(
{
defaultDataPath
:
this
.
defaultDataPath
},
'
Failed to load resource/default_data.json
'
,
);
this
.
initialized
=
true
;
return
;
}
for
(
const
[
name
,
data
]
of
Object
.
entries
(
defaultData
))
{
if
(
!
isObjectRecord
(
data
))
{
continue
;
}
const
resolvedData
=
this
.
resolveDefaultData
(
name
,
data
);
const
dataPath
=
this
.
resolveDataPath
(
name
,
data
.
file
);
this
.
dataPathByName
.
set
(
name
,
dataPath
);
const
localData
=
await
this
.
readJsonFile
(
dataPath
);
if
(
isObjectRecord
(
localData
))
{
this
.
dataByName
.
set
(
name
,
localData
);
continue
;
}
await
fs
.
writeFile
(
dataPath
,
JSON
.
stringify
(
resolvedData
,
null
,
2
),
'
utf-8
'
,
);
this
.
dataByName
.
set
(
name
,
resolvedData
);
}
this
.
initialized
=
true
;
this
.
logger
.
info
(
{
count
:
this
.
dataByName
.
size
,
dataDir
:
this
.
dataDir
},
'
File resources initialized
'
,
);
}
private
resolveDefaultData
(
name
:
string
,
data
:
Record
<
string
,
unknown
>
)
{
const
nextData
=
cloneJson
(
data
);
const
fileName
=
this
.
resolveFileName
(
name
,
data
.
file
);
nextData
.
file
=
`./data/
${
fileName
}
`
;
return
nextData
;
}
private
resolveDataPath
(
name
:
string
,
filePath
:
unknown
)
{
const
fileName
=
this
.
resolveFileName
(
name
,
filePath
);
return
path
.
join
(
this
.
dataDir
,
fileName
);
}
private
resolveFileName
(
name
:
string
,
filePath
:
unknown
)
{
if
(
typeof
filePath
===
'
string
'
&&
filePath
.
trim
())
{
return
path
.
basename
(
filePath
);
}
return
`
${
name
}
.json`
;
}
private
async
readJsonFile
(
filePath
:
string
):
Promise
<
unknown
>
{
try
{
const
text
=
await
fs
.
readFile
(
filePath
,
'
utf-8
'
);
return
JSON
.
parse
(
text
)
as
unknown
;
}
catch
{
return
undefined
;
}
}
}
export
{
FileResourceService
}
from
'
../../file-resource/file-resource-service
'
;
src/feats/resource/module.ts
View file @
6fe7fe95
...
...
@@ -2,12 +2,10 @@ import { createAppContext } from 'nfkit';
import
{
ContextState
}
from
'
../../app
'
;
import
{
BadwordProvider
}
from
'
./badword-provider
'
;
import
{
DialoguesProvider
}
from
'
./dialogues-provider
'
;
import
{
FileResourceService
}
from
'
./file-resource-service
'
;
import
{
TipsProvider
}
from
'
./tips-provider
'
;
import
{
WordsProvider
}
from
'
./words-provider
'
;
export
const
ResourceModule
=
createAppContext
<
ContextState
>
()
.
provide
(
FileResourceService
)
.
provide
(
TipsProvider
)
.
provide
(
WordsProvider
)
.
provide
(
DialoguesProvider
)
...
...
src/feats/resource/resource-util.ts
View file @
6fe7fe95
export
function
isObjectRecord
(
value
:
unknown
,
):
value
is
Record
<
string
,
unknown
>
{
return
!!
value
&&
typeof
value
===
'
object
'
&&
!
Array
.
isArray
(
value
);
}
export
function
cloneJson
<
T
>
(
value
:
T
):
T
{
if
(
value
==
null
)
{
return
value
;
}
if
(
typeof
globalThis
.
structuredClone
===
'
function
'
)
{
return
globalThis
.
structuredClone
(
value
);
}
return
JSON
.
parse
(
JSON
.
stringify
(
value
))
as
T
;
}
export
{
cloneJson
,
isObjectRecord
}
from
'
../../file-resource/resource-util
'
;
src/feats/welcome.ts
View file @
6fe7fe95
...
...
@@ -2,6 +2,7 @@ import { ChatColor } from 'ygopro-msg-encode';
import
{
Context
}
from
'
../app
'
;
import
{
Client
}
from
'
../client
'
;
import
{
OnRoomJoin
}
from
'
../room/room-event/on-room-join
'
;
import
{
ValueContainer
}
from
'
../utility/value-container
'
;
declare
module
'
../room
'
{
interface
Room
{
...
...
@@ -17,8 +18,6 @@ declare module '../client' {
}
export
class
Welcome
{
private
welcomeMessage
=
this
.
ctx
.
config
.
getString
(
'
WELCOME
'
);
constructor
(
private
ctx
:
Context
)
{
this
.
ctx
.
middleware
(
OnRoomJoin
,
async
(
event
,
client
,
next
)
=>
{
const
room
=
event
.
room
;
...
...
@@ -34,10 +33,29 @@ export class Welcome {
}
async
sendConfigWelcome
(
client
:
Client
)
{
if
(
!
this
.
welcomeMessage
||
client
.
configWelcomeSent
)
{
const
welcomeMessage
=
await
this
.
getConfigWelcome
(
client
);
if
(
!
welcomeMessage
||
client
.
configWelcomeSent
)
{
return
;
}
client
.
configWelcomeSent
=
true
;
await
client
.
sendChat
(
this
.
welcomeMessage
,
ChatColor
.
GREEN
);
await
client
.
sendChat
(
welcomeMessage
,
ChatColor
.
GREEN
);
}
async
getConfigWelcome
(
client
:
Client
)
{
const
baseWelcome
=
this
.
ctx
.
config
.
getString
(
'
WELCOME
'
);
const
event
=
await
this
.
ctx
.
dispatch
(
new
WelcomeConfigCheck
(
client
,
baseWelcome
),
client
,
);
return
event
?.
value
||
''
;
}
}
export
class
WelcomeConfigCheck
extends
ValueContainer
<
string
>
{
constructor
(
public
client
:
Client
,
initialValue
:
string
,
)
{
super
(
initialValue
);
}
}
src/file-resource/file-resource-service.ts
0 → 100644
View file @
6fe7fe95
import
*
as
fs
from
'
node:fs/promises
'
;
import
path
from
'
node:path
'
;
import
{
cloneJson
,
isObjectRecord
}
from
'
./resource-util
'
;
import
{
AppContext
}
from
'
nfkit
'
;
import
{
Logger
}
from
'
../services/logger
'
;
export
class
FileResourceService
{
private
logger
=
this
.
ctx
.
get
(()
=>
Logger
)
.
createLogger
(
this
.
constructor
.
name
);
private
readonly
dataDir
=
path
.
resolve
(
process
.
cwd
(),
'
data
'
);
private
readonly
defaultDataPath
=
path
.
resolve
(
process
.
cwd
(),
'
resource
'
,
'
default_data.json
'
,
);
private
initialized
=
false
;
private
initTask
?:
Promise
<
void
>
;
private
dataByName
=
new
Map
<
string
,
Record
<
string
,
unknown
>>
();
private
dataPathByName
=
new
Map
<
string
,
string
>
();
constructor
(
private
ctx
:
AppContext
)
{}
async
init
()
{
await
this
.
ensureInitialized
();
}
async
ensureInitialized
()
{
if
(
this
.
initialized
)
{
return
;
}
if
(
!
this
.
initTask
)
{
this
.
initTask
=
this
.
doInit
();
}
await
this
.
initTask
;
}
getDataOrEmpty
<
T
extends
object
>
(
name
:
string
,
emptyData
:
T
):
T
{
if
(
!
this
.
initialized
)
{
return
cloneJson
(
emptyData
);
}
const
data
=
this
.
dataByName
.
get
(
name
);
if
(
!
data
)
{
return
cloneJson
(
emptyData
);
}
return
cloneJson
(
data
as
T
);
}
async
getDataOrEmptyAsync
<
T
extends
object
>
(
name
:
string
,
emptyData
:
T
,
options
:
{
forceRead
?:
boolean
;
}
=
{},
):
Promise
<
T
>
{
await
this
.
ensureInitialized
();
if
(
options
.
forceRead
)
{
const
dataPath
=
this
.
dataPathByName
.
get
(
name
);
if
(
dataPath
)
{
const
localData
=
await
this
.
readJsonFile
(
dataPath
);
if
(
isObjectRecord
(
localData
))
{
this
.
dataByName
.
set
(
name
,
localData
);
return
cloneJson
(
localData
as
T
);
}
}
}
return
this
.
getDataOrEmpty
(
name
,
emptyData
);
}
async
saveData
(
name
:
string
,
data
:
Record
<
string
,
unknown
>
)
{
await
this
.
ensureInitialized
();
const
dataPath
=
this
.
dataPathByName
.
get
(
name
);
if
(
!
dataPath
)
{
return
false
;
}
await
fs
.
writeFile
(
dataPath
,
JSON
.
stringify
(
data
,
null
,
2
),
'
utf-8
'
);
this
.
dataByName
.
set
(
name
,
cloneJson
(
data
));
return
true
;
}
private
async
doInit
()
{
await
fs
.
mkdir
(
this
.
dataDir
,
{
recursive
:
true
});
const
defaultData
=
await
this
.
readJsonFile
(
this
.
defaultDataPath
);
if
(
!
isObjectRecord
(
defaultData
))
{
this
.
logger
.
warn
(
{
defaultDataPath
:
this
.
defaultDataPath
},
'
Failed to load resource/default_data.json
'
,
);
this
.
initialized
=
true
;
return
;
}
for
(
const
[
name
,
data
]
of
Object
.
entries
(
defaultData
))
{
if
(
!
isObjectRecord
(
data
))
{
continue
;
}
const
resolvedData
=
this
.
resolveDefaultData
(
name
,
data
);
const
dataPath
=
this
.
resolveDataPath
(
name
,
data
.
file
);
this
.
dataPathByName
.
set
(
name
,
dataPath
);
const
localData
=
await
this
.
readJsonFile
(
dataPath
);
if
(
isObjectRecord
(
localData
))
{
this
.
dataByName
.
set
(
name
,
localData
);
continue
;
}
await
fs
.
writeFile
(
dataPath
,
JSON
.
stringify
(
resolvedData
,
null
,
2
),
'
utf-8
'
,
);
this
.
dataByName
.
set
(
name
,
resolvedData
);
}
this
.
initialized
=
true
;
this
.
logger
.
info
(
{
count
:
this
.
dataByName
.
size
,
dataDir
:
this
.
dataDir
},
'
File resources initialized
'
,
);
}
private
resolveDefaultData
(
name
:
string
,
data
:
Record
<
string
,
unknown
>
)
{
const
nextData
=
cloneJson
(
data
);
const
fileName
=
this
.
resolveFileName
(
name
,
data
.
file
);
nextData
.
file
=
`./data/
${
fileName
}
`
;
return
nextData
;
}
private
resolveDataPath
(
name
:
string
,
filePath
:
unknown
)
{
const
fileName
=
this
.
resolveFileName
(
name
,
filePath
);
return
path
.
join
(
this
.
dataDir
,
fileName
);
}
private
resolveFileName
(
name
:
string
,
filePath
:
unknown
)
{
if
(
typeof
filePath
===
'
string
'
&&
filePath
.
trim
())
{
return
path
.
basename
(
filePath
);
}
return
`
${
name
}
.json`
;
}
private
async
readJsonFile
(
filePath
:
string
):
Promise
<
unknown
>
{
try
{
const
text
=
await
fs
.
readFile
(
filePath
,
'
utf-8
'
);
return
JSON
.
parse
(
text
)
as
unknown
;
}
catch
{
return
undefined
;
}
}
}
src/file-resource/index.ts
0 → 100644
View file @
6fe7fe95
export
*
from
'
./resource-util
'
;
export
*
from
'
./file-resource-service
'
;
src/file-resource/resource-util.ts
0 → 100644
View file @
6fe7fe95
export
function
isObjectRecord
(
value
:
unknown
,
):
value
is
Record
<
string
,
unknown
>
{
return
!!
value
&&
typeof
value
===
'
object
'
&&
!
Array
.
isArray
(
value
);
}
export
function
cloneJson
<
T
>
(
value
:
T
):
T
{
if
(
value
==
null
)
{
return
value
;
}
if
(
typeof
globalThis
.
structuredClone
===
'
function
'
)
{
return
globalThis
.
structuredClone
(
value
);
}
return
JSON
.
parse
(
JSON
.
stringify
(
value
))
as
T
;
}
src/join-handlers/join-roomlist.ts
View file @
6fe7fe95
...
...
@@ -29,7 +29,7 @@ export class JoinRoomlist {
await
this
.
menuManager
.
launchMenu
(
client
,
async
()
=>
{
const
roomNames
=
this
.
roomManager
.
allRooms
()
.
filter
((
room
)
=>
room
.
native
)
.
filter
((
room
)
=>
room
.
native
&&
!
room
.
name
.
includes
(
'
$
'
)
)
.
map
((
room
)
=>
room
.
name
);
const
menu
:
MenuEntry
[]
=
roomNames
.
map
((
roomName
)
=>
({
...
...
src/legacy-api/index.ts
0 → 100644
View file @
6fe7fe95
export
*
from
'
./legacy-api-module
'
;
export
*
from
'
./legacy-api-service
'
;
export
*
from
'
./legacy-api-replay-service
'
;
export
*
from
'
./legacy-api-deck-service
'
;
export
*
from
'
./legacy-api-record.entity
'
;
export
*
from
'
./legacy-ban.entity
'
;
export
*
from
'
./legacy-deck.entity
'
;
export
*
from
'
./legacy-stop-service
'
;
export
*
from
'
./legacy-ban-service
'
;
export
*
from
'
./legacy-welcome-service
'
;
export
*
from
'
./legacy-room-id-service
'
;
src/legacy-api/legacy-api-deck-service.ts
0 → 100644
View file @
6fe7fe95
import
{
Context
}
from
'
../app
'
;
import
{
LegacyDeckEntity
}
from
'
./legacy-deck.entity
'
;
import
{
decodeDeckBase64
,
encodeDeckBase64
}
from
'
../feats/cloud-replay
'
;
import
YGOProDeck
from
'
ygopro-deck-encode
'
;
import
{
LockDeckExpectedDeckCheck
}
from
'
../feats/lock-deck
'
;
import
{
deckNameMatch
}
from
'
./utility/deck-name-match
'
;
import
{
getDeckNameExactCandidates
,
getDeckNameRegexCandidates
,
}
from
'
./utility/deck-name-query
'
;
import
*
as
fs
from
'
node:fs/promises
'
;
import
{
IncomingForm
,
Files
}
from
'
formidable
'
;
import
{
ServerResponse
}
from
'
node:http
'
;
type
DeckApiResult
=
{
file
:
string
;
status
:
string
;
};
type
DeckDashboardBg
=
{
url
:
string
;
desc
:
string
;
};
const
DASHBOARD_STREAM_TIMEOUT_MS
=
10
*
60
*
1000
;
const
DASHBOARD_BG_REFRESH_INTERVAL_MS
=
10
*
60
*
1000
;
type
DeckDashboardStreamConnection
=
{
response
:
ServerResponse
;
timeout
:
ReturnType
<
typeof
setTimeout
>
;
};
export
class
LegacyApiDeckService
{
private
logger
=
this
.
ctx
.
createLogger
(
'
LegacyApiDeckService
'
);
private
streamConnections
=
new
Map
<
string
,
DeckDashboardStreamConnection
>
();
private
backgrounds
:
DeckDashboardBg
[]
=
[{
url
:
''
,
desc
:
''
}];
private
bgRefreshedAt
=
0
;
private
bgLoading
?:
Promise
<
void
>
;
constructor
(
private
ctx
:
Context
)
{
this
.
registerRoutes
();
this
.
registerLockDeckCheck
();
void
this
.
ensureBackgroundsFresh
();
}
private
registerRoutes
()
{
const
router
=
this
.
ctx
.
router
;
router
.
get
(
'
/api/msg
'
,
async
(
koaCtx
)
=>
{
const
ok
=
await
this
.
ctx
.
legacyApiAuth
.
auth
(
String
(
koaCtx
.
query
.
username
||
''
),
String
(
koaCtx
.
query
.
password
||
koaCtx
.
query
.
pass
||
''
),
'
deck_dashboard_read
'
,
'
login_deck_dashboard
'
,
);
if
(
!
ok
)
{
koaCtx
.
status
=
403
;
koaCtx
.
body
=
'
Auth Failed.
'
;
return
;
}
koaCtx
.
state
.
disableJsonp
=
true
;
koaCtx
.
respond
=
false
;
const
response
=
koaCtx
.
res
;
const
connectionIp
=
this
.
getConnectionIp
(
koaCtx
);
response
.
writeHead
(
200
,
{
'
Access-Control-Allow-Origin
'
:
'
*
'
,
'
Content-Type
'
:
'
text/event-stream
'
,
'
Cache-Control
'
:
'
no-cache
'
,
Connection
:
'
keep-alive
'
,
});
this
.
addStreamConnection
(
connectionIp
,
response
);
const
cleanup
=
()
=>
{
this
.
removeStreamConnection
(
connectionIp
,
response
);
};
koaCtx
.
req
.
on
(
'
close
'
,
cleanup
);
koaCtx
.
req
.
on
(
'
aborted
'
,
cleanup
);
response
.
on
(
'
close
'
,
cleanup
);
response
.
on
(
'
error
'
,
cleanup
);
this
.
sendDeckDashboardMessage
(
'
已连接。
'
,
connectionIp
);
});
router
.
get
(
'
/api/get_decks
'
,
async
(
koaCtx
)
=>
{
const
ok
=
await
this
.
ctx
.
legacyApiAuth
.
auth
(
String
(
koaCtx
.
query
.
username
||
''
),
String
(
koaCtx
.
query
.
password
||
''
),
'
deck_dashboard_read
'
,
'
get_decks
'
,
);
if
(
!
ok
)
{
koaCtx
.
status
=
403
;
koaCtx
.
body
=
'
Auth Failed.
'
;
return
;
}
const
repo
=
this
.
getDeckRepo
();
if
(
!
repo
)
{
koaCtx
.
body
=
[];
return
;
}
const
rows
=
await
repo
.
find
({
order
:
{
uploadTime
:
'
DESC
'
,
id
:
'
DESC
'
,
},
});
koaCtx
.
body
=
rows
.
map
((
row
)
=>
{
const
deck
=
decodeDeckBase64
(
row
.
payload
,
row
.
mainc
);
deck
.
name
=
row
.
name
;
return
deck
;
});
});
router
.
get
(
'
/api/get_bg
'
,
async
(
koaCtx
)
=>
{
const
ok
=
await
this
.
ctx
.
legacyApiAuth
.
auth
(
String
(
koaCtx
.
query
.
username
||
''
),
String
(
koaCtx
.
query
.
password
||
koaCtx
.
query
.
pass
||
''
),
'
deck_dashboard_read
'
,
'
login_deck_dashboard
'
,
);
if
(
!
ok
)
{
koaCtx
.
status
=
403
;
koaCtx
.
body
=
'
Auth Failed.
'
;
return
;
}
await
this
.
ensureBackgroundsFresh
();
koaCtx
.
body
=
this
.
pickRandomBackground
();
});
router
.
get
(
'
/api/del_deck
'
,
async
(
koaCtx
)
=>
{
const
ok
=
await
this
.
ctx
.
legacyApiAuth
.
auth
(
String
(
koaCtx
.
query
.
username
||
''
),
String
(
koaCtx
.
query
.
password
||
''
),
'
deck_dashboard_write
'
,
'
delete_deck
'
,
);
if
(
!
ok
)
{
koaCtx
.
status
=
403
;
koaCtx
.
body
=
'
Auth Failed.
'
;
return
;
}
const
repo
=
this
.
getDeckRepo
();
if
(
!
repo
)
{
koaCtx
.
body
=
'
数据库未开启。
'
;
return
;
}
const
deckName
=
String
(
koaCtx
.
query
.
msg
||
''
).
trim
();
try
{
await
repo
.
softDelete
({
name
:
deckName
,
});
const
text
=
`删除卡组
${
deckName
}
成功。`
;
koaCtx
.
body
=
text
;
}
catch
(
e
:
any
)
{
const
text
=
`删除卡组
${
deckName
}
失败:
${
e
.
toString
()}
`
;
koaCtx
.
body
=
text
;
}
});
router
.
get
(
'
/api/clear_decks
'
,
async
(
koaCtx
)
=>
{
const
ok
=
await
this
.
ctx
.
legacyApiAuth
.
auth
(
String
(
koaCtx
.
query
.
username
||
''
),
String
(
koaCtx
.
query
.
password
||
''
),
'
deck_dashboard_write
'
,
'
clear_decks
'
,
);
if
(
!
ok
)
{
koaCtx
.
status
=
403
;
koaCtx
.
body
=
'
Auth Failed.
'
;
return
;
}
const
repo
=
this
.
getDeckRepo
();
if
(
!
repo
)
{
koaCtx
.
body
=
'
数据库未开启。
'
;
return
;
}
try
{
await
repo
.
createQueryBuilder
().
softDelete
().
where
(
'
1 = 1
'
).
execute
();
const
text
=
'
删除全部卡组成功。
'
;
koaCtx
.
body
=
text
;
}
catch
(
e
:
any
)
{
const
text
=
`删除全部卡组失败。
${
e
.
toString
()}
`
;
koaCtx
.
body
=
text
;
}
});
router
.
get
(
'
/api/upload_to_challonge
'
,
async
(
koaCtx
)
=>
{
const
ok
=
await
this
.
ctx
.
legacyApiAuth
.
auth
(
String
(
koaCtx
.
query
.
username
||
''
),
String
(
koaCtx
.
query
.
password
||
''
),
'
deck_dashboard_write
'
,
'
upload_to_challonge
'
,
);
if
(
!
ok
)
{
koaCtx
.
status
=
403
;
koaCtx
.
body
=
'
Auth Failed.
'
;
return
;
}
this
.
sendDeckDashboardMessage
(
'
未开启Challonge模式。
'
);
koaCtx
.
body
=
'
操作完成。
'
;
});
router
.
post
(
'
/api/upload_decks
'
,
async
(
koaCtx
)
=>
{
const
ok
=
await
this
.
ctx
.
legacyApiAuth
.
auth
(
String
(
koaCtx
.
query
.
username
||
''
),
String
(
koaCtx
.
query
.
password
||
''
),
'
deck_dashboard_write
'
,
'
upload_deck
'
,
);
if
(
!
ok
)
{
koaCtx
.
status
=
403
;
koaCtx
.
body
=
'
Auth Failed.
'
;
return
;
}
const
repo
=
this
.
getDeckRepo
();
if
(
!
repo
)
{
koaCtx
.
status
=
500
;
const
result
=
[{
file
:
'
(unknown)
'
,
status
:
'
数据库未开启
'
}];
koaCtx
.
type
=
'
text/plain; charset=utf-8
'
;
koaCtx
.
body
=
JSON
.
stringify
(
result
);
return
;
}
try
{
const
files
=
await
this
.
parseUploadFiles
(
koaCtx
.
req
);
const
result
=
await
this
.
importDeckFiles
(
files
);
koaCtx
.
type
=
'
text/plain; charset=utf-8
'
;
koaCtx
.
body
=
JSON
.
stringify
(
result
);
}
catch
(
e
:
any
)
{
this
.
logger
.
warn
({
err
:
e
},
'
Deck upload failed
'
);
koaCtx
.
status
=
500
;
const
result
=
[{
file
:
'
(unknown)
'
,
status
:
e
.
toString
()
}];
koaCtx
.
type
=
'
text/plain; charset=utf-8
'
;
koaCtx
.
body
=
JSON
.
stringify
(
result
);
}
});
}
private
registerLockDeckCheck
()
{
this
.
ctx
.
middleware
(
LockDeckExpectedDeckCheck
,
async
(
event
,
client
,
next
)
=>
{
const
current
=
await
next
();
if
(
event
.
expectedDeck
!==
undefined
)
{
return
current
;
}
if
(
!
this
.
ctx
.
config
.
getBoolean
(
'
TOURNAMENT_MODE_CHECK_DECK
'
))
{
return
current
;
}
const
expectedDeck
=
await
this
.
findExpectedDeckByName
(
event
.
client
.
name
,
);
event
.
use
(
expectedDeck
);
return
current
;
},
);
}
private
async
findExpectedDeckByName
(
playerName
:
string
)
{
const
repo
=
this
.
getDeckRepo
();
if
(
!
repo
)
{
return
undefined
;
}
const
anyDeckRow
=
await
repo
.
createQueryBuilder
(
'
deck
'
)
.
select
(
'
deck.id
'
,
'
id
'
)
.
limit
(
1
)
.
getRawOne
<
{
id
:
string
}
>
();
if
(
!
anyDeckRow
)
{
return
undefined
;
}
const
[
exact0
,
exact1
,
exact2
]
=
getDeckNameExactCandidates
(
playerName
);
const
{
firstPlayerRegex
,
secondPlayerRegex
}
=
getDeckNameRegexCandidates
(
playerName
);
const
rows
=
await
repo
.
createQueryBuilder
(
'
deck
'
)
.
where
(
`(
deck.name = :exact0 OR
deck.name = :exact1 OR
deck.name = :exact2 OR
deck.name ~ :firstPlayerRegex OR
deck.name ~ :secondPlayerRegex
)`
,
{
exact0
,
exact1
,
exact2
,
firstPlayerRegex
,
secondPlayerRegex
,
},
)
.
orderBy
(
'
deck.uploadTime
'
,
'
DESC
'
)
.
addOrderBy
(
'
deck.id
'
,
'
DESC
'
)
.
limit
(
32
)
.
getMany
();
const
matched
=
rows
.
find
((
row
)
=>
deckNameMatch
(
row
.
name
,
playerName
));
if
(
!
matched
)
{
return
null
;
}
const
deck
=
decodeDeckBase64
(
matched
.
payload
,
matched
.
mainc
);
deck
.
name
=
matched
.
name
;
return
deck
;
}
private
getDeckRepo
()
{
const
database
=
this
.
ctx
.
database
;
if
(
!
database
)
{
return
undefined
;
}
return
database
.
getRepository
(
LegacyDeckEntity
);
}
private
parseUploadFiles
(
req
:
any
)
{
return
new
Promise
<
Files
>
((
resolve
,
reject
)
=>
{
const
form
=
new
IncomingForm
();
form
.
parse
(
req
,
(
err
,
fields
,
files
)
=>
{
if
(
err
)
{
reject
(
err
);
return
;
}
resolve
(
files
);
});
});
}
private
async
importDeckFiles
(
files
:
Files
)
{
const
fileList
=
Object
.
values
(
files
).
flatMap
((
v
:
any
)
=>
Array
.
isArray
(
v
)
?
v
:
[
v
],
);
const
result
:
DeckApiResult
[]
=
[];
for
(
const
item
of
fileList
)
{
const
filename
=
String
(
item
?.
originalFilename
||
''
).
trim
();
const
filepath
=
String
(
item
?.
filepath
||
''
).
trim
();
if
(
!
filename
||
!
filepath
)
{
result
.
push
({
file
:
filename
||
'
(unknown)
'
,
status
:
'
上传文件信息缺失
'
,
});
continue
;
}
if
(
!
filename
.
endsWith
(
'
.ydk
'
))
{
result
.
push
({
file
:
filename
,
status
:
'
不是卡组文件
'
,
});
continue
;
}
try
{
const
text
=
await
fs
.
readFile
(
filepath
,
{
encoding
:
'
utf-8
'
,
});
const
deck
=
YGOProDeck
.
fromYdkString
(
text
);
if
((
deck
.
main
||
[]).
length
<
40
)
{
result
.
push
({
file
:
filename
,
status
:
'
卡组不合格
'
,
});
continue
;
}
await
this
.
saveDeck
(
filename
,
deck
);
result
.
push
({
file
:
filename
,
status
:
'
OK
'
,
});
}
catch
(
e
:
any
)
{
result
.
push
({
file
:
filename
,
status
:
e
.
toString
(),
});
}
}
return
result
;
}
private
async
saveDeck
(
name
:
string
,
deck
:
YGOProDeck
)
{
const
repo
=
this
.
getDeckRepo
();
if
(
!
repo
)
{
return
;
}
const
existing
=
await
repo
.
findOne
({
where
:
{
name
,
},
withDeleted
:
true
,
order
:
{
id
:
'
DESC
'
,
},
});
if
(
existing
)
{
existing
.
payload
=
encodeDeckBase64
(
deck
);
existing
.
mainc
=
deck
.
main
.
length
;
existing
.
uploadTime
=
new
Date
();
await
repo
.
save
(
existing
);
if
(
existing
.
deleteTime
)
{
await
repo
.
recover
(
existing
);
}
return
;
}
const
row
=
repo
.
create
({
name
,
payload
:
encodeDeckBase64
(
deck
),
mainc
:
deck
.
main
.
length
,
uploadTime
:
new
Date
(),
});
await
repo
.
save
(
row
);
}
private
addStreamConnection
(
ip
:
string
,
response
:
ServerResponse
)
{
this
.
closeStreamConnection
(
ip
,
'
replaced_by_same_ip
'
);
const
timeout
=
setTimeout
(()
=>
{
this
.
closeStreamConnection
(
ip
,
'
timeout
'
,
response
);
},
DASHBOARD_STREAM_TIMEOUT_MS
);
this
.
streamConnections
.
set
(
ip
,
{
response
,
timeout
,
});
}
private
removeStreamConnection
(
ip
:
string
,
expectedResponse
?:
ServerResponse
)
{
const
connection
=
this
.
streamConnections
.
get
(
ip
);
if
(
!
connection
)
{
return
;
}
if
(
expectedResponse
&&
connection
.
response
!==
expectedResponse
)
{
return
;
}
clearTimeout
(
connection
.
timeout
);
this
.
streamConnections
.
delete
(
ip
);
}
private
closeStreamConnection
(
ip
:
string
,
reason
:
string
,
expectedResponse
?:
ServerResponse
,
)
{
const
connection
=
this
.
streamConnections
.
get
(
ip
);
if
(
!
connection
)
{
return
;
}
if
(
expectedResponse
&&
connection
.
response
!==
expectedResponse
)
{
return
;
}
this
.
removeStreamConnection
(
ip
,
expectedResponse
);
try
{
connection
.
response
.
end
();
}
catch
(
error
:
any
)
{
this
.
logger
.
debug
(
{
err
:
error
,
ip
,
reason
},
'
Failed to close deck dashboard stream
'
,
);
}
}
private
resetStreamTimeout
(
ip
:
string
,
expectedResponse
?:
ServerResponse
)
{
const
connection
=
this
.
streamConnections
.
get
(
ip
);
if
(
!
connection
)
{
return
;
}
if
(
expectedResponse
&&
connection
.
response
!==
expectedResponse
)
{
return
;
}
clearTimeout
(
connection
.
timeout
);
connection
.
timeout
=
setTimeout
(()
=>
{
this
.
closeStreamConnection
(
ip
,
'
timeout
'
,
connection
.
response
);
},
DASHBOARD_STREAM_TIMEOUT_MS
);
}
private
sendDeckDashboardMessage
(
text
:
string
,
targetIp
?:
string
)
{
const
payload
=
String
(
text
||
''
).
replace
(
/
\n
/g
,
'
<br>
'
);
const
message
=
`data:
${
payload
}
\n\n`
;
for
(
const
[
ip
,
connection
]
of
this
.
streamConnections
.
entries
())
{
if
(
targetIp
!=
null
&&
ip
!==
targetIp
)
{
continue
;
}
try
{
connection
.
response
.
write
(
message
);
this
.
resetStreamTimeout
(
ip
,
connection
.
response
);
}
catch
(
error
:
any
)
{
this
.
logger
.
debug
(
{
err
:
error
,
ip
},
'
Failed to write deck dashboard stream message
'
,
);
this
.
removeStreamConnection
(
ip
,
connection
.
response
);
}
}
}
private
getConnectionIp
(
koaCtx
:
any
)
{
const
candidates
=
[
koaCtx
?.
state
?.
realIp
,
koaCtx
?.
ip
,
koaCtx
?.
request
?.
ip
,
koaCtx
?.
req
?.
socket
?.
remoteAddress
,
];
for
(
const
value
of
candidates
)
{
const
text
=
String
(
value
||
''
).
trim
();
if
(
text
)
{
return
text
;
}
}
return
'
(unknown)
'
;
}
private
pickRandomBackground
()
{
if
(
!
this
.
backgrounds
.
length
)
{
return
{
url
:
''
,
desc
:
''
};
}
const
index
=
Math
.
floor
(
Math
.
random
()
*
this
.
backgrounds
.
length
);
return
this
.
backgrounds
[
index
];
}
private
async
ensureBackgroundsFresh
()
{
const
now
=
Date
.
now
();
if
(
this
.
backgrounds
.
length
>
1
&&
now
-
this
.
bgRefreshedAt
<
DASHBOARD_BG_REFRESH_INTERVAL_MS
)
{
return
;
}
if
(
this
.
bgLoading
)
{
return
this
.
bgLoading
;
}
this
.
bgLoading
=
this
.
refreshBackgrounds
().
finally
(()
=>
{
this
.
bgLoading
=
undefined
;
});
return
this
.
bgLoading
;
}
private
async
refreshBackgrounds
()
{
try
{
const
response
=
await
this
.
ctx
.
http
.
get
(
'
http://www.bing.com/HPImageArchive.aspx
'
,
{
params
:
{
format
:
'
js
'
,
idx
:
0
,
n
:
8
,
mkt
:
'
zh-CN
'
,
},
timeout
:
10000
,
},
);
const
body
=
response
.
data
as
any
;
const
images
=
Array
.
isArray
(
body
?.
images
)
?
body
.
images
:
[];
if
(
!
images
.
length
)
{
this
.
logger
.
warn
({
body
},
'
Deck dashboard background API returned no images
'
);
return
;
}
const
next
=
images
.
map
((
image
:
any
)
=>
{
const
urlbase
=
String
(
image
?.
urlbase
||
''
);
if
(
!
urlbase
)
{
return
undefined
;
}
return
{
url
:
`http://s.cn.bing.net
${
urlbase
}
_768x1366.jpg`
,
desc
:
String
(
image
?.
copyright
||
''
),
};
})
.
filter
((
item
):
item
is
DeckDashboardBg
=>
!!
item
);
if
(
!
next
.
length
)
{
this
.
logger
.
warn
(
'
Deck dashboard background API parse produced no valid result
'
);
return
;
}
this
.
backgrounds
=
next
;
this
.
bgRefreshedAt
=
Date
.
now
();
}
catch
(
error
:
any
)
{
this
.
logger
.
warn
(
{
err
:
error
},
'
Failed to refresh deck dashboard backgrounds
'
,
);
}
}
}
src/legacy-api/legacy-api-module.ts
0 → 100644
View file @
6fe7fe95
import
{
createAppContext
}
from
'
nfkit
'
;
import
{
LegacyApiService
}
from
'
./legacy-api-service
'
;
import
{
LegacyApiReplayService
}
from
'
./legacy-api-replay-service
'
;
import
{
LegacyApiDeckService
}
from
'
./legacy-api-deck-service
'
;
import
{
LegacyStopService
}
from
'
./legacy-stop-service
'
;
import
{
LegacyBanService
}
from
'
./legacy-ban-service
'
;
import
{
LegacyWelcomeService
}
from
'
./legacy-welcome-service
'
;
import
{
LegacyRoomIdService
}
from
'
./legacy-room-id-service
'
;
export
const
LegacyApiModule
=
createAppContext
()
.
provide
(
LegacyRoomIdService
)
.
provide
(
LegacyStopService
)
.
provide
(
LegacyBanService
)
.
provide
(
LegacyWelcomeService
)
.
provide
(
LegacyApiService
)
.
provide
(
LegacyApiReplayService
)
.
provide
(
LegacyApiDeckService
)
.
define
();
src/legacy-api/legacy-api-record.entity.ts
0 → 100644
View file @
6fe7fe95
import
{
BaseTimeEntity
,
BigintTransformer
}
from
'
../utility
'
;
import
{
Column
,
Entity
,
Generated
,
Index
,
PrimaryColumn
}
from
'
typeorm
'
;
@
Entity
(
'
legacy_api_record
'
)
export
class
LegacyApiRecordEntity
extends
BaseTimeEntity
{
@
PrimaryColumn
({
type
:
'
bigint
'
,
unsigned
:
true
,
transformer
:
new
BigintTransformer
(),
})
@
Generated
(
'
increment
'
)
id
!
:
number
;
@
Index
({
unique
:
true
})
@
Column
({
type
:
'
varchar
'
,
length
:
64
,
})
key
!
:
string
;
@
Column
({
type
:
'
text
'
,
nullable
:
true
,
})
value
!
:
string
|
null
;
}
src/legacy-api/legacy-api-replay-service.ts
0 → 100644
View file @
6fe7fe95
import
JSZip
from
'
jszip
'
;
import
{
Context
}
from
'
../app
'
;
import
{
DuelRecordEntity
,
DuelRecordPlayer
}
from
'
../feats/cloud-replay
'
;
import
{
LegacyRoomIdService
}
from
'
./legacy-room-id-service
'
;
import
{
CloudReplayService
}
from
'
../feats
'
;
type
DuelLogQuery
=
{
roomName
?:
string
;
duelCount
?:
number
;
playerName
?:
string
;
playerScore
?:
number
;
};
export
class
LegacyApiReplayService
{
private
logger
=
this
.
ctx
.
createLogger
(
'
LegacyApiReplayService
'
);
private
roomIdService
=
this
.
ctx
.
get
(()
=>
LegacyRoomIdService
);
private
cloudReplayService
=
this
.
ctx
.
get
(()
=>
CloudReplayService
);
constructor
(
private
ctx
:
Context
)
{
this
.
registerRoutes
();
}
private
registerRoutes
()
{
const
router
=
this
.
ctx
.
router
;
router
.
get
(
'
/api/duellog
'
,
async
(
koaCtx
)
=>
{
const
ok
=
await
this
.
ctx
.
legacyApiAuth
.
auth
(
String
(
koaCtx
.
query
.
username
||
''
),
String
(
koaCtx
.
query
.
pass
||
koaCtx
.
query
.
password
||
''
),
'
duel_log
'
,
'
duel_log
'
,
);
if
(
!
ok
)
{
koaCtx
.
body
=
[{
name
:
'
密码错误
'
}];
return
;
}
const
repo
=
this
.
getReplayRepo
();
if
(
!
repo
)
{
koaCtx
.
body
=
[];
return
;
}
const
query
=
this
.
parseQuery
(
koaCtx
.
query
as
Record
<
string
,
unknown
>
);
const
replays
=
await
this
.
buildReplayQuery
(
query
).
getMany
();
koaCtx
.
body
=
replays
.
map
((
replay
)
=>
this
.
toDuelLogViewJson
(
replay
));
});
router
.
get
(
'
/api/archive.zip
'
,
async
(
koaCtx
)
=>
{
const
ok
=
await
this
.
ctx
.
legacyApiAuth
.
auth
(
String
(
koaCtx
.
query
.
username
||
''
),
String
(
koaCtx
.
query
.
pass
||
koaCtx
.
query
.
password
||
''
),
'
download_replay
'
,
'
download_replay_archive
'
,
);
if
(
!
ok
)
{
koaCtx
.
status
=
403
;
koaCtx
.
body
=
'
Invalid password.
'
;
return
;
}
const
repo
=
this
.
getReplayRepo
();
if
(
!
repo
)
{
koaCtx
.
status
=
404
;
koaCtx
.
body
=
'
Replay not found.
'
;
return
;
}
const
query
=
this
.
parseQuery
(
koaCtx
.
query
as
Record
<
string
,
unknown
>
);
const
replays
=
await
this
.
buildReplayQuery
(
query
).
getMany
();
if
(
!
replays
.
length
)
{
koaCtx
.
status
=
404
;
koaCtx
.
body
=
'
Replay not found.
'
;
return
;
}
const
zip
=
new
JSZip
();
for
(
const
replay
of
replays
)
{
const
payload
=
this
.
cloudReplayService
.
buildReplayYrpPayload
(
replay
);
zip
.
file
(
`
${
replay
.
id
}
.yrp`
,
payload
);
}
koaCtx
.
state
.
disableJsonp
=
true
;
koaCtx
.
set
(
'
Content-Type
'
,
'
application/octet-stream
'
);
koaCtx
.
set
(
'
Content-Disposition
'
,
'
attachment; filename="archive.zip"
'
);
koaCtx
.
body
=
zip
.
generateNodeStream
({
compression
:
'
DEFLATE
'
,
compressionOptions
:
{
level
:
9
},
});
});
router
.
get
(
'
/api/clearlog
'
,
async
(
koaCtx
)
=>
{
const
ok
=
await
this
.
ctx
.
legacyApiAuth
.
auth
(
String
(
koaCtx
.
query
.
username
||
''
),
String
(
koaCtx
.
query
.
pass
||
koaCtx
.
query
.
password
||
''
),
'
clear_duel_log
'
,
'
clear_duel_log
'
,
);
if
(
!
ok
)
{
koaCtx
.
body
=
[{
name
:
'
密码错误
'
}];
return
;
}
const
repo
=
this
.
getReplayRepo
();
if
(
!
repo
)
{
koaCtx
.
body
=
[{
name
:
'
Success
'
}];
return
;
}
const
query
=
this
.
parseQuery
(
koaCtx
.
query
as
Record
<
string
,
unknown
>
);
const
ids
=
(
await
this
.
buildReplayQuery
(
query
)
.
select
(
'
replay.id
'
,
'
id
'
)
.
getRawMany
<
{
id
:
string
}
>
()
)
.
map
((
row
)
=>
Number
(
row
.
id
))
.
filter
((
id
)
=>
Number
.
isFinite
(
id
));
if
(
!
ids
.
length
)
{
koaCtx
.
body
=
[{
name
:
'
Success
'
}];
return
;
}
await
repo
.
softDelete
(
ids
);
koaCtx
.
body
=
[{
name
:
'
Success
'
}];
});
router
.
get
(
'
/api/replay/:filename
'
,
async
(
koaCtx
)
=>
{
const
ok
=
await
this
.
ctx
.
legacyApiAuth
.
auth
(
String
(
koaCtx
.
query
.
username
||
''
),
String
(
koaCtx
.
query
.
pass
||
koaCtx
.
query
.
password
||
''
),
'
download_replay
'
,
'
download_replay
'
,
);
if
(
!
ok
)
{
koaCtx
.
status
=
403
;
koaCtx
.
body
=
'
密码错误
'
;
return
;
}
const
filename
=
String
(
koaCtx
.
params
.
filename
||
''
);
const
matched
=
filename
.
match
(
/^
(\d
+
)\.
yrp$/
);
if
(
!
matched
)
{
koaCtx
.
status
=
404
;
koaCtx
.
body
=
`未找到文件
${
filename
}
`
;
return
;
}
const
replayId
=
Number
(
matched
[
1
]);
const
payload
=
await
this
.
cloudReplayService
.
getReplayYrpPayloadById
(
replayId
);
if
(
!
payload
)
{
koaCtx
.
status
=
404
;
koaCtx
.
body
=
`未找到文件
${
filename
}
`
;
return
;
}
koaCtx
.
state
.
disableJsonp
=
true
;
koaCtx
.
set
(
'
Content-Type
'
,
'
application/octet-stream
'
);
koaCtx
.
set
(
'
Content-Disposition
'
,
`attachment; filename="
${
replayId
}
.yrp"`
,
);
koaCtx
.
body
=
Buffer
.
from
(
payload
);
});
}
private
parseQuery
(
query
:
Record
<
string
,
unknown
>
):
DuelLogQuery
{
const
roomName
=
String
(
query
.
roomname
||
''
).
trim
();
const
playerName
=
String
(
query
.
playername
||
''
).
trim
();
const
duelCount
=
this
.
parseOptionalNumber
(
query
.
duelcount
);
const
playerScore
=
this
.
parseOptionalNumber
(
query
.
score
);
return
{
roomName
:
roomName
||
undefined
,
duelCount
,
playerName
:
playerName
||
undefined
,
playerScore
,
};
}
private
parseOptionalNumber
(
value
:
unknown
)
{
const
text
=
String
(
value
??
''
).
trim
();
if
(
!
text
.
length
)
{
return
undefined
;
}
const
parsed
=
Number
(
text
);
if
(
!
Number
.
isFinite
(
parsed
))
{
return
undefined
;
}
return
parsed
;
}
private
toDuelLogViewJson
(
replay
:
DuelRecordEntity
)
{
const
mode
=
replay
.
hostInfo
?.
mode
||
0
;
const
players
=
[...
replay
.
players
].
sort
((
a
,
b
)
=>
a
.
pos
-
b
.
pos
);
return
{
id
:
replay
.
id
,
time
:
this
.
formatDate
(
replay
.
endTime
),
originalName
:
replay
.
name
,
name
:
`
${
replay
.
name
}
(Duel:
${
replay
.
duelCount
}
)`
,
roomid
:
this
.
roomIdService
.
getRoomIdString
(
replay
.
roomIdentifier
),
cloud_replay_id
:
`R#
${
replay
.
id
}
`
,
replay_filename
:
`
${
replay
.
id
}
.yrp`
,
roommode
:
mode
,
players
:
players
.
map
((
player
)
=>
({
pos
:
player
.
pos
,
is_first
:
player
.
isFirst
,
originalName
:
player
.
name
,
name
:
player
.
name
+
` (Score:
${
player
.
score
}
)`
,
winner
:
player
.
winner
,
score
:
player
.
score
,
})),
};
}
private
formatDate
(
date
:
Date
)
{
const
normalized
=
new
Date
(
date
);
const
year
=
normalized
.
getFullYear
();
const
month
=
`
${
normalized
.
getMonth
()
+
1
}
`
.
padStart
(
2
,
'
0
'
);
const
day
=
`
${
normalized
.
getDate
()}
`
.
padStart
(
2
,
'
0
'
);
const
hour
=
`
${
normalized
.
getHours
()}
`
.
padStart
(
2
,
'
0
'
);
const
minute
=
`
${
normalized
.
getMinutes
()}
`
.
padStart
(
2
,
'
0
'
);
const
second
=
`
${
normalized
.
getSeconds
()}
`
.
padStart
(
2
,
'
0
'
);
return
`
${
year
}
-
${
month
}
-
${
day
}
${
hour
}
:
${
minute
}
:
${
second
}
`
;
}
private
getReplayRepo
()
{
const
database
=
this
.
ctx
.
database
;
if
(
!
database
)
{
return
undefined
;
}
return
database
.
getRepository
(
DuelRecordEntity
);
}
private
buildReplayQuery
(
query
:
DuelLogQuery
)
{
const
repo
=
this
.
getReplayRepo
();
if
(
!
repo
)
{
throw
new
Error
(
'
Database disabled
'
);
}
const
qb
=
repo
.
createQueryBuilder
(
'
replay
'
)
.
leftJoinAndSelect
(
'
replay.players
'
,
'
player
'
);
if
(
query
.
roomName
)
{
qb
.
andWhere
(
`replay.name LIKE :roomName || '%'`
,
{
roomName
:
query
.
roomName
,
});
}
if
(
query
.
duelCount
!=
null
&&
!
Number
.
isNaN
(
query
.
duelCount
))
{
qb
.
andWhere
(
'
replay.duelCount = :duelCount
'
,
{
duelCount
:
query
.
duelCount
,
});
}
if
(
query
.
playerName
||
query
.
playerScore
!=
null
)
{
const
subQb
=
qb
.
subQuery
()
.
select
(
'
splayer.id
'
)
.
from
(
DuelRecordPlayer
,
'
splayer
'
)
.
where
(
'
splayer.duelRecordId = replay.id
'
);
if
(
query
.
playerName
)
{
subQb
.
andWhere
(
`splayer.realName LIKE :playerName || '%'`
);
}
if
(
query
.
playerScore
!=
null
&&
!
Number
.
isNaN
(
query
.
playerScore
))
{
subQb
.
andWhere
(
'
splayer.score = :playerScore
'
);
}
const
params
:
Record
<
string
,
unknown
>
=
{};
if
(
query
.
playerName
)
{
params
.
playerName
=
query
.
playerName
;
}
if
(
query
.
playerScore
!=
null
&&
!
Number
.
isNaN
(
query
.
playerScore
))
{
params
.
playerScore
=
query
.
playerScore
;
}
qb
.
andWhere
(
`exists
${
subQb
.
getQuery
()}
`
,
params
);
}
qb
.
orderBy
(
'
replay.id
'
,
'
DESC
'
);
return
qb
;
}
}
src/legacy-api/legacy-api-service.ts
0 → 100644
View file @
6fe7fe95
import
{
ChatColor
}
from
'
ygopro-msg-encode
'
;
import
{
Context
}
from
'
../app
'
;
import
{
DialoguesProvider
,
TipsProvider
}
from
'
../feats/resource
'
;
import
{
DuelStage
,
RoomManager
}
from
'
../room
'
;
import
{
LegacyRoomIdService
}
from
'
./legacy-room-id-service
'
;
type
ApiMessageHandler
=
{
permission
:
string
;
callback
:
(
value
:
string
,
query
:
Record
<
string
,
string
>
,
)
=>
Promise
<
unknown
[]
>
;
};
const
API_MESSAGE_META_FIELDS
=
new
Set
([
'
username
'
,
'
pass
'
,
'
password
'
,
'
callback
'
,
]);
export
class
LegacyApiService
{
private
logger
=
this
.
ctx
.
createLogger
(
'
LegacyApiService
'
);
private
roomIdService
=
this
.
ctx
.
get
(()
=>
LegacyRoomIdService
);
private
handlers
=
new
Map
<
string
,
ApiMessageHandler
>
();
constructor
(
private
ctx
:
Context
)
{
this
.
registerDefaultHandlers
();
this
.
registerRoutes
();
}
addApiMessageHandler
(
name
:
string
,
permission
:
string
,
callback
:
ApiMessageHandler
[
'
callback
'
],
)
{
this
.
handlers
.
set
(
name
,
{
permission
,
callback
,
});
return
this
;
}
private
registerDefaultHandlers
()
{
this
.
addApiMessageHandler
(
'
shout
'
,
'
shout
'
,
async
(
value
)
=>
{
const
text
=
String
(
value
||
''
);
const
roomManager
=
this
.
ctx
.
get
(()
=>
RoomManager
);
for
(
const
room
of
roomManager
.
allRooms
())
{
await
room
.
sendChat
(
text
,
ChatColor
.
YELLOW
);
}
return
[
'
shout ok
'
,
text
];
});
this
.
addApiMessageHandler
(
'
loadtips
'
,
'
change_settings
'
,
async
()
=>
{
const
success
=
await
this
.
ctx
.
get
(()
=>
TipsProvider
).
refreshResources
();
return
[
success
?
'
tip ok
'
:
'
tip fail
'
,
this
.
ctx
.
config
.
getString
(
'
TIPS_GET
'
),
];
});
this
.
addApiMessageHandler
(
'
loaddialogues
'
,
'
change_settings
'
,
async
()
=>
{
const
provider
=
this
.
ctx
.
get
(()
=>
DialoguesProvider
);
const
success
=
await
provider
.
refreshResources
();
return
[
success
?
'
dialogue ok
'
:
'
dialogue fail
'
,
this
.
ctx
.
config
.
getString
(
'
DIALOGUES_GET
'
),
];
});
this
.
addApiMessageHandler
(
'
kick
'
,
'
kick_user
'
,
async
(
value
)
=>
{
const
found
=
await
this
.
kickByTarget
(
value
);
if
(
!
found
)
{
return
[
'
room not found
'
,
value
];
}
return
[
'
kick ok
'
,
value
];
});
this
.
addApiMessageHandler
(
'
reboot
'
,
'
stop
'
,
async
(
value
)
=>
{
await
this
.
kickByTarget
(
'
all
'
);
setTimeout
(()
=>
process
.
exit
(
0
),
100
);
return
[
'
reboot ok
'
,
value
];
});
}
private
registerRoutes
()
{
const
router
=
this
.
ctx
.
router
;
router
.
get
(
'
/api/getrooms
'
,
async
(
koaCtx
)
=>
{
const
username
=
String
(
koaCtx
.
query
.
username
||
''
);
const
pass
=
String
(
koaCtx
.
query
.
pass
||
koaCtx
.
query
.
password
||
''
);
const
passValidated
=
await
this
.
ctx
.
legacyApiAuth
.
auth
(
username
,
pass
,
'
get_rooms
'
,
'
get_rooms
'
,
);
if
(
!
passValidated
)
{
koaCtx
.
body
=
{
rooms
:
[
{
roomid
:
'
0
'
,
roomname
:
'
密码错误
'
,
needpass
:
'
true
'
,
},
],
};
return
;
}
const
roomManager
=
this
.
ctx
.
get
(()
=>
RoomManager
);
const
rooms
=
roomManager
.
allRooms
();
const
roomInfos
=
await
Promise
.
all
(
rooms
.
map
(
async
(
room
)
=>
{
const
info
=
await
room
.
getInfo
();
const
users
=
[...
info
.
players
]
.
sort
((
a
,
b
)
=>
a
.
pos
-
b
.
pos
)
.
map
((
player
)
=>
({
id
:
'
-1
'
,
name
:
player
.
name
,
ip
:
player
.
ip
||
null
,
status
:
info
.
duelStage
!==
DuelStage
.
Begin
?
{
score
:
player
.
score
??
0
,
lp
:
player
.
lp
??
info
.
hostinfo
.
start_lp
,
cards
:
player
.
cardCount
??
info
.
hostinfo
.
start_hand
,
}
:
null
,
pos
:
player
.
pos
,
}));
return
{
roomid
:
this
.
roomIdService
.
getRoomIdString
(
info
.
identifier
),
roomname
:
info
.
name
,
roommode
:
info
.
hostinfo
.
mode
,
needpass
:
(
info
.
name
.
includes
(
'
$
'
)
?
true
:
false
).
toString
(),
users
,
istart
:
this
.
buildRoomIstart
(
info
),
};
}),
);
koaCtx
.
body
=
{
rooms
:
roomInfos
,
};
});
router
.
get
(
'
/api/message
'
,
async
(
koaCtx
)
=>
{
const
rawQuery
=
koaCtx
.
query
as
Record
<
string
,
string
|
string
[]
|
undefined
>
;
const
query
:
Record
<
string
,
string
>
=
{};
for
(
const
[
key
,
value
]
of
Object
.
entries
(
rawQuery
))
{
query
[
key
]
=
Array
.
isArray
(
value
)
?
String
(
value
[
0
]
||
''
)
:
String
(
value
||
''
);
}
const
username
=
String
(
query
.
username
||
''
);
const
pass
=
String
(
query
.
pass
||
query
.
password
||
''
);
const
matchedName
=
Object
.
keys
(
query
).
find
(
(
key
)
=>
!
API_MESSAGE_META_FIELDS
.
has
(
key
)
&&
this
.
handlers
.
has
(
key
),
);
if
(
!
matchedName
)
{
koaCtx
.
status
=
400
;
koaCtx
.
body
=
'
400
'
;
return
;
}
const
handler
=
this
.
handlers
.
get
(
matchedName
)
!
;
const
value
=
String
(
query
[
matchedName
]
||
''
);
const
passValidated
=
await
this
.
ctx
.
legacyApiAuth
.
auth
(
username
,
pass
,
handler
.
permission
,
matchedName
,
);
if
(
!
passValidated
)
{
koaCtx
.
body
=
[
'
密码错误
'
,
0
];
return
;
}
koaCtx
.
body
=
await
handler
.
callback
(
value
,
query
);
});
}
private
async
kickByTarget
(
target
:
string
)
{
const
value
=
(
target
||
''
).
trim
();
if
(
!
value
)
{
return
false
;
}
const
roomManager
=
this
.
ctx
.
get
(()
=>
RoomManager
);
const
foundRooms
=
value
===
'
all
'
?
roomManager
.
allRooms
()
:
this
.
findRoomByTarget
(
value
);
if
(
!
foundRooms
.
length
)
{
return
false
;
}
await
Promise
.
all
(
foundRooms
.
map
((
room
)
=>
room
.
finalize
(
true
)));
return
true
;
}
findRoomByTarget
(
target
:
string
)
{
const
roomManager
=
this
.
ctx
.
get
(()
=>
RoomManager
);
const
roomByName
=
roomManager
.
findByName
(
target
);
if
(
roomByName
)
{
return
[
roomByName
];
}
const
roomName
=
this
.
roomIdService
.
findRoomNameByRoomId
(
target
);
if
(
!
roomName
)
{
return
[];
}
const
roomById
=
roomManager
.
findByName
(
roomName
);
return
roomById
?
[
roomById
]
:
[];
}
private
buildRoomIstart
(
info
:
{
duelStage
:
DuelStage
;
duels
:
unknown
[];
turnCount
?:
number
;
})
{
if
(
info
.
duelStage
===
DuelStage
.
Begin
)
{
return
'
wait
'
;
}
const
duelText
=
`Duel:
${
info
.
duels
.
length
}
`
;
if
(
info
.
duelStage
===
DuelStage
.
Siding
)
{
return
`
${
duelText
}
Siding`
;
}
if
(
info
.
duelStage
===
DuelStage
.
Finger
)
{
return
`
${
duelText
}
Finger`
;
}
if
(
info
.
duelStage
===
DuelStage
.
FirstGo
)
{
return
`
${
duelText
}
FirstGo`
;
}
if
(
info
.
duelStage
===
DuelStage
.
Dueling
)
{
const
turn
=
Number
.
isFinite
(
info
.
turnCount
)
?
Number
(
info
.
turnCount
)
:
0
;
return
`
${
duelText
}
Turn:
${
turn
}
`
;
}
return
'
start
'
;
}
}
src/legacy-api/legacy-ban-service.ts
0 → 100644
View file @
6fe7fe95
import
{
ChatColor
,
YGOProCtosJoinGame
}
from
'
ygopro-msg-encode
'
;
import
{
Context
}
from
'
../app
'
;
import
{
LegacyBanEntity
}
from
'
./legacy-ban.entity
'
;
import
{
RoomManager
}
from
'
../room
'
;
import
{
LegacyApiService
}
from
'
./legacy-api-service
'
;
export
class
LegacyBanService
{
private
logger
=
this
.
ctx
.
createLogger
(
'
LegacyBanService
'
);
constructor
(
private
ctx
:
Context
)
{
this
.
ctx
.
middleware
(
YGOProCtosJoinGame
,
async
(
msg
,
client
,
next
)
=>
{
if
(
client
.
isLocal
||
client
.
isInternal
)
{
return
next
();
}
const
nameBan
=
client
.
name
?
await
this
.
findBanRecord
({
name
:
client
.
name
})
:
null
;
if
(
nameBan
)
{
this
.
logger
.
info
(
{
name
:
client
.
name
,
ip
:
client
.
ip
},
'
Blocked banned user from joining
'
,
);
return
client
.
die
(
'
#{banned_user_login}
'
,
ChatColor
.
RED
);
}
const
ipBan
=
client
.
ip
?
await
this
.
findBanRecord
({
ip
:
client
.
ip
})
:
null
;
if
(
ipBan
)
{
this
.
logger
.
info
(
{
name
:
client
.
name
,
ip
:
client
.
ip
},
'
Blocked banned IP from joining
'
,
);
return
client
.
die
(
'
#{banned_ip_login}
'
,
ChatColor
.
RED
);
}
return
next
();
});
this
.
ctx
.
get
(()
=>
LegacyApiService
)
.
addApiMessageHandler
(
'
ban
'
,
'
ban_user
'
,
async
(
value
)
=>
{
const
result
=
await
this
.
banUser
(
value
);
return
[
result
?
'
ban ok
'
:
'
ban fail
'
,
value
];
});
}
async
banUser
(
name
:
string
)
{
const
targetName
=
(
name
||
''
).
trim
();
if
(
!
targetName
)
{
return
false
;
}
await
this
.
addBanRecord
(
targetName
,
null
);
const
pendingIps
=
new
Set
<
string
>
();
const
roomManager
=
this
.
ctx
.
get
(()
=>
RoomManager
);
const
rooms
=
roomManager
.
allRooms
();
for
(
const
room
of
rooms
)
{
const
players
=
room
.
allPlayers
;
for
(
const
player
of
players
)
{
if
(
!
player
)
{
continue
;
}
const
hitByName
=
player
.
name
===
targetName
;
const
hitByIp
=
!!
(
player
.
ip
&&
pendingIps
.
has
(
player
.
ip
));
if
(
!
hitByName
&&
!
hitByIp
)
{
continue
;
}
if
(
player
.
ip
)
{
pendingIps
.
add
(
player
.
ip
);
await
this
.
addBanRecord
(
targetName
,
player
.
ip
);
}
await
room
.
sendChat
(
`
${
player
.
name
}
#{kicked_by_system}`
,
ChatColor
.
RED
,
);
await
room
.
kick
(
player
);
}
}
this
.
logger
.
info
({
name
:
targetName
},
'
Legacy ban applied
'
);
return
true
;
}
private
async
findBanRecord
(
criteria
:
{
name
?:
string
;
ip
?:
string
})
{
const
database
=
this
.
ctx
.
database
;
if
(
!
database
)
{
return
null
;
}
const
repo
=
database
.
getRepository
(
LegacyBanEntity
);
return
repo
.
findOne
({
where
:
criteria
,
});
}
private
async
addBanRecord
(
name
:
string
|
null
,
ip
:
string
|
null
)
{
const
database
=
this
.
ctx
.
database
;
if
(
!
database
)
{
return
;
}
const
repo
=
database
.
getRepository
(
LegacyBanEntity
);
const
existing
=
await
repo
.
findOne
({
where
:
{
name
:
name
||
null
,
ip
:
ip
||
null
,
},
withDeleted
:
true
,
});
if
(
existing
)
{
if
(
existing
.
deleteTime
)
{
await
repo
.
recover
(
existing
);
}
return
;
}
const
row
=
repo
.
create
({
name
:
name
||
null
,
ip
:
ip
||
null
,
});
await
repo
.
save
(
row
);
}
}
src/legacy-api/legacy-ban.entity.ts
0 → 100644
View file @
6fe7fe95
import
{
BaseTimeEntity
,
BigintTransformer
}
from
'
../utility
'
;
import
{
Column
,
Entity
,
Generated
,
Index
,
PrimaryColumn
,
Unique
,
}
from
'
typeorm
'
;
@
Entity
(
'
legacy_ban
'
)
@
Unique
([
'
ip
'
,
'
name
'
])
export
class
LegacyBanEntity
extends
BaseTimeEntity
{
@
PrimaryColumn
({
type
:
'
bigint
'
,
unsigned
:
true
,
transformer
:
new
BigintTransformer
(),
})
@
Generated
(
'
increment
'
)
id
!
:
number
;
@
Index
()
@
Column
({
type
:
'
varchar
'
,
length
:
64
,
nullable
:
true
,
})
ip
!
:
string
|
null
;
@
Index
()
@
Column
({
type
:
'
varchar
'
,
length
:
20
,
nullable
:
true
,
})
name
!
:
string
|
null
;
}
src/legacy-api/legacy-deck.entity.ts
0 → 100644
View file @
6fe7fe95
import
{
BaseTimeEntity
,
BigintTransformer
}
from
'
../utility
'
;
import
{
Column
,
Entity
,
Generated
,
Index
,
PrimaryColumn
}
from
'
typeorm
'
;
@
Entity
(
'
legacy_deck
'
)
export
class
LegacyDeckEntity
extends
BaseTimeEntity
{
@
PrimaryColumn
({
type
:
'
bigint
'
,
unsigned
:
true
,
transformer
:
new
BigintTransformer
(),
})
@
Generated
(
'
increment
'
)
id
!
:
number
;
@
Index
()
@
Column
({
type
:
'
varchar
'
,
length
:
128
,
})
name
!
:
string
;
@
Column
({
type
:
'
text
'
,
})
payload
!
:
string
;
@
Column
(
'
smallint
'
)
mainc
!
:
number
;
@
Index
()
@
Column
({
type
:
'
timestamp
'
,
})
uploadTime
!
:
Date
;
}
src/legacy-api/legacy-room-id-service.ts
0 → 100644
View file @
6fe7fe95
import
{
Context
}
from
'
../app
'
;
import
{
OnRoomCreate
,
OnRoomFinalize
}
from
'
../room
'
;
const
ROOM_ID_PREFIX_LENGTH
=
10
;
const
ROOM_ID_BASE
=
1
_000_000
;
const
ROOM_ID_MOD
=
9
_000_000
;
export
class
LegacyRoomIdService
{
private
logger
=
this
.
ctx
.
createLogger
(
'
LegacyRoomIdService
'
);
private
roomNameToRoomId
=
new
Map
<
string
,
string
>
();
private
roomIdToRoomName
=
new
Map
<
string
,
string
>
();
constructor
(
private
ctx
:
Context
)
{
this
.
ctx
.
middleware
(
OnRoomCreate
,
async
(
event
,
client
,
next
)
=>
{
this
.
bindRoom
(
event
.
room
.
name
,
event
.
room
.
identifier
);
return
next
();
});
this
.
ctx
.
middleware
(
OnRoomFinalize
,
async
(
event
,
client
,
next
)
=>
{
this
.
releaseRoom
(
event
.
room
.
name
);
return
next
();
});
}
getRoomIdString
(
identifier
:
string
)
{
return
String
(
this
.
getRoomIdNumber
(
identifier
));
}
findRoomNameByRoomId
(
roomIdText
:
string
)
{
const
normalizedRoomId
=
this
.
normalizeRoomId
(
roomIdText
);
if
(
!
normalizedRoomId
)
{
return
undefined
;
}
return
this
.
roomIdToRoomName
.
get
(
normalizedRoomId
);
}
getRoomIdNumber
(
identifier
:
string
)
{
const
prefix
=
String
(
identifier
||
''
).
slice
(
0
,
ROOM_ID_PREFIX_LENGTH
);
let
value
=
0
n
;
for
(
const
ch
of
prefix
)
{
value
=
value
*
62
n
+
BigInt
(
this
.
toBase62Digit
(
ch
));
}
return
Number
(
value
%
BigInt
(
ROOM_ID_MOD
))
+
ROOM_ID_BASE
;
}
private
toBase62Digit
(
ch
:
string
)
{
if
(
ch
>=
'
0
'
&&
ch
<=
'
9
'
)
{
return
ch
.
charCodeAt
(
0
)
-
48
;
}
if
(
ch
>=
'
A
'
&&
ch
<=
'
Z
'
)
{
return
ch
.
charCodeAt
(
0
)
-
55
;
}
if
(
ch
>=
'
a
'
&&
ch
<=
'
z
'
)
{
return
ch
.
charCodeAt
(
0
)
-
61
;
}
return
0
;
}
private
bindRoom
(
roomName
:
string
,
identifier
:
string
)
{
const
roomId
=
this
.
getRoomIdString
(
identifier
);
const
occupiedRoomName
=
this
.
roomIdToRoomName
.
get
(
roomId
);
if
(
occupiedRoomName
&&
occupiedRoomName
!==
roomName
)
{
this
.
logger
.
warn
(
{
roomId
,
currentRoomName
:
occupiedRoomName
,
nextRoomName
:
roomName
,
},
'
Legacy room id collision detected
'
,
);
}
const
previousRoomId
=
this
.
roomNameToRoomId
.
get
(
roomName
);
if
(
previousRoomId
&&
previousRoomId
!==
roomId
)
{
const
linkedRoomName
=
this
.
roomIdToRoomName
.
get
(
previousRoomId
);
if
(
linkedRoomName
===
roomName
)
{
this
.
roomIdToRoomName
.
delete
(
previousRoomId
);
}
}
this
.
roomNameToRoomId
.
set
(
roomName
,
roomId
);
this
.
roomIdToRoomName
.
set
(
roomId
,
roomName
);
}
private
releaseRoom
(
roomName
:
string
)
{
const
roomId
=
this
.
roomNameToRoomId
.
get
(
roomName
);
if
(
!
roomId
)
{
return
;
}
this
.
roomNameToRoomId
.
delete
(
roomName
);
const
linkedRoomName
=
this
.
roomIdToRoomName
.
get
(
roomId
);
if
(
linkedRoomName
===
roomName
)
{
this
.
roomIdToRoomName
.
delete
(
roomId
);
}
}
private
normalizeRoomId
(
roomIdText
:
string
)
{
const
text
=
String
(
roomIdText
||
''
).
trim
();
if
(
!
/^
\d
+$/
.
test
(
text
))
{
return
undefined
;
}
const
roomId
=
Number
(
text
);
if
(
!
Number
.
isSafeInteger
(
roomId
))
{
return
undefined
;
}
if
(
roomId
<
ROOM_ID_BASE
||
roomId
>=
ROOM_ID_BASE
+
ROOM_ID_MOD
)
{
return
undefined
;
}
return
String
(
roomId
);
}
}
src/legacy-api/legacy-stop-service.ts
0 → 100644
View file @
6fe7fe95
import
{
ChatColor
,
YGOProCtosJoinGame
}
from
'
ygopro-msg-encode
'
;
import
{
Context
}
from
'
../app
'
;
import
{
LegacyApiRecordEntity
}
from
'
./legacy-api-record.entity
'
;
import
{
LegacyApiService
}
from
'
./legacy-api-service
'
;
const
STOP_RECORD_KEY
=
'
stop
'
;
export
class
LegacyStopService
{
private
logger
=
this
.
ctx
.
createLogger
(
'
LegacyStopService
'
);
private
stopText
?:
string
;
constructor
(
private
ctx
:
Context
)
{
this
.
ctx
.
middleware
(
YGOProCtosJoinGame
,
async
(
msg
,
client
,
next
)
=>
{
if
(
!
this
.
stopText
)
{
return
next
();
}
return
client
.
die
(
this
.
stopText
,
ChatColor
.
RED
);
});
this
.
ctx
.
get
(()
=>
LegacyApiService
)
.
addApiMessageHandler
(
'
stop
'
,
'
stop
'
,
async
(
value
)
=>
{
const
stop
=
await
this
.
setStopText
(
value
);
return
[
'
stop ok
'
,
stop
||
false
];
});
}
async
init
()
{
const
text
=
await
this
.
loadStopTextFromDatabase
();
this
.
stopText
=
text
;
if
(
text
)
{
this
.
logger
.
warn
(
{
stop
:
text
},
'
Server stop mode restored from database
'
,
);
}
}
getStopText
()
{
return
this
.
stopText
;
}
async
setStopText
(
rawValue
:
string
|
boolean
|
null
|
undefined
)
{
const
nextText
=
this
.
normalizeStopText
(
rawValue
);
this
.
stopText
=
nextText
||
undefined
;
const
database
=
this
.
ctx
.
database
;
if
(
!
database
)
{
return
this
.
stopText
;
}
const
repo
=
database
.
getRepository
(
LegacyApiRecordEntity
);
await
repo
.
delete
({
key
:
STOP_RECORD_KEY
,
});
if
(
!
nextText
)
{
this
.
logger
.
info
(
'
Cleared stop mode
'
);
return
undefined
;
}
const
record
=
repo
.
create
({
key
:
STOP_RECORD_KEY
,
value
:
nextText
,
});
await
repo
.
save
(
record
);
this
.
logger
.
info
({
stop
:
nextText
},
'
Set stop mode
'
);
return
nextText
;
}
private
async
loadStopTextFromDatabase
()
{
const
database
=
this
.
ctx
.
database
;
if
(
!
database
)
{
return
undefined
;
}
const
repo
=
database
.
getRepository
(
LegacyApiRecordEntity
);
const
record
=
await
repo
.
findOne
({
where
:
{
key
:
STOP_RECORD_KEY
,
},
});
const
value
=
(
record
?.
value
||
''
).
trim
();
return
value
||
undefined
;
}
private
normalizeStopText
(
rawValue
:
string
|
boolean
|
null
|
undefined
)
{
if
(
rawValue
===
false
||
rawValue
==
null
)
{
return
''
;
}
const
text
=
String
(
rawValue
).
trim
();
if
(
!
text
||
text
.
toLowerCase
()
===
'
false
'
)
{
return
''
;
}
return
text
;
}
}
src/legacy-api/legacy-welcome-service.ts
0 → 100644
View file @
6fe7fe95
import
{
Context
}
from
'
../app
'
;
import
{
WelcomeConfigCheck
}
from
'
../feats
'
;
import
{
LegacyApiRecordEntity
}
from
'
./legacy-api-record.entity
'
;
import
{
LegacyApiService
}
from
'
./legacy-api-service
'
;
const
WELCOME_RECORD_KEY
=
'
welcome
'
;
export
class
LegacyWelcomeService
{
private
logger
=
this
.
ctx
.
createLogger
(
'
LegacyWelcomeService
'
);
constructor
(
private
ctx
:
Context
)
{
this
.
ctx
.
middleware
(
WelcomeConfigCheck
,
async
(
event
,
client
,
next
)
=>
{
const
dbWelcome
=
await
this
.
getWelcomeFromDatabase
();
if
(
dbWelcome
)
{
event
.
use
(
dbWelcome
);
}
return
next
();
});
this
.
ctx
.
get
(()
=>
LegacyApiService
)
.
addApiMessageHandler
(
'
getwelcome
'
,
'
change_settings
'
,
async
()
=>
{
const
welcome
=
await
this
.
getWelcomeText
();
return
[
'
get ok
'
,
welcome
];
})
.
addApiMessageHandler
(
'
welcome
'
,
'
change_settings
'
,
async
(
value
)
=>
{
const
welcome
=
await
this
.
setWelcomeText
(
value
);
return
[
'
welcome ok
'
,
welcome
||
''
];
});
}
async
getWelcomeText
()
{
const
dbWelcome
=
await
this
.
getWelcomeFromDatabase
();
if
(
dbWelcome
)
{
return
dbWelcome
;
}
return
this
.
ctx
.
config
.
getString
(
'
WELCOME
'
);
}
async
setWelcomeText
(
rawValue
:
string
|
boolean
|
null
|
undefined
)
{
const
valueText
=
this
.
normalizeWelcome
(
rawValue
);
const
database
=
this
.
ctx
.
database
;
if
(
!
database
)
{
return
this
.
ctx
.
config
.
getString
(
'
WELCOME
'
);
}
const
repo
=
database
.
getRepository
(
LegacyApiRecordEntity
);
await
repo
.
delete
({
key
:
WELCOME_RECORD_KEY
,
});
if
(
!
valueText
)
{
this
.
logger
.
info
(
'
Cleared legacy welcome override
'
);
return
this
.
ctx
.
config
.
getString
(
'
WELCOME
'
);
}
const
record
=
repo
.
create
({
key
:
WELCOME_RECORD_KEY
,
value
:
valueText
,
});
await
repo
.
save
(
record
);
this
.
logger
.
info
({
welcome
:
valueText
},
'
Updated legacy welcome override
'
);
return
valueText
;
}
private
async
getWelcomeFromDatabase
()
{
const
database
=
this
.
ctx
.
database
;
if
(
!
database
)
{
return
undefined
;
}
const
repo
=
database
.
getRepository
(
LegacyApiRecordEntity
);
const
record
=
await
repo
.
findOne
({
where
:
{
key
:
WELCOME_RECORD_KEY
,
},
});
const
text
=
(
record
?.
value
||
''
).
trim
();
return
text
||
undefined
;
}
private
normalizeWelcome
(
rawValue
:
string
|
boolean
|
null
|
undefined
)
{
if
(
rawValue
===
false
||
rawValue
==
null
)
{
return
''
;
}
const
valueText
=
String
(
rawValue
).
trim
();
if
(
!
valueText
||
valueText
.
toLowerCase
()
===
'
false
'
)
{
return
''
;
}
return
valueText
;
}
}
src/legacy-api/utility/deck-name-match.ts
0 → 100644
View file @
6fe7fe95
export
function
deckNameMatch
(
deckName
:
string
,
playerName
:
string
)
{
if
(
deckName
===
playerName
||
deckName
===
`
${
playerName
}
.ydk`
||
deckName
===
`
${
playerName
}
.ydk.ydk`
)
{
return
true
;
}
const
parsedDeckName
=
deckName
.
match
(
/^
([^\+
\u
ff0b
]
+
)[\+
\u
ff0b
](
.+
?)(\.
ydk
){0,2}
$/
,
);
return
!!
(
parsedDeckName
&&
(
playerName
===
parsedDeckName
[
1
]
||
playerName
===
parsedDeckName
[
2
])
);
}
src/legacy-api/utility/deck-name-query.ts
0 → 100644
View file @
6fe7fe95
const
DELIMITER_CLASS
=
'
[+
\
uFF0B]
'
;
const
NO_DELIMITER_CLASS
=
'
[^+
\
uFF0B]
'
;
function
escapeRegex
(
value
:
string
)
{
return
value
.
replace
(
/
[\\
^$.*+?()[
\]
{}|
]
/g
,
'
\\
$&
'
);
}
export
function
getDeckNameExactCandidates
(
playerName
:
string
)
{
return
[
playerName
,
`
${
playerName
}
.ydk`
,
`
${
playerName
}
.ydk.ydk`
];
}
export
function
getDeckNameRegexCandidates
(
playerName
:
string
)
{
const
escapedPlayerName
=
escapeRegex
(
playerName
);
return
{
firstPlayerRegex
:
`^
${
escapedPlayerName
}${
DELIMITER_CLASS
}
.+(\\.ydk){0,2}$`
,
secondPlayerRegex
:
`^
${
NO_DELIMITER_CLASS
}
+
${
DELIMITER_CLASS
}${
escapedPlayerName
}
(\\.ydk){0,2}$`
,
};
}
src/room/duel-record.ts
View file @
6fe7fe95
...
...
@@ -24,7 +24,7 @@ export class DuelRecord {
messages
:
YGOProMsgBase
[]
=
[];
toSwappedPlayers
()
{
if
(
!
this
.
isSwapped
)
{
if
(
!
this
.
isSwapped
)
{
return
[...
this
.
players
];
}
const
swappedPlayers
=
[...
this
.
players
];
...
...
src/room/room-manager.ts
View file @
6fe7fe95
...
...
@@ -4,7 +4,7 @@ import BetterLock from 'better-lock';
import
{
HostInfo
}
from
'
ygopro-msg-encode
'
;
declare
module
'
./room
'
{
export
interface
Room
{
export
interface
Room
{
native
?:
boolean
;
}
}
...
...
src/room/room.ts
View file @
6fe7fe95
...
...
@@ -1872,12 +1872,17 @@ export class Room {
'
name
'
,
'
hostinfo
'
,
'
duelStage
'
,
'
turnCount
'
,
'
createTime
'
,
]),
watcherCount
:
this
.
watchers
.
size
,
players
:
this
.
playingPlayers
.
map
((
p
)
=>
({
...
pick
(
p
,
[
'
name
'
,
'
pos
'
,
'
ip
'
]),
deck
:
p
.
deck
?.
toYdkeURL
(),
score
:
this
.
getDuelPos
(
p
)
>=
0
&&
this
.
getDuelPos
(
p
)
<=
1
?
this
.
score
[
this
.
getDuelPos
(
p
)
as
0
|
1
]
:
undefined
,
lp
:
fieldInfo
?.[
this
.
getIngameDuelPos
(
p
)]?.
lp
,
cardCount
:
fieldInfo
?.[
this
.
getIngameDuelPos
(
p
)]?.
cardCount
,
})),
...
...
src/services/koa-service.ts
0 → 100644
View file @
6fe7fe95
import
Koa
from
'
koa
'
;
import
Router
from
'
@koa/router
'
;
import
*
as
ipaddr
from
'
ipaddr.js
'
;
import
{
IncomingMessage
,
Server
as
HttpServer
,
createServer
}
from
'
node:http
'
;
import
{
createServer
as
createHttpsServer
}
from
'
node:https
'
;
import
{
SSLFinder
}
from
'
./ssl-finder
'
;
import
{
AppContext
}
from
'
nfkit
'
;
import
{
ConfigService
}
from
'
./config
'
;
import
{
Logger
}
from
'
./logger
'
;
type
ProxyRange
=
[
ipaddr
.
IPv4
|
ipaddr
.
IPv6
,
number
];
export
class
KoaService
{
koa
=
new
Koa
();
router
=
new
Router
();
private
config
=
this
.
ctx
.
get
(()
=>
ConfigService
).
config
;
private
logger
=
this
.
ctx
.
get
(()
=>
Logger
).
createLogger
(
'
KoaService
'
);
private
server
?:
HttpServer
;
private
trustedProxies
:
ProxyRange
[]
=
[];
constructor
(
private
ctx
:
AppContext
)
{
this
.
initTrustedProxies
();
this
.
koa
.
use
(
async
(
ctx
,
next
)
=>
{
ctx
.
set
(
'
Access-Control-Allow-Origin
'
,
'
*
'
);
ctx
.
set
(
'
Access-Control-Allow-Private-Network
'
,
'
true
'
);
ctx
.
set
(
'
Vary
'
,
'
Origin, Access-Control-Request-Headers, Access-Control-Request-Method
'
,
);
if
((
ctx
.
method
||
''
).
toUpperCase
()
===
'
OPTIONS
'
)
{
const
requestHeaders
=
ctx
.
request
.
headers
[
'
access-control-request-headers
'
];
const
allowHeaders
=
Array
.
isArray
(
requestHeaders
)
?
requestHeaders
.
join
(
'
,
'
)
:
requestHeaders
||
'
*
'
;
ctx
.
status
=
204
;
ctx
.
set
(
'
Access-Control-Allow-Methods
'
,
'
GET,POST,OPTIONS
'
);
ctx
.
set
(
'
Access-Control-Allow-Headers
'
,
allowHeaders
);
ctx
.
set
(
'
Access-Control-Max-Age
'
,
'
86400
'
);
return
;
}
return
next
();
});
this
.
koa
.
use
(
async
(
ctx
,
next
)
=>
{
const
req
=
ctx
.
req
as
IncomingMessage
;
const
physicalIp
=
req
.
socket
.
remoteAddress
||
''
;
const
xffRaw
=
ctx
.
request
.
headers
[
'
x-forwarded-for
'
];
const
xff
=
Array
.
isArray
(
xffRaw
)
?
xffRaw
[
0
]
:
xffRaw
;
ctx
.
state
.
realIp
=
this
.
getRealIp
(
physicalIp
,
xff
);
return
next
();
});
this
.
koa
.
use
(
async
(
ctx
,
next
)
=>
{
await
next
();
if
(
ctx
.
state
.
disableJsonp
)
{
return
;
}
const
callback
=
String
(
ctx
.
query
.
callback
||
''
).
trim
();
if
(
!
callback
||
ctx
.
body
==
null
)
{
return
;
}
if
(
Buffer
.
isBuffer
(
ctx
.
body
)
||
ctx
.
body
instanceof
Uint8Array
||
typeof
(
ctx
.
body
as
any
).
pipe
===
'
function
'
)
{
return
;
}
const
payload
=
JSON
.
stringify
(
ctx
.
body
);
ctx
.
type
=
'
application/javascript; charset=utf-8
'
;
// Keep srvpro-dash compatibility: some old callbacks read global `data`.
ctx
.
body
=
`window.data=
${
payload
}
;
${
callback
}
(window.data);`
;
});
this
.
koa
.
use
(
this
.
router
.
routes
());
this
.
koa
.
use
(
this
.
router
.
allowedMethods
());
}
async
init
()
{
const
port
=
this
.
config
.
getInt
(
'
API_PORT
'
);
if
(
!
port
)
{
this
.
logger
.
info
(
'
API_PORT not configured, Legacy API server not started
'
,
);
return
;
}
const
host
=
this
.
config
.
getString
(
'
API_HOST
'
)
||
this
.
config
.
getString
(
'
HOST
'
);
const
sslOptions
=
this
.
ctx
.
get
(()
=>
SSLFinder
).
findSSL
();
if
(
sslOptions
)
{
this
.
server
=
createHttpsServer
(
sslOptions
,
this
.
koa
.
callback
());
this
.
logger
.
info
(
'
SSL configuration found, starting HTTPS Legacy API
'
);
}
else
{
this
.
server
=
createServer
(
this
.
koa
.
callback
());
this
.
logger
.
info
(
'
No SSL configuration, starting HTTP Legacy API
'
);
}
await
new
Promise
<
void
>
((
resolve
,
reject
)
=>
{
this
.
server
!
.
listen
(
port
,
host
,
()
=>
{
this
.
logger
.
info
(
{
host
,
port
,
secure
:
!!
sslOptions
,
trustedProxyCount
:
this
.
trustedProxies
.
length
,
},
'
Legacy API server listening
'
,
);
resolve
();
});
this
.
server
!
.
on
(
'
error
'
,
reject
);
});
}
async
stop
()
{
if
(
!
this
.
server
)
{
return
;
}
await
new
Promise
<
void
>
((
resolve
)
=>
{
this
.
server
!
.
close
(()
=>
{
this
.
logger
.
info
(
'
Legacy API server closed
'
);
resolve
();
});
});
}
private
initTrustedProxies
()
{
const
proxies
=
this
.
config
.
getStringArray
(
'
TRUSTED_PROXIES
'
);
for
(
const
trusted
of
proxies
)
{
try
{
this
.
trustedProxies
.
push
(
ipaddr
.
parseCIDR
(
trusted
));
}
catch
(
e
:
any
)
{
this
.
logger
.
warn
(
{
trusted
,
err
:
e
.
message
},
'
Failed to parse trusted proxy for KoaService
'
,
);
}
}
}
private
isTrustedProxy
(
ip
:
string
):
boolean
{
try
{
const
normalized
=
ip
.
startsWith
(
'
::ffff:
'
)
?
ip
.
slice
(
7
)
:
ip
;
const
addr
=
ipaddr
.
parse
(
normalized
);
return
this
.
trustedProxies
.
some
(([
range
,
mask
])
=>
addr
.
match
(
range
,
mask
),
);
}
catch
{
return
false
;
}
}
private
toIpv6
(
ip
:
string
):
string
{
if
(
/^
(\d{1,3}\.){3}\d{1,3}
$/
.
test
(
ip
))
{
return
`::ffff:
${
ip
}
`
;
}
return
ip
;
}
private
getRealIp
(
physicalIp
:
string
,
xffIp
?:
string
):
string
{
if
(
!
xffIp
||
xffIp
===
physicalIp
)
{
return
this
.
toIpv6
(
physicalIp
);
}
if
(
this
.
isTrustedProxy
(
physicalIp
))
{
return
this
.
toIpv6
(
xffIp
.
split
(
'
,
'
)[
0
].
trim
());
}
return
this
.
toIpv6
(
physicalIp
);
}
}
src/services/legacy-api-auth-service.ts
0 → 100644
View file @
6fe7fe95
import
{
AppContext
}
from
'
nfkit
'
;
import
{
FileResourceService
}
from
'
../file-resource
'
;
import
{
Logger
}
from
'
./logger
'
;
type
PermissionSet
=
Record
<
string
,
boolean
>
;
type
UserPermissions
=
string
|
PermissionSet
;
type
UserEntry
=
{
password
:
string
;
enabled
:
boolean
;
permissions
:
UserPermissions
;
[
key
:
string
]:
unknown
;
};
type
UsersFile
=
{
file
?:
string
;
permission_examples
:
Record
<
string
,
PermissionSet
>
;
users
:
Record
<
string
,
UserEntry
>
;
};
const
EMPTY_USERS_FILE
:
UsersFile
=
{
permission_examples
:
{},
users
:
{},
};
export
class
LegacyApiAuthService
{
private
logger
=
this
.
ctx
.
get
(()
=>
Logger
)
.
createLogger
(
'
LegacyApiAuthService
'
);
private
fileResource
=
this
.
ctx
.
get
(()
=>
FileResourceService
);
constructor
(
private
ctx
:
AppContext
)
{}
async
auth
(
name
:
string
,
pass
:
string
,
permissionRequired
:
string
,
action
=
'
unknown
'
,
)
{
const
usersData
=
await
this
.
fileResource
.
getDataOrEmptyAsync
(
'
users
'
,
EMPTY_USERS_FILE
,
{
forceRead
:
true
,
},
);
const
user
=
usersData
.
users
[
name
];
if
(
!
user
)
{
this
.
logger
.
info
(
{
user
:
name
,
permissionRequired
,
action
,
result
:
'
unknown_user
'
,
},
'
Legacy API auth
'
,
);
return
false
;
}
if
(
user
.
password
!==
pass
)
{
this
.
logger
.
info
(
{
user
:
name
,
permissionRequired
,
action
,
result
:
'
bad_password
'
,
},
'
Legacy API auth
'
,
);
return
false
;
}
if
(
!
user
.
enabled
)
{
this
.
logger
.
info
(
{
user
:
name
,
permissionRequired
,
action
,
result
:
'
disabled_user
'
,
},
'
Legacy API auth
'
,
);
return
false
;
}
const
permission
=
this
.
resolvePermissionSet
(
usersData
,
user
.
permissions
);
const
allowed
=
!!
permission
?.[
permissionRequired
];
this
.
logger
.
info
(
{
user
:
name
,
permissionRequired
,
action
,
result
:
allowed
?
'
ok
'
:
'
permission_denied
'
,
},
'
Legacy API auth
'
,
);
return
allowed
;
}
private
resolvePermissionSet
(
usersData
:
UsersFile
,
permissions
:
UserPermissions
,
):
PermissionSet
|
undefined
{
if
(
typeof
permissions
===
'
string
'
)
{
return
usersData
.
permission_examples
[
permissions
];
}
if
(
permissions
&&
typeof
permissions
===
'
object
'
)
{
return
permissions
;
}
return
undefined
;
}
}
src/
client
/ssl-finder.ts
→
src/
services
/ssl-finder.ts
View file @
6fe7fe95
import
{
Context
}
from
'
../app
'
;
import
{
TlsOptions
}
from
'
node:tls
'
;
import
fs
from
'
node:fs
'
;
import
path
from
'
node:path
'
;
...
...
@@ -9,6 +8,9 @@ import {
timingSafeEqual
,
KeyObject
,
}
from
'
node:crypto
'
;
import
{
AppContext
}
from
'
nfkit
'
;
import
{
ConfigService
}
from
'
./config
'
;
import
{
Logger
}
from
'
./logger
'
;
type
LoadedCandidate
=
{
certPath
:
string
;
...
...
@@ -19,13 +21,14 @@ type LoadedCandidate = {
};
export
class
SSLFinder
{
constructor
(
private
ctx
:
Context
)
{}
private
enableSSL
=
this
.
ctx
.
config
.
getBoolean
(
'
ENABLE_SSL
'
);
private
sslPath
=
this
.
ctx
.
config
.
getString
(
'
SSL_PATH
'
);
private
sslKey
=
this
.
ctx
.
config
.
getString
(
'
SSL_KEY
'
);
private
sslCert
=
this
.
ctx
.
config
.
getString
(
'
SSL_CERT
'
);
constructor
(
private
ctx
:
AppContext
)
{}
private
config
=
this
.
ctx
.
get
(()
=>
ConfigService
).
config
;
private
enableSSL
=
this
.
config
.
getBoolean
(
'
ENABLE_SSL
'
);
private
sslPath
=
this
.
config
.
getString
(
'
SSL_PATH
'
);
private
sslKey
=
this
.
config
.
getString
(
'
SSL_KEY
'
);
private
sslCert
=
this
.
config
.
getString
(
'
SSL_CERT
'
);
private
logger
=
this
.
ctx
.
createLogger
(
'
SSLFinder
'
);
private
logger
=
this
.
ctx
.
get
(()
=>
Logger
).
createLogger
(
'
SSLFinder
'
);
private
noSSL
()
{
if
(
this
.
sslPath
||
this
.
sslKey
||
this
.
sslCert
)
{
...
...
@@ -41,11 +44,9 @@ export class SSLFinder {
return
undefined
;
}
// 1) 优先 SSL_CERT + SSL_KEY
const
explicit
=
this
.
tryExplicit
(
this
.
sslCert
,
this
.
sslKey
);
if
(
explicit
)
return
{
cert
:
explicit
.
certBuf
,
key
:
explicit
.
keyBuf
};
// 2) 其次 sslPath:递归找 fullchain.pem + 同目录 privkey.pem,排除过期/不匹配;选有效期最长
const
best
=
this
.
findBestFromPath
(
this
.
sslPath
);
if
(
!
best
)
return
this
.
noSSL
();
...
...
@@ -116,7 +117,6 @@ export class SSLFinder {
const
now
=
Date
.
now
();
for
(
const
fullchainPath
of
this
.
walkFindByName
(
baseDir
,
'
fullchain.pem
'
))
{
// 先读 cert(一次),不合格就别读 key
const
certBuf
=
this
.
readFileBuffer
(
fullchainPath
);
if
(
!
certBuf
)
continue
;
...
...
@@ -171,7 +171,6 @@ export class SSLFinder {
private
parseLeafCertFromBuffer
(
certBuf
:
Buffer
,
):
{
x509
:
X509Certificate
;
validToMs
:
number
}
|
undefined
{
// fullchain.pem / cert.pem 里通常第一个 CERT block 是 leaf
const
pem
=
certBuf
.
toString
(
'
utf8
'
);
const
firstCertPem
=
this
.
extractFirstPemCertificate
(
pem
);
if
(
!
firstCertPem
)
return
undefined
;
...
...
@@ -199,16 +198,11 @@ export class SSLFinder {
keyPathForLog
:
string
,
):
boolean
{
try
{
// cert 公钥
const
certPub
=
x509
.
publicKey
;
// private key -> derive public key
const
priv
=
createPrivateKey
(
keyBuf
);
const
derivedPub
=
createPublicKey
(
priv
);
return
this
.
publicKeysEqual
(
certPub
,
derivedPub
);
}
catch
(
err
:
any
)
{
// 这里常见是:私钥被 passphrase 加密 / 格式不对
this
.
logger
.
warn
(
{
keyPath
:
keyPathForLog
,
err
:
err
?.
message
??
String
(
err
)
},
'
Failed to parse private key for match check; treating as mismatch
'
,
...
...
@@ -218,7 +212,6 @@ export class SSLFinder {
}
private
publicKeysEqual
(
a
:
KeyObject
,
b
:
KeyObject
):
boolean
{
// 统一导出成 spki der 来对比
const
aDer
=
a
.
export
({
type
:
'
spki
'
,
format
:
'
der
'
})
as
Buffer
;
const
bDer
=
b
.
export
({
type
:
'
spki
'
,
format
:
'
der
'
})
as
Buffer
;
...
...
src/services/typeorm.ts
View file @
6fe7fe95
...
...
@@ -4,6 +4,9 @@ import { ConfigService } from './config';
import
{
Logger
}
from
'
./logger
'
;
import
{
RandomDuelScore
}
from
'
../feats/random-duel
'
;
import
{
DuelRecordEntity
,
DuelRecordPlayer
}
from
'
../feats/cloud-replay
'
;
import
{
LegacyApiRecordEntity
}
from
'
../legacy-api/legacy-api-record.entity
'
;
import
{
LegacyBanEntity
}
from
'
../legacy-api/legacy-ban.entity
'
;
import
{
LegacyDeckEntity
}
from
'
../legacy-api/legacy-deck.entity
'
;
export
class
TypeormLoader
{
constructor
(
private
ctx
:
AppContext
)
{}
...
...
@@ -32,6 +35,7 @@ export const TypeormFactory = async (ctx: AppContext) => {
const
password
=
config
.
getString
(
'
DB_PASS
'
);
const
database
=
config
.
getString
(
'
DB_NAME
'
);
const
synchronize
=
!
config
.
getBoolean
(
'
DB_NO_INIT
'
);
const
dataSource
=
new
DataSource
({
type
:
'
postgres
'
,
...
...
@@ -41,7 +45,14 @@ export const TypeormFactory = async (ctx: AppContext) => {
password
,
database
,
synchronize
,
entities
:
[
RandomDuelScore
,
DuelRecordEntity
,
DuelRecordPlayer
],
entities
:
[
RandomDuelScore
,
DuelRecordEntity
,
DuelRecordPlayer
,
LegacyApiRecordEntity
,
LegacyBanEntity
,
LegacyDeckEntity
,
],
});
try
{
...
...
src/
feats/cloud-repla
y/bigint-transformer.ts
→
src/
utilit
y/bigint-transformer.ts
View file @
6fe7fe95
...
...
@@ -5,7 +5,11 @@ export class BigintTransformer implements ValueTransformer {
if
(
dbValue
==
null
)
{
return
dbValue
;
}
return
Number
.
parseInt
(
String
(
dbValue
),
10
);
const
numberValue
=
Number
.
parseInt
(
String
(
dbValue
),
10
);
if
(
!
Number
.
isFinite
(
numberValue
))
{
return
null
;
}
return
numberValue
;
}
to
(
entityValue
:
unknown
)
{
...
...
src/utility/index.ts
View file @
6fe7fe95
export
*
from
'
./panel-pagination
'
;
export
*
from
'
./base-time.entity
'
;
export
*
from
'
./bigint-transformer
'
;
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment