Compare commits

..
Author SHA1 Message Date
Chen, Tai-Lin aebe587ad3 update 2024-04-30 01:40:42 +08:00
Chen, Tai-Lin c96ee83fa3 update quest\permission 2024-04-08 22:39:23 +08:00
61 changed files with 968 additions and 780 deletions

No files matched your search

+20
View File
@@ -35,3 +35,23 @@ jobs:
- name: Test - name: Test
run: yarn test run: yarn test
e2e:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Build Test Environment, Stage 1
run: docker-compose -f docker-compose.testing.yml build base backend app-test cypress
- name: Build Test Quests
run: docker-compose -f docker-compose.testing.yml run --rm backend yarn build:quests
- name: Run Cypress Tests
env:
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
GITHUB_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }}
GITHUB_CLIENT_SECRET: ${{ secrets.GH_CLIENT_SECRET }}
run: docker-compose -f docker-compose.testing.yml run cypress
-24
View File
@@ -1,24 +0,0 @@
name: E2E Tests
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Build Test Environment, Stage 1
run: docker-compose -f docker-compose.testing.yml build base backend app-test cypress
- name: Build Test Quests
run: docker-compose -f docker-compose.testing.yml run --rm backend yarn build:quests
- name: Run Cypress Tests
env:
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
GITHUB_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }}
GITHUB_CLIENT_SECRET: ${{ secrets.GH_CLIENT_SECRET }}
run: docker-compose -f docker-compose.testing.yml run cypress
+1 -1
View File
@@ -19,7 +19,7 @@
Run mongoDB first: Run mongoDB first:
docker compose up -d docker compose -f docker-compose.dev.yml up -d db
The db will automatically restarts by Docker. The db will automatically restarts by Docker.
+1 -3
View File
@@ -6,7 +6,6 @@
"dev": "vite", "dev": "vite",
"dev:docker": "API_TARGET=http://backend:3000 vite", "dev:docker": "API_TARGET=http://backend:3000 vite",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"type-check": "vue-tsc -b",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -17,14 +16,13 @@
"@fortawesome/vue-fontawesome": "^3.0.3", "@fortawesome/vue-fontawesome": "^3.0.3",
"@linux-odyssey/constants": "*", "@linux-odyssey/constants": "*",
"@linux-odyssey/file-graph": "*", "@linux-odyssey/file-graph": "*",
"@linux-odyssey/models": "*",
"@linux-odyssey/utils": "*", "@linux-odyssey/utils": "*",
"@linux-odyssey/models": "*",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"axios": "^1.6.0", "axios": "^1.6.0",
"dompurify": "^3.0.6", "dompurify": "^3.0.6",
"echarts": "^5.4.3", "echarts": "^5.4.3",
"marked": "^10.0.0", "marked": "^10.0.0",
"pinia": "^2.1.7",
"socket.io-client": "^4.7.1", "socket.io-client": "^4.7.1",
"validator": "^13.11.0", "validator": "^13.11.0",
"vue": "^3.3.4", "vue": "^3.3.4",
-17
View File
@@ -83,16 +83,6 @@ const handleChange = () => {
errorMessage.value = 'Something went wrong' errorMessage.value = 'Something went wrong'
} }
} }
const registerGuest = async () => {
try {
await api.post('/auth/register-guest')
router.push({ name: 'map' })
} catch (err) {
console.error(err)
toast.error('Something went wrong.')
}
}
</script> </script>
<template> <template>
<!-- login page --> <!-- login page -->
@@ -229,13 +219,6 @@ const registerGuest = async () => {
</RouterLink></span </RouterLink></span
> >
</p> </p>
<button
id="register-guest"
class="rounded-lg py-2 mt-3 bg-bg-primary text-text-secondary w-full border-text-secondary border-2"
@click="registerGuest()"
>
以訪客身分繼續
</button>
</form> </form>
</div> </div>
</template> </template>
@@ -2,6 +2,6 @@
<font-awesome-icon :icon="['fas', 'list']" class="text-text-primary p-1" /> <font-awesome-icon :icon="['fas', 'list']" class="text-text-primary p-1" />
<h1 id="CommandBook" class="inline text-text-primary font-xl">指令之書</h1> <h1 id="CommandBook" class="inline text-text-primary font-xl">指令之書</h1>
<div id="cheatsheets"> <div id="cheatsheets">
<p class="text-text-secondary">-- --指令列表-- --</p> <p class="text-text-secondary">--指令列表--</p>
</div> </div>
</template> </template>
@@ -4,7 +4,7 @@ import { useToast } from 'vue-toastification'
import { ref } from 'vue' import { ref } from 'vue'
import { Vue3Lottie } from 'vue3-lottie' import { Vue3Lottie } from 'vue3-lottie'
// import { surveyUrl } from '../config' // import { surveyUrl } from '../config'
import CompleteAnimation from '../../img/complete-animation.json' import CompleteAnimation from '../img/complete-animation.json'
const showModal = ref(true) // Controls whether the modal is visible or not const showModal = ref(true) // Controls whether the modal is visible or not
const router = useRouter() const router = useRouter()
@@ -26,6 +26,7 @@ const backtoMap = async () => {
try { try {
router.push({ name: 'map' }) router.push({ name: 'map' })
} catch (err) { } catch (err) {
// useToast().error('Failed to load map')
toast.error('無法讀取地圖') toast.error('無法讀取地圖')
console.error(err) console.error(err)
} }
@@ -59,6 +60,17 @@ setTimeout(() => {
<p class="text-base text-text text-center"> <p class="text-base text-text text-center">
接下來回到地圖,迎接新的挑戰吧! 接下來回到地圖,迎接新的挑戰吧!
</p> </p>
<!-- <p class="text-base text-text">
您已成功完成挑戰,真的非常感謝您的參與!<br />
如果您能撥冗填寫這份問卷,我們將不勝感激,這對我們非常重要!
</p> -->
<!-- <a
id="SurveyButton"
:href="surveyUrl"
target="_blank"
class="inline-flex justify-center rounded-lg text-base font-black py-2 mt-3 bg-text-primary w-full"
>填寫問卷</a
> -->
<a <a
id="BacktoMap" id="BacktoMap"
target="_blank" target="_blank"
@@ -1,14 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import useSession from '../../store/session' import { createSession } from '../store/session'
import { surveyUrl } from '../../config' import { surveyUrl } from '../config'
const router = useRouter() const router = useRouter()
const sessionStore = useSession()
const reset = async () => { const reset = async () => {
console.log('resetting...') console.log('resetting...')
await sessionStore.createSession() await createSession()
} }
const continuePlay = async () => { const continuePlay = async () => {
try { try {
@@ -21,31 +20,29 @@ const continuePlay = async () => {
</script> </script>
<template> <template>
<section <section class="h-[10%] p-3 grid grid-cols-4 gap-4 place-content-between">
class="h-fit w-fit px-1 py-1 grid grid-cols-4 gap-2 place-content-between"
>
<button id="survey" class="bg-bg-secondary rounded-lg p-2"> <button id="survey" class="bg-bg-secondary rounded-lg p-2">
<a title="Survey" :href="surveyUrl" target="_blank" class="h-5 w-5"> <a title="Survey" :href="surveyUrl" target="_blank" class="h-5 w-5">
<font-awesome-icon <font-awesome-icon
:icon="['fas', 'file-invoice']" :icon="['fas', 'file-invoice']"
class="text-text-primary px-1" class="text-text-primary"
/> />
<p class="text-text-primary inline ml-1 md:flex">問卷</p> <p class="text-text-primary inline ml-1">問卷</p>
</a> </a>
</button> </button>
<button disabled id="solution" class="bg-bg-disabled rounded-lg p-2"> <button disabled id="solution" class="bg-bg-disabled rounded-lg p-2">
<font-awesome-icon <font-awesome-icon
:icon="['far', 'circle-question']" :icon="['far', 'circle-question']"
class="text-text-disabled px-1" class="text-text-disabled"
/> />
<p class="text-text-disabled inline ml-1 md:flex">解答</p> <p class="text-text-disabled inline ml-1">解答</p>
</button> </button>
<button id="reset" class="bg-bg-secondary rounded-lg p-2" @click="reset"> <button id="reset" class="bg-bg-secondary rounded-lg p-2" @click="reset">
<font-awesome-icon <font-awesome-icon
:icon="['fas', 'arrow-rotate-left']" :icon="['fas', 'arrow-rotate-left']"
class="text-text-primary px-1" class="text-text-primary"
/> />
<p class="text-text-primary inline ml-1 hidden md:flex">重來</p> <p class="text-text-primary inline ml-1">重來</p>
</button> </button>
<button <button
id="continue" id="continue"
@@ -54,9 +51,9 @@ const continuePlay = async () => {
> >
<font-awesome-icon <font-awesome-icon
:icon="['far', 'circle-right']" :icon="['far', 'circle-right']"
class="text-text-primary px-1" class="text-text-primary"
/> />
<p class="text-text-primary inline ml-1 hidden md:flex">繼續</p> <p class="text-text-primary inline ml-1">繼續</p>
</button> </button>
</section> </section>
</template> </template>
+142
View File
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { defineProps, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
import { logout } from '../utils/auth'
import { bugReportUrl, surveyUrl } from '../config'
import { reset } from '../store/session'
import userProfileStore, {
loadUserProfile,
resetUserProfile,
} from '../store/userProfile'
const router = useRouter()
const toast = useToast()
const handleLogout = async () => {
try {
await logout()
resetUserProfile()
reset()
router.push({ name: 'login' })
} catch (err) {
toast.error('登出失敗')
console.error(err)
}
}
defineProps({
title: {
type: String,
required: false,
},
})
onMounted(async () => {
try {
await loadUserProfile()
} catch (err) {
console.error(err)
toast.error('無法讀取使用者資料')
}
})
</script>
<template>
<div id="header" class="bg-bg-secondary content-center h-full w-full p-1">
<div class="h-full flex items-center justify-center mx-3">
<div class="flex items-center gap-3">
<!-- <button id="stream" class="flex items-center h-5 w-5">
<font-awesome-icon
:icon="['fas', 'bars']"
class="text-text-primary h-full w-full"
/>
</button>
<button id="backward" class="flex items-center h-5 w-5">
<font-awesome-icon
:icon="['fas', 'arrow-left']"
class="text-border h-full w-full"
/>
</button> -->
<img src="../img/icon_totem.svg" class="h-5" />
<button
id="GameTitle"
class="text-text-primary inline-block font-bold whitespace-nowrap"
style="font-size: 2vh"
>
Linux Odyssey
</button>
<p
id="QuestTitle"
class="text-text inline-block font-bold whitespace-nowrap"
style="font-size: 2vh"
>
{{ title }}
</p>
</div>
<div class="h-full w-full flex gap-3 items-center justify-end">
<!-- <button id="warning" class="flex items-center h-5 w-5">
<font-awesome-icon
:icon="['fas', 'triangle-exclamation']"
class="text-text-primary h-full w-full"
/>
</button>
<button id="setting" class="flex items-center h-5 w-5">
<font-awesome-icon
:icon="['fas', 'gear']"
class="text-text-primary h-full w-full"
/>
</button>
<button id="fullscreen" class="flex items-center h-5 w-5">
<font-awesome-icon
:icon="['fas', 'expand']"
class="text-text-primary h-full w-full"
/>
</button> -->
<p
id="UsernameText"
class="text-text inline-block whitespace-nowrap"
style="font-size: 2vh"
>
{{ userProfileStore.username }}
</p>
<a title="Survey" :href="surveyUrl" target="_blank" class="h-5 w-5">
<font-awesome-icon
:icon="['fas', 'file-invoice']"
class="text-text-primary h-full w-full"
/>
</a>
<RouterLink title="LeaderBoard" to="/leaderboard" class="h-5 w-5">
<font-awesome-icon
:icon="['fas', 'trophy']"
class="text-text-primary h-full w-full"
/>
</RouterLink>
<RouterLink title="Map" to="/map" class="h-5 w-5">
<font-awesome-icon
:icon="['fas', 'map']"
class="text-text-primary h-full w-full"
/>
</RouterLink>
<a
title="Bug Report"
:href="bugReportUrl"
target="_blank"
class="h-5 w-5"
>
<font-awesome-icon
:icon="['fas', 'bug']"
class="text-text-primary h-full w-full"
/>
</a>
<button title="Sign Out" @click="handleLogout" class="h-5 w-5">
<font-awesome-icon
:icon="['fas', 'arrow-right-from-bracket']"
class="text-text-primary h-full w-full"
/>
</button>
</div>
</div>
</div>
</template>
@@ -1,21 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { ref, watch } from 'vue'
import useSession from '../../store/session' import sessionStore from '../store/session'
import MarkdownText from '../MarkdownText.vue' import MarkdownText from './MarkdownText.vue'
const sessionStore = useSession()
const current = ref(-1) const current = ref(-1)
const length = computed(() => { watch(
return sessionStore.session?.responses.length ?? 0 () => sessionStore.session.hints,
}) (hints) => {
current.value = hints.length - 1
const disabled = computed(() => { },
return current.value === length.value - 1 { deep: true }
}) )
watch(length, () => {
current.value = length.value - 1
})
const left = () => { const left = () => {
if (current.value > 0) { if (current.value > 0) {
@@ -24,7 +19,7 @@ const left = () => {
} }
const right = () => { const right = () => {
if (current.value < length.value - 1) { if (current.value < sessionStore.session.hints.length - 1) {
current.value += 1 current.value += 1
} }
} }
@@ -32,12 +27,16 @@ const right = () => {
<template> <template>
<div class="h-full flex flex-col"> <div class="h-full flex flex-col">
<!-- Hint Header -->
<div class="flex items-center p-2 h-10"> <div class="flex items-center p-2 h-10">
<font-awesome-icon <font-awesome-icon
:icon="['far', 'lightbulb']" :icon="['far', 'lightbulb']"
class="text-yellow-200 p-2 content-center" class="text-yellow-200 p-2 content-center"
/> />
<h1 class="inline text-text w-1/2 font-xl p-2 m-1">提示 {{ length }}</h1> <h1 class="inline text-text w-1/2 font-xl p-2 m-1">
<!-- Hint -->
提示
</h1>
<div v-if="current !== -1" class="flex w-full justify-end items-end"> <div v-if="current !== -1" class="flex w-full justify-end items-end">
<button <button
class="p-2 m-1 w-1/8" class="p-2 m-1 w-1/8"
@@ -54,13 +53,13 @@ const right = () => {
<p class="inline text-text font-xl w-1/14 p-2 m-1">{{ current + 1 }}</p> <p class="inline text-text font-xl w-1/14 p-2 m-1">{{ current + 1 }}</p>
<p class="inline text-text font-xl w-1/14 p-2 m-1">/</p> <p class="inline text-text font-xl w-1/14 p-2 m-1">/</p>
<p class="inline text-text font-xl w-1/14 p-2 m-1"> <p class="inline text-text font-xl w-1/14 p-2 m-1">
{{ length }} {{ sessionStore.session.hints.length }}
</p> </p>
<button <button
class="p-2 m-1 w-1/8" class="p-2 m-1 w-1/8"
id="Rbutton" id="Rbutton"
@click="right" @click="right"
:disabled="disabled" :disabled="current === sessionStore.session.hints.length - 1"
> >
<font-awesome-icon :icon="['fas', 'arrow-right']" class="text-text" /> <font-awesome-icon :icon="['fas', 'arrow-right']" class="text-text" />
</button> </button>
@@ -69,7 +68,7 @@ const right = () => {
<div id="hint" class="bg-bg p-8 overflow-y-auto"> <div id="hint" class="bg-bg p-8 overflow-y-auto">
<ul> <ul>
<li <li
v-for="response in sessionStore.session?.responses[current]" v-for="response in sessionStore.session.responses[current]"
:key="response.type" :key="response.type"
class="text-text font-xl whitespace-pre-wrap" class="text-text font-xl whitespace-pre-wrap"
> >
@@ -78,7 +77,7 @@ const right = () => {
</div> </div>
</li> </li>
<li <li
v-for="hint in sessionStore.session?.hints[current]" v-for="hint in sessionStore.session.hints[current]"
:key="hint" :key="hint"
class="text-text-primary font-xl whitespace-pre-wrap" class="text-text-primary font-xl whitespace-pre-wrap"
> >
+13 -5
View File
@@ -14,9 +14,7 @@ import {
import { DAG } from '@linux-odyssey/utils' import { DAG } from '@linux-odyssey/utils'
import api from '../utils/api' import api from '../utils/api'
import { NodeImage } from '../img/svg.js' import { NodeImage } from '../img/svg.js'
import useUserProfile from '../store/userProfile'
const store = useUserProfile()
const marginX = 500 const marginX = 500
const marginY = 300 const marginY = 300
const chartContainer = ref(null) const chartContainer = ref(null)
@@ -42,6 +40,16 @@ async function getQuests() {
} }
} }
async function getProgress() {
try {
const res = await api.get('/users/me')
return res.data.progress
} catch (err) {
console.error(err)
throw err
}
}
const genOption = (nodes: any, edges: any) => ({ const genOption = (nodes: any, edges: any) => ({
renderer: 'svg', renderer: 'svg',
tooltip: {}, tooltip: {},
@@ -154,9 +162,9 @@ function initChart(option: any) {
} }
onMounted(async () => { onMounted(async () => {
await store.loadUserProfile()
const quests = await getQuests() const quests = await getQuests()
const option = getOption(quests, store.progress) const progress = await getProgress()
const option = getOption(quests, progress)
initChart(option) initChart(option)
}) })
</script> </script>
@@ -169,7 +177,7 @@ onMounted(async () => {
class="p-2 stroke-2 scale-y-120 justify-center items-center" class="p-2 stroke-2 scale-y-120 justify-center items-center"
/> />
<h1 <h1
class="p-10 absolute w-fit z-2 font-mono flex flax-wrap text-xl" class="p-4 absolute w-fit z-2 font-mono flex flax-wrap text-xl"
style="width: 30%; height: 6%; font-size: 3vh; color: #00ff00" style="width: 30%; height: 6%; font-size: 3vh; color: #00ff00"
> >
踏上你的Linux冒險之旅吧! 踏上你的Linux冒險之旅吧!
@@ -1,12 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import MarkdownText from '../MarkdownText.vue' import MarkdownText from './MarkdownText.vue'
import useSession from '../../store/session' import sessionStore from '../store/session'
import { Task } from '../../types' import { Task } from '../types'
const sessionStore = useSession()
const tasks = computed(() => { const tasks = computed(() => {
return sessionStore.session?.tasks.map((task: Task) => { return sessionStore.session.tasks.map((task: Task) => {
const prefix = task.completed ? '✓' : '➤' const prefix = task.completed ? '✓' : '➤'
const color = task.completed ? 'text-text-primary' : 'text-text' const color = task.completed ? 'text-text-primary' : 'text-text'
return { return {
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useTerminal } from '../../store/session' import { useTerminal } from '../store/session'
const terminal = ref<HTMLElement | null>(null) const terminal = ref<HTMLElement | null>(null)
@@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import FileNode from '../FileNode.vue' import FileNode from './FileNode.vue'
import useSession from '../../store/session' import sessionStore from '../store/session'
const sessionStore = useSession()
</script> </script>
<template> <template>
@@ -10,7 +8,6 @@ const sessionStore = useSession()
<ul> <ul>
<li> <li>
<FileNode <FileNode
v-if="sessionStore.session"
:node="sessionStore.session.graph" :node="sessionStore.session.graph"
:pwd="sessionStore.session.pwd" :pwd="sessionStore.session.pwd"
/> />
-37
View File
@@ -1,37 +0,0 @@
<script setup lang="ts">
import { useToast } from 'vue-toastification'
import { computed } from 'vue'
import useSession from '../../store/session'
const toast = useToast()
const sessionStore = useSession()
const showCover = computed(() => sessionStore.session === null)
const startSession = async () => {
try {
await sessionStore.createSession()
} catch (err: any) {
console.error(err)
toast.error(err.message)
}
}
</script>
<template>
<div
v-if="showCover"
class="bg-bg-secondary h-full w-1/2 fixed bg-opacity-50 flex justify-center items-center"
>
<button
id="start"
class="bg-bg-secondary rounded-lg p-3"
@click="startSession"
>
<font-awesome-icon
:icon="['fas', 'play']"
class="text-text-primary h-full px-3"
/>
<p class="text-text-primary inline ml-1 md:flex">開始</p>
</button>
</div>
</template>
@@ -1,107 +0,0 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
import { logout } from '../../utils/auth'
import { bugReportUrl, surveyUrl } from '../../config'
import useSession from '../../store/session'
import useUserProfile from '../../store/userProfile'
const userStore = useUserProfile()
const sessionStore = useSession()
const router = useRouter()
const toast = useToast()
const handleLogout = async () => {
try {
await logout()
userStore.resetUserProfile()
sessionStore.reset()
router.push({ name: 'login' })
} catch (err) {
toast.error('登出失敗')
console.error(err)
}
}
onMounted(async () => {
try {
await userStore.loadUserProfile()
} catch (err) {
toast.error('無法讀取使用者資料')
console.error(err)
}
})
</script>
<template>
<!-- <button id="warning" class="flex items-center h-5 w-5">
<font-awesome-icon
:icon="['fas', 'triangle-exclamation']"
class="text-text-primary h-full w-full"
/>
</button>
<button id="setting" class="flex items-center h-5 w-5">
<font-awesome-icon
:icon="['fas', 'gear']"
class="text-text-primary h-full w-full"
/>
</button>
<button id="fullscreen" class="flex items-center h-5 w-5">
<font-awesome-icon
:icon="['fas', 'expand']"
class="text-text-primary h-full w-full"
/>
</button> -->
<p
id="UsernameText"
class="text-text inline-block whitespace-nowrap px-1.5"
style="font-size: 2vh"
>
{{ userStore.username }}
</p>
<a
title="Survey"
:href="surveyUrl"
target="_blank"
class="md:h-5 md:w-5 px-1.5 w-auto"
>
<font-awesome-icon
:icon="['fas', 'file-invoice']"
class="text-text-primary inline"
/>
<span class="text-text sm:hidden px-2">Survey</span>
</a>
<RouterLink
title="LeaderBoard"
to="/leaderboard"
class="md:h-5 md:w-5 px-1.5 w-auto"
>
<font-awesome-icon :icon="['fas', 'trophy']" class="text-text-primary" />
<span class="text-text sm:hidden px-2">Leader Board</span>
</RouterLink>
<RouterLink title="Map" to="/map" class="md:h-5 md:w-5 px-1.5 w-auto">
<font-awesome-icon :icon="['fas', 'map']" class="text-text-primary" />
<span class="text-text sm:hidden px-2">Map</span>
</RouterLink>
<a
title="Bug Report"
:href="bugReportUrl"
target="_blank"
class="md:h-5 md:w-5 px-1.5 w-auto"
>
<font-awesome-icon :icon="['fas', 'bug']" class="text-text-primary" />
<span class="text-text sm:hidden px-2">Bug Report</span>
</a>
<button
title="Sign Out"
@click="handleLogout"
class="md:h-5 md:w-5 px-1.5 w-auto"
>
<font-awesome-icon
:icon="['fas', 'arrow-right-from-bracket']"
class="text-text-primary"
/>
<span class="text-text sm:hidden px-2">Sign Out</span>
</button>
</template>
-66
View File
@@ -1,66 +0,0 @@
<script setup lang="ts">
import { defineProps, ref } from 'vue'
defineProps({
title: {
type: String,
required: false,
},
headerComponent: {
type: Object,
required: false,
},
})
const menuOpen = ref(false)
const windowWidth = window.innerWidth < 768
const toggleMenu = () => {
menuOpen.value = !menuOpen.value
}
</script>
<template>
<div
class="w-full top-0 px-5 fixed flex flex-row flex-wrap bg-bg-secondary justify-between items-center sm:justify-between"
>
<div class="flex flex-row items-center gap-3" :class="{ hidden: menuOpen }">
<img src="../../img/icon_totem.svg" class="h-8 justify-self-start" />
<RouterLink
to="/"
id="GameTitle"
class="text-text-primary inline-block font-bold whitespace-nowrap pt-1 transition ease-in-out hover:scale-110 duration-300"
style="font-size: 2vh"
>
Linux Odyssey
</RouterLink>
<p
id="QuestTitle"
class="text-text inline-block font-bold whitespace-nowrap"
style="font-size: 2vh"
>
{{ title }}
</p>
</div>
<div
class="w-full flex flex-col items-center justify-end sm:w-auto sm:block sm:ml-6"
:class="{ hidden: !menuOpen }"
>
<component :is="headerComponent" />
</div>
<div
v-if="windowWidth"
:class="{ 'w-full flex justify-end pb-3': menuOpen }"
>
<button
type="button"
class="flex items-center h-5 w-5 rounded-md sm:hidden"
aria-controls="mobile-menu"
aria-expanded="false"
@click="toggleMenu"
>
<font-awesome-icon
:icon="['fas', 'bars']"
class="text-text-primary h-full w-full"
/>
</button>
</div>
</div>
</template>
@@ -1,62 +0,0 @@
<script setup lang="ts">
import { bugReportUrl } from '../../config'
const scrollToSection = (sectionId: string) => {
const section = document.getElementById(sectionId)
if (section) {
const container = document.querySelector('.overflow-y-auto') as HTMLElement // Replace '.overflow-y-auto' with the appropriate class or selector for your container
if (container) {
container.scrollTo({
top: section.offsetTop - container.offsetTop,
behavior: 'smooth', // Add smooth scrolling behavior if desired
})
}
}
}
</script>
<template>
<button
id="Intro"
class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary"
@click="scrollToSection('intro')"
style="font-size: 2vh"
>
前言
</button>
<button
id="Feature"
class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary"
@click="scrollToSection('feature')"
style="font-size: 2vh"
>
特色</button
><button
id="Info"
class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary"
@click="scrollToSection('about')"
style="font-size: 2vh"
>
關於
</button>
<button
id="Contact"
class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary"
@click="scrollToSection('contact')"
style="font-size: 2vh"
>
聯絡我們
</button>
<RouterLink
title="Sign In"
id="SignIn"
to="/register"
class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary"
style="font-size: 2vh"
>
登入
</RouterLink>
<a title="Bug Report" :href="bugReportUrl" target="_blank" class="h-5 w-5">
<font-awesome-icon :icon="['fas', 'bug']" class="text-text-primary" />
</a>
</template>
+1 -3
View File
@@ -19,7 +19,6 @@ import {
faTrophy, faTrophy,
faFileInvoice, faFileInvoice,
faEnvelope, faEnvelope,
faPlay,
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { import {
faLightbulb, faLightbulb,
@@ -55,6 +54,5 @@ library.add(
faGoogle, faGoogle,
faFileInvoice, faFileInvoice,
faFacebook, faFacebook,
faEnvelope, faEnvelope
faPlay
) )
-4
View File
@@ -1,6 +1,4 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css' import './style.css'
/* import font awesome icon component */ /* import font awesome icon component */
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
@@ -15,11 +13,9 @@ import App from './App.vue'
import router from './router' import router from './router'
const app = createApp(App) const app = createApp(App)
const pinia = createPinia()
app.component('font-awesome-icon', FontAwesomeIcon) app.component('font-awesome-icon', FontAwesomeIcon)
app.use(pinia)
app.use(router) app.use(router)
const options = { const options = {
+119 -61
View File
@@ -1,75 +1,142 @@
import { defineStore } from 'pinia' import { reactive } from 'vue'
import { useToast } from 'vue-toastification'
import { FileGraph, FileObject } from '@linux-odyssey/file-graph' import { FileGraph, FileObject } from '@linux-odyssey/file-graph'
import { IQuest } from '@linux-odyssey/models' import { IQuest } from '@linux-odyssey/models'
import { createSession, getActiveSession, getQuest } from '../utils/api' import api from '../utils/api'
import Socket from '../utils/socket' import Socket from '../utils/socket'
import SocketTerminal from '../utils/terminal' import SocketTerminal from '../utils/terminal'
import { LoadQuestError, LoadSessionError } from '../utils/errors'
import { Session, StageResponse } from '../types' import { Session, StageResponse } from '../types'
const socket = new Socket() const toast = useToast()
const term = new SocketTerminal(40, 80)
let hasSetup = false function newSession() {
const graph = new FileGraph({
path: '/',
type: 'folder',
discovered: false,
})
return {
_id: '',
status: 'inactive',
graph,
pwd: '',
hints: [],
tasks: [],
responses: [],
}
}
interface Store { interface Store {
session: Session | null session: Session
questId: string questId: string
quest: IQuest | null quest: IQuest | null
} }
const useSession = defineStore('session', { const store = reactive<Store>({
state: (): Store => ({ session: newSession(),
session: null,
questId: '', questId: '',
quest: null, quest: null,
}), })
actions: {
async setQuest(questId: string) { const socket = new Socket()
this.quest = await getQuest(questId) const term = new SocketTerminal(40, 80)
this.questId = questId
}, function setQuest(questId: string) {
async setSession(session: Session) { return api.get(`/quests/${questId}`).then((res) => {
this.session = session store.questId = res.data._id
this.session.graph = new FileGraph(session.graph) store.quest = res.data
})
}
async function setSession(session: Session) {
try {
console.log('Setting session...', session)
store.session = session
store.session.graph = new FileGraph(session.graph)
term.reset() term.reset()
await socket.connect(session) await socket.connect(session)
term.focus() term.focus()
}, } catch (err: any) {
async createSession() { console.error(err)
const session = await createSession(this.questId) if (err instanceof Error) {
await this.setSession(session) toast.error(err.message)
socket.emit('terminal', 'echo start\n') } else {
}, toast.error('Failed to connect to the session. Please try again later.')
async getActiveSession() {
const session = await getActiveSession(this.questId)
if (session) {
this.setSession(session)
} }
}, throw err
updateGraph(event: { discover?: FileObject[]; pwd?: string }) { }
if (!this.session) return }
export async function createSession() {
try {
console.log('Creating a new session...')
const res = await api.post('/sessions', { questId: store.questId })
const { data } = res
await setSession(data)
} catch (err) {
console.error(err)
toast.error('Failed to create a new session. Please try again later.')
}
}
async function getActiveSession(questId: string) {
try {
const res = await api.post('/sessions/active', { questId })
await setSession(res.data)
} catch (err) {
console.error(err)
toast.warning('Failed to connect previous session. Creating a new one...')
await createSession()
}
}
function updateGraph(event: { discover: FileObject[]; pwd: string }) {
if (event.discover) { if (event.discover) {
this.session.graph.discover(event.discover) store.session.graph.discover(event.discover)
} }
if (event.pwd) { if (event.pwd) {
this.session.pwd = event.pwd store.session.pwd = event.pwd
} }
},
newResponse(response: StageResponse) {
if (!this.session) return
this.session.responses.push(response.responses)
this.session.hints.push(response.hints)
if (response.tasks) {
this.session.tasks = response.tasks
} }
this.session.status = response.status
}, function newResponse(response: StageResponse) {
reset() { console.log('newResponse', response, 'session', store.session)
store.session.responses.push(response.responses)
store.session.hints.push(response.hints)
store.session.tasks = response.tasks
store.session.status = response.status
}
export function useTerminal() {
return term
}
export function reset() {
socket.reset() socket.reset()
term.reset() term.reset()
this.$reset() store.session = newSession()
}, store.quest = null
setup() { }
if (hasSetup) return
export async function init(questId: string) {
if (!questId) throw new Error('No quest ID provided')
reset()
try {
await setQuest(questId)
} catch (err) {
console.error(err)
throw new LoadQuestError('Failed to load quest', questId)
}
try {
await getActiveSession(questId)
} catch (err) {
console.error(err)
throw new LoadSessionError('Failed to load session', questId)
}
}
function setup() {
socket.on('terminal', (data: string) => { socket.on('terminal', (data: string) => {
term.write(data) term.write(data)
}) })
@@ -77,22 +144,13 @@ const useSession = defineStore('session', {
socket.emit('terminal', data) socket.emit('terminal', data)
}) })
socket.on('graph', (event: { discover: FileObject[]; pwd: string }) => { socket.on('graph', (event: { discover: FileObject[]; pwd: string }) => {
this.updateGraph(event) updateGraph(event)
}) })
socket.on('response', (response: StageResponse) => { socket.on('response', (response: StageResponse) => {
this.newResponse(response) newResponse(response)
}) })
hasSetup = true
},
setStatus(status: string) {
if (!this.session) return
this.session.status = status
},
},
})
export function useTerminal() {
return term
} }
export default useSession setup()
export default store
+25 -30
View File
@@ -1,35 +1,30 @@
import { defineStore } from 'pinia' import { reactive } from 'vue'
import { getUserProfile } from '../utils/api' import api from '../utils/api'
interface IProgress { const store = reactive({
quest: string
sessions: string[]
completed: boolean
startedAt: Date
finishedAt?: Date
}
export interface UserProfile {
username: string
email: string
progress: Map<string, IProgress>
}
const useUserProfile = defineStore('userProfile', {
state: (): UserProfile => ({
username: '', username: '',
email: '', email: '',
progress: new Map<string, IProgress>(), progress: {},
}),
actions: {
async loadUserProfile() {
const res = await getUserProfile()
this.$patch(res)
},
async resetUserProfile() {
this.$reset()
},
},
}) })
export default useUserProfile export async function loadUserProfile() {
try {
const res = await api.get('/users/me')
const { username, email, progress } = res.data
store.username = username
store.email = email
store.progress = progress
return store
} catch (err: any) {
console.error(err)
throw new Error(`Failed to load user profile: ${err.message}`)
}
}
export function resetUserProfile() {
store.username = ''
store.email = ''
store.progress = {}
}
export default store
-35
View File
@@ -1,8 +1,4 @@
import axios from 'axios' import axios from 'axios'
import { IQuest } from '@linux-odyssey/models'
import { GetSessionsRequest } from '@linux-odyssey/constants'
import { Session } from '../types'
import { UserProfile } from '../store/userProfile'
const api = axios.create({ const api = axios.create({
baseURL: '/api/v1', baseURL: '/api/v1',
@@ -12,34 +8,3 @@ const api = axios.create({
withCredentials: true, withCredentials: true,
}) })
export default api export default api
export async function getUserProfile(): Promise<UserProfile> {
const res = await api.get<UserProfile>('/users/me')
return res.data
}
export async function getQuest(id: string): Promise<IQuest> {
const res = await api.get<IQuest>(`/quests/${id}`)
return res.data
}
export async function createSession(questId: string): Promise<Session> {
const res = await api.post<Session>('/sessions', { questId })
return res.data
}
export async function getSessions(
request: GetSessionsRequest
): Promise<Session[]> {
const res = await api.get<Session[]>('/sessions', { params: request })
return res.data
}
export async function getActiveSession(
questId: string
): Promise<Session | null> {
const res = await api.get<Session | null>('/sessions/active', {
params: { questId },
})
return res.data
}
+1
View File
@@ -46,6 +46,7 @@ class SocketTerminal {
mount(reference: HTMLElement): void { mount(reference: HTMLElement): void {
this.term.open(reference) this.term.open(reference)
this.term.focus()
this.resizeScreen() this.resizeScreen()
} }
+33 -26
View File
@@ -1,20 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } from 'vue' import { computed, onMounted } from 'vue'
import useSession from '../store/session' import { useToast } from 'vue-toastification'
import HeaderPart from '../components/header/HeaderPart.vue' import sessionStore, { init } from '../store/session'
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue' import GameHeaderPart from '../components/GameHeaderPart.vue'
import HintPart from '../components/game/HintPart.vue' // import CommandlistPart from '../components/CommandlistPart.vue'
import QuestPart from '../components/game/QuestPart.vue' import HintPart from '../components/HintPart.vue'
import TerminalPart from '../components/game/TerminalPart.vue' import QuestPart from '../components/QuestPart.vue'
import VisualizationPart from '../components/game/VisualizationPart.vue' import TerminalPart from '../components/TerminalPart.vue'
import ControlPalette from '../components/game/ControlPalette.vue' import VisualizationPart from '../components/VisualizationPart.vue'
import CompleteModal from '../components/game/CompleteModal.vue' import ControlPalette from '../components/ControlPalette.vue'
import StartButton from '../components/game/StartButton.vue' import CompleteModal from '../components/CompleteModal.vue'
import { LoadQuestError, LoadSessionError } from '../utils/errors'
const sessionStore = useSession()
const completed = computed(() => { const completed = computed(() => {
return sessionStore.session?.status === 'finished' return sessionStore.session.status === 'finished'
}) })
const props = defineProps({ const props = defineProps({
@@ -24,30 +23,39 @@ const props = defineProps({
}, },
}) })
const toast = useToast()
onMounted(async () => { onMounted(async () => {
sessionStore.reset() try {
sessionStore.setup() await init(props.questId)
await sessionStore.setQuest(props.questId) } catch (err: any) {
await sessionStore.getActiveSession() if (err instanceof LoadQuestError) {
toast.error(`無法讀取關卡: ${err.questId},請確認網頁連結。`)
return
}
if (err instanceof LoadSessionError) {
toast.error(`無法建立工作階段: ${err.questId},請重新登入再試一次。`)
return
}
console.error(err)
toast.error(err.message)
}
}) })
</script> </script>
<template> <template>
<!-- game header --> <!-- game header -->
<div class="w-full"> <div class="h-[6vh] w-full">
<HeaderPart <GameHeaderPart :title="sessionStore.quest?.title" />
:title="sessionStore.quest?.title"
:headerComponent="GameHeaderComponents"
/>
</div> </div>
<!-- current status indicator --> <!-- current status indicator -->
<input <input
type="hidden" type="hidden"
id="currentStatus" id="currentStatus"
:value="sessionStore.session?.status" :value="sessionStore.session.status"
/> />
<!-- main --> <!-- main -->
<div id="main" class="h-full pt-10 w-full flex p-3 space-x-3"> <div id="main" class="h-[94vh] w-full flex p-3 space-x-3">
<!-- Topic and Command List --> <!-- Topic and Command List -->
<div class="bg-bg-secondary h-full w-1/4 rounded-lg"> <div class="bg-bg-secondary h-full w-1/4 rounded-lg">
<section id="quest" class="h-full p-3 overflow-auto"> <section id="quest" class="h-full p-3 overflow-auto">
@@ -59,8 +67,7 @@ onMounted(async () => {
</section> --> </section> -->
</div> </div>
<!-- Terminal and Hint --> <!-- Terminal and Hint -->
<div class="bg-bg h-full rounded-lg w-1/2"> <div class="bg-bg h-full w-1/2 rounded-lg">
<StartButton :questId="$props.questId" />
<section id="hint" class="h-3/5"> <section id="hint" class="h-3/5">
<HintPart /> <HintPart />
</section> </section>
+101 -27
View File
@@ -1,19 +1,97 @@
<script setup lang="ts"> <script setup lang="ts">
import ContactButton from '../components/ContactButton.vue' import ContactButton from '../components/ContactButton.vue'
import DynamicBackground from '../components/DynamicBackground.vue' import DynamicBackground from '../components/DynamicBackground.vue'
import LandingHeaderComponents from '../components/header/LandingHeaderComponents.vue' import { bugReportUrl, facebookUrl, githubUrl } from '../config'
import HeaderPart from '../components/header/HeaderPart.vue'
import { facebookUrl, githubUrl } from '../config' const scrollToSection = (sectionId: string) => {
const section = document.getElementById(sectionId)
if (section) {
const container = document.querySelector('.overflow-y-auto') // Replace '.overflow-y-auto' with the appropriate class or selector for your container
if (container) {
container.scrollTo({
top: section.offsetTop,
behavior: 'smooth', // Add smooth scrolling behavior if desired
})
}
}
}
</script> </script>
<template> <template>
<div class="w-screen h-screen bg-scroll bg-bg"> <div class="w-screen h-screen bg-scroll bg-bg">
<DynamicBackground class="w-full h-full" /> <DynamicBackground class="w-full h-full" />
<div class="h-screen w-screen absolute top-0 left-0"> <div class="h-screen w-screen absolute top-0 left-0">
<!-- game header --> <!-- game header -->
<HeaderPart :headerComponent="LandingHeaderComponents" class="z-50" /> <div
class="w-full h-[6vh] px-5 flex flex-row items-center justify-between bg-bg-secondary"
>
<div class="flex flex-row items-center gap-3">
<img src="../img/icon_totem.svg" class="h-8 justify-self-start" />
<button
id="GameTitle"
class="text-text-primary inline-block font-bold whitespace-nowrap pt-1"
style="font-size: 2vh"
>
Linux Odyssey
</button>
</div>
<div class="flex flex-row items-center gap-3">
<button
id="Intro"
class="text-text inline-block font-bold whitespace-nowrap pt-1"
@click="scrollToSection('intro')"
style="font-size: 2vh"
>
Intro
</button>
<button
id="Feature"
class="text-text inline-block font-bold whitespace-nowrap pt-1"
@click="scrollToSection('feature')"
style="font-size: 2vh"
>
Feature</button
><button
id="Info"
class="text-text inline-block font-bold whitespace-nowrap pt-1"
@click="scrollToSection('about')"
style="font-size: 2vh"
>
About
</button>
<button
id="Contact"
class="text-text inline-block font-bold whitespace-nowrap pt-1"
@click="scrollToSection('contact')"
style="font-size: 2vh"
>
Contact Us
</button>
<RouterLink
title="Sign In"
id="SignIn"
to="/register"
class="text-text inline-block font-bold whitespace-nowrap pt-1"
style="font-size: 2vh"
>
Sign In
</RouterLink>
<a
title="Bug Report"
:href="bugReportUrl"
target="_blank"
class="h-5 w-5"
>
<font-awesome-icon
:icon="['fas', 'bug']"
class="text-text-primary h-full w-full"
/>
</a>
</div>
</div>
<!-- main content --> <!-- main content -->
<main class="w-full h-full overflow-y-auto"> <main class="w-full h-[94vh] overflow-y-auto">
<div class="w-full flex flex-col items-center"> <div class="w-full flex flex-col items-center">
<div <div
class="flex flex-col md:flex-row mt-10 justify-around gap-5 items-center my-10" class="flex flex-col md:flex-row mt-10 justify-around gap-5 items-center my-10"
@@ -26,6 +104,7 @@ import { facebookUrl, githubUrl } from '../config'
Odyssey Odyssey
</h1> </h1>
</div> </div>
<!-- intro --> <!-- intro -->
<div class="w-full md:w-2/3 px-5"> <div class="w-full md:w-2/3 px-5">
<section <section
@@ -149,43 +228,40 @@ import { facebookUrl, githubUrl } from '../config'
鈦坦敏捷開發特別獎 第二名 鈦坦敏捷開發特別獎 第二名
</h1> </h1>
</div> </div>
<div class="container mx-auto">
<div <!-- <h1
class="justify-items-center grid grid-cols-1 sm:grid-cols-3 gap-5" class="text-text-primary p-5 font-extrabold font-mono col-span-3 gap-3"
> style="font-size: 3.5vh"
<h1
class="text-text-primary text-3xl font-extrabold font-mono sm:col-span-3 gap-6"
> >
玩家感想 玩家感想
<!--可以做成翻頁卡牌一次一個-->
</h1> </h1>
<h1 class="text-xl text-center">"很好玩很像益智遊戲"</h1> <h1 class="text-text p-3" style="font-size: 2.5vh">
<h1 class="text-xl text-center"> "很好玩很像益智遊戲"
</h1>
<h1 class="text-text p-3" style="font-size: 2.5vh">
"我從來不知道自由軟體的存在,謝謝你們。" "我從來不知道自由軟體的存在,謝謝你們。"
</h1> </h1>
<h1 class="text-xl text-center"> <h1 class="text-text p-3" style="font-size: 2.5vh">
"還蠻好玩的,讓程式變好玩" "還蠻好玩的,讓程式變好玩"
</h1> </h1>
<h1 class="text-xl text-center"> <h1 class="text-text p-3" style="font-size: 2.5vh">
"以前不喜歡電腦課現在覺得喜歡一點點" "以前不喜歡電腦課現在覺得喜歡一點點"
</h1> </h1>
<h1 class="text-xl text-center"> <h1 class="text-text p-3" style="font-size: 2.5vh">
"程式真的對新手很友善,讓我學到了很多東西" "程式真的對新手很友善,讓我學到了很多東西"
</h1> </h1>
<h1 class="text-xl text-center"> <h1 class="text-text p-3" style="font-size: 2.5vh">
"感覺做得很好很簡明易懂關卡設計很用心" "感覺做得很好很簡明易懂關卡設計很用心"
</h1> </h1>
<h1 class="text-xl text-center"> <h1 class="text-text p-3" style="font-size: 2.5vh">
"這個體驗我覺得非常有趣,可以體驗到沒辦法體驗的程式,學長也都熱心指導,非常感謝學長的用心的講解,希望以後還可以再體驗這種活動" "這個體驗我覺得非常有趣,可以體驗到沒辦法體驗的程式,學長也都熱心指導,非常感謝學長的用心的講解,希望以後還可以再體驗這種活動"
</h1> </h1>
<h1 class="text-xl text-center"> <h1 class="text-text p-3" style="font-size: 2.5vh">
"以前我覺得寫程式很難不過現在覺得很有趣" "以前我覺得寫程式很難不過現在覺得很有趣"
</h1> </h1>
<h1 class="text-xl text-center"> <h1 class="text-text p-3" style="font-size: 2.5vh">
"以前覺得寫程式是我生活中碰不到而且很難的項目。但通過這堂課之後,覺得寫程式不再那麼難以親近" "以前覺得寫程式是我生活中碰不到而且很難的項目。但通過這堂課之後,覺得寫程式不再那麼難以親近"
</h1> </h1> -->
</div>
</div>
</section> </section>
</div> </div>
<section <section
@@ -195,9 +271,7 @@ import { facebookUrl, githubUrl } from '../config'
<h1 class="text-text-primary font-extrabold font-mono text-3xl"> <h1 class="text-text-primary font-extrabold font-mono text-3xl">
聯絡我們 聯絡我們
</h1> </h1>
<div <div class="flex flex-row items-center justify-around mt-5 gap-5">
class="flex flex-row items-center justify-around mt-5 gap-5 mb-5"
>
<ContactButton <ContactButton
:url="facebookUrl" :url="facebookUrl"
:icons="['fab', 'facebook']" :icons="['fab', 'facebook']"
+3 -4
View File
@@ -2,8 +2,7 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import DynamicBackground from '../components/DynamicBackground.vue' import DynamicBackground from '../components/DynamicBackground.vue'
import HeaderPart from '../components/header/HeaderPart.vue' import GameHeaderPart from '../components/GameHeaderPart.vue'
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
import api from '../utils/api' import api from '../utils/api'
const leaderboard = ref< const leaderboard = ref<
@@ -36,8 +35,8 @@ onMounted(async () => {
<div <div
class="w-screen h-screen absolute top-0 left-0 flex flex-wrap justify-center content-center" class="w-screen h-screen absolute top-0 left-0 flex flex-wrap justify-center content-center"
> >
<div class="w-full"> <div class="h-[6vh] w-full">
<HeaderPart title="排行榜" :headerComponent="GameHeaderComponents" /> <GameHeaderPart title="排行榜" />
</div> </div>
<div class="w-fit h-[94vh] m-3"> <div class="w-fit h-[94vh] m-3">
<div <div
+2 -4
View File
@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import AuthForm from '../components/AuthForm.vue' import AuthForm from '../components/AuthForm.vue'
import DynamicBackground from '../components/DynamicBackground.vue' import Background from '../components/DynamicBackground.vue'
import HeaderPart from '../components/header/HeaderPart.vue'
import { import {
TooManyRequestsError, TooManyRequestsError,
@@ -46,11 +45,10 @@ const handleLogin = async ({
<template> <template>
<div class="w-screen h-screen"> <div class="w-screen h-screen">
<DynamicBackground class="w-full h-full" /> <Background class="w-full h-full" />
<div <div
class="w-screen h-screen absolute top-0 left-0 flex flex-wrap justify-center content-center" class="w-screen h-screen absolute top-0 left-0 flex flex-wrap justify-center content-center"
> >
<HeaderPart />
<div class="w-fit"> <div class="w-fit">
<AuthForm @onSubmit="handleLogin" type="login" /> <AuthForm @onSubmit="handleLogin" type="login" />
</div> </div>
+4 -9
View File
@@ -1,18 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import QuestMap from '../components/QuestMap.vue' import QuestMap from '../components/QuestMap.vue'
import HeaderPart from '../components/header/HeaderPart.vue' import GameHeaderPart from '../components/GameHeaderPart.vue'
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
</script> </script>
<template> <template>
<div class="w-fit"> <div class="h-[6vh] w-full">
<HeaderPart <GameHeaderPart title="關卡地圖" />
title="關卡地圖"
:headerComponent="GameHeaderComponents"
class="z-50"
/>
</div> </div>
<div class="flex flex-col justify-center items-center w-full h-full pt-5"> <div class="flex flex-col justify-center items-center w-full h-[94vh]">
<QuestMap /> <QuestMap />
</div> </div>
</template> </template>
+4 -5
View File
@@ -3,8 +3,8 @@ import validator from 'validator'
import { isValidUsername } from '@linux-odyssey/utils' import { isValidUsername } from '@linux-odyssey/utils'
import { passwordPolicy } from '@linux-odyssey/constants' import { passwordPolicy } from '@linux-odyssey/constants'
import AuthForm from '../components/AuthForm.vue' import AuthForm from '../components/AuthForm.vue'
import DynamicBackground from '../components/DynamicBackground.vue' import Background from '../components/DynamicBackground.vue'
import HeaderPart from '../components/header/HeaderPart.vue'
import { checkUsername, register } from '../utils/auth' import { checkUsername, register } from '../utils/auth'
import { import {
UnauthorizedError, UnauthorizedError,
@@ -89,12 +89,11 @@ async function check({
<template> <template>
<div class="w-screen h-screen"> <div class="w-screen h-screen">
<DynamicBackground class="w-full h-full" /> <Background class="w-full h-full" />
<div <div
class="h-screen w-screen absolute top-0 left-0 flex flex-wrap justify-center content-center" class="h-screen w-screen absolute top-0 left-0 flex flex-wrap justify-center content-center"
> >
<HeaderPart /> <div class="w-fit">
<div class="w-fit mt-8">
<AuthForm <AuthForm
@onSubmit="handleRegister" @onSubmit="handleRegister"
@onChange="check" @onChange="check"
+95
View File
@@ -0,0 +1,95 @@
version: '3'
services:
app:
depends_on:
- backend
build:
context: .
dockerfile: app/Dockerfile
target: dev
ports:
- 5173:5173
networks:
- internal
volumes:
- ./app:/app/app
- ./packages:/app/packages
backend:
depends_on:
- db
build:
context: .
dockerfile: server/Dockerfile
target: dev
networks:
- internal
- players
ports:
- '3000:3000'
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./server/src:/app/server/src
- ./server/dist:/app/server/dist
- ./server/swagger.yaml:/app/server/swagger.yaml
- ./packages:/app/packages
- ./quests:/app/quests
- ./logs:/app/logs
environment:
- BASE_URL=http://localhost:5173
- MONGO_URL=mongodb://db:27017/odyssey-test
- DOCKER_NETWORK=linux-odyssey-players
- SECRET_KEY=b1d2e917-17b9-4fce-9055-082dee6ec5d6 # Only for testing
- TRUSTED_PROXIES=${TRUSTED_PROXIES}
- SURVEY_URL=${SURVEY_URL}
- BUG_REPORT_URL=${BUG_REPORT_URL}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
- TESTING=true
- TESTING_USERNAME=alex
- TESTING_PASSWORD=Alex1234
- HOST_PWD=${PWD}
- MOUNT_QUEST=${MOUNT_QUEST}
analytics:
build:
context: .
dockerfile: packages/analytics/Dockerfile
target: dev
depends_on:
- db
networks:
- internal
ports:
- '3001:3001'
environment:
- MONGO_URL=mongodb://db:27017/odyssey-test
- ADMIN_USERNAME=alex
- ADMIN_PASSWORD=Alex1234
volumes:
- ./packages/analytics:/app/packages/analytics
- ./packages/models:/app/packages/models
db:
image: mongo
restart: always
networks:
- internal
ports:
- '27017:27017'
volumes:
- ./data:/data/db
base:
image: linuxodyssey/quest-base
build:
context: .
dockerfile: ./quests/Dockerfile
networks:
internal:
players:
name: linux-odyssey-players
internal: true
-23
View File
@@ -1,23 +0,0 @@
version: '3'
services:
db:
image: mongo
restart: always
networks:
- internal
ports:
- '27017:27017'
volumes:
- ./data:/data/db
base:
image: linuxodyssey/quest-base
build:
context: .
dockerfile: ./quests/Dockerfile
networks:
internal:
players:
name: linux-odyssey-players
internal: true
+3 -10
View File
@@ -1,5 +1,3 @@
import { z } from 'zod'
export const passwordPolicy = { export const passwordPolicy = {
minLength: 8, minLength: 8,
minNumbers: 1, minNumbers: 1,
@@ -8,11 +6,6 @@ export const passwordPolicy = {
minSymbols: 0, minSymbols: 0,
} }
export const getSessionsRequestSchema = z.object({ export default {
questId: z.string().optional(), passwordPolicy,
status: z.enum(['active', 'inactive', 'finished']).optional(), }
limit: z.coerce.number().optional(),
order: z.enum(['asc', 'desc']).optional(),
})
export type GetSessionsRequest = z.infer<typeof getSessionsRequestSchema>
-3
View File
@@ -10,8 +10,5 @@
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"dev": "tsc -w" "dev": "tsc -w"
},
"dependencies": {
"zod": "^3.23.8"
} }
} }
-6
View File
@@ -28,7 +28,6 @@ export interface IUser {
socialLogins: Map<string, ISocialLogin> socialLogins: Map<string, ISocialLogin>
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
isGuest: boolean
} }
export const User = model<IUser>( export const User = model<IUser>(
@@ -52,11 +51,6 @@ export const User = model<IUser>(
default: {}, default: {},
required: true, required: true,
}, },
isGuest: {
type: Boolean,
default: false,
required: true,
},
}, },
{ {
timestamps: true, timestamps: true,
+1
View File
@@ -8,4 +8,5 @@ mv world.txt wow.txt
ls ls
mv notes docs mv notes docs
ls ls
echo hint
mv docs/hello.txt hi.txt mv docs/hello.txt hi.txt
+3
View File
@@ -0,0 +1,3 @@
FROM linuxodyssey/quest-base
COPY home ${HOME}
View File
Whitespace-only changes.
+278
View File
@@ -0,0 +1,278 @@
title: 查看檔案存取權限
instruction: |
在這個關卡中,你將會學到:
1. 用`ls -l`指令查看檔案的存取權限
2. 了解檔案存取權限的意義
準備好就輸入 `echo start` 開始吧!
requirements:
- helloworld
stages:
- id: start
task: 輸入 `echo start` 來開始
condition:
output:
- start
exceptions:
- condition:
command:
- ^echo$
responses:
- type: narrative
content:
- 你忘記輸入 `start` 了嗎?
- 請輸入 `echo start` 來開始
- catchAll: true
responses:
- type: narrative
content:
- 請輸入 `echo start` 來開始
responses:
- type: narrative
content: 現在我們要學習如何查看檔案的存取權限。
使用`ls -l`指令來查看不同檔案的存取權限吧!
hints:
- ➤ 使用 `ls -l` 來查看檔案的存取權限
- id: ls-l
task: 查看檔案的存取權限
requirements:
- start
condition:
command:
- ls -l
pwd:
- /home/commander
responses:
- type: narrative
content:
- 你可以看到檔案的存取權限的左邊類似 `-rw-r--r--` 的字串。
- 這個字串由十個字元組成,第一個字元代表檔案的類型,後面九個字元分別代表檔案的存取權限。
- 第一個字元如果是 `-` 代表這是一個檔案,如果是 `d` 代表這是一個資料夾。
- 後面九個字元中以三個字元一組,分別代表檔案的擁有者、群組、以及其他人的存取權限。
- 每一組中的三個字元分別代表讀取(`r`)、寫入(`w`)、執行(`x`)的權限。
- 如果該欄位是 `-` 則表示沒有該權限。
- 例如 `-w-` 代表擁有寫入的權限,但沒有讀取和執行的權限;
- 現在試著查看 `file1` 、 `file2` 、 `file3` 的內容吧!
- 如果沒有權限將會顯示 `Permission denied` 的訊息。
- id: read-file1
task: 嘗試查看 `file1` 的內容
requirements:
- ls-l
condition:
pwd: /home/commander
command:
- cat file1
- id: read-file2
task: 嘗試查看 `file2` 的內容
requirements:
- ls-l
condition:
pwd: /home/commander
command:
- cat file2
- id: read-file3
task: 嘗試查看 `file3` 的內容
requirements:
- ls-l
condition:
pwd: /home/commander
command:
- cat file3
- id: read-files
requirements:
- read-file1
- read-file2
- read-file3
responses:
- type: narrative
content:
- 你已經試著查看 `file1` 、 `file2` 、 `file3` 的內容了!
- 現在請用 `echo [檔案名稱]` 回答現在有哪的檔案的讀取權限。
# {unfinished}
- id: cat-continue
task: 使用 `echo continue` 來繼續
requirements:
- cat-hello
- cat-world
condition:
command:
- echo
responses:
- type: narrative
content:
- 接下來我們要來學習複製檔案。
- '`cp` 這個指令,是 copy 的縮寫,就是複製的意思。'
- 我們可以用 `cp [來源檔案] [目的檔案]` 的方式來複製檔案。
- 請你使用 `cp` 來複製 `hello.txt` 到 `hello2.txt`。
hints:
- ➤ 使用 `cp hello.txt hello2.txt` 來複製 `hello.txt` 到 `hello2.txt`
- id: cp-hello
task: 複製 `hello.txt` 到 `hello2.txt`
requirements:
- cat-continue
condition:
command:
- ^cp hello.txt hello2.txt$
files:
- path: hello2.txt
type: file
responses:
- type: narrative
content:
- 你成功複製 `hello.txt` 到 `hello2.txt` 了!
- 我們打開來確認一下 `hello2.txt` 的內容吧!
hints:
- ➤ 使用 `cat hello2.txt` 來查看 `hello2.txt` 的內容
- id: cat-hello2
requirements:
- cp-hello
task: 查看 `hello2.txt` 的內容
condition:
command:
- ^cat hello2.txt$
output:
- Hello
responses:
- type: narrative
content:
- 你成功查看到 `hello2.txt` 的內容了!
- 正如我們所見,`hello2.txt` 的內容跟 `hello.txt` 一模一樣。
- 那我們再來試試看複製 `world.txt` 到 `world2.txt` 吧!
hints:
- ➤ 使用 `cp world.txt world2.txt` 來複製 `world.txt` 到 `world2.txt`
- id: cp-world
requirements:
- cat-hello2
task: 複製 `world.txt` 到 `world2.txt`
condition:
command:
- ^cp world.txt world2.txt$
files:
- path: world2.txt
type: file
responses:
- type: narrative
content:
- 你成功複製 `world.txt` 到 `world2.txt` 了!
- 我們打開來確認一下 `world2.txt` 的內容吧!
hints:
- ➤ 使用 `cat world2.txt` 來查看 `world2.txt` 的內容
- id: cat-world2
requirements:
- cp-world
task: 查看 `world2.txt` 的內容
condition:
command:
- ^cat world2.txt$
output:
- World
responses:
- type: narrative
content:
- 你成功查看到 `world2.txt` 的內容了!
- '`world2.txt` 的內容跟 `world.txt` 一模一樣。'
- 接下來我們要來學習如何複製資料夾。
- 現在目錄下有一個 `notes` 資料夾,裡面有一個 `note.txt` 的檔案。
- 我們要能整個資料夾包含裡面的檔案一起複製到 `notes2`。
- 跟複製單一檔案不一樣的是,複製資料夾時,我們要加上 `-r` 的選項(recursive)代表將資料夾中的所有檔案都一起複製。
- 在使用指令時,如果想要調整指令的行為,可以在指令後面加上選項。
- 選項的格式通常是 `-` 加上一個字母,例如 `-r`。
- 選項擺放的位置沒有特別的限制,通常會跟在指令的後面,置於檔案名稱的前面。
- 請你使用 `cp -r notes notes2` 來複製 `notes` 資料夾到 `notes2`。
hints:
- ➤ 使用 `cp -r notes notes2` 來複製 `notes` 資料夾到 `notes2`
- ➤ 記得要加上 `-r` 的選項來複製所有檔案
- id: cp-notes
requirements:
- cat-world2
task: 複製 `notes` 資料夾到 `notes2`
condition:
command:
- ^cp
files:
- path: notes2
type: directory
- path: notes2/note.txt
type: file
responses:
- type: narrative
content:
- 你成功複製 `notes` 資料夾到 `notes2` 了!
- 我們打開來確認一下 `notes2` 資料夾的內容吧!
hints:
- ➤ 使用 `ls notes2` 來查看 `notes2` 資料夾的內容
exceptions:
- condition:
error:
- 'cp: omitting directory'
responses:
- type: narrative
content:
- 你忘記加上 `-r` 的參數了嗎?
- 請輸入 `cp -r notes notes2` 來複製 `notes` 資料夾到 `notes2`
- id: ls-notes2
requirements:
- cp-notes
task: 查看 `notes2` 資料夾的內容
condition:
command:
- ^ls notes2$
output:
- note.txt
responses:
- type: narrative
content:
- 你成功查看到 `notes2` 資料夾的內容了!
- 我們可以看到,整個 `notes` 資料夾包含裡面的檔案,都被複製到 `notes2` 資料夾了。
- 那麼最後我們要來試試看,如果複製一個檔案到已經存在的檔案會發生什麼事。
hints:
- ➤ 使用 `cp hello.txt world.txt` 來複製 `hello.txt` 到 `world.txt`
- id: cp-overwrite
requirements:
- ls-notes2
task: 複製 `hello.txt` 到 `world.txt`
condition:
command:
- ^cp hello.txt world.txt$
responses:
- type: narrative
content:
- 你成功複製 `hello.txt` 到 `world.txt` 了!
- 我們打開來確認一下 `world.txt` 的內容吧!
hints:
- ➤ 使用 `cat world.txt`
- id: END
requirements:
- cp-overwrite
task: 檢查 `world.txt` 的內容
condition:
command:
- ^cat world.txt$
output:
- Hello
responses:
- type: narrative
content:
- '`world.txt` 的內容變成 `Hello` 了!'
- 這是因為,當我們複製檔案時,如果目的檔案已經存在,那麼目的檔案的內容就會被覆蓋掉。
- 所以在複製檔案的時候,要特別注意目的檔案是否已經存在,否則可能會不小心把資料弄丟。
- 以上就是複製檔案的基本操作!
+1
View File
@@ -0,0 +1 @@
你有閱讀這個檔案的權限
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
echo
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
echo 你有執行這個檔案的權限
+12
View File
@@ -0,0 +1,12 @@
# 關卡名稱
## stage 名稱 {stage-id}
> {speaker=角色名稱}
> 對話內容
### exception1
content1
### exception2
content2
+1 -4
View File
@@ -95,13 +95,10 @@ stages:
not: not:
pwd: pwd:
- ^/home/commander$ - ^/home/commander$
responses:
- type: narrative
content: content:
- 請留在 `/home/commander` 資料夾中。 - 請留在 `/home/commander` 資料夾中。
- 請輸入 `cd ~` 回到 `/home/commander` 資料夾。 - 請輸入 `cd ~` 回到 `/home/commander` 資料夾。
responses: responses:
- type: narrative - content:
content:
- 你成功地查看到 `file.txt` 的內容了。 - 你成功地查看到 `file.txt` 的內容了。
- 你已經會使用 `cat` 指令查看檔案的內容了。 - 你已經會使用 `cat` 指令查看檔案的內容了。
-23
View File
@@ -1,23 +0,0 @@
import connectDB, { User } from '@linux-odyssey/models'
async function migrate() {
await connectDB(
process.env.MONGO_URL || 'mongodb://localhost:27017/odyssey-test'
)
await User.updateMany(
{
isGuest: { $exists: false },
},
{ $set: { isGuest: false } }
)
}
migrate()
.then(() => {
console.log('Migration completed')
process.exit(0)
})
.catch((err) => {
console.error(err)
process.exit(1)
})
+1 -3
View File
@@ -9,8 +9,7 @@
"clean": "node dist/cli/clean.js", "clean": "node dist/cli/clean.js",
"build": "tsc -b", "build": "tsc -b",
"dev": "nodemon", "dev": "nodemon",
"build:quests": "node dist/cli/build.js", "build:quests": "node dist/cli/build.js"
"ts-node": "node --loader ts-node/esm"
}, },
"dependencies": { "dependencies": {
"@linux-odyssey/constants": "*", "@linux-odyssey/constants": "*",
@@ -21,7 +20,6 @@
"commander": "^11.0.0", "commander": "^11.0.0",
"connect-mongo": "^5.0.0", "connect-mongo": "^5.0.0",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"date-fns": "^3.6.0",
"dockerode": "^3.3.5", "dockerode": "^3.3.5",
"dotenv": "^16.4.1", "dotenv": "^16.4.1",
"express": "^4.19.2", "express": "^4.19.2",
+1 -25
View File
@@ -1,7 +1,7 @@
import type { Request, Response, NextFunction, Express } from 'express' import type { Request, Response, NextFunction, Express } from 'express'
import { matchedData } from 'express-validator' import { matchedData } from 'express-validator'
import { genJWT } from '../../utils/auth.js' import { genJWT } from '../../utils/auth.js'
import { createUser, createGuestUser } from '../../models/userManager.js' import { createUser } from '../../models/userManager.js'
import { asyncHandler } from '../../middleware/error.js' import { asyncHandler } from '../../middleware/error.js'
import logger from '../../utils/logger.js' import logger from '../../utils/logger.js'
@@ -39,30 +39,6 @@ export const register = asyncHandler(
} }
) )
export const registerGuest = asyncHandler(
async (req: Request, res: Response) => {
const user = await createGuestUser()
req.login(
{
id: user.id,
username: user.username,
email: user.email,
},
(err) => {
if (err) {
res.status(500).json({
message: 'error logging in',
})
return
}
res.status(201).json({
message: 'user created',
})
}
)
}
)
export function logout(req: Request, res: Response) { export function logout(req: Request, res: Response) {
req.logout((err) => { req.logout((err) => {
if (err) { if (err) {
@@ -49,6 +49,8 @@ export const newCommand = asyncHandler(async (req: Request, res: Response) => {
c.stage = response.stage c.stage = response.stage
await c.save() await c.save()
session.responses.push(response.responses)
if ((session.status as string) === 'finished') { if ((session.status as string) === 'finished') {
await finishSession(session) await finishSession(session)
} }
@@ -3,6 +3,7 @@ import type { Express, Request, Response } from 'express'
import { User, Session, ISession } from '@linux-odyssey/models' import { User, Session, ISession } from '@linux-odyssey/models'
import { import {
createNewSession, createNewSession,
getOrCreateActiveSession,
isQuestUnlocked, isQuestUnlocked,
} from '../../models/sessionManager.js' } from '../../models/sessionManager.js'
import { asyncHandler } from '../../middleware/error.js' import { asyncHandler } from '../../middleware/error.js'
@@ -55,6 +56,7 @@ export const createSession = asyncHandler(
export const getSessionById = asyncHandler( export const getSessionById = asyncHandler(
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
const { sessionId } = matchedData(req) const { sessionId } = matchedData(req)
console.log(sessionId)
const session = await Session.findOne({ const session = await Session.findOne({
_id: sessionId, _id: sessionId,
user: (req.user as Express.ExistingUser).id, user: (req.user as Express.ExistingUser).id,
@@ -69,7 +71,7 @@ export const getSessionById = asyncHandler(
} }
) )
export const getActiveSessionHandler = asyncHandler( export const getOrCreateSession = asyncHandler(
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
const { questId } = matchedData(req) const { questId } = matchedData(req)
const user = await User.findById((req.user as Express.ExistingUser).id) const user = await User.findById((req.user as Express.ExistingUser).id)
@@ -77,12 +79,8 @@ export const getActiveSessionHandler = asyncHandler(
user && user &&
((await isQuestUnlocked(user, questId)) || config.testing.enabled) ((await isQuestUnlocked(user, questId)) || config.testing.enabled)
) { ) {
const session = await Session.findOne({ const session = await getOrCreateActiveSession(user, questId)
user: user.id, res.json(sessionDetail(session))
quest: questId,
status: 'active',
})
res.json(session ? sessionDetail(session) : null)
} else { } else {
res.status(403).json({ message: 'Quest is locked.' }) res.status(403).json({ message: 'Quest is locked.' })
} }
-3
View File
@@ -9,7 +9,6 @@ import {
logout, logout,
registerFromSession, registerFromSession,
socialLoginHandler, socialLoginHandler,
registerGuest,
} from '../controllers/authController.js' } from '../controllers/authController.js'
import { import {
checkUsernameValidators, checkUsernameValidators,
@@ -33,8 +32,6 @@ router.post(
router.post('/register', authenticateRateLimit, registerValidators, register) router.post('/register', authenticateRateLimit, registerValidators, register)
router.post('/register-guest', authenticateRateLimit, registerGuest)
router.post('/logout', logout) router.post('/logout', logout)
router.get( router.get(
+2 -2
View File
@@ -17,10 +17,10 @@ router.post(
sessionRateLimit, sessionRateLimit,
sessionController.createSession sessionController.createSession
) )
router.get( router.post(
'/active', '/active',
activeSessionValidator, activeSessionValidator,
sessionController.getActiveSessionHandler sessionController.getOrCreateSession
) )
router.get( router.get(
'/:sessionId', '/:sessionId',
+3 -1
View File
@@ -8,6 +8,7 @@ import SessionMiddleware from '../middleware/session.js'
import { genJWT } from '../utils/auth.js' import { genJWT } from '../utils/auth.js'
import logger from '../utils/logger.js' import logger from '../utils/logger.js'
// eslint-disable-next-line no-unused-vars
type SocketCallback = (event: string, ...args: any[]) => void type SocketCallback = (event: string, ...args: any[]) => void
const sessions = new Map<string, SocketCallback[]>() const sessions = new Map<string, SocketCallback[]>()
@@ -43,6 +44,7 @@ async function connectContainer(socket: Socket, next: (err?: Error) => void) {
next(new Error('User not found.')) next(new Error('User not found.'))
return return
} }
// console.log(socket.handshake)
const { sessionId } = socket.handshake.query const { sessionId } = socket.handshake.query
if ( if (
!sessionId || !sessionId ||
@@ -77,7 +79,7 @@ async function connectContainer(socket: Socket, next: (err?: Error) => void) {
stream, stream,
} }
next() next()
} catch { } catch (err) {
next(new Error('Failed to start container.')) next(new Error('Failed to start container.'))
} }
} }
+1 -1
View File
@@ -82,7 +82,7 @@ export async function deleteContainer(id: string) {
} }
try { try {
await container.stop() await container.stop()
} catch { } catch (error) {
logger.warn('Failed to stop container', id) logger.warn('Failed to stop container', id)
} }
await container.remove() await container.remove()
+1 -1
View File
@@ -112,7 +112,7 @@ export default class CommandHandler extends SessionHandler {
) )
) )
return checks.every((c) => c === true) return checks.every((c) => c === true)
} catch { } catch (e) {
return false return false
} }
} }
+15
View File
@@ -79,6 +79,21 @@ export async function createNewSession(
return session return session
} }
export async function getOrCreateActiveSession(
user: HydratedDocument<IUser>,
questId: string
): Promise<ISession> {
const session = await Session.findOne({
user,
quest: questId,
status: 'active',
})
if (session) {
return session
}
return createNewSession(user, questId)
}
export async function finishSession( export async function finishSession(
session: HydratedDocument<ISession> session: HydratedDocument<ISession>
): Promise<void> { ): Promise<void> {
-18
View File
@@ -1,5 +1,4 @@
import { Express } from 'express' import { Express } from 'express'
import { format } from 'date-fns'
import { User, UserProfile } from '@linux-odyssey/models' import { User, UserProfile } from '@linux-odyssey/models'
import { hashPassword } from '../utils/auth.js' import { hashPassword } from '../utils/auth.js'
@@ -32,23 +31,6 @@ export async function createUser(
return user return user
} }
export async function createGuestUser() {
const username = `guest_${format(new Date(), 'yyyyMMddHHmmss')}`
const user = new User({
username,
email: `${username}@example.com`,
isGuest: true,
})
await user.save()
const profile = new UserProfile({
user,
})
await profile.save()
return user
}
export function leaderboard() { export function leaderboard() {
return UserProfile.aggregate([ return UserProfile.aggregate([
{ {
+2 -31
View File
@@ -1,14 +1,14 @@
openapi: 3.0.0 openapi: 3.0.0
servers: servers:
- url: /api/v1 - url: /api/v1
- url: https://linuxodyssey.xyz/api/v1 - url: https://odyssey.wancat.cc/api/v1
- url: http://localhost:3000/api/v1 - url: http://localhost:3000/api/v1
info: info:
description: Linux Odyssey API description: Linux Odyssey API
version: '1.0.0' version: '1.0.0'
title: Session API title: Session API
contact: contact:
email: contact@linuxodyssey.xyz email: wancat@wancat.cc
license: license:
name: Apache 2.0 name: Apache 2.0
url: 'http://www.apache.org/licenses/LICENSE-2.0.html' url: 'http://www.apache.org/licenses/LICENSE-2.0.html'
@@ -37,19 +37,6 @@ paths:
schema: schema:
type: string type: string
enum: [active, finished, inactive] enum: [active, finished, inactive]
- in: query
name: limit
description: limit the number of sessions to return
schema:
type: number
example: 10
- in: query
name: order
description: order the sessions by createdAt
schema:
type: string
enum: [asc, desc]
example: desc
responses: responses:
'200': '200':
description: list of existing session description: list of existing session
@@ -246,22 +233,6 @@ paths:
type: string type: string
example: connect.sid=s%3Aq1Z; Path=/; HttpOnly; SameSite=Strict; Secure example: connect.sid=s%3Aq1Z; Path=/; HttpOnly; SameSite=Strict; Secure
/auth/register-guest:
post:
summary: Register a new guest user
responses:
201:
description: User created
headers:
Set-Cookie:
schema:
type: string
example: connect.sid=s%3Aq1Z; Path=/; HttpOnly; SameSite=Strict; Secure
content:
application/json:
schema:
$ref: '#/components/schemas/User'
/auth/login: /auth/login:
post: post:
summary: Log in a user summary: Log in a user
+4 -4
View File
@@ -5,7 +5,7 @@ export default function checkLoginUI() {
describe('example helloworld app', () => { describe('example helloworld app', () => {
describe('Login tests', () => { describe('Login tests', () => {
beforeEach(() => { beforeEach(() => {
cy.visit('/register') cy.visit('/')
}) })
it('Check Login Page Elements-Register', () => { it('Check Login Page Elements-Register', () => {
checkLoginUI() checkLoginUI()
@@ -83,11 +83,11 @@ describe('example helloworld app', () => {
cy.get('#UsernameText') cy.get('#UsernameText')
.should('contain', Cypress.env('defaultAccount')) .should('contain', Cypress.env('defaultAccount'))
.and('be.visible') .and('be.visible')
cy.findByRole('link', { name: 'Survey' }).should('be.visible')
cy.findByRole('link', { name: 'LeaderBoard' }).should('be.visible')
cy.findByRole('link', { name: 'Map' }).should('be.visible')
cy.findByRole('link', { name: 'Bug Report' }).should('be.visible') cy.findByRole('link', { name: 'Bug Report' }).should('be.visible')
cy.findByRole('button', { name: 'Sign Out' }).should('be.visible') cy.findByRole('button', { name: 'Sign Out' }).should('be.visible')
cy.findByRole('button', { name: 'Map' }).should('be.visible')
cy.findByRole('button', { name: 'LeaderBoard' }).should('be.visible')
cy.findByRole('link', { name: 'Survey' }).should('be.visible')
}) })
it('Check QuestInfo', () => { it('Check QuestInfo', () => {
cy.get('#topic').should('contain', title) cy.get('#topic').should('contain', title)
+5 -5
View File
@@ -8,7 +8,7 @@ declare global {
namespace Cypress { namespace Cypress {
interface Chainable { interface Chainable {
LoginWithPassword(username: string, password: string): Chainable<string> LoginWithPassword(username: string, password: string): Chainable<string>
PrepareForGame(questId: string): Chainable<string> PrepareForGame(): Chainable<void>
InitTerminal(): Chainable<void> InitTerminal(): Chainable<void>
typeInCommand(command: string): Chainable<string> typeInCommand(command: string): Chainable<string>
getQuestInfo(id: string): Chainable<JQuery<HTMLParagraphElement>> getQuestInfo(id: string): Chainable<JQuery<HTMLParagraphElement>>
@@ -38,8 +38,8 @@ Cypress.Commands.add('LoginWithPassword', (username, password) => {
cy.get('#password').type(password) cy.get('#password').type(password)
cy.get('#LogInOrSignUp').click() cy.get('#LogInOrSignUp').click()
}) })
Cypress.Commands.add('PrepareForGame', (questId: string) => { Cypress.Commands.add('PrepareForGame', (questId) => {
cy.visit('/login') cy.visit('/')
// make sure the login page is loaded // make sure the login page is loaded
cy.url().should('satisfy', (elements: string) => { cy.url().should('satisfy', (elements: string) => {
const text = elements const text = elements
@@ -143,9 +143,9 @@ Cypress.Commands.add('CompleteStageWithCommands', (stagename) => {
}) })
Cypress.Commands.add('CheckTextElement', (id, chText, enText) => { Cypress.Commands.add('CheckTextElement', (id, chText, enText) => {
if (Cypress.env('isCHVersion')) { if (Cypress.env('isCHVersion')) {
cy.get(id, { timeout: 7000 }).should('contain', chText).and('be.visible') cy.get(id, { timeout: 50000 }).should('contain', chText).and('be.visible')
} else { } else {
cy.get(id, { timeout: 7000 }).should('contain', enText).and('be.visible') cy.get(id, { timeout: 50000 }).should('contain', enText).and('be.visible')
} }
}) })
Cypress.Commands.add('CheckPlaceholder', (id, chText, enText) => { Cypress.Commands.add('CheckPlaceholder', (id, chText, enText) => {
+3 -31
View File
@@ -2479,11 +2479,6 @@
"@vue/compiler-dom" "3.4.19" "@vue/compiler-dom" "3.4.19"
"@vue/shared" "3.4.19" "@vue/shared" "3.4.19"
"@vue/devtools-api@^6.5.0":
version "6.6.3"
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.6.3.tgz#b23a588154cba8986bba82b6e1d0248bde3fd1a0"
integrity sha512-0MiMsFma/HqA6g3KLKn+AGpL1kgKhFWszC9U29NfpWK5LE7bjeXxySWJrOJ77hBz+TBrBQ7o4QJqbPbqbs8rJw==
"@vue/devtools-api@^6.5.1": "@vue/devtools-api@^6.5.1":
version "6.6.1" version "6.6.1"
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.6.1.tgz#7c14346383751d9f6ad4bea0963245b30220ef83" resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.6.1.tgz#7c14346383751d9f6ad4bea0963245b30220ef83"
@@ -3599,11 +3594,6 @@ date-fns@^2.30.0:
dependencies: dependencies:
"@babel/runtime" "^7.21.0" "@babel/runtime" "^7.21.0"
date-fns@^3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-3.6.0.tgz#f20ca4fe94f8b754951b24240676e8618c0206bf"
integrity sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==
dayjs@^1.10.4: dayjs@^1.10.4:
version "1.11.10" version "1.11.10"
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.10.tgz#68acea85317a6e164457d6d6947564029a6a16a0" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.10.tgz#68acea85317a6e164457d6d6947564029a6a16a0"
@@ -6778,14 +6768,6 @@ pify@^2.2.0, pify@^2.3.0:
resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog== integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==
pinia@^2.1.7:
version "2.1.7"
resolved "https://registry.yarnpkg.com/pinia/-/pinia-2.1.7.tgz#4cf5420d9324ca00b7b4984d3fbf693222115bbc"
integrity sha512-+C2AHFtcFqjPih0zpYuvof37SFxMQ7OEG2zV9jRI12i9BOy3YQVAHwdKtyyc8pDcDyIc33WCIsZaCFWU7WWxGQ==
dependencies:
"@vue/devtools-api" "^6.5.0"
vue-demi ">=0.14.5"
pirates@^4.0.1, pirates@^4.0.4: pirates@^4.0.1, pirates@^4.0.4:
version "4.0.6" version "4.0.6"
resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9" resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9"
@@ -7735,9 +7717,9 @@ tar-stream@^2.0.0:
readable-stream "^3.1.1" readable-stream "^3.1.1"
tar@^6.1.11: tar@^6.1.11:
version "6.2.1" version "6.2.0"
resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.1.tgz#717549c541bc3c2af15751bea94b1dd068d4b03a" resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.0.tgz#b14ce49a79cb1cd23bc9b016302dea5474493f73"
integrity sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A== integrity sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==
dependencies: dependencies:
chownr "^2.0.0" chownr "^2.0.0"
fs-minipass "^2.0.0" fs-minipass "^2.0.0"
@@ -8173,11 +8155,6 @@ vite@^4.5.3:
optionalDependencies: optionalDependencies:
fsevents "~2.3.2" fsevents "~2.3.2"
vue-demi@>=0.14.5:
version "0.14.8"
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.8.tgz#00335e9317b45e4a68d3528aaf58e0cec3d5640a"
integrity sha512-Uuqnk9YE9SsWeReYqK2alDI5YzciATE0r2SkA6iMAtuXvNTMNACJLJEXNXaEy94ECuBe4Sk6RzRU80kjdbIo1Q==
vue-eslint-parser@^9.4.2: vue-eslint-parser@^9.4.2:
version "9.4.2" version "9.4.2"
resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-9.4.2.tgz#02ffcce82042b082292f2d1672514615f0d95b6d" resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-9.4.2.tgz#02ffcce82042b082292f2d1672514615f0d95b6d"
@@ -8475,11 +8452,6 @@ yocto-queue@^0.1.0:
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
zod@^3.23.8:
version "3.23.8"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==
zrender@5.5.0: zrender@5.5.0:
version "5.5.0" version "5.5.0"
resolved "https://registry.yarnpkg.com/zrender/-/zrender-5.5.0.tgz#54d0d6c4eda81a96d9f60a9cd74dc48ea026bc1e" resolved "https://registry.yarnpkg.com/zrender/-/zrender-5.5.0.tgz#54d0d6c4eda81a96d9f60a9cd74dc48ea026bc1e"