mirror of
https://github.com/linux-odyssey/linux-odyssey.git
synced 2026-11-08 03:20:12 +08:00
Merge branch 'main' into feat-automation-test
This commit is contained in:
commit
cb376caff0
41 files changed
+536
-301
No files matched your search
+2
-1
@@ -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"
|
||||||
|
|||||||
@@ -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'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
title: Discover the World!
|
||||||
|
instruction: |
|
||||||
|
試著探索這個世界吧!
|
||||||
|
|
||||||
|
requirements:
|
||||||
|
- spell
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
title: Read the File
|
||||||
|
instruction: |
|
||||||
|
閱讀檔案
|
||||||
|
|
||||||
|
requirements:
|
||||||
|
- spell
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
})
|
||||||
@@ -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' })
|
||||||
}
|
})
|
||||||
@@ -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 })
|
||||||
}
|
})
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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]
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default async function loadAndUpdateQuests() {
|
|||||||
return Quest.findByIdAndUpdate(
|
return Quest.findByIdAndUpdate(
|
||||||
id,
|
id,
|
||||||
{
|
{
|
||||||
id,
|
_id: id,
|
||||||
...quest,
|
...quest,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+75
-52
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in new issue
Block a user