First commit

This commit is contained in:
Gilles Lazures 2025-05-14 11:33:24 +02:00
parent cf99f21b9a
commit b4c388b551
15 changed files with 2323 additions and 2 deletions

4
.gitignore vendored
View File

@ -130,3 +130,7 @@ dist
.yarn/install-state.gz
.pnp.*
# Project specifc files to ignore
test.js
config.json
keys/

View File

@ -1,2 +1,2 @@
# Yuzu-Online-JS
# YuZu-Online
Nothing more simple, you just have to launch the server

15
config.example.json Normal file
View File

@ -0,0 +1,15 @@
{
"web": {
"port": 4862
},
"auth": {
"defaultProvider": "discord",
"providers": {
"discord": {
"clientId": "",
"clientSecret": "",
"redirectUri": ""
}
}
}
}

313
controllers/db.js Normal file
View File

@ -0,0 +1,313 @@
const Database = require("better-sqlite3")
const logger = require("../modules/logger")
const path = require("node:path")
const jwt = require("jsonwebtoken")
const fs = require("node:fs")
require("colors")
const db = new Database(path.join(__dirname, "..", "data", "lobby.db"), { verbose: (message) => logger.log(`[${"SQLite".yellow}] ${message}`) })
const privateKey = fs.readFileSync(path.join(__dirname, "..", "keys", "private.pem"))
function initDB() {
db.exec(`
CREATE TABLE IF NOT EXISTS rooms (
externalGuid TEXT PRIMARY KEY,
id TEXT NOT NULL,
address TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
owner TEXT NOT NULL,
port INTEGER NOT NULL,
preferredGameName TEXT NOT NULL,
preferredGameId INTEGER NOT NULL,
maxPlayers INTEGER NOT NULL,
netVersion TEXT NOT NULL,
hasPassword BOOLEAN NOT NULL DEFAULT 0,
password TEXT DEFAULT ""
)
`)
db.exec(`
CREATE TABLE IF NOT EXISTS users (
discordId TEXT NOT NULL,
username TEXT NOT NULL PRIMARY KEY,
nickname TEXT NOT NULL,
avatarUrl TEXT NOT NULL,
token TEXT NOT NULL
)
`)
db.exec(`
CREATE TABLE IF NOT EXISTS players (
nickname TEXT NOT NULL,
username TEXT NOT NULL,
avatarUrl TEXT NOT NULL,
roomId TEXT NOT NULL,
gameId INTEGER NOT NULL,
FOREIGN KEY(roomId) REFERENCES rooms(externalGuid)
)
`)
}
function loginWithTokenAndToken(username, token) {
const stmt = db.prepare("SELECT * FROM users WHERE username = ?")
const user = stmt.get(username)
if (user) {
if (user.token == token) {
delete user.token
return user
} else {
return { success: false, error: "Invalid token", code: 401 }
}
} else {
return { success: false, error: "No user found with that username.", code: 404 }
}
}
function loginWithToken(token) {
try {
const payload = jwt.verify(token, privateKey)
const stmt = db.prepare("SELECT * FROM users WHERE username = ?")
const user = stmt.get(payload.username)
if (user) {
delete user.token
return user
} else {
return { success: false, error: "No user found with that username.", code: 404 }
}
} catch (error) {
return { success: false, error: "Invalid token.", code: 401 }
}
}
function login(req, res, next) {
const xToken = req.headers["x-token"]
const xUsername = req.headers["x-username"]
const bearer = res.headers["authorization"]
const token = bearer && bearer.startsWith("Bearer ") ? bearer.split(" ")[1] : null
if (!xToken && !xUsername && !token) {
return res.status(401).json({ error: "No token or/and username provided." })
}
if (!xToken || !xUsername) {
return res.status(422).json({ error: "No token or/and username provided." })
}
if (token) {
const user = loginWithToken(token)
if (user.error) {
return res.status(user.code).json({ error: user.error })
} else {
req.user = user
next()
}
} else {
const user = loginWithTokenAndToken(xUsername, xToken)
if (user.error) {
return res.status(user.code).json({ error: user.error })
} else {
req.user = user
next()
}
}
}
function getPlayersFromRoom(roomId) {
const stmt = db.prepare("SELECT * FROM players WHERE roomId = ?")
const players = stmt.all(roomId)
if (players) {
return { success: true, players: players }
} else {
return { success: false, error: "No players found in that room.", code: 404 }
}
}
function putPlayerInRoom(username, roomId, nickname, avatarUrl, gameId) {
const stmt = db.prepare("INSERT INTO players (username, roomId, nickname, avatarUrl, gameId) VALUES (?, ?, ?, ?, ?)")
try {
stmt.run(username, roomId, nickname, avatarUrl, gameId)
return { success: true }
} catch (error) {
console.error(error)
return { success: false, error: "Failed to add player to the room.", code: 500 }
}
}
function addRoomToLobby(externalGuid, id, address, name, description, owner, port, preferredGameName, preferredGameId, maxPlayers, netVersion, hasPassword, password) {
const stmt = db.prepare("INSERT INTO rooms (externalGuid, id, address, name, description, owner, port, preferredGameName, preferredGameId, maxPlayers, netVersion, hasPassword, password) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
try {
stmt.run(externalGuid, id, address, name, description, owner, port, preferredGameName, preferredGameId, maxPlayers, netVersion, hasPassword, password)
return { success: true }
} catch (error) {
console.error(error)
return { success: false, error: "Failed to room to lobby", code: 500 }
}
}
function movePlayerAnotherRoom(username, roomId) {
const stmt = db.prepare("UPDATE players SET roomId = ? WHERE username = ?")
try {
const result = stmt.run(roomId, username)
if (result.changes > 0) {
return { success: true }
} else {
return { success: false, error: "Player not found or already in the specified room.", code: 404 }
}
} catch (error) {
return { success: false, error: "Failed to move player to another room.", code: 500 }
}
}
function removePlayer(username, roomId) {
const stmt = db.prepare("DELETE FROM players WHERE username = ? AND roomId = ?")
try {
const result = stmt.run(username, roomId)
if (result.changes > 0) {
return { success: true }
} else {
return { success: false, error: "Player not found in the specified room.", code: 404 }
}
} catch (error) {
return { success: false, error: "Failed to remove player from the room.", code: 500 }
}
}
function listRooms() {
const stmt = db.prepare("SELECT * FROM rooms")
try {
const rooms = stmt.all()
return { success: true, rooms: rooms, code: 200 }
} catch (error) {
return { success: false, error: "Failed to retrieve rooms.", code: 500 }
}
}
function deleteRoom(roomId) {
const stmt = db.prepare("DELETE FROM rooms WHERE id = ?")
try {
stmt.run(roomId)
return { success: true, code: 204 }
} catch (error) {
console.error(error)
return { success: false, error: "Failed to delete room", code: 500 }
}
}
function getRoom(roomId) {
const stmt = db.prepare("SELECT * FROM rooms WHERE id = ?")
const room = stmt.get(roomId)
if (room) {
return { success: true, code: 200, room }
} else {
return { success: false, error: "No room found with that ID.", code: 404 }
}
}
function updateKeyAndValueForRoom(roomId, key, value) {
const stmt = db.prepare(`UPDATE rooms SET ${key} = ? WHERE id = ?`)
try {
const result = stmt.run(value, roomId)
if (result.changes > 0) {
return { success: true }
} else {
return { success: false, error: "Room not found or no changes made.", code: 404 }
}
} catch (error) {
console.error(error)
return { success: false, error: "Failed to update room.", code: 500 }
}
}
function addUser(discordId, username, nickname, avatarUrl) {
const stmt = db.prepare("INSERT INTO users (discordId, username, nickname, avatarUrl, token) VALUES (?, ?, ?, ?, ?)")
try {
const token = jwt.sign({ username, nickname, avatarUrl }, privateKey, { algorithm: "RS256", expiresIn: "168h" })
stmt.run(discordId, username, nickname, avatarUrl, token)
return { success: true, user: { username, nickname, avatarUrl, token } }
} catch (error) {
console.error(error)
return { success: false, error: "Failed to register user.", code: 500 }
}
}
function removeUser(username) {
const stmt = db.prepare("DELETE FROM users WHERE username = ?")
try {
stmt.run(username)
return { success: true, code: 204 }
} catch (error) {
console.error(error)
return { success: false, error: "Failed to delete user", code: 500 }
}
}
function updateUserNickname(nickname, discordId) {
const stmt = db.prepare("UPDATE users SET nickname = ? WHERE discordId = ?")
try {
const result = stmt.run(nickname, discordId)
if (result.changes > 0) {
return { success: true }
} else {
return { success: false, error: "Player not found or already in the specified room.", code: 404 }
}
} catch (error) {
return { success: false, error: "Failed to move player to another room.", code: 500 }
}
}
function updateUserUsername(username, discordId) {
const stmt = db.prepare("UPDATE users SET username = ? WHERE discordId = ?")
try {
const result = stmt.run(username, discordId)
if (result.changes > 0) {
return { success: true }
} else {
return { success: false, error: "Player not found or already in the specified room.", code: 404 }
}
} catch (error) {
return { success: false, error: "Failed to move player to another room.", code: 500 }
}
}
function updateUserAvatar(avatarUrl, discordId) {
const stmt = db.prepare("UPDATE users SET avatarUrl = ? WHERE discordId = ?")
try {
const result = stmt.run(avatarUrl, discordId)
if (result.changes > 0) {
return { success: true }
} else {
return { success: false, error: "Player not found or already in the specified room.", code: 404 }
}
} catch (error) {
return { success: false, error: "Failed to move player to another room.", code: 500 }
}
}
function getUser(userId) {
const stmt = db.prepare("SELECT * FROM users WHERE discordId = ?")
const user = stmt.get(userId)
if (user) {
return { success: true, code: 200, user }
} else {
return { success: false, error: "No user found with that ID.", code: 404 }
}
}
module.exports = {
initDB,
login,
loginWithToken,
loginWithTokenAndToken,
getPlayersFromRoom,
putPlayerInRoom,
movePlayerAnotherRoom,
removePlayer,
listRooms,
addRoomToLobby,
deleteRoom,
getRoom,
updateKeyAndValueForRoom,
removeUser,
addUser,
updateUserAvatar,
updateUserNickname,
updateUserUsername,
getUser
}

33
controllers/keys.js Normal file
View File

@ -0,0 +1,33 @@
const fs = require("node:fs")
const path = require("node:path")
const crypto = require("node:crypto")
const logger = require("../modules/logger")
require("colors")
function initKeys() {
if (!isExists("private.pem") && isExists("public.pem")) {
logger.log(`[${"RSA Keys".blue}] Public key is missing.`)
fs.unlinkSync("public.pem")
}
if (!isExists("public.pem") && isExists("private.pem")) {
logger.log(`[${"RSA Keys".blue}] Private key is missing`)
fs.unlinkSync("private.pem")
}
if (!isExists("private.pem") && !isExists("public.pem")) {
logger.log(`[${"RSA Keys".blue}] RSA Keys are missings, generating`)
const { privateKey, publicKey } = crypto.generateKeyPairSync("rsa", {
modulusLength: 2048
})
fs.writeFileSync(path.join(__dirname, "..", "keys", "private.pem"), privateKey.export({ type: "pkcs1", format: "pem" }))
fs.writeFileSync(path.join(__dirname, "..", "keys", "public.pem"), publicKey.export({ type: "spki", format: "pem" }))
logger.log(`[${"RSA Keys".blue}] RSA Keys generated`)
}
}
function isExists(filename) {
return fs.existsSync(path.join(__dirname, "keys", filename))
}
module.exports = {
initKeys
}

BIN
data/lobby.db Normal file

Binary file not shown.

22
modules/logger.js Normal file
View File

@ -0,0 +1,22 @@
const fs = require("node:fs")
const path = require("node:path")
require("colors")
if (!fs.existsSync(path.join(__dirname, "..", "logs"))) {
fs.mkdirSync(path.join(__dirname, "..", "logs"))
}
function log(message) {
console.log(`[${new Date().toISOString().magenta}] [${"INFO".green}] : ${message}`)
fs.appendFileSync(path.join(__dirname, "..", "logs", "info.log") ,`[${new Date().toISOString()}] [INFO] :\r\n${message}\r\n`)
}
function error(message) {
console.error(`[${new Date().toISOString().magenta}] [${"ERROR".red}] : ${message}`)
fs.appendFileSync(path.join(__dirname, "..", "logs", "error.log") ,`[${new Date().toISOString()}] [ERROR] :\r\n${message}\r\n`)
}
module.exports = {
error,
log
}

1680
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
package.json Normal file
View File

@ -0,0 +1,41 @@
{
"name": "yuzu-online-js",
"version": "0.0.1-alpha",
"description": "attempt to implement yuzu online server in js and use discord oauth2 to auth people and get profile info without ui",
"main": "server.js",
"scripts": {
"start": "node .",
"start:dev": "nodemon ."
},
"repository": {
"type": "git",
"url": "https://gitea.azures.fr/azures04/Yuzu-Online-JS"
},
"keywords": [
"yuzu",
"online",
"room",
"rooms",
"yuzu-rooms",
"yuzu-room",
"yuzu-online",
"multiplayer",
"yuzu-multiplayer"
],
"author": {
"email": "gilleslazure04@gmail.com",
"name": "Gilles Lazure <azures04>",
"url": "https://gitea.azures.fr/azures04"
},
"license": "AGPL-3.0-or-later",
"dependencies": {
"better-sqlite3": "^11.9.1",
"colors": "^1.4.0",
"discord-oauth2": "^2.12.1",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2"
},
"devDependencies": {
"nodemon": "^3.1.10"
}
}

View File

@ -0,0 +1,51 @@
const express = require("express")
const router = express()
const config = require("../../../config.json")
const controller = require("../../../controllers/db")
const DiscordOauth2 = require("discord-oauth2")
const oauth2 = new DiscordOauth2({
clientId: config.auth.providers.discord.clientId,
clientSecret: config.auth.providers.discord.clientSecret,
redirectUri: config.auth.providers.discord.redirectUri,
})
router.get("", async (req, res) => {
const { code } = req.query
if (code) {
try {
const token = await oauth2.tokenRequest({ code, grantType: "authorization_code", scope: "identify" })
const user = await oauth2.getUser(token.access_token)
if (user) {
const isUserAlreadyRegisteredRequest = controller.getUser(user.id)
if (isUserAlreadyRegisteredRequest.success) {
const $user = isUserAlreadyRegisteredRequest.user
if (user.username != $user.username) {
controller.updateUserUsername(user.id, user.username)
}
if (user.global_name != $user.nickname) {
controller.updateUserNickname(user.id, user.global_name)
}
if (buildAvatarURL(user.id, user.avatar) != $user.avatarUrl) {
controller.updateUserAvatar(user.id, buildAvatarURL(user.id, user.avatar))
}
res.json(controller.getUser(user.id))
} else {
const registerUserRequest = controller.addUser(user.id, user.username, user.global_name, `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`)
res.json(registerUserRequest)
}
}
} catch (error) {
res.status(500).json({ error: new String(error) })
console.error(error)
}
} else {
res.redirect(config.auth.providers.discord.oauth2Route)
}
})
function buildAvatarURL(discordId, avatarId) {
return `https://cdn.discordapp.com/avatars/${discordId}/${avatarId}.png?size=2048`
}
module.exports = router

9
routes/index.js Normal file
View File

@ -0,0 +1,9 @@
const express = require("express")
const router = express.Router()
const config = require("../config.json")
router.get("", (req, res) => {
res.redirect(`/auth/provider/${config.auth.defaultProvider}`)
})
module.exports = router

20
routes/jwt.js Normal file
View File

@ -0,0 +1,20 @@
const fs = require("node:fs")
const jwt = require("jsonwebtoken")
const path = require("node:path")
const express = require("express")
const router = express.Router()
const controller = require("../controllers/db")
const privateKey = fs.readFileSync(path.join(__dirname, "..", "keys", "private.pem"))
router.get("/external/key.pem", (req, res) => {
res.sendFile(path.join(__dirname, "..", "keys", "public.pem"))
})
router.post("/internal", controller.login, (req, res) => {
delete req.user.token
const token = jwt.sign(req.user, privateKey, { algorithm: "RS256", expiresIn: "168h" })
res.status(200).send(token)
})
module.exports = router

85
routes/lobby.js Normal file
View File

@ -0,0 +1,85 @@
const express = require("express")
const controller = require("../controllers/db")
const router = express.Router()
router.get("/", (req, res) => {
const roomsRequest = controller.listRooms()
if (!roomsRequest.success) {
res.status(roomsRequest.code).json({
error: roomsRequest.error,
code: roomsRequest.code
})
}
const rooms = []
for (const room of roomsRequest.rooms) {
const playersRequest = controller.getPlayersFromRoom(room.id)
if (!playersRequest.success) {
res.status(playersRequest.code).json({
error: playersRequest.error,
code: playersRequest.code
})
break
}
delete room.password
room.players = playersRequest.players
rooms.push(room)
}
res.json(rooms)
})
//TO-DO : Finish it
router.post("/:roomId", controller.login, (req, res) => {
const { roomId } = req.params
const roomGetRequest = controller.getRoom(roomId)
if (roomGetRequest.success) {
if (roomGetRequest.room.owner == req.user.username) {
} else {
res.status(401).json({ success: false, error: "You must be the owner of this room to update it.", code: 401 })
}
} else {
res.status(roomGetRequest.code).json(roomGetRequest)
}
})
router.post("/:roomId/room", controller.login, (req, res) => {
const { roomId } = req.params
const { password } = req.body
const roomGetRequest = controller.getRoom(roomId)
if (roomGetRequest.success) {
if (!roomGetRequest.room.hasPassword) {
res.status(200).send()
} else {
if (!password) {
res.status(422).json({
code: 422,
message: "Missing parameter in body (password)"
})
}
if (password == roomGetRequest.room.password) {
res.status(200).send()
} else {
res.status(401).json({ code: 401, error: "Invalid credential." })
}
}
} else {
res.status(roomGetRequest.code).json(roomGetRequest)
}
})
router.delete("/:roomId", controller.login, (req, res) => {
const { roomId } = req.params
const roomGetRequest = controller.getRoom(roomId)
if (roomGetRequest.success) {
if (roomGetRequest.room.owner == req.user.username) {
const roomDeleteRequest = controller.deleteRoom()
res.json(roomDeleteRequest)
} else {
res.status(401).json({ success: false, error: "You must be the owner of this room to delete it.", code: 401 })
}
} else {
res.status(roomGetRequest.code).json(roomGetRequest)
}
})
module.exports = router

10
routes/profile.js Normal file
View File

@ -0,0 +1,10 @@
const express = require("express")
const controller = require("../controllers/db")
const router = express.Router()
router.use("", controller.login, (req, res) => {
delete req.user.token
res.json(req.user)
})
module.exports = router

38
server.js Normal file
View File

@ -0,0 +1,38 @@
const fs = require("node:fs")
const path = require("node:path")
const logger = require("./modules/logger")
const config = require("./config.json")
const express = require("express")
const dbController = require("./controllers/db")
const keysController = require("./controllers/keys")
const app = express()
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
keysController.initKeys()
dbController.initDB()
const routes = fs.readdirSync(path.join(__dirname, "routes"), { recursive: true })
for (let route of routes) {
if (route.endsWith(".js")) {
if (route.endsWith("index.js")) {
route = "/" + route.replace("index.js", "/").replace(/\\/g, "/").replace(/\/\//g, "")
} else {
route = "/" + route.replace(".js", "").replace(/\\/g, "/")
}
const routeHandler = require(`./routes/${route}`)
app.use(route, routeHandler)
logger.log(`Route ${route.cyan.bold} registered`)
}
}
// app.use("/", (req, res) => {
// console.log(req.headers)
// console.log(req.path)
// })
app.listen(config.web.port, () => {
logger.log(`Server listening at port : ${config.web.port}`)
})