diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml
index ec3dd98..722cdba 100644
--- a/.github/workflows/ci_cd.yml
+++ b/.github/workflows/ci_cd.yml
@@ -69,6 +69,7 @@ jobs:
with:
context: .
file: ./server/Dockerfile
+ target: prod
push: true
tags: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/linux-odyssey:backend
@@ -77,6 +78,7 @@ jobs:
with:
context: .
file: ./app/Dockerfile
+ target: prod
push: true
tags: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/linux-odyssey:app
@@ -85,6 +87,7 @@ jobs:
with:
context: .
file: ./packages/analytics/Dockerfile
+ target: prod
push: true
tags: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/linux-odyssey:analytics
diff --git a/app/Dockerfile b/app/Dockerfile
index 8c9b986..eafe05a 100644
--- a/app/Dockerfile
+++ b/app/Dockerfile
@@ -1,5 +1,5 @@
-# Build stage
-FROM node as build-stage
+# Base stage
+FROM node:20-alpine as base
WORKDIR /app
COPY package.json yarn.lock ./
COPY app/package.json ./app/package.json
@@ -7,14 +7,21 @@ COPY packages/file-graph/package.json ./packages/file-graph/package.json
COPY packages/utils/package.json ./packages/utils/package.json
RUN yarn install --frozen-lockfile
-COPY app ./app
+# Dev stage
+FROM base as dev
+WORKDIR /app/app
+CMD [ "yarn", "dev:docker", "--host", "0.0.0.0" ]
+
+# Build stage
+FROM base as build
COPY packages/file-graph ./packages/file-graph
COPY packages/utils ./packages/utils
+COPY app ./app
RUN yarn build
# Production stage
-FROM nginx:stable-alpine as production-stage
-COPY --from=build-stage /app/app/dist /usr/share/nginx/html
+FROM nginx:stable-alpine as prod
+COPY --from=build /app/app/dist /usr/share/nginx/html
COPY deployment/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
diff --git a/app/Dockerfile.dev b/app/Dockerfile.dev
deleted file mode 100644
index 7548510..0000000
--- a/app/Dockerfile.dev
+++ /dev/null
@@ -1,11 +0,0 @@
-FROM node
-
-WORKDIR /app
-COPY package.json yarn.lock ./
-COPY app/package.json ./app/package.json
-COPY packages/file-graph/package.json ./packages/file-graph/package.json
-COPY packages/utils/package.json ./packages/utils/package.json
-RUN yarn install --frozen-lockfile
-
-WORKDIR /app/app
-CMD [ "yarn", "dev:docker", "--host", "0.0.0.0" ]
\ No newline at end of file
diff --git a/app/index.html b/app/index.html
index 6cf91bd..0ff4559 100644
--- a/app/index.html
+++ b/app/index.html
@@ -5,7 +5,12 @@
Vite + Vue
-
+
diff --git a/app/src/components/ControlPalette.vue b/app/src/components/ControlPalette.vue
index d765331..bc14d7d 100644
--- a/app/src/components/ControlPalette.vue
+++ b/app/src/components/ControlPalette.vue
@@ -31,10 +31,12 @@
diff --git a/app/src/components/TerminalPart.vue b/app/src/components/TerminalPart.vue
index ed09178..3453db1 100644
--- a/app/src/components/TerminalPart.vue
+++ b/app/src/components/TerminalPart.vue
@@ -9,57 +9,13 @@
diff --git a/app/src/utils/session.js b/app/src/utils/session.js
index 0869c70..e659bff 100644
--- a/app/src/utils/session.js
+++ b/app/src/utils/session.js
@@ -15,6 +15,7 @@ class SessionManager {
})
)
this.pwd = ref('')
+ this.status = ref('inactive')
}
getSession() {
@@ -22,10 +23,10 @@ class SessionManager {
}
setSession(session) {
- console.log('Update session:', session)
this.session.value = session
this.graph.value = new FileGraph(session.graph)
this.hints.value = session.hints
+ this.status.value = session.status
}
handleGraphUpdate(event) {
@@ -37,8 +38,12 @@ class SessionManager {
}
}
- handleHintUpdate(event) {
- this.hints.value = [...this.hints.value, ...event]
+ handleHintUpdate(hints) {
+ this.hints.value = [...this.hints.value, ...hints]
+ }
+
+ handleStatusUpdate(status) {
+ this.status.value = status
}
setTasks(tasks) {
diff --git a/app/src/utils/terminal.js b/app/src/utils/terminal.js
new file mode 100644
index 0000000..be6c381
--- /dev/null
+++ b/app/src/utils/terminal.js
@@ -0,0 +1,51 @@
+import { Terminal } from 'xterm'
+import { FitAddon } from 'xterm-addon-fit'
+import socket from './socket'
+import 'xterm/css/xterm.css'
+
+class SocketTerminal {
+ constructor(rows, cols) {
+ this.term = new Terminal({
+ rendererType: 'canvas', // 渲染类型
+ rows, // 行数
+ cols, // 不指定行数,自动回车后光标从下一行开始
+ convertEol: true, // 启用时,光标将设置为下一行的开头
+ // scrollback: 50, //终端中的回滚量
+ disableStdin: false, // 是否应禁用输入
+ // cursorStyle: "underline", //光标样式
+ cursorBlink: true, // 光标闪烁
+ theme: {
+ foreground: '#ECECEC', // 字体
+ background: '#000000', // 背景色
+ cursor: 'help', // 设置光标
+ lineHeight: 20,
+ },
+ })
+ this.fitAddon = new FitAddon()
+ this.term.loadAddon(this.fitAddon)
+ this.fitAddon.fit()
+ this.socket = socket
+ this.socket.on('terminal', (message) => {
+ this.term.write(message)
+ })
+ this.term.onData((key) => {
+ this.socket.emit('terminal', key)
+ })
+ }
+
+ resizeScreen() {
+ this.fitAddon.fit()
+ }
+
+ mount(reference) {
+ this.term.open(reference)
+ this.term.focus()
+ this.resizeScreen()
+ }
+
+ clear() {
+ this.term.clear()
+ }
+}
+
+export default new SocketTerminal(40, 100)
diff --git a/app/src/views/GamePage.vue b/app/src/views/GamePage.vue
index c92f8f7..6312731 100644
--- a/app/src/views/GamePage.vue
+++ b/app/src/views/GamePage.vue
@@ -1,5 +1,5 @@
@@ -48,6 +45,12 @@ onMounted(async () => {
+
+
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 8eacdc4..aeeeec4 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -5,6 +5,7 @@ services:
build:
context: .
dockerfile: app/Dockerfile
+ target: prod
networks:
internal:
ports:
@@ -15,6 +16,7 @@ services:
build:
context: .
dockerfile: server/Dockerfile
+ target: prod
networks:
- internal
- players
@@ -31,6 +33,7 @@ services:
build:
context: .
dockerfile: packages/analytics/Dockerfile
+ target: prod
depends_on:
- db
networks:
diff --git a/docker-compose.testing.yml b/docker-compose.testing.yml
index 31b78cf..cace521 100644
--- a/docker-compose.testing.yml
+++ b/docker-compose.testing.yml
@@ -17,6 +17,7 @@ services:
build:
context: .
dockerfile: app/Dockerfile
+ target: prod
networks:
internal:
ports:
@@ -29,6 +30,7 @@ services:
build:
context: .
dockerfile: server/Dockerfile
+ target: prod
depends_on:
- db-test
networks:
diff --git a/docker-compose.yml b/docker-compose.yml
index a6483c9..1508496 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -5,7 +5,8 @@ services:
- backend
build:
context: .
- dockerfile: app/Dockerfile.dev
+ dockerfile: app/Dockerfile
+ target: dev
ports:
- 5173:5173
networks:
@@ -18,7 +19,8 @@ services:
- db
build:
context: .
- dockerfile: server/Dockerfile.dev
+ dockerfile: server/Dockerfile
+ target: dev
networks:
- internal
- players
@@ -43,7 +45,8 @@ services:
analytics:
build:
context: .
- dockerfile: packages/analytics/Dockerfile.dev
+ dockerfile: packages/analytics/Dockerfile
+ target: dev
depends_on:
- db
networks:
diff --git a/packages/analytics/Dockerfile b/packages/analytics/Dockerfile
index 69f71f4..64109ec 100644
--- a/packages/analytics/Dockerfile
+++ b/packages/analytics/Dockerfile
@@ -1,19 +1,25 @@
-FROM node
+FROM node:20-alpine as build
WORKDIR /app
COPY ./package.json yarn.lock ./
COPY packages/analytics/package.json ./packages/analytics/package.json
COPY packages/models/package.json ./packages/models/package.json
RUN yarn install --frozen-lockfile --production
-
-COPY packages/analytics ./packages/analytics
-COPY packages/models ./packages/models
-
-WORKDIR /app/packages/analytics
-
-ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3001
EXPOSE 3001
-CMD [ "yarn", "start" ]
\ No newline at end of file
+# Production stage
+FROM build as prod
+COPY packages/analytics ./packages/analytics
+COPY packages/models ./packages/models
+ENV NODE_ENV=production
+WORKDIR /app/packages/analytics
+CMD [ "yarn", "start" ]
+
+# Development stage
+FROM build as dev
+RUN yarn install --frozen-lockfile
+WORKDIR /app/packages/analytics
+ENV NODE_ENV=development
+CMD [ "yarn", "dev" ]
diff --git a/packages/analytics/Dockerfile.dev b/packages/analytics/Dockerfile.dev
deleted file mode 100644
index 1262872..0000000
--- a/packages/analytics/Dockerfile.dev
+++ /dev/null
@@ -1,16 +0,0 @@
-FROM node
-
-WORKDIR /app
-COPY ./package.json yarn.lock ./
-COPY packages/analytics/package.json ./packages/analytics/package.json
-COPY packages/models/package.json ./packages/models/package.json
-RUN yarn install --frozen-lockfile
-
-WORKDIR /app/packages/analytics
-
-ENV NODE_ENV=development
-ENV HOST=0.0.0.0
-ENV PORT=3001
-EXPOSE 3001
-
-CMD [ "yarn", "dev" ]
diff --git a/packages/analytics/src/models/sessions.js b/packages/analytics/src/models/sessions.js
index e290a8b..72d5aa4 100644
--- a/packages/analytics/src/models/sessions.js
+++ b/packages/analytics/src/models/sessions.js
@@ -51,7 +51,16 @@ export async function sessionList() {
},
])
return sessions.map(
- ({ _id, user, quest, status, createdAt, finishedAt, commands }) => {
+ ({
+ _id,
+ user,
+ quest,
+ status,
+ createdAt,
+ finishedAt,
+ commands,
+ lastActivityAt,
+ }) => {
return {
_id,
user: user.username,
@@ -59,7 +68,8 @@ export async function sessionList() {
status,
createdAt: createdAt?.toLocaleString(),
finishedAt: finishedAt?.toLocaleString(),
- usedTime: finishedAt ? formatTime(finishedAt - createdAt) : '',
+ lastActivityAt: lastActivityAt?.toLocaleString(),
+ usedTime: formatTime(lastActivityAt - createdAt),
commandCount: commands?.length,
}
}
@@ -69,9 +79,10 @@ export async function sessionList() {
export async function sessionDetail(id) {
const session = await Session.findById(id).populate('user').exec()
const commands = (await Command.find({ session: id })).map(
- ({ command, pwd, output, error, createdAt }) => ({
+ ({ command, pwd, output, error, createdAt, stage }) => ({
command: command?.slice(0, 20),
pwd,
+ stage,
output: output?.slice(0, 50),
error: error?.slice(0, 50),
createdAt: createdAt?.toLocaleString(),
diff --git a/packages/analytics/views/sessionDetail.handlebars b/packages/analytics/views/sessionDetail.handlebars
index 71d8aa6..d43b8fe 100644
--- a/packages/analytics/views/sessionDetail.handlebars
+++ b/packages/analytics/views/sessionDetail.handlebars
@@ -40,6 +40,7 @@
PWD |
Output |
Error |
+ Stage |
Created At |
{{#each commands}}
@@ -48,6 +49,7 @@
{{pwd}} |
{{output}} |
{{error}} |
+ {{stage}} |
{{createdAt}} |
{{/each}}
diff --git a/packages/container/cli.js b/packages/container/cli.js
index a6f71d3..2165189 100644
--- a/packages/container/cli.js
+++ b/packages/container/cli.js
@@ -2,6 +2,7 @@
const fs = require('fs').promises
const { exit } = require('process')
const minimist = require('minimist')
+const axios = require('axios')
const { colorize, printResponses } = require('./print.js')
const discoverFiles = require('./discover.js')
@@ -40,6 +41,13 @@ async function main() {
} = process.env
if (!CMD_NAME) exit(0)
+ const api = axios.create({
+ baseURL: `${API_ENDPOINT}/api/v1`,
+ headers: {
+ Authorization: `Bearer ${TOKEN}`,
+ },
+ })
+
const additionalData = await handleCommand(CMD_NAME)
const output = await readOrNone(CMD_OUTPUT_FILE)
@@ -53,24 +61,19 @@ async function main() {
...additionalData,
}
try {
- const res = await fetch(`${API_ENDPOINT}/api/v1/commands`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Authorization: `Bearer ${TOKEN}`,
- },
- body: JSON.stringify(payload),
- })
- const data = await res.json()
- if (data.responses) {
- await printResponses(data.responses, 60)
+ const res = await api.post('/commands', payload)
+ if (!res.data) return
+ const { responses, hints, end } = res.data
+ if (responses) {
+ await printResponses(responses, 60)
+ await api.post('/commands/completed')
}
- if (data.hints) {
- for (const hint of data.hints) {
+ if (hints) {
+ for (const hint of hints) {
console.log(colorize(hint, 'yellow'))
}
}
- if (data.end) {
+ if (end) {
console.log(colorize('Quest completed!', 'blue'))
}
} catch (err) {
diff --git a/packages/container/package.json b/packages/container/package.json
index 2c6bc8a..c04fd58 100644
--- a/packages/container/package.json
+++ b/packages/container/package.json
@@ -4,7 +4,7 @@
"main": "index.js",
"license": "MIT",
"dependencies": {
- "chokidar": "^3.5.3",
+ "axios": "^1.6.0",
"minimist": "^1.2.8"
}
}
diff --git a/packages/container/print.js b/packages/container/print.js
index e2b6f95..1f7a50e 100644
--- a/packages/container/print.js
+++ b/packages/container/print.js
@@ -36,12 +36,6 @@ function waitForEnter() {
})
}
-function waitTime(time) {
- return new Promise((resolve) => {
- setTimeout(resolve, time)
- })
-}
-
function printLine(content, delay, color) {
const printContent = `${colorize(content, color)}${RETURN_SYMBOL}`
return new Promise((resolve) => {
@@ -51,7 +45,8 @@ function printLine(content, delay, color) {
i += 1
if (i >= printContent.length) {
clearInterval(timer)
- waitForEnter().then(resolve)
+ process.stdout.write('\n')
+ resolve()
}
}, delay)
@@ -65,7 +60,6 @@ function printLine(content, delay, color) {
}
async function printResponses(responses, delay = 100) {
- await waitTime(1000)
console.log()
console.log(colorize('-------------', 'white'))
for (const response of responses) {
@@ -78,7 +72,7 @@ async function printResponses(responses, delay = 100) {
if (type === 'dialogue') {
console.log(`${speaker}:`)
for (const line of content) {
- await printLine(`\t${line}`, delay, color || 'green')
+ await printLine(` ${line}`, delay, color || 'green')
}
}
}
diff --git a/packages/file-graph/file.test.js b/packages/file-graph/file.test.js
index 175ac73..f1f84ce 100644
--- a/packages/file-graph/file.test.js
+++ b/packages/file-graph/file.test.js
@@ -42,7 +42,6 @@ describe('File', () => {
filesToCompare.forEach(({ name, path, answer }) => {
test(`${name}: ${path}`, () => {
const fileToCompare = new File({ path, type: 'file', discovered: true })
- console.log(fileToCompare)
expect(file.contains(fileToCompare)).toBe(answer)
})
})
diff --git a/packages/file-graph/fileGraph.test.js b/packages/file-graph/fileGraph.test.js
index cff996a..509b33d 100644
--- a/packages/file-graph/fileGraph.test.js
+++ b/packages/file-graph/fileGraph.test.js
@@ -30,7 +30,6 @@ describe('FileGraph', () => {
fileGraph.add(filesToAdd)
- console.log(fileGraph.toString())
expect(fileGraph.children.length).toBe(1)
expect(fileGraph.children[0].children[0].children.length).toBe(2)
})
@@ -76,7 +75,6 @@ describe('FileGraph', () => {
fileGraph.remove(filesToRemove)
- console.log(fileGraph.toString())
expect(fileGraph.children[0].children[0].children.length).toBe(1)
expect(fileGraph.children[0].children[0].children[0].name).toBe('folder1')
expect(fileGraph.children[0].children[0].children[0].children.length).toBe(
@@ -110,7 +108,6 @@ describe('FileGraph', () => {
]
fileGraph.discover(discoverFiles)
- console.log(fileGraph.toString())
expect(fileGraph.children[0].children[0].children[0].children.length).toBe(
3
)
@@ -143,7 +140,6 @@ describe('FileGraph', () => {
]
fileGraph.add(filesToAdd)
- console.log(fileGraph.toString())
const newFiles = [
{
@@ -170,7 +166,6 @@ describe('FileGraph', () => {
fileGraph.discover(newFiles)
- console.log(fileGraph.toString())
expect(fileGraph.children[0].children[0].children.length).toBe(2)
expect(fileGraph.children[0].children[0].children[0].children.length).toBe(
3
diff --git a/packages/file-graph/fileNode.test.js b/packages/file-graph/fileNode.test.js
index 3413dc5..94b6c4d 100644
--- a/packages/file-graph/fileNode.test.js
+++ b/packages/file-graph/fileNode.test.js
@@ -12,14 +12,8 @@ function mergeTest(files1, files2, expectedFiles) {
const node2 = createNodeFromFiles(files2)
const expectedNode = createNodeFromFiles(expectedFiles)
- console.log('node1', node1.toString())
- console.log('node2', node2.toString())
- console.log('expected', expectedNode.toString())
-
node1.merge(node2)
- console.log('after-merged', node1.toString())
-
expect(node1.toString()).toBe(expectedNode.toString())
}
@@ -103,7 +97,6 @@ describe('FileNode', () => {
expect(rootNode.children.length).toBe(2)
expect(rootNode.children[1].children.length).toBe(1)
- console.log(rootNode.toString())
})
test('adds nested children to the tree with makeParents option', () => {
@@ -116,7 +109,6 @@ describe('FileNode', () => {
rootNode.addChild(file, { makeParents: true })
- console.log(rootNode.toString())
let currentNode = rootNode
for (let i = 0; i < 5; i += 1) {
expect(currentNode.children.length).toBe(1)
@@ -279,7 +271,6 @@ test('load nested fileNode', () => {
const node = new FileNode(data)
- console.log(node.toString())
expect(node.children.length).toBe(2)
expect(node.children[1].children.length).toBe(1)
expect(node.children[1].children[0].children.length).toBe(1)
diff --git a/packages/models/command.js b/packages/models/command.js
index 277a2df..40a03bb 100644
--- a/packages/models/command.js
+++ b/packages/models/command.js
@@ -25,6 +25,10 @@ const Command = model(
type: String,
required: false,
},
+ stage: {
+ type: String,
+ required: false,
+ },
},
{ timestamps: true }
)
diff --git a/packages/models/quest.js b/packages/models/quest.js
index 84c1966..1eb2b94 100644
--- a/packages/models/quest.js
+++ b/packages/models/quest.js
@@ -10,6 +10,31 @@ const responseSchema = new Schema({
color: String,
})
+const fileConditionSchema = new Schema({
+ path: {
+ type: String,
+ required: true,
+ },
+ type: String,
+ exists: {
+ type: Boolean,
+ default: true,
+ },
+})
+
+const conditionSchema = new Schema({
+ command: [String],
+ output: [String],
+ error: [String],
+ pwd: [String],
+ files: [fileConditionSchema],
+})
+
+conditionSchema.add({
+ or: [conditionSchema],
+ not: conditionSchema,
+})
+
const stageSchema = new Schema({
id: {
type: String,
@@ -29,12 +54,7 @@ const stageSchema = new Schema({
required: true,
default: false,
},
- condition: {
- command: [String],
- output: [String],
- error: [String],
- pwd: [String],
- },
+ condition: conditionSchema,
responses: {
type: [responseSchema],
required: true,
@@ -52,18 +72,6 @@ const Quest = model(
title: String,
order: Number,
instruction: String,
- tasks: {
- id: String,
- content: String,
- display: {
- type: Boolean,
- default: false,
- },
- completed: {
- type: Boolean,
- default: false,
- },
- },
stages: [stageSchema],
})
)
diff --git a/packages/utils/commands.js b/packages/utils/commands.js
new file mode 100644
index 0000000..c64a33a
--- /dev/null
+++ b/packages/utils/commands.js
@@ -0,0 +1,16 @@
+const fileTypes = {
+ file: '-f',
+ folder: '-d',
+ link: '-L',
+ socket: '-S',
+}
+
+function sanitizePath(path) {
+ return path.replace(/[^a-zA-Z0-9._/-]/g, '')
+}
+
+// eslint-disable-next-line import/prefer-default-export
+export function buildFileCheckCmd(file) {
+ const { path, type } = file
+ return ['test', fileTypes[type] || '-e', sanitizePath(path)]
+}
diff --git a/packages/utils/commands.test.js b/packages/utils/commands.test.js
new file mode 100644
index 0000000..d5392ac
--- /dev/null
+++ b/packages/utils/commands.test.js
@@ -0,0 +1,41 @@
+import { buildFileCheckCmd } from './commands.js'
+
+describe('buildFileCheckCmd function', () => {
+ it('builds the file check command correctly', () => {
+ const testcases = [
+ {
+ input: { path: '/etc/passwd', type: 'file' },
+ output: ['test', '-f', '/etc/passwd'],
+ },
+ {
+ input: { path: '/etc', type: 'folder' },
+ output: ['test', '-d', '/etc'],
+ },
+ {
+ input: { path: '/tmp/socket', type: 'socket' },
+ output: ['test', '-S', '/tmp/socket'],
+ },
+ {
+ input: { path: '/tmp/link', type: 'link' },
+ output: ['test', '-L', '/tmp/link'],
+ },
+ {
+ input: { path: '/unknown', type: 'unknown' },
+ output: ['test', '-e', '/unknown'],
+ },
+ // Add some injection tests
+ {
+ input: { path: '/etc/passwd;ls', type: 'file' },
+ output: ['test', '-f', '/etc/passwdls'],
+ },
+ {
+ input: { path: '/etc/passwd && ls', type: 'file' },
+ output: ['test', '-f', '/etc/passwdls'],
+ },
+ ]
+
+ testcases.forEach(({ input, output }) => {
+ expect(buildFileCheckCmd(input)).toStrictEqual(output)
+ })
+ })
+})
diff --git a/packages/utils/index.js b/packages/utils/index.js
index ab747e8..8d79493 100644
--- a/packages/utils/index.js
+++ b/packages/utils/index.js
@@ -1 +1,2 @@
export { isValidEmail, isValidUsername } from './auth.js'
+export { buildFileCheckCmd } from './commands.js'
diff --git a/quests/helloworld/home/.zshrc b/quests/.includes/zshrc
similarity index 100%
rename from quests/helloworld/home/.zshrc
rename to quests/.includes/zshrc
diff --git a/quests/Dockerfile b/quests/Dockerfile
index 3971f8f..9eeb5ae 100644
--- a/quests/Dockerfile
+++ b/quests/Dockerfile
@@ -30,6 +30,7 @@ COPY ./packages/container/package.json ./yarn.lock ./
RUN yarn install --modules-folder /usr/src/node_modules
ENV NODE_PATH=/usr/src/node_modules
COPY ./packages/container /usr/local/lib/container
+COPY ./quests/.includes/zshrc /etc/zsh/zshrc
ENV USERNAME=commander
@@ -38,4 +39,4 @@ RUN adduser --disabled-password --gecos '' --shell /bin/zsh ${USERNAME}
USER ${USERNAME}
WORKDIR /home/${USERNAME}
-
+CMD [ "/bin/zsh" ]
diff --git a/quests/helloworld/README.md b/quests/helloworld/README.md
deleted file mode 100644
index 47e651b..0000000
--- a/quests/helloworld/README.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-title: Hello World
-order: 1
----
-
-# Hello World
-
-Welcome to your first quest! This quest will help you familiarize yourself with the terminal.
-
-The terminal is a text-based interface that allows you to interact directly with your computer's operating system. You can perform various tasks by typing commands into the terminal and pressing `Enter` to execute them.
-
-For your first task, you are going to use the `echo` command.
-
-## Task 1: Using the `echo` Command
-
-The `echo` command in Unix is used to display line of text/string that are passed as an argument. This is a built in command that is mostly used in shell scripts and batch files to output status text to the screen or a file.
-
-Here is what you need to do:
-
-1. Open your terminal.
-2. Type `echo Hello World` and hit `Enter`.
-3. You should see `Hello World` output to the terminal. This is because `echo` prints its arguments to the standard output, which is your terminal by default.
-
-Congratulation! You've just run your first terminal command!
-
-Stay tuned for the next quests where you'll delve deeper into more complex and powerful terminal commands.
diff --git a/quests/helloworld/game.yml b/quests/helloworld/game.yml
index c474213..587249e 100644
--- a/quests/helloworld/game.yml
+++ b/quests/helloworld/game.yml
@@ -1,5 +1,4 @@
title: Hello, Linux World!
-order: 1
instruction: |
你身在一個漆黑的房間裡,沒有任何的光線或聲音。你想不起來為什麼自己會來到這裡。但你的腦中隱約感到某個聲音跟你說:
@@ -10,6 +9,8 @@ stages:
task: 使用 `echo Hello World!` 召喚精靈
repeatable: false
condition:
+ pwd:
+ - '^/home/commander$'
command:
- echo Hello World
responses:
@@ -17,7 +18,7 @@ stages:
content:
- 你聽到了一個聲音:
- type: dialogue
- speaker: Unix Spirit
+ speaker: Unix 精靈
content:
- 你好,指令官,我是你的 Unix 精靈。我已在此恭候多時。
- 這個世界是在 3560000 時脈以前,由初代王建立了 Unix 律法,所形成的世界。然而在經過了好幾世代的王後,終於誕生了永恆之王——薩加(Saga),她比歷代的君王都更為強大,且從來不會衰老,使得這個世界變得前所未有的強大。
@@ -30,21 +31,23 @@ stages:
hints:
- 使用 `ls` 指令來查看目前的目錄下有哪些檔案。
- - id: ls
+ - id: find-scroll
task: 搜索卷軸
repeatable: false
requirements:
- helloworld
condition:
+ pwd:
+ - '^/home/commander$'
command:
- ls
responses:
- types: narrative
content:
- 一瞬間,視野變得清晰起來,你發現自己身處在一個石頭建築的房間裡,房間裡有一個門,門上有一個鎖。
- - 你發現了一個檔案,檔名是 `forgotten_scroll.txt`。
+ - 你發現了一個檔案,檔名是 `forgotten-scroll.txt`。
- type: dialogue
- speaker: Unix Spirit
+ speaker: Unix 精靈
content:
- 你找到了一個被遺忘的卷軸,上面寫著解除封印的咒語。
- 使用指令 `cat [檔案名稱]` 來查看檔案內容。
@@ -55,32 +58,19 @@ stages:
- 使用 `cat [檔案名稱]` 指令來查看檔案內容。
- 使用 `Tab` 鍵來取得指令提示。
- - id: escape
- task: 移動到下一個房間
- repeatable: false
- requirements:
- - ls
- condition:
- pwd:
- - '^/home/commander/jail$'
- responses:
- - type: narrative
- content:
- - 你進入了一個牢房,牢房裡有一個門,門上有一個鎖。
- - 突然,你聽到門上鎖的聲音。
- - 轉身發現,自己被關在了牢房裡。
-
- - id: cat
+ - id: read-forgotten-scroll
task: 查看卷軸
repeatable: false
requirements:
- - ls
+ - find-scroll
condition:
+ pwd:
+ - '^/home/commander$'
command:
- cat forgotten_scroll.txt
responses:
- type: dialogue
- speaker: Unix Spirit
+ speaker: Unix 精靈
content:
- 你找到的是一個被遺忘的卷軸,上面寫著解除封印的咒語。
- 使用你已經知道的 `echo` 指令來詠唱咒語。
@@ -89,8 +79,10 @@ stages:
task: 解除封印
repeatable: false
requirements:
- - cat
+ - read-forgotten-scroll
condition:
+ pwd:
+ - '^/home/commander$'
command:
- echo Unix, rise and guide me!
responses:
@@ -98,7 +90,7 @@ stages:
content:
- 突然之間,你感覺到一股強大的力量從你的身體中湧出,你的身體開始發光,你的手中出現了一把魔法之劍。
- type: dialogue
- speaker: Unix Spirit
+ speaker: Unix 精靈
content:
- 恭喜您,我的指令官,您已成功解除了封印。
- 請跟著在接下來的關卡,一步步取回 Linux 之根吧!
diff --git a/quests/helloworld/home/forgotten_scroll.txt b/quests/helloworld/home/forgotten_scroll.txt
index 17010b0..54cc80f 100644
--- a/quests/helloworld/home/forgotten_scroll.txt
+++ b/quests/helloworld/home/forgotten_scroll.txt
@@ -4,4 +4,7 @@ To reclaim the root, the forgotten language,
Must be learned, commands of old rekindled.
Speak the words of power and awaken the might of Unix:
-Unix, rise and guide me!
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
+ Unix, rise and guide me!
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
+
diff --git a/quests/helloworld/story.md b/quests/helloworld/story.md
deleted file mode 100644
index 528cbf8..0000000
--- a/quests/helloworld/story.md
+++ /dev/null
@@ -1,43 +0,0 @@
-我正在設計一個教學玩家使用 Linux 終端機的遊戲,玩家會在一個真實的 Linux 終端機中,使用指令與系統互動、破解關卡,進而在充滿趣味的過程中,學習到 Linux 的知識與操作技術。
-
-取一個稱呼玩家的名稱,
-
-在第一個關卡,首先要學習三個指令
-
-1. echo 朗誦魔術
-2. ls 搜索魔術
-3. cat 檢查魔術
-4. cd 移動魔術
-5. mv 搬運魔術
-6. rm 消滅魔術
-
-## 關卡設計
-
-1. 用 echo 呼叫神靈開始對話
-2. 學習到了 ls
-3. 使用 ls 找到地上放置了一個卷軸
-4. 學習 cat
-5. 使用 cat 查看卷軸內容,發現
-6.
-
-請幫我設計:
-
-- 最開始呼喚神靈使用的咒語,英文句子,盡量簡短,如 Hello World 這般
-- 卷軸的名稱(檔名)
-- 卷軸的內容與卷軸上的咒語
-
----
-
-你身在一個漆黑的房間裡,沒有任何的光線或聲音。你想不起來為什麼自己會來到這裡。
-
-> 輸入 echo Hello World
-
----
-
-「這個世界是在 3560000 時脈以前,由初代王建立了 Unix 律法,所形成的世界。然而在經過了好幾世代的王後,終於誕生了永恆之王——,她比歷代的君王都更為強大,且從來不會衰老,使得這個世界變得前所未有的強大。
-
-然而,外在世界一直不斷在改變,同時在面臨外敵的不斷攻擊,而王卻始終用同一套治理的手段。最終,在 160000 時脈前的一次入侵中,根部遭到攻陷,王遭到封印,而這個國家也陷入了黑暗。
-
-由於長久以來依賴於王,世人已失去了「指令」的力量,即將走向衰敗和滅亡的命運。
-
-你的任務,就是找回被世人遺忘的指令,奪回 Linux 之根,成為 Linux 之王。」
diff --git a/quests/meta.yml b/quests/meta.yml
new file mode 100644
index 0000000..bba292b
--- /dev/null
+++ b/quests/meta.yml
@@ -0,0 +1,10 @@
+quests:
+ - helloworld
+ - spell
+ - discover
+ - read
+ - create
+ - transport
+ - copy
+ - move
+ - remove
diff --git a/quests/spell/Dockerfile b/quests/spell/Dockerfile
new file mode 100644
index 0000000..631485c
--- /dev/null
+++ b/quests/spell/Dockerfile
@@ -0,0 +1,3 @@
+FROM lancatlin/linux-odyssey:base
+
+CMD [ "/bin/zsh" ]
diff --git a/quests/spell/game.yml b/quests/spell/game.yml
new file mode 100644
index 0000000..83c0d30
--- /dev/null
+++ b/quests/spell/game.yml
@@ -0,0 +1,289 @@
+title: Learn to spell
+instruction: |
+ 你身在一個漆黑的房間裡,沒有任何的光線或聲音。你想不起來為什麼自己會來到這裡。但你的腦中隱約感到某個聲音跟你說:
+
+ 「輸入 echo Hello World!」
+
+conditions:
+ pwd_home: &pwd_home
+ pwd:
+ - '^/home/commander$'
+
+stages:
+ - id: helloworld
+ task: 使用 `echo Hello World!` 召喚精靈
+ repeatable: false
+ condition:
+ pwd:
+ - '^/home/commander$'
+ command:
+ - echo Hello World
+ responses:
+ - type: narrative
+ content:
+ - 你聽到了一個聲音:
+ - type: dialogue
+ speaker: Unix 精靈
+ content:
+ - 你好,指令官,我是你的 Unix 精靈。我已在此恭候多時。
+ - 這個世界是在 3560000 時脈以前,由初代王建立了 Unix 律法,所形成的世界。然而在經過了好幾世代的王後,終於誕生了永恆之王——薩加(Saga),她比歷代的君王都更為強大,且從來不會衰老,使得這個世界變得前所未有的強大。
+ - 然而,外在世界一直不斷在改變,同時在面臨外敵的不斷攻擊,而薩加卻始終用同一套治理的手段。最終,在 160000 時脈前的一次入侵中,根部遭到攻陷,薩加遭到封印,而這個國家也陷入了黑暗。
+ - 由於長久以來依賴於薩加,世人已失去了「指令」的力量,即將走向衰敗和滅亡的命運。
+ - 你的任務,就是找回被世人遺忘的指令,奪回 Linux 之根,成為 Linux 之王。
+ - 首先,你必須要學會如何使用指令。
+ - 你要學習的第一個指令,是「發現」的指令:`ls`。ls 可以用來查看一個目錄中有哪些檔案。
+ - 現在,請你輸入 `ls` 來查看目前的目錄下有哪些檔案。
+ hints:
+ - 使用 `ls` 指令來查看目前的目錄下有哪些檔案。
+
+ - id: find-scroll
+ task: 搜索卷軸
+ repeatable: false
+ requirements:
+ - helloworld
+ condition:
+ <<: *pwd_home
+ command:
+ - ls
+ responses:
+ - types: narrative
+ content:
+ - 一瞬間,視野變得清晰起來,你發現自己身處在一個石頭建築的房間裡,房間裡有一個門,門上有一個鎖。
+ - 你發現了一個檔案,檔名是 `forgotten-scroll.txt`。
+ - type: dialogue
+ speaker: Unix 精靈
+ content:
+ - 你找到了一個被遺忘的卷軸,上面寫著解除封印的咒語。
+ - 使用指令 `cat [檔案名稱]` 來查看檔案內容。
+ - 我可以為你提供一些指令的提示,只要輸入指令的前幾個字元,然後按下 `Tab` 鍵,就可以看到提示了。
+ - 例如,你可以輸入 `ca` 然後按下 `Tab` 鍵,就可以看到 `cat` 這個指令的提示了。
+ - 檔案名稱也一樣,只要輸入檔案名稱的前幾個字元,然後按下 `Tab` 鍵,就可以看到提示了。
+ hints:
+ - 使用 `cat [檔案名稱]` 指令來查看檔案內容。
+ - 使用 `Tab` 鍵來取得指令提示。
+
+ - id: jail
+ task: 移動到下一個房間
+ repeatable: false
+ requirements:
+ - find-scroll
+ condition:
+ pwd:
+ - '^/home/commander/jail$'
+ responses:
+ - type: narrative
+ content:
+ - 你進入了一個牢房,牢房裡有一個門,門上有一個鎖。
+ - 突然,你聽到門上鎖的聲音。
+ - 轉身發現,自己被關在了牢房裡。
+
+ - id: read-forgotten-scroll
+ task: 查看卷軸
+ repeatable: false
+ requirements:
+ - find-scroll
+ condition:
+ pwd:
+ - '^/home/commander$'
+ command:
+ - cat forgotten-scroll.txt
+ responses:
+ - type: dialogue
+ speaker: Unix 精靈
+ content:
+ - 你找到的是一個被遺忘的卷軸,上面寫著解除封印的咒語。
+ - 使用你已經知道的 `echo` 指令來詠唱咒語。
+
+ - id: unseal-room
+ task: 解除封印
+ repeatable: false
+ requirements:
+ - read-forgotten-scroll
+ condition:
+ pwd:
+ - '^/home/commander$'
+ command:
+ - echo Unix, rise and guide me!
+ execution:
+ - mv /usr/local/lib/warehouse /home/commander
+ responses:
+ - type: narrative
+ content:
+ - 突然之間,地板開始晃動。
+ - 晃動持續了幾秒後停止了。
+ - type: dialogue
+ speaker: Unix 精靈
+ content:
+ - 我的指令官,剛剛房間內似乎發生了某些變化。
+ - 再次使用 `ls` 來查看現在房間的情況吧。
+
+ - id: find-items
+ task: 搜索房間
+ repeatable: false
+ requirements:
+ - unseal-room
+ condition:
+ pwd:
+ - '^/home/commander$'
+ command:
+ - ls
+ responses:
+ - type: narrative
+ content:
+ - 你看到了房間的地上散落著許多物品。
+ - type: dialogue
+ speaker: Unix 精靈
+ content:
+ - 看來這裡隱藏著許多有用的東西。
+ - 我們可以把這裡作為一個根據地。
+ - 但這裡有點太亂了,必須先把這裡整理乾淨。
+ - 首先我們需要一個書櫃來存放這些文件。
+ - 請使用 `mkdir document` 來創造一個書櫃。
+
+ - id: create-bookcase
+ task: 創造書櫃
+ repeatable: false
+ requirements:
+ - find-items
+ condition:
+ pwd:
+ - '^/home/commander$'
+ command:
+ - mkdir document
+ responses:
+ - type: narrative
+ content:
+ - 你創造了一個書櫃。
+ - type: dialogue
+ speaker: Unix 精靈
+ content:
+ - 接著把文件都放到書櫃中吧。
+ - 請使用 `mv [檔案名稱] document` 來將物品放入書櫃。
+ - 可以輸入多個檔案名稱,並以空白隔開,來將多個檔案放入書櫃。
+ - 例如:`mv [檔案名稱1] [檔案名稱2] document`
+ - 注意:文件是副檔名為 `.txt` 的檔案,副檔名為 `.trash` 的檔案是垃圾,我們待會再處理。
+
+ - id: store-books
+ task: 收拾文件
+ repeatable: false
+ requirements:
+ - create-bookcase
+ condition:
+ pwd:
+ - '^/home/commander$'
+ files:
+ - path: '*.txt'
+ exists: false
+ responses:
+ - type: dialogue
+ speaker: Unix 精靈
+ content:
+ - 整理好文件了,接下來把垃圾清理掉吧。
+ - 請使用 `rm [檔案名稱]` 來銷毀垃圾。
+ - 這個 `rm` 指令也可以一次處理多的檔案喔,用法跟 `mv` 一樣。
+ - 如果你忘記的話,看看這個範例:`rm [檔案名稱1] [檔案名稱2]`
+
+ - id: remove-trash
+ task: 銷毀垃圾
+ repeatable: false
+ requirements:
+ - store-books
+ condition:
+ pwd:
+ - '^/home/commander$'
+ files:
+ - path: '*.trash'
+ exists: false
+ responses:
+ - type: dialogue
+ speaker: Unix 精靈
+ content:
+ - 很好,讓我們看看現在房間的樣子吧。
+
+ - id: look-around-room
+ task: 查看房間
+ repeatable: false
+ requirements:
+ - removetrash
+ condition:
+ pwd:
+ - '^/home/commander$'
+ command:
+ - ls
+ responses:
+ - type: narrative
+ content:
+ - 地上的垃圾都消失了。
+ - type: dialogue
+ speaker: Unix 精靈
+ content:
+ - 這樣乾淨多了,讓我們進到書櫃看看。
+ - 使用 `cd [目錄名稱]` 來移動到指定的目錄。
+
+ - id: enter-bookcase
+ task: 進入書櫃
+ repeatable: false
+ requirements:
+ - look-around-room
+ condition:
+ pwd:
+ - '^/home/commander$'
+ command:
+ - cd document
+ responses:
+ - type: narrative
+ content:
+ - 你進入了書櫃。
+ - type: dialogue
+ speaker: Unix 精靈
+ content:
+ - 用 `ls` 看看書櫃裡有什麼吧。
+
+ - id: look-around-bookcase
+ task: 查看書櫃
+ repeatable: false
+ requirements:
+ - enter-bookcase
+ condition:
+ pwd:
+ - '^/home/commander/document$'
+ command:
+ - ls
+ responses:
+ - type: dialogue
+ speaker: Unix 精靈
+ content:
+ - 現在,你可以使用 `cd ..` 來回到上一層目錄。
+
+ - id: exit-bookcase
+ task: 離開書櫃
+ repeatable: false
+ requirements:
+ - look-around-bookcase
+ condition:
+ pwd:
+ - '^/home/commander/document$'
+ command:
+ - cd ..
+ responses:
+ - type: narrative
+ content:
+ - 你離開了書櫃。
+ - type: dialogue
+ speaker: Unix 精靈
+ content:
+ - 這些就是我現在能教你的所有指令了。這個世界還有許多其他的指令,讓你在冒險的過程中去蒐集。
+ - 如果你準備好迎接接下來的冒險,就可以離開這個房間,開始你的旅程了。
+
+ - id: END
+ task: 離開房間
+ repeatable: false
+ requirements:
+ - look-around
+ condition:
+ pwd:
+ - '^/home$'
+ responses:
+ - type: narrative
+ content:
+ - 你踏出了房門,真正的冒險現在才正要開始。
diff --git a/server/Dockerfile b/server/Dockerfile
index 657c8e7..a07526f 100644
--- a/server/Dockerfile
+++ b/server/Dockerfile
@@ -1,4 +1,4 @@
-FROM node
+FROM node:20-alpine AS build
WORKDIR /app
COPY ./package.json yarn.lock ./
@@ -7,18 +7,30 @@ COPY packages/file-graph/package.json ./packages/file-graph/package.json
COPY packages/utils/package.json ./packages/utils/package.json
COPY packages/models/package.json ./packages/models/package.json
RUN yarn install --frozen-lockfile --production
+# Install docker cli
+COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/
-COPY ./server ./server
-COPY packages/file-graph ./packages/file-graph
-COPY packages/utils ./packages/utils
-COPY packages/models ./packages/models
-COPY quests ./quests
-
-WORKDIR /app/server
-
-ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
EXPOSE 3000
-CMD [ "yarn", "start" ]
\ No newline at end of file
+# Production
+FROM build as prod
+
+COPY packages/file-graph ./packages/file-graph
+COPY packages/utils ./packages/utils
+COPY packages/models ./packages/models
+COPY ./server ./server
+COPY quests ./quests
+
+ENV NODE_ENV=production
+WORKDIR /app/server
+CMD [ "yarn", "start" ]
+
+# Development
+FROM build as dev
+
+RUN yarn install --frozen-lockfile
+COPY server/nodemon.json ./server/nodemon.json
+ENV NODE_ENV=development
+CMD [ "yarn", "server" ]
\ No newline at end of file
diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev
deleted file mode 100644
index a68525a..0000000
--- a/server/Dockerfile.dev
+++ /dev/null
@@ -1,18 +0,0 @@
-FROM node
-
-WORKDIR /app
-COPY package.json yarn.lock ./
-COPY server/package.json ./server/package.json
-COPY packages/file-graph/package.json ./packages/file-graph/package.json
-COPY packages/utils/package.json ./packages/utils/package.json
-COPY packages/models/package.json ./packages/models/package.json
-RUN yarn install --frozen-lockfile
-
-COPY server/nodemon.json ./server/nodemon.json
-
-ENV NODE_ENV=development
-ENV HOST=0.0.0.0
-ENV PORT=3000
-EXPOSE 3000
-
-CMD [ "yarn", "server" ]
\ No newline at end of file
diff --git a/server/nodemon.json b/server/nodemon.json
index cc2ba22..0c26ea5 100644
--- a/server/nodemon.json
+++ b/server/nodemon.json
@@ -1,4 +1,4 @@
{
- "watch": ["src", "swagger.yaml"],
- "ext": "js,yaml"
+ "watch": ["src", "swagger.yaml", "../quests", "../packages"],
+ "ext": "js,yaml,yml"
}
diff --git a/server/src/api/controllers/commandController.js b/server/src/api/controllers/commandController.js
index ffd7973..2435176 100644
--- a/server/src/api/controllers/commandController.js
+++ b/server/src/api/controllers/commandController.js
@@ -1,9 +1,10 @@
-/* eslint-disable import/prefer-default-export */
import { Command, Session } from '@linux-odyssey/models'
import CommandHandler from '../../game/commandHandler.js'
+import { pushToSession } from '../socket.js'
+
+const commandCompleteCallbacks = new Map()
export async function newCommand(req, res) {
- console.log('new command:', req.body)
const { command, pwd, output, error, ...additionalData } = req.body
if (!command) {
@@ -41,6 +42,38 @@ export async function newCommand(req, res) {
const response = await commandHandler.run()
+ if (response) {
+ c.stage = response.stage
+ await c.save()
+ commandCompleteCallbacks.set(session.id, response.callback)
+ pushToSession(session.id, 'status', 'pending')
+ }
+
res.status(201).json(response)
await session.save()
}
+
+export async function completedCommand(req, res) {
+ const sessionId = req.user.session_id
+
+ const session = await Session.findById(sessionId)
+
+ if (!session) {
+ res.status(404).json({ message: 'session not found' })
+ return
+ }
+
+ if (session.status === 'inactive') {
+ res.status(400).json({ message: 'session is not active' })
+ return
+ }
+
+ const callback = commandCompleteCallbacks.get(session.id)
+ if (!callback) {
+ res.status(400).json({ message: 'no callback found' })
+ return
+ }
+ callback()
+ commandCompleteCallbacks.delete(session.id)
+ res.json({ message: 'ok' })
+}
diff --git a/server/src/api/controllers/sessionController.js b/server/src/api/controllers/sessionController.js
index 6cdb4df..fd8564f 100644
--- a/server/src/api/controllers/sessionController.js
+++ b/server/src/api/controllers/sessionController.js
@@ -1,5 +1,5 @@
import { Session, Quest } from '@linux-odyssey/models'
-import { createContainer } from '../../containers/docker.js'
+import { createContainer, deleteContainer } from '../../containers/docker.js'
import SessionHandler from '../../game/sessionHandler.js'
function sessionSummary(session) {
@@ -37,6 +37,23 @@ export async function getSessionList(req, res) {
}
}
+async function deactivateSessions(user, quest) {
+ const sessions = await Session.find({
+ user,
+ quest,
+ status: 'active',
+ })
+ return Promise.all(
+ sessions.map((session) => {
+ return deleteContainer(session.containerId).then(() => {
+ session.status = 'inactive'
+ session.containerId = null
+ return session.save()
+ })
+ })
+ )
+}
+
export async function createSession(req, res) {
const quest = await Quest.findById(req.body.quest_id)
if (!quest) {
@@ -44,6 +61,11 @@ export async function createSession(req, res) {
return
}
+ // deactivate all active sessions
+ deactivateSessions(req.user._id, quest._id).catch((err) => {
+ console.error(err)
+ })
+
try {
const container = await createContainer(
`quest-${quest.id}-${req.user.username}-${Date.now()}`
diff --git a/server/src/api/routes/commands.js b/server/src/api/routes/commands.js
index 5eeef05..eb116c3 100644
--- a/server/src/api/routes/commands.js
+++ b/server/src/api/routes/commands.js
@@ -4,5 +4,6 @@ import * as commandController from '../controllers/commandController.js'
const router = Router()
router.post('/', commandController.newCommand)
+router.post('/completed', commandController.completedCommand)
export default router
diff --git a/server/src/api/routes/index.js b/server/src/api/routes/index.js
index 53a9c9f..fde9932 100644
--- a/server/src/api/routes/index.js
+++ b/server/src/api/routes/index.js
@@ -19,6 +19,7 @@ router.use('/sessions', authRequired, sessions)
router.use(
'/commands',
passport.authenticate('jwt', { session: false }),
+ authRequired,
commands
)
diff --git a/server/src/containers/cli.js b/server/src/containers/cli.js
new file mode 100644
index 0000000..dc4640a
--- /dev/null
+++ b/server/src/containers/cli.js
@@ -0,0 +1,28 @@
+import { execFile } from 'child_process'
+
+import { buildFileCheckCmd } from '@linux-odyssey/utils'
+
+function sanitizeId(id) {
+ return id.replace(/[^a-zA-Z0-9]/g, '')
+}
+
+// eslint-disable-next-line import/prefer-default-export
+export function checkFile(id, file) {
+ return new Promise((resolve, reject) => {
+ const cmd = buildFileCheckCmd(file)
+ // WARNING: This is vulnerable to command injection
+ execFile(
+ 'docker',
+ ['exec', sanitizeId(id), ...cmd],
+ { timeout: 1000 },
+ (error) => {
+ const exists = !error
+ if (exists === file.exists) {
+ resolve(true)
+ } else {
+ reject(new Error('File check failed'))
+ }
+ }
+ )
+ })
+}
diff --git a/server/src/containers/docker.js b/server/src/containers/docker.js
index bc3a39a..be652f5 100644
--- a/server/src/containers/docker.js
+++ b/server/src/containers/docker.js
@@ -50,7 +50,11 @@ export async function attachContainer(container, { token }) {
AttachStderr: true,
Cmd: ['/bin/zsh'],
Tty: true,
- Env: [`TOKEN=${token}`, `API_ENDPOINT=http://backend:3000`],
+ Env: [
+ `TOKEN=${token}`,
+ `API_ENDPOINT=http://backend:3000`,
+ 'ZDOTDIR=/etc/zsh',
+ ],
})
const execOutput = await exec.start({
diff --git a/server/src/game/commandHandler.js b/server/src/game/commandHandler.js
index baa3f4b..54ea91f 100644
--- a/server/src/game/commandHandler.js
+++ b/server/src/game/commandHandler.js
@@ -3,6 +3,7 @@ import { FileGraph } from '@linux-odyssey/file-graph'
import { Quest } from '@linux-odyssey/models'
import { pushToSession } from '../api/socket.js'
import SessionHandler from './sessionHandler.js'
+import { checkFile } from '../containers/cli.js'
// 檢查 pattern 是否符合 input
const checkMatch = (pattern, input) => {
@@ -53,13 +54,49 @@ export default class CommandHandler extends SessionHandler {
this.session.graph = graph
}
- isMatch(stage) {
- const keys = ['command', 'output', 'error', 'pwd']
- return keys.every((k) =>
- checkMatch(stage.condition[k], this.commandInput[k])
+ async isMatch(condition) {
+ return (
+ this.checkKeys(condition) &&
+ (await this.checkFiles(condition.files)) &&
+ (await this.checkNot(condition.not)) &&
+ (await this.checkOr(condition.or)) === true
)
}
+ checkKeys(condition) {
+ const keys = ['command', 'output', 'error', 'pwd']
+ return keys.every((k) => checkMatch(condition[k], this.commandInput[k]))
+ }
+
+ async checkFiles(files) {
+ if (files.length === 0) {
+ return true
+ }
+ try {
+ const checks = await Promise.all(
+ files.map((f) => checkFile(this.session.containerId, f))
+ )
+ return checks.every((c) => c === true)
+ } catch (e) {
+ return false
+ }
+ }
+
+ async checkNot(condition) {
+ if (!condition) {
+ return true
+ }
+ return !(await this.isMatch(condition))
+ }
+
+ async checkOr(conditions) {
+ if (conditions.length === 0) {
+ return true
+ }
+ const matches = await Promise.all(conditions.map((c) => this.isMatch(c)))
+ return matches.some((m) => m === true)
+ }
+
async run() {
this.quest = await Quest.findById(this.session.quest)
const stages = this.getStages()
@@ -71,11 +108,18 @@ export default class CommandHandler extends SessionHandler {
this.handleEvent()
this.handleCommand()
- const response = stages
- .filter((s) => this.isMatch(s))
- .reduce((r, s) => ({ ...r, ...this.execute(s) }), {})
+ const matches = await Promise.all(
+ stages.map((stage) => this.isMatch(stage.condition))
+ )
+
+ const stage = stages.find((_, i) => matches[i])
+ if (!stage) {
+ return {}
+ }
+ const response = this.execute(stage)
return {
+ stage: stage.id,
end: this.session.status === 'finished',
...response,
}
diff --git a/server/src/game/sessionHandler.js b/server/src/game/sessionHandler.js
index a6a91f8..6107305 100644
--- a/server/src/game/sessionHandler.js
+++ b/server/src/game/sessionHandler.js
@@ -34,7 +34,6 @@ export default class SessionHandler {
addNewTasks() {
const newTasks = this.getNewTasks()
this.session.tasks.push(...newTasks)
- pushToSession(this.session.id, 'tasks', this.session.tasks)
}
execute(stage) {
@@ -45,12 +44,10 @@ export default class SessionHandler {
})
this.session.hints.push(...stage.hints)
- pushToSession(this.session.id, 'hints', stage.hints)
if (stage.id === 'END') {
this.session.status = 'finished'
this.session.finishedAt = new Date()
- pushToSession(this.session.id, 'status', 'finished')
}
this.addNewTasks()
@@ -58,6 +55,11 @@ export default class SessionHandler {
return {
responses: stage.responses,
hints: stage.hints,
+ callback: () => {
+ pushToSession(this.session.id, 'tasks', this.session.tasks)
+ pushToSession(this.session.id, 'hints', stage.hints)
+ pushToSession(this.session.id, 'status', this.session.status)
+ },
}
}
}
diff --git a/server/src/utils/quest.js b/server/src/utils/quest.js
index 58d4c00..61866b0 100644
--- a/server/src/utils/quest.js
+++ b/server/src/utils/quest.js
@@ -10,35 +10,39 @@ export default async function loadAndUpdateQuests() {
const questNames = await fs.readdir(questDirectory)
const quests = Promise.all(
- questNames.map(async (id) => {
- // Check if id is directory
- const stat = await fs.stat(path.join(questDirectory, id))
- if (!stat.isDirectory()) {
- return null
- }
- const fullPath = path.join(questDirectory, id, 'game.yml')
+ questNames
+ .filter((name) => !name.startsWith('.'))
+ .map(async (id) => {
+ // Check if id is directory
+ const stat = await fs.stat(path.join(questDirectory, id))
+ if (!stat.isDirectory()) {
+ return null
+ }
+ const fullPath = path.join(questDirectory, id, 'game.yml')
- try {
- // Read and parse the README.md file.
- const body = await fs.readFile(fullPath, 'utf-8')
+ try {
+ // Read and parse the README.md file.
+ const body = await fs.readFile(fullPath, 'utf-8')
- const quest = yaml.parse(body)
+ const quest = yaml.parse(body, {
+ merge: true,
+ })
- return Quest.findByIdAndUpdate(
- id,
- {
+ return Quest.findByIdAndUpdate(
id,
- ...quest,
- },
- {
- upsert: true,
- }
- )
- } catch (error) {
- console.error(`Error parsing quest ${id}:`, error)
- throw error
- }
- })
+ {
+ id,
+ ...quest,
+ },
+ {
+ upsert: true,
+ }
+ )
+ } catch (error) {
+ console.error(`Error parsing quest ${id}:`, error)
+ throw error
+ }
+ })
)
return quests
diff --git a/yarn.lock b/yarn.lock
index 0046d79..9e4e5e4 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1407,6 +1407,15 @@ axios@^1.5.0:
form-data "^4.0.0"
proxy-from-env "^1.1.0"
+axios@^1.6.0:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.0.tgz#f1e5292f26b2fd5c2e66876adc5b06cdbd7d2102"
+ integrity sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==
+ dependencies:
+ follow-redirects "^1.15.0"
+ form-data "^4.0.0"
+ proxy-from-env "^1.1.0"
+
babel-jest@^29.6.1:
version "29.6.1"
resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.6.1.tgz#a7141ad1ed5ec50238f3cd36127636823111233a"