From 760d00c05d6c87f00c2337b0702dae0631bf1850 Mon Sep 17 00:00:00 2001 From: "Chen, Tai-Lin" Date: Wed, 11 Oct 2023 13:51:48 +0800 Subject: [PATCH 01/23] update: update quest story --- quests/helloworld/game.yml | 14 +++++++------- quests/helloworld/story.md | 22 ++++++++++++---------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/quests/helloworld/game.yml b/quests/helloworld/game.yml index c474213..6f4ccc5 100644 --- a/quests/helloworld/game.yml +++ b/quests/helloworld/game.yml @@ -17,7 +17,7 @@ stages: content: - 你聽到了一個聲音: - type: dialogue - speaker: Unix Spirit + speaker: Unix 精靈 content: - 你好,指令官,我是你的 Unix 精靈。我已在此恭候多時。 - 這個世界是在 3560000 時脈以前,由初代王建立了 Unix 律法,所形成的世界。然而在經過了好幾世代的王後,終於誕生了永恆之王——薩加(Saga),她比歷代的君王都更為強大,且從來不會衰老,使得這個世界變得前所未有的強大。 @@ -44,7 +44,7 @@ stages: - 一瞬間,視野變得清晰起來,你發現自己身處在一個石頭建築的房間裡,房間裡有一個門,門上有一個鎖。 - 你發現了一個檔案,檔名是 `forgotten_scroll.txt`。 - type: dialogue - speaker: Unix Spirit + speaker: Unix 精靈 content: - 你找到了一個被遺忘的卷軸,上面寫著解除封印的咒語。 - 使用指令 `cat [檔案名稱]` 來查看檔案內容。 @@ -80,12 +80,12 @@ stages: - cat forgotten_scroll.txt responses: - type: dialogue - speaker: Unix Spirit + speaker: Unix 精靈 content: - 你找到的是一個被遺忘的卷軸,上面寫著解除封印的咒語。 - 使用你已經知道的 `echo` 指令來詠唱咒語。 - - id: END + - id: unseal task: 解除封印 repeatable: false requirements: @@ -96,9 +96,9 @@ stages: responses: - type: narrative content: - - 突然之間,你感覺到一股強大的力量從你的身體中湧出,你的身體開始發光,你的手中出現了一把魔法之劍。 + - 突然之間,牆壁上出現了一道閃光,閃光消失之後,你發現房間裡多了一扇門,門上有一個壞掉的鎖。 - type: dialogue - speaker: Unix Spirit + speaker: Unix 精靈 content: - 恭喜您,我的指令官,您已成功解除了封印。 - - 請跟著在接下來的關卡,一步步取回 Linux 之根吧! + - 這是一間被封印的倉庫,裡面應該會有許多有用的東西。 diff --git a/quests/helloworld/story.md b/quests/helloworld/story.md index 528cbf8..4831751 100644 --- a/quests/helloworld/story.md +++ b/quests/helloworld/story.md @@ -2,7 +2,7 @@ 取一個稱呼玩家的名稱, -在第一個關卡,首先要學習三個指令 +在第一個關卡,首先要學習基本指令 1. echo 朗誦魔術 2. ls 搜索魔術 @@ -13,18 +13,20 @@ ## 關卡設計 -1. 用 echo 呼叫神靈開始對話 +1. 使用 echo 呼叫神靈開始對話 2. 學習到了 ls 3. 使用 ls 找到地上放置了一個卷軸 4. 學習 cat -5. 使用 cat 查看卷軸內容,發現 -6. - -請幫我設計: - -- 最開始呼喚神靈使用的咒語,英文句子,盡量簡短,如 Hello World 這般 -- 卷軸的名稱(檔名) -- 卷軸的內容與卷軸上的咒語 +5. 使用 cat 查看卷軸內容,發現咒語 +6. 使用 echo 朗誦咒語,解除封印,出現新房間 +7. 學習使用 cd 移動 +8. 使用 cd 進入房間 +9. 使用 ls 查看房間,發現房間凌亂 +10. 學習 mv 移動物品 +11. 使用 mv 將散落的物品搬運到對應的櫃子 +12. 學習 rm 將物品消滅 +13. 使用 rm 將垃圾桶中的物品消除 +14. 待續... --- From 857e0e7f6a06f06c2d3fde72d3ccb0b78c2a9a0e Mon Sep 17 00:00:00 2001 From: "Chen, Tai-Lin" Date: Tue, 17 Oct 2023 10:37:27 +0800 Subject: [PATCH 02/23] update: update quests/helloworld --- quests/helloworld/game.yml | 205 ++++++++++++++++++-- quests/helloworld/home/forgotten_scroll.txt | 6 +- 2 files changed, 197 insertions(+), 14 deletions(-) diff --git a/quests/helloworld/game.yml b/quests/helloworld/game.yml index 6f4ccc5..5a3be6a 100644 --- a/quests/helloworld/game.yml +++ b/quests/helloworld/game.yml @@ -10,6 +10,8 @@ stages: task: 使用 `echo Hello World!` 召喚精靈 repeatable: false condition: + pwd: + - '^/home/commander$' command: - echo Hello World responses: @@ -30,19 +32,21 @@ 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 精靈 content: @@ -55,11 +59,11 @@ stages: - 使用 `cat [檔案名稱]` 指令來查看檔案內容。 - 使用 `Tab` 鍵來取得指令提示。 - - id: escape + - id: jail task: 移動到下一個房間 repeatable: false requirements: - - ls + - find-scroll condition: pwd: - '^/home/commander/jail$' @@ -70,14 +74,16 @@ stages: - 突然,你聽到門上鎖的聲音。 - 轉身發現,自己被關在了牢房裡。 - - id: cat + - id: read-forgotten-scroll task: 查看卷軸 repeatable: false requirements: - - ls + - find-scroll condition: + pwd: + - '^/home/commander$' command: - - cat forgotten_scroll.txt + - cat forgotten-scroll.txt responses: - type: dialogue speaker: Unix 精靈 @@ -85,20 +91,195 @@ stages: - 你找到的是一個被遺忘的卷軸,上面寫著解除封印的咒語。 - 使用你已經知道的 `echo` 指令來詠唱咒語。 - - id: unseal + - id: unseal-room task: 解除封印 repeatable: false requirements: - - cat + - 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$' + include: + - !*.txt + responses: + - type: dialogue + speaker: Unix 精靈 + content: + - 整理好文件了,接下來把垃圾清理掉吧。 + - 請使用 `rm [檔案名稱]` 來銷毀垃圾。 + - 這個 `rm` 指令也可以一次處理多的檔案喔,用法跟 `mv` 一樣。 + - 如果你忘記的話,看看這個範例:`rm [檔案名稱1] [檔案名稱2]` +scroll + - id: remove-trash + task: 銷毀垃圾 + repeatable: false + requirements: + - store-books + condition: + pwd: + - '^/home/commander$' + include: + - !*.trash + 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: + - 你踏出了房門,真正的冒險現在才正要開始。 + \ No newline at end of file diff --git a/quests/helloworld/home/forgotten_scroll.txt b/quests/helloworld/home/forgotten_scroll.txt index 17010b0..f4982cf 100644 --- a/quests/helloworld/home/forgotten_scroll.txt +++ b/quests/helloworld/home/forgotten_scroll.txt @@ -2,6 +2,8 @@ Once a mighty kingdom, now in darkness, The power of Unix lies dormant, waiting. 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: +Speak the words of pow er and awaken the might of Unix: -Unix, rise and guide me! + ~~~~~~~~~~~~~~~~~~~~~~~~~~ + Unix, rise and guide me! + ~~~~~~~~~~~~~~~~~~~~~~~~~~ \ No newline at end of file From 2f64175df2d0e8ad788103a47219b210b9c7bb74 Mon Sep 17 00:00:00 2001 From: "Lin, Hung-Shin" Date: Wed, 18 Oct 2023 13:54:30 +0800 Subject: [PATCH 03/23] feat: update quest schema * files * boolean operator --- packages/models/quest.js | 38 ++++++++++++++++++++------------------ quests/helloworld/game.yml | 15 ++++++++------- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/packages/models/quest.js b/packages/models/quest.js index 84c1966..bb4ca73 100644 --- a/packages/models/quest.js +++ b/packages/models/quest.js @@ -10,6 +10,25 @@ const responseSchema = new Schema({ color: String, }) +const fileConditionSchema = new Schema({ + path: String, + type: String, + exists: Boolean, +}) + +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 +48,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 +66,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/quests/helloworld/game.yml b/quests/helloworld/game.yml index 5a3be6a..c9e7fb7 100644 --- a/quests/helloworld/game.yml +++ b/quests/helloworld/game.yml @@ -168,8 +168,9 @@ stages: condition: pwd: - '^/home/commander$' - include: - - !*.txt + files: + - path: '*.txt' + exists: false responses: - type: dialogue speaker: Unix 精靈 @@ -178,7 +179,7 @@ stages: - 請使用 `rm [檔案名稱]` 來銷毀垃圾。 - 這個 `rm` 指令也可以一次處理多的檔案喔,用法跟 `mv` 一樣。 - 如果你忘記的話,看看這個範例:`rm [檔案名稱1] [檔案名稱2]` -scroll + - id: remove-trash task: 銷毀垃圾 repeatable: false @@ -187,8 +188,9 @@ scroll condition: pwd: - '^/home/commander$' - include: - - !*.trash + files: + - path: '*.trash' + exists: false responses: - type: dialogue speaker: Unix 精靈 @@ -248,7 +250,7 @@ scroll - type: dialogue speaker: Unix 精靈 content: - - 現在,你可以使用 `cd ..` 來回到上一層目錄。 + - 現在,你可以使用 `cd ..` 來回到上一層目錄。 - id: exit-bookcase task: 離開書櫃 @@ -282,4 +284,3 @@ scroll - type: narrative content: - 你踏出了房門,真正的冒險現在才正要開始。 - \ No newline at end of file From f864cecfa4d0970333e2d6604f1bbde25b856625 Mon Sep 17 00:00:00 2001 From: "Chen, Tai-Lin" Date: Wed, 18 Oct 2023 14:27:27 +0800 Subject: [PATCH 04/23] feat: multi-quest structure --- .../home/.zshrc => .includes/zshrc} | 0 quests/Dockerfile | 3 +- quests/helloworld/README.md | 26 -- quests/helloworld/story.md | 45 --- quests/meta.yml | 10 + quests/spell/Dockerfile | 3 + quests/spell/game.yml | 286 ++++++++++++++++++ 7 files changed, 301 insertions(+), 72 deletions(-) rename quests/{helloworld/home/.zshrc => .includes/zshrc} (100%) delete mode 100644 quests/helloworld/README.md delete mode 100644 quests/helloworld/story.md create mode 100644 quests/meta.yml create mode 100644 quests/spell/Dockerfile create mode 100644 quests/spell/game.yml 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..10571f8 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/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/story.md b/quests/helloworld/story.md deleted file mode 100644 index 4831751..0000000 --- a/quests/helloworld/story.md +++ /dev/null @@ -1,45 +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. 使用 echo 朗誦咒語,解除封印,出現新房間 -7. 學習使用 cd 移動 -8. 使用 cd 進入房間 -9. 使用 ls 查看房間,發現房間凌亂 -10. 學習 mv 移動物品 -11. 使用 mv 將散落的物品搬運到對應的櫃子 -12. 學習 rm 將物品消滅 -13. 使用 rm 將垃圾桶中的物品消除 -14. 待續... - ---- - -你身在一個漆黑的房間裡,沒有任何的光線或聲音。你想不起來為什麼自己會來到這裡。 - -> 輸入 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..2a4fc41 --- /dev/null +++ b/quests/spell/game.yml @@ -0,0 +1,286 @@ +title: Hello, Linux World! +order: 2 +instruction: | + 你身在一個漆黑的房間裡,沒有任何的光線或聲音。你想不起來為什麼自己會來到這裡。但你的腦中隱約感到某個聲音跟你說: + + 「輸入 echo Hello World!」 + +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/commander$' + 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: + - 你踏出了房門,真正的冒險現在才正要開始。 From b4a37f7606b27215520b7951174f9684fca29287 Mon Sep 17 00:00:00 2001 From: "Chen, Tai-Lin" Date: Wed, 25 Oct 2023 11:19:42 +0800 Subject: [PATCH 05/23] fix: remove order in game schema --- app/index.html | 7 ++++- docker-compose.yml | 2 +- quests/Dockerfile | 2 +- quests/helloworld/game.yml | 1 - quests/spell/game.yml | 11 +++++--- server/nodemon.json | 2 +- server/src/utils/quest.js | 54 ++++++++++++++++++++------------------ 7 files changed, 45 insertions(+), 34 deletions(-) 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/docker-compose.yml b/docker-compose.yml index a6483c9..edfe197a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,7 +34,7 @@ services: - BASE_URL=http://localhost:5173 - MONGO_URL=mongodb://db:27017/odyssey-test - DOCKER_NETWORK=linux-odyssey-players - - HOST_PWD=${PWD} + # - HOST_PWD=${PWD} - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID} diff --git a/quests/Dockerfile b/quests/Dockerfile index 10571f8..9eeb5ae 100644 --- a/quests/Dockerfile +++ b/quests/Dockerfile @@ -30,7 +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/zshrc +COPY ./quests/.includes/zshrc /etc/zsh/zshrc ENV USERNAME=commander diff --git a/quests/helloworld/game.yml b/quests/helloworld/game.yml index c9e7fb7..c223747 100644 --- a/quests/helloworld/game.yml +++ b/quests/helloworld/game.yml @@ -1,5 +1,4 @@ title: Hello, Linux World! -order: 1 instruction: | 你身在一個漆黑的房間裡,沒有任何的光線或聲音。你想不起來為什麼自己會來到這裡。但你的腦中隱約感到某個聲音跟你說: diff --git a/quests/spell/game.yml b/quests/spell/game.yml index 2a4fc41..83c0d30 100644 --- a/quests/spell/game.yml +++ b/quests/spell/game.yml @@ -1,10 +1,14 @@ -title: Hello, Linux World! -order: 2 +title: Learn to spell instruction: | 你身在一個漆黑的房間裡,沒有任何的光線或聲音。你想不起來為什麼自己會來到這裡。但你的腦中隱約感到某個聲音跟你說: 「輸入 echo Hello World!」 +conditions: + pwd_home: &pwd_home + pwd: + - '^/home/commander$' + stages: - id: helloworld task: 使用 `echo Hello World!` 召喚精靈 @@ -38,8 +42,7 @@ stages: requirements: - helloworld condition: - pwd: - - '^/home/commander$' + <<: *pwd_home command: - ls responses: diff --git a/server/nodemon.json b/server/nodemon.json index cc2ba22..7544261 100644 --- a/server/nodemon.json +++ b/server/nodemon.json @@ -1,4 +1,4 @@ { - "watch": ["src", "swagger.yaml"], + "watch": ["src", "swagger.yaml", "../quests"], "ext": "js,yaml" } 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 From e52682686a6ad7202e432ed143d396ca8635e355 Mon Sep 17 00:00:00 2001 From: "Lin, Hung-Shin" Date: Wed, 25 Oct 2023 12:20:00 +0800 Subject: [PATCH 06/23] fix: load .zshrc in system dir --- quests/helloworld/home/forgotten_scroll.txt | 3 ++- server/src/containers/docker.js | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/quests/helloworld/home/forgotten_scroll.txt b/quests/helloworld/home/forgotten_scroll.txt index f4982cf..9c7569d 100644 --- a/quests/helloworld/home/forgotten_scroll.txt +++ b/quests/helloworld/home/forgotten_scroll.txt @@ -6,4 +6,5 @@ Speak the words of pow er and awaken the might of Unix: ~~~~~~~~~~~~~~~~~~~~~~~~~~ Unix, rise and guide me! - ~~~~~~~~~~~~~~~~~~~~~~~~~~ \ No newline at end of file + ~~~~~~~~~~~~~~~~~~~~~~~~~~ + \ No newline at end of file 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({ From b7ecfe39800b8f0f51ca96a84aab7381244ae762 Mon Sep 17 00:00:00 2001 From: "Lin, Hung-Shin" Date: Wed, 25 Oct 2023 12:43:28 +0800 Subject: [PATCH 07/23] feat: implement $not and $or condition --- packages/models/quest.js | 4 +- quests/helloworld/game.yml | 190 +++++------------------------- server/nodemon.json | 2 +- server/src/game/commandHandler.js | 12 +- 4 files changed, 42 insertions(+), 166 deletions(-) diff --git a/packages/models/quest.js b/packages/models/quest.js index bb4ca73..8ef7879 100644 --- a/packages/models/quest.js +++ b/packages/models/quest.js @@ -25,8 +25,8 @@ const conditionSchema = new Schema({ }) conditionSchema.add({ - OR: [conditionSchema], - NOT: conditionSchema, + $or: [conditionSchema], + $not: conditionSchema, }) const stageSchema = new Schema({ diff --git a/quests/helloworld/game.yml b/quests/helloworld/game.yml index c223747..ad47ff6 100644 --- a/quests/helloworld/game.yml +++ b/quests/helloworld/game.yml @@ -90,7 +90,7 @@ stages: - 你找到的是一個被遺忘的卷軸,上面寫著解除封印的咒語。 - 使用你已經知道的 `echo` 指令來詠唱咒語。 - - id: unseal-room + - id: END task: 解除封印 repeatable: false requirements: @@ -100,8 +100,6 @@ stages: - '^/home/commander$' command: - echo Unix, rise and guide me! - execution: - - mv /usr/local/lib/warehouse /home/commander responses: - type: narrative content: @@ -113,173 +111,47 @@ stages: - 我的指令官,剛剛房間內似乎發生了某些變化。 - 再次使用 `ls` 來查看現在房間的情況吧。 - - id: find-items - task: 搜索房間 - repeatable: false - requirements: - - unseal-room + - id: not-test + task: Test "not" condition condition: - pwd: - - '^/home/commander$' - command: - - ls + $not: + pwd: + - '^/home/commander$' responses: - type: narrative content: - - 你看到了房間的地上散落著許多物品。 - - type: dialogue - speaker: Unix 精靈 - content: - - 看來這裡隱藏著許多有用的東西。 - - 我們可以把這裡作為一個根據地。 - - 但這裡有點太亂了,必須先把這裡整理乾淨。 - - 首先我們需要一個書櫃來存放這些文件。 - - 請使用 `mkdir document` 來創造一個書櫃。 + - You are not in the home directory. - - id: create-bookcase - task: 創造書櫃 - repeatable: false - requirements: - - find-items + - id: or-test + task: Test "or" condition + repeatable: true condition: - pwd: - - '^/home/commander$' - command: - - mkdir document + $or: + - command: + - less + - output: + - wow responses: - type: narrative content: - - 你創造了一個書櫃。 - - type: dialogue - speaker: Unix 精靈 - content: - - 接著把文件都放到書櫃中吧。 - - 請使用 `mv [檔案名稱] document` 來將物品放入書櫃。 - - 可以輸入多個檔案名稱,並以空白隔開,來將多個檔案放入書櫃。 - - 例如:`mv [檔案名稱1] [檔案名稱2] document` - - 注意:文件是副檔名為 `.txt` 的檔案,副檔名為 `.trash` 的檔案是垃圾,我們待會再處理。 + - You are using `less` or the output contains `wow`. - - id: store-books - task: 收拾文件 - repeatable: false - requirements: - - create-bookcase + - id: nested-condition + task: Test nested condition + repeatable: true 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 + $or: + - pwd: + - '^/etc/zsh$' + $not: + command: + - ls + - $not: + pwd: + - '^/etc/zsh$' + 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: - - 你踏出了房門,真正的冒險現在才正要開始。 + - You are in `/etc/zsh` and not using `ls`, or you are not in `/etc/zsh` and using `ls`. diff --git a/server/nodemon.json b/server/nodemon.json index 7544261..67a8688 100644 --- a/server/nodemon.json +++ b/server/nodemon.json @@ -1,4 +1,4 @@ { "watch": ["src", "swagger.yaml", "../quests"], - "ext": "js,yaml" + "ext": "js,yaml,yml" } diff --git a/server/src/game/commandHandler.js b/server/src/game/commandHandler.js index baa3f4b..da00864 100644 --- a/server/src/game/commandHandler.js +++ b/server/src/game/commandHandler.js @@ -53,10 +53,14 @@ export default class CommandHandler extends SessionHandler { this.session.graph = graph } - isMatch(stage) { + isMatch(condition) { const keys = ['command', 'output', 'error', 'pwd'] - return keys.every((k) => - checkMatch(stage.condition[k], this.commandInput[k]) + console.log('condition', condition) + return ( + keys.every((k) => checkMatch(condition[k], this.commandInput[k])) && + (condition.$or.length === 0 || + condition.$or.some((c) => this.isMatch(c))) && + (!condition.$not || !this.isMatch(condition.$not)) ) } @@ -72,7 +76,7 @@ export default class CommandHandler extends SessionHandler { this.handleCommand() const response = stages - .filter((s) => this.isMatch(s)) + .filter((s) => this.isMatch(s.condition)) .reduce((r, s) => ({ ...r, ...this.execute(s) }), {}) return { From af2778076322b14dec6f1164b651beb557994ef4 Mon Sep 17 00:00:00 2001 From: "Lin, Hung-Shin" Date: Wed, 25 Oct 2023 13:34:10 +0800 Subject: [PATCH 08/23] feat: buildFileCheckCmd --- packages/utils/commands.js | 16 ++++++++++++++ packages/utils/commands.test.js | 38 +++++++++++++++++++++++++++++++++ packages/utils/index.js | 1 + 3 files changed, 55 insertions(+) create mode 100644 packages/utils/commands.js create mode 100644 packages/utils/commands.test.js diff --git a/packages/utils/commands.js b/packages/utils/commands.js new file mode 100644 index 0000000..45c6a9a --- /dev/null +++ b/packages/utils/commands.js @@ -0,0 +1,16 @@ +const fileTypes = { + file: '-f', + folder: '-d', + link: '-L', + socket: '-S', +} + +// eslint-disable-next-line import/prefer-default-export +export function buildFileCheckCmd(files) { + return files + .map( + ({ path, type, exists }) => + `${exists ? '' : '! '}test ${fileTypes[type] || '-e'} ${path}` + ) + .join(' && ') +} diff --git a/packages/utils/commands.test.js b/packages/utils/commands.test.js new file mode 100644 index 0000000..25c81a6 --- /dev/null +++ b/packages/utils/commands.test.js @@ -0,0 +1,38 @@ +import { buildFileCheckCmd } from './commands.js' + +describe('buildFileCheckCmd function', () => { + it('builds the file check command correctly', () => { + const testcases = [ + { + input: [{ path: '/etc/passwd', type: 'file', exists: true }], + output: 'test -f /etc/passwd', + }, + { + input: [{ path: '/etc', type: 'folder', exists: false }], + output: '! test -d /etc', + }, + { + input: [ + { path: '/etc/passwd', type: 'file', exists: true }, + { path: '/etc', type: 'folder', exists: false }, + ], + output: 'test -f /etc/passwd && ! test -d /etc', + }, + { + input: [ + { path: '/tmp/socket', type: 'socket', exists: true }, + { path: '/tmp/link', type: 'link', exists: false }, + ], + output: 'test -S /tmp/socket && ! test -L /tmp/link', + }, + { + input: [{ path: '/unknown', type: 'unknown', exists: true }], + output: 'test -e /unknown', + }, + ] + + testcases.forEach(({ input, output }) => { + expect(buildFileCheckCmd(input)).toBe(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' From 625c1a80da2aae5f1989f84f59fe39a0b33f3978 Mon Sep 17 00:00:00 2001 From: "Lin, Hung-Shin" Date: Wed, 25 Oct 2023 15:01:38 +0800 Subject: [PATCH 09/23] feat: able to test files --- packages/container/print.js | 1 - packages/models/quest.js | 10 +++++-- quests/helloworld/game.yml | 22 ++++++-------- server/src/containers/docker.js | 46 +++++++++++++++++++++++++++++ server/src/game/commandHandler.js | 48 ++++++++++++++++++++++++------- 5 files changed, 99 insertions(+), 28 deletions(-) diff --git a/packages/container/print.js b/packages/container/print.js index e2b6f95..7a98bc4 100644 --- a/packages/container/print.js +++ b/packages/container/print.js @@ -65,7 +65,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) { diff --git a/packages/models/quest.js b/packages/models/quest.js index 8ef7879..39a35ab 100644 --- a/packages/models/quest.js +++ b/packages/models/quest.js @@ -11,9 +11,15 @@ const responseSchema = new Schema({ }) const fileConditionSchema = new Schema({ - path: String, + path: { + type: String, + required: true, + }, type: String, - exists: Boolean, + exists: { + type: Boolean, + default: true, + }, }) const conditionSchema = new Schema({ diff --git a/quests/helloworld/game.yml b/quests/helloworld/game.yml index ad47ff6..0b4c575 100644 --- a/quests/helloworld/game.yml +++ b/quests/helloworld/game.yml @@ -136,22 +136,16 @@ stages: content: - You are using `less` or the output contains `wow`. - - id: nested-condition - task: Test nested condition + - id: test-file-exists + task: Test file exists repeatable: true condition: - $or: - - pwd: - - '^/etc/zsh$' - $not: - command: - - ls - - $not: - pwd: - - '^/etc/zsh$' - command: - - ls + command: + - ls + files: + - path: /home/commander/jail + type: folder responses: - type: narrative content: - - You are in `/etc/zsh` and not using `ls`, or you are not in `/etc/zsh` and using `ls`. + - There is a jail diff --git a/server/src/containers/docker.js b/server/src/containers/docker.js index be652f5..12b81cd 100644 --- a/server/src/containers/docker.js +++ b/server/src/containers/docker.js @@ -1,4 +1,6 @@ +import { PassThrough } from 'stream' import Docker from 'dockerode' +import { buildFileCheckCmd } from '@linux-odyssey/utils' import config from '../config.js' const engine = new Docker() @@ -77,3 +79,47 @@ export async function deleteContainer(id) { } await container.remove() } + +// Utility function to convert a stream to a string +function streamToString(stream) { + const chunks = [] + return new Promise((resolve, reject) => { + stream.on('data', (chunk) => chunks.push(chunk)) + stream.on('error', reject) + stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) + setTimeout(() => resolve(Buffer.concat(chunks).toString('utf8')), 100) + }) +} + +export async function checkFiles(id, files) { + const container = engine.getContainer(id) + if (!container) { + throw new Error('Container not found') + } + + const cmd = buildFileCheckCmd(files) + console.log(cmd) + const exec = await container.exec({ + AttachStdout: true, + AttachStderr: true, + Cmd: ['sh', '-c', cmd], + }) + const execStream = await exec.start() + const outStream = new PassThrough() + const errStream = new PassThrough() + + console.log('exec started') + + exec.modem.demuxStream(execStream, outStream, errStream) + const [output, errorOutput] = await Promise.all([ + streamToString(outStream), + streamToString(errStream), + ]) + + console.log('output', output, 'errorOutput', errorOutput) + + const execOutput = await exec.inspect() + console.log('exit code', execOutput.ExitCode) + console.log('exit code === 0', execOutput.ExitCode === 0) + return execOutput.ExitCode === 0 +} diff --git a/server/src/game/commandHandler.js b/server/src/game/commandHandler.js index da00864..5fe13c5 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 { checkFiles } from '../containers/docker.js' // 檢查 pattern 是否符合 input const checkMatch = (pattern, input) => { @@ -53,15 +54,31 @@ export default class CommandHandler extends SessionHandler { this.session.graph = graph } - isMatch(condition) { + async isMatch(condition) { const keys = ['command', 'output', 'error', 'pwd'] - console.log('condition', condition) - return ( - keys.every((k) => checkMatch(condition[k], this.commandInput[k])) && - (condition.$or.length === 0 || - condition.$or.some((c) => this.isMatch(c))) && - (!condition.$not || !this.isMatch(condition.$not)) - ) + if (keys.some((k) => !checkMatch(condition[k], this.commandInput[k]))) { + return false + } + if (condition.files.length > 0) { + const match = await this.checkFiles(condition.files) + if (!match) return false + } + if (condition.$not) { + const match = await this.isMatch(condition.$not) + if (match) return false + } + if (condition.$or.length > 0) { + const matches = await Promise.all( + condition.$or.map((c) => this.isMatch(c)) + ) + if (matches.every((m) => m === false)) return false + } + return true + } + + checkFiles(files) { + console.log('checkFiles', files) + return checkFiles(this.session.containerId, files) } async run() { @@ -75,9 +92,18 @@ export default class CommandHandler extends SessionHandler { this.handleEvent() this.handleCommand() - const response = stages - .filter((s) => this.isMatch(s.condition)) - .reduce((r, s) => ({ ...r, ...this.execute(s) }), {}) + const matches = await Promise.all( + stages.map((stage) => this.isMatch(stage.condition)) + ) + + console.log('matches', matches) + + const filteredStages = stages.filter((_, i) => matches[i]) + console.log('filteredStages', filteredStages) + const response = filteredStages.reduce( + (r, s) => ({ ...r, ...this.execute(s) }), + {} + ) return { end: this.session.status === 'finished', From 579d53a273e2253a5a51d507334eea8fb4a6fe97 Mon Sep 17 00:00:00 2001 From: "Lin, Hung-Shin" Date: Thu, 26 Oct 2023 14:10:00 +0800 Subject: [PATCH 10/23] feat: refactor dockerfiles * combine dev and prod into multi-stages dockerfile * replace node with node:20-alpine to reduce image size --- .github/workflows/ci_cd.yml | 3 +++ app/Dockerfile | 15 +++++++++++---- app/Dockerfile.dev | 11 ----------- docker-compose.prod.yml | 3 +++ docker-compose.testing.yml | 2 ++ docker-compose.yml | 9 ++++++--- packages/analytics/Dockerfile | 24 +++++++++++++++--------- packages/analytics/Dockerfile.dev | 16 ---------------- server/Dockerfile | 25 +++++++++++++++++++------ server/Dockerfile.dev | 18 ------------------ 10 files changed, 59 insertions(+), 67 deletions(-) delete mode 100644 app/Dockerfile.dev delete mode 100644 packages/analytics/Dockerfile.dev delete mode 100644 server/Dockerfile.dev 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..89ded80 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 +# 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 app ./app COPY packages/file-graph ./packages/file-graph COPY packages/utils ./packages/utils 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/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 edfe197a..d751e4f 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/server/Dockerfile b/server/Dockerfile index 657c8e7..4424f7e 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 ./ @@ -8,17 +8,30 @@ COPY packages/utils/package.json ./packages/utils/package.json COPY packages/models/package.json ./packages/models/package.json RUN yarn install --frozen-lockfile --production +ENV HOST=0.0.0.0 +ENV PORT=3000 +EXPOSE 3000 + +# Production +FROM build as prod + COPY ./server ./server COPY packages/file-graph ./packages/file-graph COPY packages/utils ./packages/utils COPY packages/models ./packages/models COPY quests ./quests +ENV NODE_ENV=production + WORKDIR /app/server -ENV NODE_ENV=production -ENV HOST=0.0.0.0 -ENV PORT=3000 -EXPOSE 3000 +CMD [ "yarn", "start" ] -CMD [ "yarn", "start" ] \ No newline at end of file +# Development +FROM build as dev + +RUN yarn install --frozen-lockfile + +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 From 38f3dbcd65595b625a8c52054a068a284dd3c302 Mon Sep 17 00:00:00 2001 From: "Lin, Hung-Shin" Date: Thu, 26 Oct 2023 14:20:45 +0800 Subject: [PATCH 11/23] feat: install docker cli in backend * change the copy order to improve performance --- app/Dockerfile | 2 +- server/Dockerfile | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/app/Dockerfile b/app/Dockerfile index 89ded80..eafe05a 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -14,9 +14,9 @@ CMD [ "yarn", "dev:docker", "--host", "0.0.0.0" ] # Build stage FROM base as build -COPY app ./app COPY packages/file-graph ./packages/file-graph COPY packages/utils ./packages/utils +COPY app ./app RUN yarn build # Production stage diff --git a/server/Dockerfile b/server/Dockerfile index 4424f7e..76fb334 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,6 @@ FROM node:20-alpine AS build +COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/ WORKDIR /app COPY ./package.json yarn.lock ./ COPY server/package.json ./server/package.json @@ -15,23 +16,19 @@ EXPOSE 3000 # Production FROM build as prod -COPY ./server ./server 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 - ENV NODE_ENV=development - CMD [ "yarn", "server" ] \ No newline at end of file From 50b1fe512405791c7d1c5dc7780c253711abfd2f Mon Sep 17 00:00:00 2001 From: "Lin, Hung-Shin" Date: Thu, 26 Oct 2023 14:57:58 +0800 Subject: [PATCH 12/23] feat: execute files check via docker cli WARNING: possible command injection vunerability. --- packages/container/print.js | 6 ---- server/src/containers/cli.js | 16 +++++++++++ server/src/containers/docker.js | 46 ------------------------------- server/src/game/commandHandler.js | 2 +- 4 files changed, 17 insertions(+), 53 deletions(-) create mode 100644 server/src/containers/cli.js diff --git a/packages/container/print.js b/packages/container/print.js index 7a98bc4..3ad7b29 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) => { diff --git a/server/src/containers/cli.js b/server/src/containers/cli.js new file mode 100644 index 0000000..392e949 --- /dev/null +++ b/server/src/containers/cli.js @@ -0,0 +1,16 @@ +import { exec } from 'child_process' + +import { buildFileCheckCmd } from '@linux-odyssey/utils' + +// eslint-disable-next-line import/prefer-default-export +export function checkFiles(id, files) { + return new Promise((resolve) => { + const cmd = buildFileCheckCmd(files) + // WARNING: This is not secure! + exec(`docker exec ${id} ${cmd}`, (error, stdout, stderr) => { + console.log('stdout', stdout) + console.log('stderr', stderr) + resolve(!error) + }) + }) +} diff --git a/server/src/containers/docker.js b/server/src/containers/docker.js index 12b81cd..be652f5 100644 --- a/server/src/containers/docker.js +++ b/server/src/containers/docker.js @@ -1,6 +1,4 @@ -import { PassThrough } from 'stream' import Docker from 'dockerode' -import { buildFileCheckCmd } from '@linux-odyssey/utils' import config from '../config.js' const engine = new Docker() @@ -79,47 +77,3 @@ export async function deleteContainer(id) { } await container.remove() } - -// Utility function to convert a stream to a string -function streamToString(stream) { - const chunks = [] - return new Promise((resolve, reject) => { - stream.on('data', (chunk) => chunks.push(chunk)) - stream.on('error', reject) - stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) - setTimeout(() => resolve(Buffer.concat(chunks).toString('utf8')), 100) - }) -} - -export async function checkFiles(id, files) { - const container = engine.getContainer(id) - if (!container) { - throw new Error('Container not found') - } - - const cmd = buildFileCheckCmd(files) - console.log(cmd) - const exec = await container.exec({ - AttachStdout: true, - AttachStderr: true, - Cmd: ['sh', '-c', cmd], - }) - const execStream = await exec.start() - const outStream = new PassThrough() - const errStream = new PassThrough() - - console.log('exec started') - - exec.modem.demuxStream(execStream, outStream, errStream) - const [output, errorOutput] = await Promise.all([ - streamToString(outStream), - streamToString(errStream), - ]) - - console.log('output', output, 'errorOutput', errorOutput) - - const execOutput = await exec.inspect() - console.log('exit code', execOutput.ExitCode) - console.log('exit code === 0', execOutput.ExitCode === 0) - return execOutput.ExitCode === 0 -} diff --git a/server/src/game/commandHandler.js b/server/src/game/commandHandler.js index 5fe13c5..4066323 100644 --- a/server/src/game/commandHandler.js +++ b/server/src/game/commandHandler.js @@ -3,7 +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 { checkFiles } from '../containers/docker.js' +import { checkFiles } from '../containers/cli.js' // 檢查 pattern 是否符合 input const checkMatch = (pattern, input) => { From 8f22babe7d1469878b44431b573904011bef7c13 Mon Sep 17 00:00:00 2001 From: wancat Date: Sun, 29 Oct 2023 15:21:36 +0800 Subject: [PATCH 13/23] feat: sanitize file checks --- packages/utils/commands.js | 14 +++--- packages/utils/commands.test.js | 37 +++++++++------- quests/helloworld/game.yml | 15 +++++++ server/Dockerfile | 1 + server/nodemon.json | 2 +- server/src/containers/cli.js | 36 ++++++++++----- server/src/game/commandHandler.js | 73 +++++++++++++++++++------------ 7 files changed, 115 insertions(+), 63 deletions(-) diff --git a/packages/utils/commands.js b/packages/utils/commands.js index 45c6a9a..c64a33a 100644 --- a/packages/utils/commands.js +++ b/packages/utils/commands.js @@ -5,12 +5,12 @@ const fileTypes = { socket: '-S', } +function sanitizePath(path) { + return path.replace(/[^a-zA-Z0-9._/-]/g, '') +} + // eslint-disable-next-line import/prefer-default-export -export function buildFileCheckCmd(files) { - return files - .map( - ({ path, type, exists }) => - `${exists ? '' : '! '}test ${fileTypes[type] || '-e'} ${path}` - ) - .join(' && ') +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 index 25c81a6..d5392ac 100644 --- a/packages/utils/commands.test.js +++ b/packages/utils/commands.test.js @@ -4,35 +4,38 @@ describe('buildFileCheckCmd function', () => { it('builds the file check command correctly', () => { const testcases = [ { - input: [{ path: '/etc/passwd', type: 'file', exists: true }], - output: 'test -f /etc/passwd', + input: { path: '/etc/passwd', type: 'file' }, + output: ['test', '-f', '/etc/passwd'], }, { - input: [{ path: '/etc', type: 'folder', exists: false }], - output: '! test -d /etc', + input: { path: '/etc', type: 'folder' }, + output: ['test', '-d', '/etc'], }, { - input: [ - { path: '/etc/passwd', type: 'file', exists: true }, - { path: '/etc', type: 'folder', exists: false }, - ], - output: 'test -f /etc/passwd && ! test -d /etc', + input: { path: '/tmp/socket', type: 'socket' }, + output: ['test', '-S', '/tmp/socket'], }, { - input: [ - { path: '/tmp/socket', type: 'socket', exists: true }, - { path: '/tmp/link', type: 'link', exists: false }, - ], - output: 'test -S /tmp/socket && ! test -L /tmp/link', + input: { path: '/tmp/link', type: 'link' }, + output: ['test', '-L', '/tmp/link'], }, { - input: [{ path: '/unknown', type: 'unknown', exists: true }], - output: 'test -e /unknown', + 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)).toBe(output) + expect(buildFileCheckCmd(input)).toStrictEqual(output) }) }) }) diff --git a/quests/helloworld/game.yml b/quests/helloworld/game.yml index 0b4c575..8cbc85e 100644 --- a/quests/helloworld/game.yml +++ b/quests/helloworld/game.yml @@ -149,3 +149,18 @@ stages: - type: narrative content: - There is a jail + + - id: test-file-not-exist + task: Remove the forgotten_scroll.txt + repeatable: true + condition: + command: + - ls -a + $not: + files: + - path: /home/commander/forgotten_scroll.txt + type: file + responses: + - type: narrative + content: + - There is no forgotten_scroll.txt diff --git a/server/Dockerfile b/server/Dockerfile index 76fb334..ca8951d 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -30,5 +30,6 @@ CMD [ "yarn", "start" ] 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/nodemon.json b/server/nodemon.json index 67a8688..0c26ea5 100644 --- a/server/nodemon.json +++ b/server/nodemon.json @@ -1,4 +1,4 @@ { - "watch": ["src", "swagger.yaml", "../quests"], + "watch": ["src", "swagger.yaml", "../quests", "../packages"], "ext": "js,yaml,yml" } diff --git a/server/src/containers/cli.js b/server/src/containers/cli.js index 392e949..0bdedbb 100644 --- a/server/src/containers/cli.js +++ b/server/src/containers/cli.js @@ -1,16 +1,32 @@ -import { exec } from 'child_process' +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 checkFiles(id, files) { - return new Promise((resolve) => { - const cmd = buildFileCheckCmd(files) - // WARNING: This is not secure! - exec(`docker exec ${id} ${cmd}`, (error, stdout, stderr) => { - console.log('stdout', stdout) - console.log('stderr', stderr) - resolve(!error) - }) +export function checkFile(id, file) { + return new Promise((resolve, reject) => { + const cmd = buildFileCheckCmd(file) + console.log(cmd) + // WARNING: This is vulnerable to command injection + execFile( + 'docker', + ['exec', sanitizeId(id), ...cmd], + { timeout: 1000 }, + (error, stdout, stderr) => { + if (stderr) console.log('error', error) + if (stdout) console.log('stdout', stdout) + if (error) console.log('error', error) + const exists = !error + if (exists === file.exists) { + resolve(true) + } else { + reject(new Error('File check failed')) + } + } + ) }) } diff --git a/server/src/game/commandHandler.js b/server/src/game/commandHandler.js index 4066323..1960333 100644 --- a/server/src/game/commandHandler.js +++ b/server/src/game/commandHandler.js @@ -3,7 +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 { checkFiles } from '../containers/cli.js' +import { checkFile } from '../containers/cli.js' // 檢查 pattern 是否符合 input const checkMatch = (pattern, input) => { @@ -55,30 +55,47 @@ export default class CommandHandler extends SessionHandler { } async isMatch(condition) { - const keys = ['command', 'output', 'error', 'pwd'] - if (keys.some((k) => !checkMatch(condition[k], this.commandInput[k]))) { - return false - } - if (condition.files.length > 0) { - const match = await this.checkFiles(condition.files) - if (!match) return false - } - if (condition.$not) { - const match = await this.isMatch(condition.$not) - if (match) return false - } - if (condition.$or.length > 0) { - const matches = await Promise.all( - condition.$or.map((c) => this.isMatch(c)) - ) - if (matches.every((m) => m === false)) return false - } - return true + return ( + this.checkKeys(condition) && + (await this.checkFiles(condition.files)) && + (await this.checkNot(condition.$not)) && + (await this.checkOr(condition.$or)) === true + ) } - checkFiles(files) { + 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 + } console.log('checkFiles', files) - return checkFiles(this.session.containerId, files) + 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() { @@ -98,12 +115,12 @@ export default class CommandHandler extends SessionHandler { console.log('matches', matches) - const filteredStages = stages.filter((_, i) => matches[i]) - console.log('filteredStages', filteredStages) - const response = filteredStages.reduce( - (r, s) => ({ ...r, ...this.execute(s) }), - {} - ) + const stage = stages.find((_, i) => matches[i]) + console.log('stage', stage) + if (!stage) { + return {} + } + const response = this.execute(stage) return { end: this.session.status === 'finished', From aec0cf84adcbfbf6ed074014f2733c4b03ffd9e2 Mon Sep 17 00:00:00 2001 From: wancat Date: Sun, 29 Oct 2023 15:38:32 +0800 Subject: [PATCH 14/23] feat: include complete stage name in command --- packages/analytics/src/models/sessions.js | 17 ++++++++++++++--- .../analytics/views/sessionDetail.handlebars | 2 ++ packages/models/command.js | 4 ++++ quests/helloworld/game.yml | 2 +- server/src/api/controllers/commandController.js | 5 +++++ server/src/game/commandHandler.js | 4 +--- 6 files changed, 27 insertions(+), 7 deletions(-) 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/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/quests/helloworld/game.yml b/quests/helloworld/game.yml index 8cbc85e..9c7e9cc 100644 --- a/quests/helloworld/game.yml +++ b/quests/helloworld/game.yml @@ -82,7 +82,7 @@ stages: pwd: - '^/home/commander$' command: - - cat forgotten-scroll.txt + - cat forgotten_scroll.txt responses: - type: dialogue speaker: Unix 精靈 diff --git a/server/src/api/controllers/commandController.js b/server/src/api/controllers/commandController.js index ffd7973..36f998a 100644 --- a/server/src/api/controllers/commandController.js +++ b/server/src/api/controllers/commandController.js @@ -41,6 +41,11 @@ export async function newCommand(req, res) { const response = await commandHandler.run() + if (response) { + c.stage = response.stage + await c.save() + } + res.status(201).json(response) await session.save() } diff --git a/server/src/game/commandHandler.js b/server/src/game/commandHandler.js index 1960333..aec371a 100644 --- a/server/src/game/commandHandler.js +++ b/server/src/game/commandHandler.js @@ -113,16 +113,14 @@ export default class CommandHandler extends SessionHandler { stages.map((stage) => this.isMatch(stage.condition)) ) - console.log('matches', matches) - const stage = stages.find((_, i) => matches[i]) - console.log('stage', stage) if (!stage) { return {} } const response = this.execute(stage) return { + stage: stage.id, end: this.session.status === 'finished', ...response, } From d724f97403c7347f538daaaf95afc34766682b5f Mon Sep 17 00:00:00 2001 From: wancat Date: Sun, 29 Oct 2023 17:12:31 +0800 Subject: [PATCH 15/23] feat: add pending signal to status --- app/src/utils/session.js | 10 ++++-- app/src/views/GamePage.vue | 19 +++++++----- docker-compose.yml | 2 +- packages/container/cli.js | 31 ++++++++++--------- packages/container/package.json | 2 +- .../src/api/controllers/commandController.js | 31 ++++++++++++++++++- .../src/api/controllers/sessionController.js | 6 ++++ server/src/api/routes/commands.js | 1 + server/src/api/routes/index.js | 1 + server/src/game/sessionHandler.js | 8 +++-- yarn.lock | 9 ++++++ 11 files changed, 90 insertions(+), 30 deletions(-) diff --git a/app/src/utils/session.js b/app/src/utils/session.js index 0869c70..65c2dba 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() { @@ -26,6 +27,7 @@ class SessionManager { 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 +39,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/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.yml b/docker-compose.yml index d751e4f..1508496 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,7 +36,7 @@ services: - BASE_URL=http://localhost:5173 - MONGO_URL=mongodb://db:27017/odyssey-test - DOCKER_NETWORK=linux-odyssey-players - # - HOST_PWD=${PWD} + - HOST_PWD=${PWD} - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID} 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/server/src/api/controllers/commandController.js b/server/src/api/controllers/commandController.js index 36f998a..f3af3d7 100644 --- a/server/src/api/controllers/commandController.js +++ b/server/src/api/controllers/commandController.js @@ -1,6 +1,8 @@ -/* 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) @@ -44,8 +46,35 @@ export async function newCommand(req, res) { 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..51f8e69 100644 --- a/server/src/api/controllers/sessionController.js +++ b/server/src/api/controllers/sessionController.js @@ -44,6 +44,12 @@ export async function createSession(req, res) { return } + // deactive all active sessions + await Session.updateMany( + { user: req.user._id, quest, status: 'active' }, + { status: 'inactive' } + ) + 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/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/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" From 40b6d3cd86165041eef2c33ca83d6ff65bf0e94b Mon Sep 17 00:00:00 2001 From: wancat Date: Sun, 29 Oct 2023 17:22:59 +0800 Subject: [PATCH 16/23] feat: remove testing stages --- quests/helloworld/game.yml | 76 ++------------------------------------ 1 file changed, 3 insertions(+), 73 deletions(-) diff --git a/quests/helloworld/game.yml b/quests/helloworld/game.yml index 9c7e9cc..587249e 100644 --- a/quests/helloworld/game.yml +++ b/quests/helloworld/game.yml @@ -58,21 +58,6 @@ stages: - 使用 `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 @@ -103,64 +88,9 @@ stages: responses: - type: narrative content: - - 突然之間,地板開始晃動。 - - 晃動持續了幾秒後停止了。 + - 突然之間,你感覺到一股強大的力量從你的身體中湧出,你的身體開始發光,你的手中出現了一把魔法之劍。 - type: dialogue speaker: Unix 精靈 content: - - 我的指令官,剛剛房間內似乎發生了某些變化。 - - 再次使用 `ls` 來查看現在房間的情況吧。 - - - id: not-test - task: Test "not" condition - condition: - $not: - pwd: - - '^/home/commander$' - responses: - - type: narrative - content: - - You are not in the home directory. - - - id: or-test - task: Test "or" condition - repeatable: true - condition: - $or: - - command: - - less - - output: - - wow - responses: - - type: narrative - content: - - You are using `less` or the output contains `wow`. - - - id: test-file-exists - task: Test file exists - repeatable: true - condition: - command: - - ls - files: - - path: /home/commander/jail - type: folder - responses: - - type: narrative - content: - - There is a jail - - - id: test-file-not-exist - task: Remove the forgotten_scroll.txt - repeatable: true - condition: - command: - - ls -a - $not: - files: - - path: /home/commander/forgotten_scroll.txt - type: file - responses: - - type: narrative - content: - - There is no forgotten_scroll.txt + - 恭喜您,我的指令官,您已成功解除了封印。 + - 請跟著在接下來的關卡,一步步取回 Linux 之根吧! From 917083ad309d3e13234236da643583dbb8ccd8fe Mon Sep 17 00:00:00 2001 From: wancat Date: Sun, 29 Oct 2023 17:26:48 +0800 Subject: [PATCH 17/23] fix: remove debugging console log --- app/src/utils/session.js | 1 - packages/file-graph/file.test.js | 1 - packages/file-graph/fileGraph.test.js | 5 ----- packages/file-graph/fileNode.test.js | 9 --------- server/src/api/controllers/commandController.js | 1 - server/src/containers/cli.js | 6 +----- server/src/game/commandHandler.js | 1 - 7 files changed, 1 insertion(+), 23 deletions(-) diff --git a/app/src/utils/session.js b/app/src/utils/session.js index 65c2dba..e659bff 100644 --- a/app/src/utils/session.js +++ b/app/src/utils/session.js @@ -23,7 +23,6 @@ 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 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/server/src/api/controllers/commandController.js b/server/src/api/controllers/commandController.js index f3af3d7..2435176 100644 --- a/server/src/api/controllers/commandController.js +++ b/server/src/api/controllers/commandController.js @@ -5,7 +5,6 @@ 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) { diff --git a/server/src/containers/cli.js b/server/src/containers/cli.js index 0bdedbb..dc4640a 100644 --- a/server/src/containers/cli.js +++ b/server/src/containers/cli.js @@ -10,16 +10,12 @@ function sanitizeId(id) { export function checkFile(id, file) { return new Promise((resolve, reject) => { const cmd = buildFileCheckCmd(file) - console.log(cmd) // WARNING: This is vulnerable to command injection execFile( 'docker', ['exec', sanitizeId(id), ...cmd], { timeout: 1000 }, - (error, stdout, stderr) => { - if (stderr) console.log('error', error) - if (stdout) console.log('stdout', stdout) - if (error) console.log('error', error) + (error) => { const exists = !error if (exists === file.exists) { resolve(true) diff --git a/server/src/game/commandHandler.js b/server/src/game/commandHandler.js index aec371a..7ee78c8 100644 --- a/server/src/game/commandHandler.js +++ b/server/src/game/commandHandler.js @@ -72,7 +72,6 @@ export default class CommandHandler extends SessionHandler { if (files.length === 0) { return true } - console.log('checkFiles', files) try { const checks = await Promise.all( files.map((f) => checkFile(this.session.containerId, f)) From 96678a45c3c39f771f4f38ee2b0db8e0755910ac Mon Sep 17 00:00:00 2001 From: wancat Date: Sun, 29 Oct 2023 17:32:18 +0800 Subject: [PATCH 18/23] feat: remove $ from condition field name --- packages/models/quest.js | 4 ++-- server/src/game/commandHandler.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/models/quest.js b/packages/models/quest.js index 39a35ab..1eb2b94 100644 --- a/packages/models/quest.js +++ b/packages/models/quest.js @@ -31,8 +31,8 @@ const conditionSchema = new Schema({ }) conditionSchema.add({ - $or: [conditionSchema], - $not: conditionSchema, + or: [conditionSchema], + not: conditionSchema, }) const stageSchema = new Schema({ diff --git a/server/src/game/commandHandler.js b/server/src/game/commandHandler.js index 7ee78c8..54ea91f 100644 --- a/server/src/game/commandHandler.js +++ b/server/src/game/commandHandler.js @@ -58,8 +58,8 @@ export default class CommandHandler extends SessionHandler { return ( this.checkKeys(condition) && (await this.checkFiles(condition.files)) && - (await this.checkNot(condition.$not)) && - (await this.checkOr(condition.$or)) === true + (await this.checkNot(condition.not)) && + (await this.checkOr(condition.or)) === true ) } From 2285192eac5a919e86d4e10922afb539f2ba75c5 Mon Sep 17 00:00:00 2001 From: "Lin, Hung-Shin" Date: Mon, 30 Oct 2023 13:31:59 +0800 Subject: [PATCH 19/23] feat: delete previous container on session creation --- server/Dockerfile | 3 +- .../src/api/controllers/sessionController.js | 28 +++++++++++++++---- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index ca8951d..a07526f 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,6 +1,5 @@ FROM node:20-alpine AS build -COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/ WORKDIR /app COPY ./package.json yarn.lock ./ COPY server/package.json ./server/package.json @@ -8,6 +7,8 @@ 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/ ENV HOST=0.0.0.0 ENV PORT=3000 diff --git a/server/src/api/controllers/sessionController.js b/server/src/api/controllers/sessionController.js index 51f8e69..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,11 +61,10 @@ export async function createSession(req, res) { return } - // deactive all active sessions - await Session.updateMany( - { user: req.user._id, quest, status: 'active' }, - { status: 'inactive' } - ) + // deactivate all active sessions + deactivateSessions(req.user._id, quest._id).catch((err) => { + console.error(err) + }) try { const container = await createContainer( From fcebb25b4665b1d407e237e430d6c39aeeba2563 Mon Sep 17 00:00:00 2001 From: "CHEN, TAI-LIN" <88068175+NeilChen0812@users.noreply.github.com> Date: Mon, 30 Oct 2023 13:53:43 +0800 Subject: [PATCH 20/23] Update forgotten_scroll.txt --- quests/helloworld/home/forgotten_scroll.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/quests/helloworld/home/forgotten_scroll.txt b/quests/helloworld/home/forgotten_scroll.txt index 9c7569d..54cc80f 100644 --- a/quests/helloworld/home/forgotten_scroll.txt +++ b/quests/helloworld/home/forgotten_scroll.txt @@ -2,9 +2,9 @@ Once a mighty kingdom, now in darkness, The power of Unix lies dormant, waiting. To reclaim the root, the forgotten language, Must be learned, commands of old rekindled. -Speak the words of pow er and awaken the might of Unix: +Speak the words of power and awaken the might of Unix: ~~~~~~~~~~~~~~~~~~~~~~~~~~ Unix, rise and guide me! ~~~~~~~~~~~~~~~~~~~~~~~~~~ - \ No newline at end of file + From 0392fe9a6520564392de9326340446dd4a8c379c Mon Sep 17 00:00:00 2001 From: "Lin, Hung-Shin" Date: Mon, 30 Oct 2023 13:55:10 +0800 Subject: [PATCH 21/23] refactor: separate Terminal instance --- app/src/components/TerminalPart.vue | 50 ++-------------------------- app/src/utils/terminal.js | 51 +++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 47 deletions(-) create mode 100644 app/src/utils/terminal.js 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/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) From 8a0512aac891b4a4d7191235834afb036b1d183a Mon Sep 17 00:00:00 2001 From: "Lin, Hung-Shin" Date: Mon, 30 Oct 2023 13:58:33 +0800 Subject: [PATCH 22/23] feat: clear terminal on reset * TODO: have some trailing --- app/src/components/ControlPalette.vue | 2 ++ 1 file changed, 2 insertions(+) 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 @@ From 51bf06e2a778eb00f1e0d7f255e5ab9065d07875 Mon Sep 17 00:00:00 2001 From: "Lin, Hung-Shin" Date: Mon, 30 Oct 2023 14:21:52 +0800 Subject: [PATCH 23/23] feat: remove wait for enter in dialogue --- packages/container/print.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/container/print.js b/packages/container/print.js index 3ad7b29..1f7a50e 100644 --- a/packages/container/print.js +++ b/packages/container/print.js @@ -45,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) @@ -71,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') } } }