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