Merge branch 'main' into feat-automation-test

This commit is contained in:
Lin, Hung-Shin committed 2023-11-17 09:43:26 +08:00
commit cb376caff0
41 files changed
+536 -301

No files matched your search

+2 -1
View File
@@ -16,14 +16,15 @@
"@fortawesome/free-regular-svg-icons": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/vue-fontawesome": "^3.0.3",
"@linux-odyssey/constants": "*",
"@linux-odyssey/file-graph": "*",
"@linux-odyssey/utils": "*",
"@linux-odyssey/constants": "*",
"axios": "^1.6.0",
"socket.io-client": "^4.7.1",
"validator": "^13.11.0",
"vue": "^3.3.4",
"vue-router": "^4.2.4",
"vue-toastification": "^2.0.0-rc.5",
"xterm": "^5.2.1",
"xterm-addon-attach": "^0.8.0",
"xterm-addon-fit": "^0.7.0"
+4 -3
View File
@@ -1,6 +1,7 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
import api from '../utils/api'
const emit = defineEmits(['onSubmit', 'onChange'])
@@ -24,6 +25,7 @@ const username = ref('')
const email = ref('')
const password = ref('')
const errorMessage = ref('')
const toast = useToast()
const availableMethods = ref({})
onMounted(async () => {
@@ -39,11 +41,10 @@ const hasSocialLogins = computed(() => {
})
const success = () => {
router.push({ name: 'game' })
router.push({ name: 'game', params: { questId: 'helloworld' } })
}
const error = (err) => {
console.error(err)
errorMessage.value = err
}
@@ -62,8 +63,8 @@ const handleSubmit = () => {
error,
})
} catch (err) {
toast.error('Something went wrong.')
console.error(err)
errorMessage.value = 'Something went wrong'
}
}
+11 -23
View File
@@ -1,34 +1,24 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
import { logout } from '../utils/auth'
import { bugReportUrl } from '../config'
import api from '../utils/api'
import { reset } from '../store/session'
import sessionStore, { reset } from '../store/session'
const router = useRouter()
const toast = useToast()
const handleLogout = async () => {
try {
await logout()
reset()
router.push({ name: 'login' })
} catch (err) {
toast.error('Failed to logout')
console.error(err)
}
}
const questData = ref(null)
const questErr = ref(null)
onMounted(async () => {
try {
const res = await api.get('/quests/helloworld')
questData.value = res.data
} catch (err) {
questErr.value = err
}
})
</script>
<template>
@@ -47,14 +37,12 @@ onMounted(async () => {
class="text-text-tertiary h-full w-full"
/>
</button> -->
<button>
<p
class="text-text-primary inline-block font-bold whitespace-nowrap"
v-if="questData"
>
{{ questData.title }}
</p>
</button>
<p
class="text-text-primary inline-block font-bold whitespace-nowrap"
v-if="sessionStore.quest"
>
{{ sessionStore.quest.title }}
</p>
</div>
<div class="h-full w-full flex gap-3 items-center justify-end">
<!-- <button id="warning" class="flex items-center h-5 w-5">
+9
View File
@@ -3,6 +3,9 @@ import './style.css'
/* import font awesome icon component */
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import './library'
/* import Vue Toastification */
import Toast, { POSITION } from 'vue-toastification'
import 'vue-toastification/dist/index.css'
import App from './App.vue'
import router from './router'
@@ -13,4 +16,10 @@ app.component('font-awesome-icon', FontAwesomeIcon)
app.use(router)
const options = {
position: POSITION.TOP_RIGHT,
}
app.use(Toast, options)
app.mount('#app')
+11 -2
View File
@@ -8,7 +8,12 @@ import { isLoggedIn } from './utils/auth'
const routes = [
{
path: '/',
redirect: '/game',
redirect: {
name: 'game',
params: {
questId: 'helloworld',
},
},
},
{
path: '/login',
@@ -23,9 +28,10 @@ const routes = [
meta: { requiresGuest: true },
},
{
path: '/game',
path: '/game/:questId',
name: 'game',
component: GamePage,
props: true,
meta: { requiresAuth: true },
},
{
@@ -54,6 +60,9 @@ router.beforeEach(async (to, from, next) => {
} else if (to.meta.requiresGuest && loggedIn) {
next({
name: 'game', // Or any default authenticated user route
params: {
questId: 'helloworld',
},
})
} else {
next() // Always call next() at the end
+28 -16
View File
@@ -1,9 +1,12 @@
import { reactive } from 'vue'
import { useToast } from 'vue-toastification'
import { FileGraph } from '@linux-odyssey/file-graph'
import api from '../utils/api'
import Socket from '../utils/socket'
import SocketTerminal from '../utils/terminal'
const toast = useToast()
function newSession() {
const graph = new FileGraph({
path: '/',
@@ -36,30 +39,39 @@ function setQuest(questId) {
}
async function setSession(session) {
store.session = session
store.session.graph = new FileGraph(session.graph)
term.reset()
await socket.connect(session)
term.focus()
try {
store.session = session
store.session.graph = new FileGraph(session.graph)
term.reset()
await socket.connect(session)
term.focus()
} catch (err) {
console.error(err)
toast.error(err.message)
throw err
}
}
export async function createSession() {
console.log('Creating a new session...')
const res = await api.post('/sessions', { quest_id: store.questId })
const { data } = res
await setSession(data)
try {
console.log('Creating a new session...')
const res = await api.post('/sessions', { questId: store.questId })
const { data } = res
await setSession(data)
} catch (err) {
console.error(err)
toast.error('Failed to create a new session. Please try again later.')
}
}
async function getActiveSession(questId) {
const res = await api.post('/sessions/active', {
quest_id: questId,
})
const session = res.data
try {
await setSession(session)
const res = await api.post('/sessions/active', { questId })
await setSession(res.data)
} catch (err) {
console.log(err)
await createSession().catch(console.error)
console.error(err)
toast.warning('Failed to connect previous session. Creating a new one...')
await createSession()
}
}
+2 -2
View File
@@ -12,7 +12,7 @@ class SocketWrapper {
this.socket = io('', {
query: {
session_id: session._id,
sessionId: session._id,
},
})
@@ -23,7 +23,7 @@ class SocketWrapper {
this.socket.once('terminal', () => {
resolve()
})
this.socket.once('error', reject)
this.socket.on('connect_error', reject)
this.bindListeners()
})
}
+16 -1
View File
@@ -1,5 +1,6 @@
<script setup>
import { computed, onMounted } from 'vue'
import { useToast } from 'vue-toastification'
import sessionStore, { init } from '../store/session'
import GameHeaderPart from '../components/GameHeaderPart.vue'
import CommandlistPart from '../components/CommandlistPart.vue'
@@ -14,8 +15,22 @@ const completed = computed(() => {
return sessionStore.session.status === 'finished'
})
const props = defineProps({
questId: {
type: String,
required: true,
},
})
const toast = useToast()
onMounted(async () => {
await init('helloworld')
try {
await init(props.questId)
} catch (err) {
console.error(err)
toast.error('Failed to initialize game session')
}
})
</script>
+2 -2
View File
@@ -2,8 +2,8 @@
"name": "linux-odyssey",
"version": "0.0.0",
"main": "index.js",
"repository": "https://github.com/lancatlin/linux-odyssey",
"license": "MIT",
"repository": "https://github.com/linux-odyssey/linux-odyssey",
"license": "GPL",
"private": true,
"workspaces": [
"app",
+2 -2
View File
@@ -27,7 +27,7 @@ function debug(...args) {
async function createSession() {
console.log('Creating a new session...')
const res = await api.post('/sessions', {
quest_id: 'helloworld',
questId: 'helloworld',
})
const { data } = await res
if (!data._id) {
@@ -68,7 +68,7 @@ async function connect(sessionId, cookie) {
Cookie: cookie,
},
query: {
session_id: sessionId,
sessionId,
},
})
+7 -3
View File
@@ -48,7 +48,7 @@ async function main() {
},
})
const additionalData = await handleCommand(CMD_NAME)
const params = await handleCommand(CMD_NAME)
const output = await readOrNone(CMD_OUTPUT_FILE)
const error = await readOrNone(CMD_ERROR_FILE)
@@ -58,7 +58,7 @@ async function main() {
error,
exit_code: CMD_EXIT_CODE,
pwd: PWD,
...additionalData,
params,
}
try {
const res = await api.post('/commands', payload)
@@ -77,7 +77,11 @@ async function main() {
console.log(colorize('Quest completed!', 'blue'))
}
} catch (err) {
console.error(err)
if (err.response) {
console.error(err.response.data)
return
}
console.error(err.message)
}
}
+1
View File
@@ -4,6 +4,7 @@ export { default as User } from './user.js'
export { default as Quest } from './quest.js'
export { default as Session } from './session.js'
export { default as Command } from './command.js'
export { default as UserProfile } from './userProfile.js'
export default async function connectDB(uri) {
const db = await mongoose.connect(uri, {
+29 -6
View File
@@ -54,7 +54,10 @@ const stageSchema = new Schema({
required: true,
default: false,
},
condition: conditionSchema,
condition: {
type: conditionSchema,
required: true,
},
responses: {
type: [responseSchema],
required: true,
@@ -68,11 +71,31 @@ const stageSchema = new Schema({
const Quest = model(
'Quest',
new Schema({
_id: String,
title: String,
order: Number,
instruction: String,
stages: [stageSchema],
_id: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
requirements: {
type: [
{
type: String,
ref: 'Quest',
},
],
required: true,
},
instruction: {
type: String,
required: true,
},
stages: {
type: [stageSchema],
required: true,
},
})
)
+51
View File
@@ -0,0 +1,51 @@
import { model, Schema } from 'mongoose'
const progressSchema = new Schema({
quest: {
type: String,
ref: 'Quest',
required: true,
},
sessions: [
{
type: Schema.ObjectId,
ref: 'Session',
required: true,
},
],
completed: {
type: Boolean,
default: false,
required: true,
},
startedAt: {
type: Date,
required: true,
},
finishedAt: Date,
})
const UserProfile = model(
'UserProfile',
new Schema(
{
user: {
type: Schema.ObjectId,
ref: 'User',
unique: true,
required: true,
},
progress: {
type: Map,
of: progressSchema,
default: {},
required: true,
},
},
{
timestamps: true,
}
)
)
export default UserProfile
+4 -20
View File
@@ -1,31 +1,15 @@
FROM ubuntu:22.04
FROM alpine:3.18
LABEL maintainer="wancat@wancat.cc"
RUN apt-get update && apt-get install -y \
locales \
curl \
RUN apk add --no-cache \
vim \
zsh \
ca-certificates \
gnupg
RUN sed -i -e 's/# zh_TW.UTF-8 UTF-8/zh_TW.UTF-8 UTF-8/' /etc/locale.gen && \
dpkg-reconfigure --frontend=noninteractive locales && \
update-locale LANG=zh_TW.UTF-8
ENV LANG zh_TW.UTF-8
ENV LANGUAGE zh_TW:zh
ENV LC_ALL zh_TW.UTF-8
ENV NODE_MAJOR=20
RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list
RUN apt-get update && apt-get install nodejs -y
nodejs \
yarn
WORKDIR /usr/local/lib/container
RUN npm install -g yarn
COPY ./packages/container/package.json ./yarn.lock ./
RUN yarn install --modules-folder /usr/src/node_modules
ENV NODE_PATH=/usr/src/node_modules
+6
View File
@@ -0,0 +1,6 @@
title: Discover the World!
instruction: |
試著探索這個世界吧!
requirements:
- spell
+6
View File
@@ -0,0 +1,6 @@
title: Read the File
instruction: |
閱讀檔案
requirements:
- spell
+3
View File
@@ -4,6 +4,9 @@ instruction: |
「輸入 echo Hello World!」
requirements:
- helloworld
conditions:
pwd_home: &pwd_home
pwd:
+19 -28
View File
@@ -1,8 +1,9 @@
import { matchedData } from 'express-validator'
import { User } from '@linux-odyssey/models'
import { genJWT, hashPassword } from '../../utils/auth.js'
import { asyncHandler } from '../../middleware/error.js'
export async function issueToken(req, res) {
export const issueToken = asyncHandler(async (req, res) => {
const { user } = req
const token = await genJWT({
_id: user.id,
@@ -10,34 +11,27 @@ export async function issueToken(req, res) {
email: user.email,
})
res.json({ token })
}
})
export async function register(req, res, next) {
export const register = asyncHandler(async (req, res, next) => {
const { username, password, email } = matchedData(req)
const user = new User({
username,
email,
})
try {
user.hashedPassword = await hashPassword(password)
await user.save()
req.login(user, (err) => {
if (err) {
next(err)
return
}
res.status(201).json({
message: 'user created',
})
user.hashedPassword = await hashPassword(password)
await user.save()
req.login(user, (err) => {
if (err) {
next(err)
return
}
res.status(201).json({
message: 'user created',
})
} catch (err) {
console.error(err)
res.status(500).json({
message: 'error registering user',
})
}
}
})
})
export function logout(req, res) {
req.logout((err) => {
@@ -60,7 +54,7 @@ export function checkSession(req, res) {
})
}
export async function socialLogin(req, res) {
export function socialLogin(req, res) {
const { newUser } = req.user
if (newUser) {
req.session.newUser = newUser
@@ -70,7 +64,7 @@ export async function socialLogin(req, res) {
res.redirect('/')
}
export async function registerFromSession(req, res) {
export const registerFromSession = asyncHandler(async (req, res, next) => {
const { username } = matchedData(req)
const { newUser } = req.session
@@ -88,10 +82,7 @@ export async function registerFromSession(req, res) {
req.login(user, (err) => {
if (err) {
console.error(err)
res.status(500).json({
message: 'error logging in',
})
next(err)
return
}
delete req.session.newUser
@@ -99,4 +90,4 @@ export async function registerFromSession(req, res) {
message: 'user created',
})
})
}
})
+20 -18
View File
@@ -1,26 +1,26 @@
import { Command, Session } from '@linux-odyssey/models'
import { matchedData } from 'express-validator'
import CommandHandler from '../../game/commandHandler.js'
import { pushToSession } from '../socket.js'
import { finishSession } from '../../game/sessionManager.js'
import { asyncHandler } from '../../middleware/error.js'
const commandCompleteCallbacks = new Map()
export async function newCommand(req, res) {
const { command, pwd, output, error, ...additionalData } = req.body
if (!command) {
res.status(400).json({ message: 'command is required' })
return
}
const sessionId = req.user.session_id
export const newCommand = asyncHandler(async (req, res) => {
const { command, pwd, output, error, params } = matchedData(req)
const { sessionId } = req.user
const session = await Session.findById(sessionId)
if (!session) {
res.status(404).json({ message: 'session not found' })
return
}
if (session.status === 'finished') {
// Allow commands to be sent to finished sessions
res.status(200).end()
return
}
if (session.status !== 'active') {
res.status(400).json({ message: 'session is not active' })
return
@@ -38,23 +38,25 @@ export async function newCommand(req, res) {
await c.save()
const commandHandler = new CommandHandler(session, c, additionalData)
const commandHandler = new CommandHandler(session, c, params)
const response = await commandHandler.run()
if (response) {
c.stage = response.stage
await c.save()
if (response.stage === 'END') {
await finishSession(session)
}
commandCompleteCallbacks.set(session.id, response.callback)
pushToSession(session.id, 'status', 'pending')
}
res.status(201).json(response)
await session.save()
}
})
export async function completedCommand(req, res) {
const sessionId = req.user.session_id
export const completedCommand = asyncHandler(async (req, res) => {
const { sessionId } = req.user
const session = await Session.findById(sessionId)
@@ -76,4 +78,4 @@ export async function completedCommand(req, res) {
callback()
commandCompleteCallbacks.delete(session.id)
res.json({ message: 'ok' })
}
})
+11 -9
View File
@@ -1,23 +1,25 @@
import { Quest } from '@linux-odyssey/models'
import { matchedData } from 'express-validator'
import { asyncHandler } from '../../middleware/error.js'
export async function getQuests(req, res) {
const quests = await Quest.find({}).sort({ order: 1 })
export const getQuests = asyncHandler(async (req, res) => {
const quests = await Quest.find({})
res.json(
quests.map(({ title, order, _id }) => ({
quests.map(({ title, _id, requirements }) => ({
_id,
title,
order,
requirements,
}))
)
}
})
export async function getQuestDetail(req, res) {
const { id } = req.params
const quest = await Quest.findById(id)
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 })
}
})
+38 -63
View File
@@ -3,7 +3,9 @@ import { Session } from '@linux-odyssey/models'
import {
createNewSession,
getOrCreateActiveSession,
isQuestUnlocked,
} from '../../game/sessionManager.js'
import { asyncHandler } from '../../middleware/error.js'
function sessionSummary(session) {
return {
@@ -25,75 +27,48 @@ function sessionDetail(session) {
}
}
export async function getSessionList(req, res) {
try {
const query = { user: req.user._id }
if (req.query.quest_id) {
query.quest = req.query.quest_id
}
query.status = req.query.status || 'active'
const sessions = await Session.find(query)
res.json(sessions.map(sessionSummary))
} catch (err) {
console.error(err)
res.status(500).json({ message: err.message })
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 async function createSession(req, res) {
const { quest_id } = matchedData(req)
try {
const session = await createNewSession(req.user, quest_id)
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))
} catch (err) {
console.error(err)
res.status(500).json({ message: err.message })
} else {
res.status(403).json({ message: 'Quest is locked.' })
}
}
})
export async function getSessionById(req, res) {
try {
const session = await Session.findOne({
_id: req.params.id,
user: req.user._id,
})
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
}
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)) {
const session = await getOrCreateActiveSession(req.user, questId)
res.json(sessionDetail(session))
} catch (err) {
res.status(500).json({ message: err.message })
} else {
res.status(403).json({ message: 'Quest is locked.' })
}
}
export async function deleteSessionById(req, res) {
try {
const session = await Session.findOneAndDelete({
_id: req.params.id,
user: req.user._id,
})
if (!session) {
res.status(404).json({ message: 'Session not found.' })
return
}
res.status(204).end()
} catch (err) {
res.status(500).json({ message: err.message })
}
}
export async function getOrCreateSession(req, res) {
const { quest_id } = matchedData(req)
try {
const session = await getOrCreateActiveSession(req.user, quest_id)
res.json(sessionDetail(session))
} catch (err) {
console.error(err)
res.status(500).json({ message: err.message })
}
}
})
@@ -0,0 +1,20 @@
import { UserProfile } from '@linux-odyssey/models'
import { asyncHandler } from '../../middleware/error.js'
export const me = asyncHandler(async (req, res) => {
const userProfile = await UserProfile.findOne({
user: req.user._id,
})
if (!userProfile) {
throw new Error(
`UserProfile not found for user ${req.user.username} ${req.user._id}!`
)
}
const { username, email } = req.user
const { progress } = userProfile
res.json({
username,
email,
progress,
})
})
@@ -32,7 +32,7 @@ router.post(
router.post('/register', authenticateRateLimit, registerValidators, register)
router.post('/logout', authenticateRateLimit, logout)
router.post('/logout', logout)
router.get('/check-username', checkUsernameValidators, (req, res) =>
res.json({ available: true })
@@ -1,9 +1,10 @@
import { Router } from 'express'
import * as commandController from '../controllers/commandController.js'
import { newCommandValidator } from '../validators/commandValidator.js'
const router = Router()
router.post('/', commandController.newCommand)
router.post('/', newCommandValidator, commandController.newCommand)
router.post('/completed', commandController.completedCommand)
export default router
+6 -4
View File
@@ -1,10 +1,11 @@
import { Router } from 'express'
import passport from 'passport'
import sessions from './sessions.js'
import quests from './quests.js'
import commands from './commands.js'
import authRouter from './auth.js'
import sessions from './sessionRouter.js'
import quests from './questRouter.js'
import commands from './commandRouter.js'
import authRouter from './authRouter.js'
import authRequired from '../../middleware/authRequired.js'
import userRouter from './userRouter.js'
import config from '../../config.js'
const router = Router()
@@ -21,6 +22,7 @@ if (!config.isProduction) {
router.use('/auth', authRouter)
router.use('/quests', quests)
router.use('/users', userRouter)
router.use('/sessions', authRequired, sessions)
router.use(
@@ -1,9 +1,10 @@
import { Router } from 'express'
import * as questController from '../controllers/questController.js'
import { questIdValidator } from '../validators/questValidator.js'
const router = Router()
router.get('/', questController.getQuests)
router.get('/:id', questController.getQuestDetail)
router.get('/:questId', questIdValidator, questController.getQuestDetail)
export default router
@@ -4,11 +4,13 @@ import { sessionRateLimit } from '../../middleware/rateLimiter.js'
import {
createSessionValidator,
activeSessionValidator,
sessionListValidator,
sessionDetailValidator,
} from '../validators/sessionValidator.js'
const router = Router()
router.get('/', sessionController.getSessionList)
router.get('/', sessionListValidator, sessionController.getSessionList)
router.post(
'/',
createSessionValidator,
@@ -20,7 +22,6 @@ router.post(
activeSessionValidator,
sessionController.getOrCreateSession
)
router.get('/:id', sessionController.getSessionById)
router.delete('/:id', sessionController.deleteSessionById)
router.get('/:id', sessionDetailValidator, sessionController.getSessionById)
export default router
+9
View File
@@ -0,0 +1,9 @@
import express from 'express'
import { me } from '../controllers/userController.js'
import authRequired from '../../middleware/authRequired.js'
const router = express.Router()
router.get('/me', authRequired, me)
export default router
+28 -25
View File
@@ -1,4 +1,5 @@
import { Server } from 'socket.io'
import validator from 'validator'
import { Session } from '@linux-odyssey/models'
import { getAndStartContainer, attachContainer } from '../containers/docker.js'
import SessionMiddleware from '../middleware/session.js'
@@ -26,46 +27,46 @@ export function pushToSession(sessionId, event, ...args) {
}
}
async function connectClient(socket) {
async function connectContainer(socket, next) {
const user = socket.request.session?.passport?.user
// console.log(user)
if (!user) {
socket.send('User not found.')
socket.disconnect()
next(new Error('User not found.'))
return
}
const sessionId = socket.handshake.query.session_id
if (!sessionId) {
socket.emit('error', 'Session ID not found.')
socket.disconnect()
// console.log(socket.handshake)
const { sessionId } = socket.handshake.query
if (!(sessionId && validator.isMongoId(sessionId))) {
console.warn('Invalid Session ID.', sessionId)
next(new Error('Invalid Session ID.'))
return
}
const session = await Session.findOne({
_id: sessionId,
user,
status: 'active',
})
const session = await Session.findById(sessionId)
if (!session) {
socket.emit('error', 'Session not found.')
socket.disconnect()
next(new Error('Session not found.'))
return
}
const token = await genJWT({
session_id: session.id,
sessionId: session.id,
})
let container
let stream
try {
container = await getAndStartContainer(session.containerId)
stream = await attachContainer(container, { token })
const container = await getAndStartContainer(session.containerId)
const stream = await attachContainer(container, { token })
socket.context = {
session,
stream,
}
next()
} catch (err) {
socket.emit('error', 'Failed to start container.')
socket.disconnect()
return
next(new Error('Failed to start container.'))
}
}
function onConnect(socket) {
const { session, stream } = socket.context
stream.socket.on('data', (chunk) => {
socket.emit('terminal', chunk.toString())
})
@@ -87,14 +88,16 @@ async function connectClient(socket) {
removeFromSession(session.id, socketCallback)
stream.destroy()
})
socket.send(`${container.id}\n`)
}
export default (server) => {
const io = new Server(server)
io.engine.use(SessionMiddleware)
io.use(connectContainer)
io.on('connection', connectClient)
io.on('connection', onConnect)
io.on('connect_error', (err) => {
console.error(err)
})
}
@@ -0,0 +1,17 @@
import { body } from 'express-validator'
import { noError } from '../../middleware/validator.js'
const checkCommand = () => body('command').isString().notEmpty().trim()
const checkPwd = () => body('pwd').isString().notEmpty().trim()
const checkOutput = () => body('output').trim().optional()
const checkError = () => body('error').trim().optional()
const checkParams = () => body('params').isObject().optional()
export const newCommandValidator = [
checkCommand(),
checkPwd(),
checkOutput(),
checkError(),
checkParams(),
noError,
]
@@ -0,0 +1,7 @@
import { check } from 'express-validator'
import { noError } from '../../middleware/validator.js'
export const checkQuestId = () =>
check('questId').notEmpty().isString().isAlphanumeric().isLength({ max: 64 })
export const questIdValidator = [checkQuestId(), noError]
@@ -1,8 +1,14 @@
import { check } from 'express-validator'
import { checkQuestId } from './questValidator.js'
import { noError } from '../../middleware/validator.js'
const checkQuestId = () =>
check('quest_id').notEmpty().isString().isLowercase().isLength({ max: 64 })
const checkSessionStatus = () =>
check('status').isString().isIn(['active', 'inactive', 'finished'])
export const checkSessionId = () =>
check('sessionId').notEmpty().isString().isMongoId()
export const sessionListValidator = [checkQuestId(), checkSessionStatus()]
export const sessionDetailValidator = [checkSessionId(), noError]
export const createSessionValidator = [checkQuestId(), noError]
export const activeSessionValidator = [checkQuestId(), noError]
+5 -1
View File
@@ -1,4 +1,5 @@
import passport from 'passport'
import { UserProfile } from '@linux-odyssey/models'
import passwordStrategy from './passwordStrategy.js'
import jwtStrategy from './jwtStrategy.js'
import googleStrategy from './googleStrategy.js'
@@ -24,7 +25,10 @@ if (enabledMethods.github) {
}
passport.serializeUser((user, done) => {
process.nextTick(() => {
process.nextTick(async () => {
if (!(await UserProfile.exists({ user: user._id }))) {
UserProfile.create({ user: user._id })
}
done(null, {
_id: user._id,
username: user.username,
+5 -5
View File
@@ -15,16 +15,16 @@ const checkMatch = (pattern, input) => {
}
export default class CommandHandler extends SessionHandler {
constructor(session, commandInput, additionalData) {
constructor(session, commandInput, params) {
super(session)
this.commandInput = commandInput
this.argv = minimist(this.commandInput.command.split(' '))
this.additionalData = additionalData
this.params = params
}
handleEvent() {
if (this.additionalData.discover) this.discoverHandler()
if (this.params.discover) this.discoverHandler()
}
handleCommand() {
@@ -39,7 +39,7 @@ export default class CommandHandler extends SessionHandler {
case 'ls':
pushToSession(this.session.id, 'graph', {
pwd: this.commandInput.pwd,
discover: this.additionalData.discover,
discover: this.params.discover,
})
break
@@ -50,7 +50,7 @@ export default class CommandHandler extends SessionHandler {
async discoverHandler() {
const graph = new FileGraph(this.session.graph)
graph.discover(this.additionalData.discover)
graph.discover(this.params.discover)
this.session.graph = graph
}
-5
View File
@@ -49,11 +49,6 @@ export default class SessionHandler {
this.session.hints.push(...stage.hints)
if (stage.id === 'END') {
this.session.status = 'finished'
this.session.finishedAt = new Date()
}
this.addNewTasks()
return {
+53 -1
View File
@@ -1,4 +1,4 @@
import { Session, Quest } from '@linux-odyssey/models'
import { Session, Quest, UserProfile } from '@linux-odyssey/models'
import { createContainer, deleteContainer } from '../containers/docker.js'
import SessionHandler from './sessionHandler.js'
@@ -48,6 +48,23 @@ export async function createNewSession(user, questId) {
const session = sessionHandler.getSession()
await session.save()
const userProfile = await UserProfile.findOne({ user: user._id })
if (!userProfile) {
throw new Error(`UserProfile ${user._id} not found`)
}
const progress = userProfile.progress.get(quest._id)
if (!progress) {
userProfile.progress.set(quest._id, {
quest: quest._id,
sessions: [session._id],
startedAt: new Date(),
})
} else {
progress.sessions.push(session._id)
}
await userProfile.save()
return session
}
@@ -62,3 +79,38 @@ export async function getOrCreateActiveSession(user, questId) {
}
return createNewSession(user, questId)
}
export async function finishSession(session) {
session.status = 'finished'
await session.save()
const userProfile = await UserProfile.findOne({ user: session.user })
if (!userProfile) {
throw new Error(`UserProfile ${session.user} not found`)
}
const progress = userProfile.progress.get(session.quest)
if (!progress) {
throw new Error(`Progress ${session.quest} not found`)
}
if (!progress.completed) {
progress.finishedAt = new Date()
progress.completed = true
}
await userProfile.save()
}
export async function isQuestUnlocked(user, questId) {
const userProfile = await UserProfile.findOne({ user: user._id })
if (!userProfile) {
throw new Error(`UserProfile ${user._id} not found`)
}
const quest = await Quest.findById(questId)
if (!quest) {
throw new Error(`Quest ${questId} not found`)
}
return quest.requirements.every((requiredQuestId) => {
const progress = userProfile.progress.get(requiredQuestId)
return progress && progress.completed
})
}
+6
View File
@@ -1,3 +1,9 @@
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) {
console.error(err.stack) // Log error stack trace to the console
+1 -1
View File
@@ -31,7 +31,7 @@ export default async function loadAndUpdateQuests() {
return Quest.findByIdAndUpdate(
id,
{
id,
_id: id,
...quest,
},
{
+75 -52
View File
@@ -27,7 +27,7 @@ paths:
- cookieAuth: []
parameters:
- in: query
name: quest_id
name: questId
description: find sessions by quest id
schema:
type: string
@@ -60,7 +60,7 @@ paths:
schema:
type: object
properties:
quest_id:
questId:
type: string
example: helloworld
responses:
@@ -79,6 +79,8 @@ paths:
properties:
message:
type: string
'403':
description: This quest is locked.
/sessions/active:
post:
@@ -93,9 +95,9 @@ paths:
schema:
type: 'object'
required:
- quest_id
- questId
properties:
quest_id:
questId:
type: 'string'
description: 'The ID of the quest.'
example: 'helloworld'
@@ -108,10 +110,12 @@ paths:
$ref: '#/components/schemas/Session'
400:
description: 'Bad Request. The request is invalid or malformed.'
403:
description: This quest is locked.
500:
description: 'Internal Server Error.'
/sessions/{session_id}:
/sessions/{sessionId}:
get:
tags:
- signed-in
@@ -120,7 +124,7 @@ paths:
- cookieAuth: []
parameters:
- in: path
name: session_id
name: sessionId
description: ID of the session to retrieve
required: true
schema:
@@ -143,44 +147,6 @@ paths:
properties:
message:
type: string
delete:
tags:
- signed-in
summary: delete a specific session by ID
security:
- cookieAuth: []
parameters:
- in: path
name: session_id
required: true
schema:
type: string
format: objectId
example: 64b7a3a3c182b4826dd1aaa1
responses:
'204':
description: Session successfully deleted
'404':
description: Session not found
content:
application/json:
schema:
type: object
properties:
message:
type: string
example: 'Session not found.'
'401':
description: Unauthorized
content:
application/json:
schema:
type: object
properties:
message:
type: string
example: 'Unauthorized.'
/auth/register:
post:
@@ -468,12 +434,12 @@ paths:
items:
$ref: '#/components/schemas/QuestSummary'
/quests/{quest_id}:
/quests/{questId}:
get:
summary: get a quest's detail by name
parameters:
- in: path
name: quest_id
name: questId
description: name of the quest to retrieve
required: true
schema:
@@ -534,6 +500,23 @@ paths:
type: string
example: '127.0.0.1'
/users/me:
get:
summary: Get current user's profile
tags:
- signed-in
security:
- cookieAuth: []
responses:
'200':
description: User profile
content:
application/json:
schema:
$ref: '#/components/schemas/UserProfile'
'401':
description: Unauthorized
components:
schemas:
User:
@@ -549,6 +532,46 @@ components:
email:
type: string
format: email
UserProfile:
type: object
properties:
username:
type: string
example: 'alex'
email:
type: string
format: email
example: alex@example.com
progress:
type: object
additionalProperties:
$ref: '#/components/schemas/Progress'
Progress:
type: object
properties:
questId:
type: string
example: helloworld
sessions:
type: array
items:
type: string
format: objectId
example: 64b7a3a3c182b4826dd1aaa1
completed:
type: boolean
example: true
startedAt:
type: string
format: date-time
example: 2020-01-01T00:00:00.000Z
finishedAt:
type: string
format: date-time
example: 2020-01-01T00:00:00.000Z
Quest:
type: object
properties:
@@ -587,13 +610,13 @@ components:
$ref: '#/components/schemas/User'
quest:
$ref: '#/components/schemas/Quest'
created_at:
createdAt:
type: string
format: date-time
updated_at:
updatedAt:
type: string
format: date-time
finished_at:
finishedAt:
type: string
format: date-time
hints:
@@ -621,13 +644,13 @@ components:
example: 64b7a3a3c182b4826dd1aaa1
quest:
$ref: '#/components/schemas/Quest'
created_at:
createdAt:
type: string
format: date-time
updated_at:
updatedAt:
type: string
format: date-time
finished_at:
finishedAt:
type: string
format: date-time
+5
View File
@@ -6515,6 +6515,11 @@ vue-router@^4.2.4:
dependencies:
"@vue/devtools-api" "^6.5.0"
vue-toastification@^2.0.0-rc.5:
version "2.0.0-rc.5"
resolved "https://registry.yarnpkg.com/vue-toastification/-/vue-toastification-2.0.0-rc.5.tgz#92798604d806ae473cfb76ed776fae294280f8f8"
integrity sha512-q73e5jy6gucEO/U+P48hqX+/qyXDozAGmaGgLFm5tXX4wJBcVsnGp4e/iJqlm9xzHETYOilUuwOUje2Qg1JdwA==
vue@^3.3.4:
version "3.3.4"
resolved "https://registry.yarnpkg.com/vue/-/vue-3.3.4.tgz#8ed945d3873667df1d0fcf3b2463ada028f88bd6"