Merge branch 'main' into feat-automation-test

This commit is contained in:
wancat committed 2024-02-23 14:59:00 +08:00
commit 025553f42a
123 files changed
+8922 -11964

No files matched your search

+6 -3
View File
@@ -1,6 +1,5 @@
FROM node:20-alpine AS build
RUN yarn set version berry
WORKDIR /app
COPY ./package.json yarn.lock tsconfig.json ./
COPY packages/file-graph/package.json ./packages/file-graph/package.json
@@ -27,7 +26,11 @@ COPY server ./server
COPY quests ./quests
ENV NODE_ENV=production
RUN yarn build
RUN yarn workspace @linux-odyssey/file-graph build
RUN yarn workspace @linux-odyssey/utils build
RUN yarn workspace @linux-odyssey/models build
RUN yarn workspace @linux-odyssey/constants build
RUN yarn workspace server build
WORKDIR /app/server
CMD [ "yarn", "start" ]
@@ -39,4 +42,4 @@ COPY server/nodemon.json ./server/nodemon.json
COPY tsconfig.json ./tsconfig.json
COPY server/tsconfig.json ./server/tsconfig.json
ENV NODE_ENV=development
CMD [ "yarn", "server" ]
CMD [ "yarn", "server" ]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"watch": ["dist", "swagger.yaml", "../quests", "../packages"],
"ext": "js,ts,yaml,yml",
"exec": "node dist/index.js",
"exec": "node -r source-map-support/register dist/index.js",
"delay": 200
}
+17 -2
View File
@@ -5,11 +5,10 @@
"type": "module",
"scripts": {
"start": "node dist/index.js",
"dev": "nodemon",
"setup": "node dist/cli/setup.js",
"clean": "node dist/cli/clean.js",
"build": "tsc",
"watch": "concurrently \"nodemon\" \"tsc -w\"",
"dev": "concurrently \"tsc -w\" \"sleep 5 && nodemon\"",
"build:quests": "node dist/cli/build.js"
},
"dependencies": {
@@ -20,6 +19,7 @@
"bcrypt": "^5.1.1",
"commander": "^11.0.0",
"connect-mongo": "^5.0.0",
"cookie-parser": "^1.4.6",
"dockerode": "^3.3.5",
"dotenv": "^16.4.1",
"express": "^4.18.2",
@@ -27,6 +27,7 @@
"express-session": "^1.17.3",
"express-validator": "^7.0.1",
"jsonwebtoken": "^9.0.1",
"lusca": "^1.7.0",
"minimist": "^1.2.8",
"mongodb": "^6.3.0",
"mongoose": "^8.0.3",
@@ -43,11 +44,25 @@
"yaml": "^2.3.1"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/dockerode": "^3.3.23",
"@types/express": "^4.17.21",
"@types/express-session": "^1.17.10",
"@types/jsonwebtoken": "^9.0.5",
"@types/minimist": "^1.2.5",
"@types/node-schedule": "^2.1.6",
"@types/passport": "^1.0.16",
"@types/passport-github2": "^1.2.9",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/passport-oauth2": "^1.4.15",
"@types/source-map-support": "^0",
"@types/swagger-ui-express": "^4.1.6",
"@types/validator": "^13.11.9",
"concurrently": "^8.2.2",
"jest": "^29.6.1",
"nodemon": "^3.0.1",
"source-map-support": "^0.5.21",
"typescript": "^5.3.3"
}
}
@@ -1,92 +0,0 @@
import { matchedData } from 'express-validator'
import { genJWT } from '../../utils/auth.js'
import { createUser } from '../../models/userManager.js'
import { asyncHandler } from '../../middleware/error.js'
import logger from '../../utils/logger.js'
export const issueToken = asyncHandler(async (req, res) => {
const { user } = req
const token = await genJWT({
_id: user.id,
username: user.username,
email: user.email,
})
res.json({ token })
})
export const register = asyncHandler(async (req, res, next) => {
const { username, password, email } = matchedData(req)
const user = await createUser(username, email, { password })
req.login(user, (err) => {
if (err) {
next(err)
return
}
res.status(201).json({
message: 'user created',
})
})
})
export function logout(req, res) {
req.logout((err) => {
if (err) {
logger.error(err)
res.status(500).json({
message: 'error logging out',
})
return
}
res.status(200).json({
message: 'logged out',
})
})
}
export function checkSession(req, res) {
res.json({
loggedIn: req.isAuthenticated() && !req.session.newUser,
})
}
export function socialLoginHandler(req, res) {
const { newUser } = req.user
if (newUser) {
req.session.newUser = newUser
res.redirect('/choose-username')
return
}
res.redirect('/')
}
export const registerFromSession = asyncHandler(async (req, res, next) => {
const { username } = matchedData(req)
const { newUser } = req.session
if (!newUser) {
res.status(401).json({
message: 'no new user found',
})
return
}
const { email, socialLogin } = newUser
if (!socialLogin) {
throw new Error('Socail Login not found')
}
const user = await createUser(username, email, { socialLogin })
req.login(user, (err) => {
if (err) {
next(err)
return
}
delete req.session.newUser
res.status(201).json({
message: 'user created',
})
})
})
@@ -0,0 +1,111 @@
import type { Request, Response, NextFunction, Express } from 'express'
import { matchedData } from 'express-validator'
import { genJWT } from '../../utils/auth.js'
import { createUser } from '../../models/userManager.js'
import { asyncHandler } from '../../middleware/error.js'
import logger from '../../utils/logger.js'
export const issueToken = asyncHandler(async (req: Request, res: Response) => {
const user = req.user as Express.ExistingUser
const token = await genJWT({
_id: user.id,
username: user.username,
email: user.email,
})
res.json({ token })
})
export const register = asyncHandler(
async (req: Request, res: Response, next: NextFunction) => {
const { username, password, email } = matchedData(req)
const user = await createUser(username, email, { password })
req.login(
{
id: user.id,
username: user.username,
email: user.email,
},
(err) => {
if (err) {
next(err)
return
}
res.status(201).json({
message: 'user created',
})
}
)
}
)
export function logout(req: Request, res: Response) {
req.logout((err) => {
if (err) {
logger.error(err)
res.status(500).json({
message: 'error logging out',
})
return
}
res.status(200).json({
message: 'logged out',
})
})
}
export function checkSession(req: Request, res: Response) {
res.json({
loggedIn: req.isAuthenticated() && !req.session.newUser,
})
}
export function socialLoginHandler(req: Request, res: Response) {
const { newUser } = req.user as Express.NewUser
if (newUser) {
req.session.newUser = newUser
res.redirect('/choose-username')
return
}
res.redirect('/')
}
export const registerFromSession = asyncHandler(
async (req: Request, res: Response, next: NextFunction) => {
const { username } = matchedData(req)
const { newUser } = req.session
if (!newUser) {
res.status(401).json({
message: 'no new user found',
})
return
}
const { email, socialLogin } = newUser
if (!socialLogin) {
throw new Error('Socail Login not found')
}
const user = await createUser(username, email, { socialLogin })
req.login(
{
id: user.id,
username: user.username,
email: user.email,
},
(err) => {
if (err) {
next(err)
return
}
delete req.session.newUser
res.status(201).json({
message: 'user created',
})
}
)
}
)
@@ -1,5 +1,6 @@
import { Command, Session } from '@linux-odyssey/models'
import { Command, Quest, Session } from '@linux-odyssey/models'
import { matchedData } from 'express-validator'
import type { Request, Response } from 'express'
import CommandHandler from '../../game/commandHandler.js'
import { pushToSession } from '../socket.js'
import { finishSession } from '../../models/sessionManager.js'
@@ -7,10 +8,10 @@ import { asyncHandler } from '../../middleware/error.js'
const commandCompleteCallbacks = new Map()
export const newCommand = asyncHandler(async (req, res) => {
export const newCommand = asyncHandler(async (req: Request, res: Response) => {
const { command, pwd, output, error, params } = matchedData(req)
const { sessionId } = req.user
const { sessionId } = req.user as any
const session = await Session.findById(sessionId)
if (!session) {
res.status(404).json({ message: 'session not found' })
@@ -38,7 +39,13 @@ export const newCommand = asyncHandler(async (req, res) => {
await c.save()
const commandHandler = new CommandHandler(session, c, params)
const quest = await Quest.findById(session.quest)
if (!quest) {
res.status(404).json({ message: 'quest not found' })
return
}
const commandHandler = new CommandHandler(session, quest, c, params)
const response = await commandHandler.run()
if (response) {
c.stage = response.stage
@@ -49,33 +56,37 @@ export const newCommand = asyncHandler(async (req, res) => {
}
commandCompleteCallbacks.set(session.id, response.callback)
pushToSession(session.id, 'status', 'pending')
res.status(201).json(response)
} else {
res.status(200).end()
}
res.status(201).json(response)
await session.save()
})
export const completedCommand = asyncHandler(async (req, res) => {
const { sessionId } = req.user
export const completedCommand = asyncHandler(
async (req: Request, res: Response) => {
const { sessionId } = req.user as any
const session = await Session.findById(sessionId)
const session = await Session.findById(sessionId)
if (!session) {
res.status(404).json({ message: 'session not found' })
return
if (!session) {
res.status(404).json({ message: 'session not found' })
return
}
if (session.status === 'inactive') {
res.status(400).json({ message: 'session is not active' })
return
}
const callback = commandCompleteCallbacks.get(session.id)
if (!callback) {
res.status(200).json({ message: 'no callback found' })
return
}
callback()
commandCompleteCallbacks.delete(session.id)
res.json({ message: 'ok' })
}
if (session.status === 'inactive') {
res.status(400).json({ message: 'session is not active' })
return
}
const callback = commandCompleteCallbacks.get(session.id)
if (!callback) {
res.status(200).json({ message: 'no callback found' })
return
}
callback()
commandCompleteCallbacks.delete(session.id)
res.json({ message: 'ok' })
})
)
@@ -1,7 +1,8 @@
import type { Request, Response } from 'express'
import { asyncHandler } from '../../middleware/error.js'
import { leaderboard } from '../../models/userManager.js'
export default asyncHandler(async (req, res) => {
export default asyncHandler(async (req: Request, res: Response) => {
const users = await leaderboard()
const response = users.map((user) => ({
username: user.user.username,
@@ -1,25 +0,0 @@
import { Quest } from '@linux-odyssey/models'
import { matchedData } from 'express-validator'
import { asyncHandler } from '../../middleware/error.js'
export const getQuests = asyncHandler(async (req, res) => {
const quests = await Quest.find({})
res.json(
quests.map(({ title, _id, requirements }) => ({
_id,
title,
requirements,
}))
)
})
export const getQuestDetail = asyncHandler(async (req, res) => {
const { questId } = matchedData(req)
const quest = await Quest.findById(questId)
if (!quest) {
res.status(404).json({ message: 'Quest not found.' })
return
}
const { _id, title, order, instruction } = quest
res.json({ _id, title, order, instruction })
})
@@ -0,0 +1,28 @@
import { Quest } from '@linux-odyssey/models'
import { matchedData } from 'express-validator'
import type { Request, Response } from 'express'
import { asyncHandler } from '../../middleware/error.js'
export const getQuests = asyncHandler(async (req: Request, res: Response) => {
const quests = await Quest.find({})
res.json(
quests.map(({ title, _id, requirements }) => ({
_id,
title,
requirements,
}))
)
})
export const getQuestDetail = asyncHandler(
async (req: Request, res: Response) => {
const { questId } = matchedData(req)
const quest = await Quest.findById(questId)
if (!quest) {
res.status(404).json({ message: 'Quest not found.' })
return
}
const { _id, title, instruction, requirements } = quest
res.json({ _id, title, instruction, requirements })
}
)
@@ -1,75 +0,0 @@
import { matchedData } from 'express-validator'
import { Session } from '@linux-odyssey/models'
import {
createNewSession,
getOrCreateActiveSession,
isQuestUnlocked,
} from '../../models/sessionManager.js'
import { asyncHandler } from '../../middleware/error.js'
import config from '../../config.js'
function sessionSummary(session) {
return {
_id: session._id,
user: session.user._id || session.user,
quest: session.quest._id || session.quest,
status: session.status,
createdAt: session.createdAt,
lastActivityAt: session.lastActivityAt,
}
}
function sessionDetail(session) {
return {
...sessionSummary(session),
hints: session.hints,
tasks: session.tasks,
graph: session.graph,
}
}
export const getSessionList = asyncHandler(async (req, res) => {
const query = { user: req.user._id }
const { questId, status } = matchedData(req)
if (questId) {
query.quest = questId
}
query.status = status || 'active'
const sessions = await Session.find(query)
res.json(sessions.map(sessionSummary))
})
export const createSession = asyncHandler(async (req, res) => {
const { questId } = matchedData(req)
if (await isQuestUnlocked(req.user, questId)) {
const session = await createNewSession(req.user, questId)
res.status(201).json(sessionDetail(session))
} else {
res.status(403).json({ message: 'Quest is locked.' })
}
})
export const getSessionById = asyncHandler(async (req, res) => {
const { sessionId } = matchedData(req)
const session = await Session.findOne({
_id: sessionId,
user: req.user._id,
})
if (!session) {
res.status(404).json({ message: 'Session not found.' })
return
}
res.json(sessionDetail(session))
})
export const getOrCreateSession = asyncHandler(async (req, res) => {
const { questId } = matchedData(req)
if ((await isQuestUnlocked(req.user, questId)) || config.testing.enabled) {
const session = await getOrCreateActiveSession(req.user, questId)
res.json(sessionDetail(session))
} else {
res.status(403).json({ message: 'Quest is locked.' })
}
})
@@ -0,0 +1,86 @@
import { matchedData } from 'express-validator'
import type { Express, Request, Response } from 'express'
import { User, Session, ISession } from '@linux-odyssey/models'
import {
createNewSession,
getOrCreateActiveSession,
isQuestUnlocked,
} from '../../models/sessionManager.js'
import { asyncHandler } from '../../middleware/error.js'
import config from '../../config.js'
function sessionSummary(session: ISession) {
return {
_id: session._id,
user: session.user,
quest: session.quest,
status: session.status,
createdAt: session.createdAt,
lastActivityAt: session.lastActivityAt,
}
}
function sessionDetail(session: ISession) {
return {
...sessionSummary(session),
hints: session.hints,
tasks: session.tasks,
graph: session.graph,
}
}
export const getSessionList = asyncHandler(
async (req: Request, res: Response) => {
const user = req.user as Express.ExistingUser
const { questId, status } = matchedData(req)
const query = { user: user.id, quest: questId, status }
const sessions = await Session.find(query)
res.json(sessions.map(sessionSummary))
}
)
export const createSession = asyncHandler(
async (req: Request, res: Response) => {
const { questId } = matchedData(req)
const user = await User.findById((req.user as Express.ExistingUser).id)
if (user && (await isQuestUnlocked(user, questId))) {
const session = await createNewSession(user, questId)
res.status(201).json(sessionDetail(session))
} else {
res.status(403).json({ message: 'Quest is locked.' })
}
}
)
export const getSessionById = asyncHandler(
async (req: Request, res: Response) => {
const { sessionId } = matchedData(req)
const session = await Session.findOne({
_id: sessionId,
user: (req.user as Express.ExistingUser).id,
})
if (!session) {
res.status(404).json({ message: 'Session not found.' })
return
}
res.json(sessionDetail(session))
}
)
export const getOrCreateSession = asyncHandler(
async (req: Request, res: Response) => {
const { questId } = matchedData(req)
const user = await User.findById((req.user as Express.ExistingUser).id)
if (
user &&
((await isQuestUnlocked(user, questId)) || config.testing.enabled)
) {
const session = await getOrCreateActiveSession(user, questId)
res.json(sessionDetail(session))
} else {
res.status(403).json({ message: 'Quest is locked.' })
}
}
)
@@ -1,16 +1,16 @@
import type { Express, Request, Response } from 'express'
import { UserProfile } from '@linux-odyssey/models'
import { asyncHandler } from '../../middleware/error.js'
export const me = asyncHandler(async (req, res) => {
export const me = asyncHandler(async (req: Request, res: Response) => {
const { id, username, email } = req.user as Express.ExistingUser
const userProfile = await UserProfile.findOne({
user: req.user._id,
user: id,
})
if (!userProfile) {
throw new Error(
`UserProfile not found for user ${req.user.username} ${req.user._id}!`
)
throw new Error(`UserProfile not found for user ${username} ${id}!`)
}
const { username, email } = req.user
const { progress } = userProfile
res.json({
username,
@@ -1,4 +1,4 @@
import { Router } from 'express'
import { Router, Request, Response } from 'express'
import passport from 'passport'
import enabledMethods from '../../auth/passport.js'
@@ -25,7 +25,7 @@ router.post(
authenticateRateLimit,
loginValidators,
passport.authenticate('local', { failureMessage: true }),
(req, res) => {
(req: Request, res: Response) => {
res.json({ message: 'success' })
}
)
@@ -34,8 +34,10 @@ router.post('/register', authenticateRateLimit, registerValidators, register)
router.post('/logout', logout)
router.get('/check-username', checkUsernameValidators, (req, res) =>
res.json({ available: true })
router.get(
'/check-username',
checkUsernameValidators,
(req: Request, res: Response) => res.json({ available: true })
)
router.get('/check-session', checkSession)
@@ -10,7 +10,6 @@ import config from '../../config.js'
import leaderboardController from '../controllers/leaderboardController.js'
const router = Router()
if (!config.isProduction) {
router.get('/', (req, res) => {
res.send('Hello API!')
@@ -21,17 +20,17 @@ if (!config.isProduction) {
})
}
router.use('/auth', authRouter)
router.use('/quests', quests)
router.use('/users', userRouter)
router.use('/sessions', authRequired, sessions)
router.use(
'/commands',
passport.authenticate('jwt', { session: false }),
authRequired,
commands
)
router.use('/auth', authRouter)
router.use('/sessions', authRequired, sessions)
router.use('/quests', quests)
router.use('/users', userRouter)
router.get('/survey', (req, res) => {
if (!config.surveyUrl) {
File renamed without changes.
File renamed without changes.
@@ -1,43 +1,56 @@
import { Server } from 'socket.io'
import type { Socket } from 'socket.io'
import validator from 'validator'
import type { Server as HttpServer } from 'http'
import { Session } from '@linux-odyssey/models'
import { getAndStartContainer, attachContainer } from '../containers/docker.js'
import SessionMiddleware from '../middleware/session.js'
import { genJWT } from '../utils/auth.js'
import logger from '../utils/logger.js'
const sessions = new Map()
// eslint-disable-next-line no-unused-vars
type SocketCallback = (event: string, ...args: any[]) => void
function listenToSession(sessionId, callback) {
const sessions = new Map<string, SocketCallback[]>()
function listenToSession(sessionId: string, callback: SocketCallback) {
const callbacks = sessions.get(sessionId) || []
sessions.set(sessionId, callbacks.concat(callback))
}
function removeFromSession(sessionId, event, callback) {
const callbacks = sessions.get((sessionId, event)) || []
function removeFromSession(sessionId: string, callback: SocketCallback) {
const callbacks = sessions.get(sessionId) || []
sessions.set(
(sessionId, event),
sessionId,
callbacks.filter((cb) => cb !== callback)
)
}
export function pushToSession(sessionId, event, ...args) {
export function pushToSession(
sessionId: string,
event: string,
...args: any[]
) {
const callbacks = sessions.get(sessionId)
if (callbacks) {
callbacks.forEach((callback) => callback(event, ...args))
}
}
async function connectContainer(socket, next) {
const user = socket.request.session?.passport?.user
// console.log(user)
// eslint-disable-next-line no-unused-vars
async function connectContainer(socket: Socket, next: (err?: Error) => void) {
const user = (socket.request as any).session?.passport?.user
if (!user) {
next(new Error('User not found.'))
return
}
// console.log(socket.handshake)
const { sessionId } = socket.handshake.query
if (!(sessionId && validator.isMongoId(sessionId))) {
if (
!sessionId ||
typeof sessionId !== 'string' ||
!validator.isMongoId(sessionId)
) {
logger.warn('Invalid Session ID.', user.username, sessionId)
next(new Error('Invalid Session ID.'))
return
@@ -53,10 +66,15 @@ async function connectContainer(socket, next) {
sessionId: session.id,
})
if (!session.containerId) {
next(new Error('Session has no container.'))
return
}
try {
const container = await getAndStartContainer(session.containerId)
const stream = await attachContainer(container, { token })
socket.context = {
socket.data.context = {
session,
stream,
}
@@ -66,9 +84,9 @@ async function connectContainer(socket, next) {
}
}
function onConnect(socket) {
const { session, stream } = socket.context
stream.socket.on('data', (chunk) => {
function onConnect(socket: Socket) {
const { session, stream } = socket.data.context
stream.socket.on('data', (chunk: Buffer) => {
socket.emit('terminal', chunk.toString())
})
@@ -76,7 +94,7 @@ function onConnect(socket) {
stream.socket.write(message)
})
const socketCallback = (event, ...data) => {
const socketCallback = (event: string, ...data: any[]) => {
socket.emit(event, ...data)
}
@@ -89,7 +107,7 @@ function onConnect(socket) {
})
}
export default (server) => {
export default (server: HttpServer) => {
const io = new Server(server)
io.engine.use(SessionMiddleware)
@@ -1,9 +1,16 @@
import GitHubStrategy from 'passport-github2'
import { Strategy as GitHubStrategy } from 'passport-github2'
import type { Profile } from 'passport'
import type { VerifyCallback } from 'passport-oauth2'
import config from '../config.js'
import oauthVerify from './oauthVerify.js'
function verify(accessToken, refreshToken, profile, cb) {
function verify(
accessToken: string,
refreshToken: string,
profile: Profile,
cb: VerifyCallback
) {
oauthVerify('github', profile, { 'socialLogins.github.id': profile.id }, cb)
}
@@ -1,9 +1,9 @@
import GoogleStrategy from 'passport-google-oidc'
import { Strategy as GoogleStrategy } from 'passport-google-oidc'
import config from '../config.js'
import oauthVerify from './oauthVerify.js'
function verify(issuer, profile, cb) {
function verify(issuer: string, profile: any, cb: any) {
oauthVerify('google', profile, { 'socialLogins.google.id': profile.id }, cb)
}
@@ -15,7 +15,6 @@ export default enabled
clientID: config.google.clientID,
clientSecret: config.google.clientSecret,
callbackURL: `${config.baseUrl}/api/v1/auth/google/callback`,
scope: ['profile', 'email'],
},
verify
)
@@ -1,4 +1,5 @@
import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt'
import type { VerifyCallback } from 'passport-jwt'
import config from '../config.js'
const jwtOptions = {
@@ -6,7 +7,7 @@ const jwtOptions = {
secretOrKey: config.secret,
}
function verifyJwt(jwtPayload, done) {
function verifyJwt(jwtPayload: any, done: VerifyCallback) {
return done(null, jwtPayload)
}
@@ -1,9 +1,16 @@
import { User } from '@linux-odyssey/models'
import type { Profile } from 'passport'
import type { VerifyCallback } from 'passport-oauth2'
export default async function oauthVerify(provider, profile, query, cb) {
export default async function oauthVerify(
provider: string,
profile: Profile,
query: Record<string, any>,
cb: VerifyCallback
) {
process.nextTick(async () => {
try {
const email = profile.emails[0]?.value
const email = profile.emails ? profile.emails[0]?.value : undefined
if (!email) {
return cb(new Error('No email found'))
}
@@ -24,9 +31,15 @@ export default async function oauthVerify(provider, profile, query, cb) {
await user.save()
}
return cb(null, user)
return cb(null, {
id: user.id,
username: user.username,
email: user.email,
})
} catch (err) {
return cb(err)
return cb(
err instanceof Error ? err : new Error('An unknown error occurred')
)
}
})
}
@@ -1,5 +1,6 @@
import passport from 'passport'
import { UserProfile } from '@linux-odyssey/models'
import type { Express } from 'express'
import passwordStrategy from './passwordStrategy.js'
import jwtStrategy from './jwtStrategy.js'
import googleStrategy from './googleStrategy.js'
@@ -17,32 +18,33 @@ const enabledMethods = {
logger.info('Enabled login methods', enabledMethods)
if (enabledMethods.google) {
if (googleStrategy !== null) {
passport.use(googleStrategy)
}
if (enabledMethods.github) {
if (githubStrategy !== null) {
passport.use(githubStrategy)
}
passport.serializeUser((user, done) => {
passport.serializeUser((user: Express.User, done) => {
process.nextTick(async () => {
try {
if (user._id && !(await UserProfile.exists({ user: user._id }))) {
await UserProfile.create({ user: user._id })
if (!('id' in user)) {
done(new Error('User has no _id'))
return
}
done(null, {
_id: user._id,
username: user.username,
email: user.email,
})
const { id } = user
if (!(await UserProfile.exists({ user: id }))) {
await UserProfile.create({ user: id })
}
done(null, user)
} catch (err) {
done(err)
}
})
})
passport.deserializeUser((user, done) => {
passport.deserializeUser((user: Express.User, done) => {
process.nextTick(() => {
done(null, user)
})
-24
View File
@@ -1,24 +0,0 @@
import bcrypt from 'bcrypt'
import LocalStrategy from 'passport-local'
import { User } from '@linux-odyssey/models'
async function verifyPassword(username, password, done) {
try {
const user = await User.findOne({
$or: [{ username }, { email: username }],
})
if (!user) {
return done(null, false, { message: 'Incorrect username or password.' })
}
const isPasswordValid = await bcrypt.compare(password, user.hashedPassword)
if (!isPasswordValid) {
return done(null, false, { message: 'Incorrect username or password.' })
}
return done(null, user)
} catch (err) {
return done(err)
}
}
export default new LocalStrategy(verifyPassword)
+42
View File
@@ -0,0 +1,42 @@
/* eslint-disable no-unused-vars */
import bcrypt from 'bcrypt'
import { Strategy as LocalStrategy } from 'passport-local'
import type { IVerifyOptions } from 'passport-local'
import type { Express } from 'express'
import { User } from '@linux-odyssey/models'
async function verifyPassword(
username: string,
password: string,
done: (
error: any,
user?: Express.User | false,
options?: IVerifyOptions
) => void
) {
try {
const user = await User.findOne({
$or: [{ username }, { email: username }],
})
if (!user) {
return done(null, false, { message: 'Incorrect username or password.' })
}
const isPasswordValid = await bcrypt.compare(
password,
user.hashedPassword || ''
)
if (!isPasswordValid) {
return done(null, false, { message: 'Incorrect username or password.' })
}
return done(null, {
id: user.id,
username: user.username,
email: user.email,
})
} catch (err) {
return done(err)
}
}
export default new LocalStrategy(verifyPassword)
File renamed without changes.
File renamed without changes.
File renamed without changes.
-76
View File
@@ -1,76 +0,0 @@
import validator from 'validator'
import dotenv from 'dotenv'
import { get } from './utils/env.js'
// Import dotenv and load ../.env
dotenv.config({ path: '../.env' })
if (process.env.NODE_ENV !== 'production') {
dotenv.config({ path: '../.env.dev' })
}
function getTrustProxies(key) {
if (!process.env[key]) {
return []
}
return process.env[key]
.split(',')
.map((p) => validator.trim(p))
.filter((p) => validator.isIPRange(p) || validator.isIP(p))
}
function getUrl(key) {
const url = get(key, '')
if (validator.isURL(url)) {
return validator.trim(url)
}
return ''
}
const config = {
host: get('HOST', 'localhost'),
port: get('PORT', 3000),
db: get('MONGO_URL', 'mongodb://localhost:27017/odyssey-test'),
secret: get('SECRET_KEY', ''),
isProduction: process.env.NODE_ENV === 'production',
containerExpiry: get('EXPIRY', 1000 * 60 * 60),
sessionMaxAge: get('SESSION_MAX_AGE', 1000 * 60 * 60 * 24 * 7),
google: {
clientID: get('GOOGLE_CLIENT_ID', ''),
clientSecret: get('GOOGLE_CLIENT_SECRET', ''),
},
github: {
clientID: get('GITHUB_CLIENT_ID', ''),
clientSecret: get('GITHUB_CLIENT_SECRET', ''),
},
trustedProxies: getTrustProxies('TRUSTED_PROXIES'),
surveyUrl: getUrl('SURVEY_URL', ''),
bugReportUrl: getUrl('BUG_REPORT_URL', ''),
docker: {
network: get('DOCKER_NETWORK', 'host'),
defaultImage: get('QUEST_IMAGE', 'linuxodyssey/quest-base'),
imagePrefix: get('DOCKER_PREFIX', 'linuxodyssey/quest-'),
mountQuest: get('MOUNT_QUEST', ''),
hostPwd: get('HOST_PWD', ''),
},
log: {
path: get('LOG_PATH', '../logs'),
},
}
config.baseUrl = get('BASE_URL', `http://${config.host}:${config.port}`)
config.backendUrl = get('BACKEND_URL', config.baseUrl)
config.testing = {
enabled: !config.isProduction && process.env.TESTING === 'true',
username: get('TESTING_USERNAME', ''),
password: get('TESTING_PASSWORD', ''),
}
export function getQuestImage(id) {
return `${config.docker.imagePrefix}${id}`
}
console.log(config)
export default config
+85
View File
@@ -0,0 +1,85 @@
import validator from 'validator'
import dotenv from 'dotenv'
import { get } from './utils/env.js'
// Import dotenv and load ../.env
dotenv.config({ path: '../.env' })
if (process.env.NODE_ENV !== 'production') {
dotenv.config({ path: '../.env.dev' })
}
function getTrustProxies(key: string): string[] {
const value = process.env[key]
if (!value) {
return []
}
return value
.split(',')
.map((p) => validator.trim(p))
.filter((p) => validator.isIPRange(p) || validator.isIP(p))
}
function getUrl(key: string): string {
const url = get(key, '')
if (validator.isURL(url)) {
return validator.trim(url)
}
return ''
}
function createConfig() {
const isProduction = process.env.NODE_ENV === 'production'
const host = get('HOST', 'localhost')
const port = Number(get('PORT', 3000))
const baseUrl = get('BASE_URL', `http://${host}:${port}`)
const url = new URL(baseUrl)
return {
host,
port,
baseUrl,
domain: url.hostname,
protocol: url.protocol,
backendUrl: get('BACKEND_URL', baseUrl),
db: get('MONGO_URL', 'mongodb://localhost:27017/odyssey-test'),
secret: get('SECRET_KEY', ''),
isProduction: process.env.NODE_ENV === 'production',
containerExpiry: get('EXPIRY', 1000 * 60 * 60),
sessionMaxAge: get('SESSION_MAX_AGE', 1000 * 60 * 60 * 24 * 7),
google: {
clientID: get('GOOGLE_CLIENT_ID', ''),
clientSecret: get('GOOGLE_CLIENT_SECRET', ''),
},
github: {
clientID: get('GITHUB_CLIENT_ID', ''),
clientSecret: get('GITHUB_CLIENT_SECRET', ''),
},
trustedProxies: getTrustProxies('TRUSTED_PROXIES'),
surveyUrl: getUrl('SURVEY_URL'),
bugReportUrl: getUrl('BUG_REPORT_URL'),
docker: {
network: get('DOCKER_NETWORK', 'host'),
defaultImage: get('QUEST_IMAGE', 'linuxodyssey/quest-base'),
imagePrefix: get('DOCKER_PREFIX', 'linuxodyssey/quest-'),
mountQuest: get('MOUNT_QUEST', ''),
hostPwd: get('HOST_PWD', ''),
},
log: {
path: get('LOG_PATH', '../logs'),
},
testing: {
enabled: !isProduction && process.env.TESTING === 'true',
username: get('TESTING_USERNAME', ''),
password: get('TESTING_PASSWORD', ''),
},
}
}
const config = createConfig()
export function getQuestImage(id: string): string {
return `${config.docker.imagePrefix}${id}`
}
export default config
@@ -1,13 +1,14 @@
import { execFile } from 'child_process'
import { buildFileCheckCmd } from '@linux-odyssey/utils'
import type { IFileCondition } from '@linux-odyssey/models'
function sanitizeId(id) {
function sanitizeId(id: string) {
return id.replace(/[^a-zA-Z0-9]/g, '')
}
// eslint-disable-next-line import/prefer-default-export
export function checkFile(id, file) {
export function checkFile(id: string, file: IFileCondition) {
return new Promise((resolve, reject) => {
const cmd = buildFileCheckCmd(file)
// WARNING: This is vulnerable to command injection
@@ -14,9 +14,12 @@ const containerOptions = {
HostConfig: {
NetworkMode: config.docker.network,
},
}
} as Docker.ContainerCreateOptions
export function createContainer(name, questId) {
export function createContainer(
name: string,
questId: string
): Promise<Docker.Container> {
const option = {
...containerOptions,
name,
@@ -25,7 +28,7 @@ export function createContainer(name, questId) {
const { hostPwd, mountQuest } = config.docker
if (!config.isProduction && hostPwd && mountQuest === questId) {
logger.info('Mounting quest folder', mountQuest)
option.HostConfig.Binds = [
option.HostConfig!.Binds = [
`${hostPwd}/quests/${mountQuest}/home:/home/commander`,
`${hostPwd}/packages/container:/usr/local/lib/container`,
]
@@ -33,7 +36,9 @@ export function createContainer(name, questId) {
return engine.createContainer(option)
}
export async function getAndStartContainer(id) {
export async function getAndStartContainer(
id: string
): Promise<Docker.Container> {
logger.debug(`Getting container: ${id}`)
const container = engine.getContainer(id)
if (!container) {
@@ -45,7 +50,10 @@ export async function getAndStartContainer(id) {
return container
}
export async function attachContainer(container, { token }) {
export async function attachContainer(
container: Docker.Container,
{ token }: { token: string }
) {
const exec = await container.exec({
AttachStdin: true,
AttachStdout: true,
@@ -67,7 +75,7 @@ export async function attachContainer(container, { token }) {
return execOutput
}
export async function deleteContainer(id) {
export async function deleteContainer(id: string) {
const container = engine.getContainer(id)
if (!container) {
return
@@ -80,8 +88,8 @@ export async function deleteContainer(id) {
await container.remove()
}
function parseJSONOutput(data) {
const { stream, error } = JSON.parse(data.toString())
function parseJSONOutput(data: string) {
const { stream, error } = JSON.parse(data)
if (stream) {
// eslint-disable-next-line no-console
console.log(stream)
@@ -91,7 +99,7 @@ function parseJSONOutput(data) {
}
}
export function buildQuestImage(questPath, questId) {
export function buildQuestImage(questPath: string, questId: string) {
return new Promise((resolve, reject) => {
engine.buildImage(
{
@@ -114,9 +122,9 @@ export function buildQuestImage(questPath, questId) {
data
.toString()
.split('\n')
.map((line) => line.trim())
.filter((line) => line !== '')
.forEach((line) => {
.map((line: string) => line.trim())
.filter((line: string) => line !== '')
.forEach((line: string) => {
parseJSONOutput(line)
})
} catch (error) {
@@ -1,18 +1,30 @@
import schedule from 'node-schedule'
import { Session } from '@linux-odyssey/models'
import type { ISession } from '@linux-odyssey/models'
import type { HydratedDocument } from 'mongoose'
import config from '../config.js'
import { deleteContainer } from './docker.js'
import logger from '../utils/logger.js'
async function disableSession(session) {
async function disableSession(session: HydratedDocument<ISession>) {
if (!session.containerId) {
logger.warn(`Deleting session ${session._id} has no containerId`)
return
}
try {
await deleteContainer(session.containerId)
} catch (err) {
// If the container doesn't exist, we can ignore the error
if (err.statusCode !== 404) {
throw new Error(
`Failed to delete container ${session.containerId} for session ${session._id}`
)
// Perform runtime check to safely access statusCode
if (typeof err === 'object' && err !== null && 'statusCode' in err) {
const error = err as { statusCode: number }
if (error.statusCode !== 404) {
throw new Error(
`Failed to delete container ${session.containerId} for session ${session._id}`
)
}
} else {
// Handle the case where err is not the expected type
logger.error(`An unexpected error occurred: ${err}`)
}
}
if (session.status === 'active') {
@@ -1,23 +1,41 @@
import minimist from 'minimist'
import { HydratedDocument } from 'mongoose'
import { FileGraph } from '@linux-odyssey/file-graph'
import { Quest } from '@linux-odyssey/models'
import {
ICommand,
ICondition,
IFileCondition,
IQuest,
ISession,
IStage,
IException,
} from '@linux-odyssey/models'
import { pushToSession } from '../api/socket.js'
import SessionHandler from './sessionHandler.js'
import SessionHandler, { ExecuteResult } from './sessionHandler.js'
import { checkFile } from '../containers/cli.js'
import logger from '../utils/logger.js'
// 檢查 pattern 是否符合 input
const checkMatch = (pattern, input) => {
const checkMatch = (pattern?: string[], input?: string) => {
if (!pattern || pattern.length === 0) return true
return pattern.some((p) => {
const regex = new RegExp(p)
return regex.test(input)
return input && regex.test(input)
})
}
export default class CommandHandler extends SessionHandler {
constructor(session, commandInput, params) {
super(session)
commandInput: ICommand
argv: minimist.ParsedArgs
params: Record<string, any>
constructor(
session: HydratedDocument<ISession>,
quest: HydratedDocument<IQuest>,
commandInput: ICommand,
params: Record<string, any>
) {
super(session, quest)
this.commandInput = commandInput
this.argv = minimist(this.commandInput.command.split(' '))
@@ -56,7 +74,7 @@ export default class CommandHandler extends SessionHandler {
this.session.graph = graph
} catch (e) {
logger.error('Update file graph failed', {
error: e.message,
error: (e as Error).message,
session: this.session.id,
graph: this.session.graph,
discover: this.params.discover,
@@ -64,7 +82,7 @@ export default class CommandHandler extends SessionHandler {
}
}
async isMatch(condition) {
async isMatch(condition: ICondition): Promise<boolean> {
return (
this.checkKeys(condition) &&
(await this.checkFiles(condition.files)) &&
@@ -73,18 +91,25 @@ export default class CommandHandler extends SessionHandler {
)
}
checkKeys(condition) {
const keys = ['command', 'output', 'error', 'pwd']
return keys.every((k) => checkMatch(condition[k], this.commandInput[k]))
checkKeys(condition: ICondition) {
return (
checkMatch(condition.command, this.commandInput.command) &&
checkMatch(condition.output, this.commandInput.output) &&
checkMatch(condition.error, this.commandInput.error) &&
checkMatch(condition.pwd, this.commandInput.pwd)
)
}
async checkFiles(files) {
if (files.length === 0) {
async checkFiles(files?: IFileCondition[]): Promise<boolean> {
if (!files || files.length === 0) {
return true
}
try {
const checks = await Promise.all(
files.map((f) => checkFile(this.session.containerId, f))
files.map(
(f) =>
this.session.containerId && checkFile(this.session.containerId, f)
)
)
return checks.every((c) => c === true)
} catch (e) {
@@ -92,22 +117,22 @@ export default class CommandHandler extends SessionHandler {
}
}
async checkNot(condition) {
async checkNot(condition?: ICondition): Promise<boolean> {
if (!condition) {
return true
}
return !(await this.isMatch(condition))
}
async checkOr(conditions) {
if (conditions.length === 0) {
async checkOr(conditions?: ICondition[]): Promise<boolean> {
if (!conditions || conditions.length === 0) {
return true
}
const matches = await Promise.all(conditions.map((c) => this.isMatch(c)))
return matches.some((m) => m === true)
}
async checkException(stage) {
async checkException(stage: IStage): Promise<IException | null> {
const exceptions = stage.exceptions || []
for (const exception of exceptions) {
// eslint-disable-next-line no-await-in-loop
@@ -119,20 +144,19 @@ export default class CommandHandler extends SessionHandler {
return null
}
async run() {
async run(): Promise<ExecuteResult | null> {
try {
this.quest = await Quest.findById(this.session.quest)
const stages = this.getStages()
if (stages.length === 0) {
logger.warn('stage not found', { session: this.session.id })
return {}
return null
}
this.handleEvent()
this.handleCommand()
const matches = await Promise.all(
stages.map((stage) => this.isMatch(stage.condition))
stages.map((stage) => stage.condition && this.isMatch(stage.condition))
)
const stage = stages.find((_, i) => matches[i])
@@ -161,22 +185,25 @@ export default class CommandHandler extends SessionHandler {
this.quest.exceptions
)
for (const exception of globalExceptions) {
// eslint-disable-next-line no-await-in-loop
if (await this.isMatch(exception.condition)) {
if (
exception.condition &&
// eslint-disable-next-line no-await-in-loop
(await this.isMatch(exception.condition))
) {
return this.executeException(exception)
}
}
}
return {}
return null
} catch (err) {
logger.error('CommandHandler error', {
error: err.message,
error: (err as Error).message,
session: this.session._id,
quest: this.quest._id,
command: this.commandInput,
})
return {}
return null
}
}
}
@@ -1,27 +1,51 @@
import { HydratedDocument } from 'mongoose'
import type {
IException,
IQuest,
IRequirement,
IResponse,
ISession,
IStage,
ITask,
} from '@linux-odyssey/models'
import { pushToSession } from '../api/socket.js'
export interface ExecuteResult {
responses: IResponse[]
hints: string[]
callback: () => void
stage?: string
end?: boolean
}
export default class SessionHandler {
constructor(session) {
session: HydratedDocument<ISession>
quest: HydratedDocument<IQuest>
constructor(
session: HydratedDocument<ISession>,
quest: HydratedDocument<IQuest>
) {
this.session = session
this.quest = session.quest
this.quest = quest
}
getSession() {
getSession(): HydratedDocument<ISession> {
return this.session
}
getTask(stage) {
getTask(stage: Partial<IStage>): ITask | undefined {
return this.session.tasks.find((t) => t.id === stage.id)
}
isCompleted(stage) {
isCompleted(stage: Partial<IStage>): boolean {
const task = this.getTask(stage)
return task ? task.completed : false
}
getUnlockedComponents(components) {
getUnlockedComponents<T extends IRequirement>(components: T[]): T[] {
return components.filter((component) =>
component.requirements.every((r) => this.isCompleted({ id: r }))
component.requirements.every((r: string) => this.isCompleted({ id: r }))
)
}
@@ -43,7 +67,7 @@ export default class SessionHandler {
this.session.tasks.push(...newTasks)
}
execute(stage) {
execute(stage: IStage): ExecuteResult {
this.session.tasks
.filter((t) => t.id === stage.id)
.forEach((t) => {
@@ -67,7 +91,7 @@ export default class SessionHandler {
}
}
executeException(exception) {
executeException(exception: IException): ExecuteResult {
const { hints, responses } = exception
if (hints && hints.length > 0) this.session.hints.push(hints)
+1 -7
View File
@@ -62,13 +62,7 @@ async function main() {
app.use(globalRateLimit)
app.use(sessionMiddleware)
app.use(passport.session())
app.use(
'/api-docs',
swaggerUI.serve,
swaggerUI.setup(swaggerDocument, {
withCredentials: true,
})
)
app.use('/api-docs', swaggerUI.serve, swaggerUI.setup(swaggerDocument))
app.use(express.json())
app.get('/', (req, res) => {
-9
View File
@@ -1,9 +0,0 @@
export default function authRequired(req, res, next) {
if (req.isAuthenticated() && !req.session.newUser) {
next()
return
}
res.status(401).json({
message: 'not authenticated',
})
}
+15
View File
@@ -0,0 +1,15 @@
import type { Request, Response, NextFunction } from 'express'
export default function authRequired(
req: Request,
res: Response,
next: NextFunction
) {
if (req.isAuthenticated() && !('newUser' in req.user)) {
next()
return
}
res.status(401).json({
message: 'not authenticated',
})
}
-13
View File
@@ -1,13 +0,0 @@
import logger from '../utils/logger.js'
export function asyncHandler(fn) {
return (req, res, next) => {
fn(req, res, next).catch(next)
}
}
// eslint-disable-next-line no-unused-vars
export default function errorHandler(err, req, res, next) {
logger.error(err) // Log error stack trace to the console
res.status(500).send({ message: 'Something broke!' }) // Send error response
}
+20
View File
@@ -0,0 +1,20 @@
import type { Request, Response, NextFunction } from 'express'
import logger from '../utils/logger.js'
export function asyncHandler(fn: any) {
return (req: Request, res: Response, next: NextFunction) => {
fn(req, res, next).catch(next)
}
}
// eslint-disable-next-line no-unused-vars
export default function errorHandler(
err: any,
req: Request,
res: Response,
// eslint-disable-next-line no-unused-vars
next: NextFunction
) {
logger.error(err) // Log error stack trace to the console
res.status(500).send({ message: 'Something broke!' }) // Send error response
}
@@ -1,9 +1,11 @@
import rateLimit from 'express-rate-limit'
import type { Options } from 'express-rate-limit'
import type { Request, Response, NextFunction } from 'express'
import config from '../config.js'
function createRateLimiter(options) {
function createRateLimiter(options: Partial<Options>) {
if (config.testing.enabled) {
return (req, res, next) => next()
return (req: Request, res: Response, next: NextFunction) => next()
}
return rateLimit(options)
}
@@ -21,7 +23,7 @@ export const authenticateRateLimit = createRateLimiter({
export const sessionRateLimit = createRateLimiter({
windowMs: 10 * 1000,
max: 1,
keyGenerator: (req) => {
return req.user ? req.user._id : req.ip
keyGenerator: (req: Request) => {
return req.user ? (req.user as any)._id : req.ip || ''
},
})
@@ -9,9 +9,10 @@ export default session({
saveUninitialized: false,
rolling: true,
cookie: {
domain: config.domain,
httpOnly: true,
secure: false,
sameSite: 'lax',
secure: config.protocol === 'https:',
sameSite: 'strict',
maxAge: config.sessionMaxAge,
},
store: MongoStore.create({
@@ -1,6 +1,7 @@
import { validationResult } from 'express-validator'
import type { Request, Response, NextFunction } from 'express'
export function noError(req, res, next) {
export function noError(req: Request, res: Response, next: NextFunction) {
const result = validationResult(req)
if (result.isEmpty()) {
next()
@@ -1,28 +1,36 @@
import { HydratedDocument, Types } from 'mongoose'
import { Session, Quest, UserProfile } from '@linux-odyssey/models'
import type { ISession, IUser } from '@linux-odyssey/models'
import { createContainer, deleteContainer } from '../containers/docker.js'
import SessionHandler from '../game/sessionHandler.js'
import logger from '../utils/logger.js'
async function deactivateSessions(user, quest) {
async function deactivateSessions(userId: Types.ObjectId, quest: string) {
const sessions = await Session.find({
user,
user: userId,
quest,
status: 'active',
})
return Promise.all(
sessions.map((session) => {
return deleteContainer(session.containerId)
.catch((err) => logger.error('Failed to delete container', err))
.finally(() => {
session.status = 'inactive'
session.containerId = null
return session.save()
})
return (
session.containerId &&
deleteContainer(session.containerId)
.catch((err) => logger.error('Failed to delete container', err))
.finally(() => {
return Session.findByIdAndUpdate(session._id, {
$set: { status: 'inactive', containerId: null },
}).catch((err) => logger.error('Failed to update session', err))
})
)
})
)
}
export async function createNewSession(user, questId) {
export async function createNewSession(
user: HydratedDocument<IUser>,
questId: string
): Promise<ISession> {
const quest = await Quest.findById(questId)
if (!quest) {
throw new Error(`Quest ${questId} not found`)
@@ -38,13 +46,13 @@ export async function createNewSession(user, questId) {
quest.image
)
const sessionHandler = new SessionHandler(
new Session({
user,
quest,
containerId: container.id,
})
)
const newSession = new Session({
user,
quest,
containerId: container.id,
})
const sessionHandler = new SessionHandler(newSession, quest)
sessionHandler.addNewTasks()
@@ -62,6 +70,7 @@ export async function createNewSession(user, questId) {
quest: quest._id,
sessions: [session._id],
startedAt: new Date(),
completed: false,
})
} else {
progress.sessions.push(session._id)
@@ -70,7 +79,10 @@ export async function createNewSession(user, questId) {
return session
}
export async function getOrCreateActiveSession(user, questId) {
export async function getOrCreateActiveSession(
user: HydratedDocument<IUser>,
questId: string
): Promise<ISession> {
const session = await Session.findOne({
user,
quest: questId,
@@ -82,7 +94,9 @@ export async function getOrCreateActiveSession(user, questId) {
return createNewSession(user, questId)
}
export async function finishSession(session) {
export async function finishSession(
session: HydratedDocument<ISession>
): Promise<void> {
session.status = 'finished'
await session.save()
@@ -102,10 +116,13 @@ export async function finishSession(session) {
await userProfile.save()
}
export async function isQuestUnlocked(user, questId) {
const userProfile = await UserProfile.findOne({ user: user._id })
export async function isQuestUnlocked(
user: HydratedDocument<IUser>,
questId: string
): Promise<boolean> {
const userProfile = await UserProfile.findOne({ user: user.id })
if (!userProfile) {
throw new Error(`UserProfile ${user._id} not found`)
throw new Error(`UserProfile ${user.id} not found`)
}
const quest = await Quest.findById(questId)
if (!quest) {
@@ -1,7 +1,15 @@
import { Express } from 'express'
import { User, UserProfile } from '@linux-odyssey/models'
import { hashPassword } from '../utils/auth.js'
export async function createUser(username, email, { password, socialLogin }) {
export async function createUser(
username: string,
email: string,
{
password,
socialLogin,
}: { password?: string; socialLogin?: Express.SocialLogin }
) {
const user = new User({
username,
email,
+9
View File
@@ -0,0 +1,9 @@
/* eslint-disable no-undef */
/* eslint-disable no-unused-vars */
import 'express-session'
declare module 'express-session' {
interface SessionData {
newUser?: Express.PendingUser // Adjust the type according to your newUser structure
}
}
+25
View File
@@ -0,0 +1,25 @@
/* eslint-disable no-unused-vars */
declare namespace Express {
export type SocialLogin = {
provider: string
id: string
displayName?: string
}
export type PendingUser = {
email: string
socialLogin: SocialLogin
}
export interface ExistingUser {
id: string
username: string
email: string
}
export interface NewUser {
newUser?: PendingUser
}
export type User = ExistingUser | NewUser
}
+25
View File
@@ -0,0 +1,25 @@
/* eslint-disable no-unused-vars */
declare module 'passport-google-oidc' {
// Add specific type declarations here as needed.
// For example, if you're only using it as a middleware in Express:
import { Strategy as PassportStrategy } from 'passport'
import { Request } from 'express'
interface StrategyOptions {
clientID: string
clientSecret: string
callbackURL: string
}
interface VerifyFunction {
(
issuer: string,
profile: any,
done: (error: any, user?: any, info?: any) => void
): void
}
export class Strategy extends PassportStrategy {
constructor(options: StrategyOptions, verify: VerifyFunction)
}
}
@@ -2,11 +2,11 @@ import jwt from 'jsonwebtoken'
import bcrypt from 'bcrypt'
import config from '../config.js'
export function hashPassword(password) {
export function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 10)
}
export function genJWT(payload) {
export function genJWT(payload: any): Promise<string> {
return new Promise((resolve, reject) => {
jwt.sign(
payload,
@@ -15,6 +15,8 @@ export function genJWT(payload) {
(err, token) => {
if (err) {
reject(err)
} else if (token === undefined) {
reject(new Error('Token is undefined'))
} else {
resolve(token)
}
@@ -23,7 +25,7 @@ export function genJWT(payload) {
})
}
export function verifyJWT(token) {
export function verifyJWT(token: string): Promise<any> {
return new Promise((resolve, reject) => {
jwt.verify(token, config.secret, (err, decoded) => {
if (err) {
-13
View File
@@ -1,13 +0,0 @@
import crypto from 'crypto'
export function get(key, defaultValue) {
const value = process.env[key]
if (value != null) {
return value
}
return defaultValue
}
export function genSecret() {
return crypto.randomUUID()
}
+10
View File
@@ -0,0 +1,10 @@
import crypto from 'crypto'
export function get<T>(key: string, defaultValue: T): T {
const value = process.env[key]
return value !== undefined ? (value as unknown as T) : defaultValue
}
export function genSecret() {
return crypto.randomUUID()
}
File renamed without changes.
@@ -8,7 +8,10 @@ import logger from './logger.js'
const questDirectory = path.join(process.cwd(), '..', 'quests')
class QuestValidationError extends Error {
constructor(message, questId, error) {
questId: string
error: any
constructor(message: string, questId: string, error: any) {
super(message)
this.name = 'QuestValidationError'
this.questId = questId
@@ -16,7 +19,10 @@ class QuestValidationError extends Error {
}
}
async function mapQuests(callback) {
// eslint-disable-next-line no-unused-vars
type QuestCallback = (id: string, questPath: string) => Promise<any>
async function mapQuests(callback: QuestCallback) {
const questNames = await fs.readdir(questDirectory, {
withFileTypes: true,
})
@@ -44,7 +50,7 @@ export async function loadAndUpdateQuests() {
return mapQuests(async (id, questPath) => {
const files = await fs.readdir(questPath)
if (!files.includes('game.yml')) {
throw new QuestValidationError(`Missing game.yml`, id)
throw new QuestValidationError(`Missing game.yml`, id, null)
}
try {
File renamed without changes.