Add start session button (#134)

- Add new file StartButton
- Covering Hint part and Terminal part
- Load instruction before pressing button
- Load session after pressing the button
This commit is contained in:
Lin, Hung-Shin authored and GitHub committed 2024-06-28 16:15:26 +08:00
commit 57c080d88b
31 files changed
+354 -521

No files matched your search

-20
View File
@@ -35,23 +35,3 @@ jobs:
- name: 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
@@ -0,0 +1,24 @@
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:
docker compose -f docker-compose.dev.yml up -d db
docker compose up -d
The db will automatically restarts by Docker.
+2 -1
View File
@@ -16,13 +16,14 @@
"@fortawesome/vue-fontawesome": "^3.0.3",
"@linux-odyssey/constants": "*",
"@linux-odyssey/file-graph": "*",
"@linux-odyssey/utils": "*",
"@linux-odyssey/models": "*",
"@linux-odyssey/utils": "*",
"@types/dompurify": "^3.0.5",
"axios": "^1.6.0",
"dompurify": "^3.0.6",
"echarts": "^5.4.3",
"marked": "^10.0.0",
"pinia": "^2.1.7",
"socket.io-client": "^4.7.1",
"validator": "^13.11.0",
"vue": "^3.3.4",
-142
View File
@@ -1,142 +0,0 @@
<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>
+3 -12
View File
@@ -14,7 +14,9 @@ import {
import { DAG } from '@linux-odyssey/utils'
import api from '../utils/api'
import { NodeImage } from '../img/svg.js'
import useUserProfile from '../store/userProfile'
const store = useUserProfile()
const marginX = 500
const marginY = 300
const chartContainer = ref(null)
@@ -40,16 +42,6 @@ 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) => ({
renderer: 'svg',
tooltip: {},
@@ -163,8 +155,7 @@ function initChart(option: any) {
onMounted(async () => {
const quests = await getQuests()
const progress = await getProgress()
const option = getOption(quests, progress)
const option = getOption(quests, store.progress)
initChart(option)
})
</script>
+5 -4
View File
@@ -1,13 +1,14 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
import { createSession } from '../../store/session'
import useSession from '../../store/session'
import { surveyUrl } from '../../config'
const router = useRouter()
const sessionStore = useSession()
const reset = async () => {
console.log('resetting...')
await createSession()
await sessionStore.createSession()
}
const continuePlay = async () => {
try {
@@ -29,7 +30,7 @@ const continuePlay = async () => {
:icon="['fas', 'file-invoice']"
class="text-text-primary px-1"
/>
<p class="text-text-primary inline ml-1 hidden md:flex">問卷</p>
<p class="text-text-primary inline ml-1 md:flex">問卷</p>
</a>
</button>
<button disabled id="solution" class="bg-bg-disabled rounded-lg p-2">
@@ -37,7 +38,7 @@ const continuePlay = async () => {
:icon="['far', 'circle-question']"
class="text-text-disabled px-1"
/>
<p class="text-text-disabled inline ml-1 hidden md:flex">解答</p>
<p class="text-text-disabled inline ml-1 md:flex">解答</p>
</button>
<button id="reset" class="bg-bg-secondary rounded-lg p-2" @click="reset">
<font-awesome-icon
+20 -15
View File
@@ -1,16 +1,21 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import sessionStore from '../../store/session'
import { computed, ref, watch } from 'vue'
import useSession from '../../store/session'
import MarkdownText from '../MarkdownText.vue'
const sessionStore = useSession()
const current = ref(-1)
watch(
() => sessionStore.session.hints,
(hints) => {
current.value = hints.length - 1
},
{ deep: true }
)
const length = computed(() => {
return sessionStore.session?.responses.length ?? 0
})
const disabled = computed(() => {
return current.value === length.value - 1
})
watch(length, () => {
current.value = length.value - 1
})
const left = () => {
if (current.value > 0) {
@@ -19,7 +24,7 @@ const left = () => {
}
const right = () => {
if (current.value < sessionStore.session.hints.length - 1) {
if (current.value < length.value - 1) {
current.value += 1
}
}
@@ -32,7 +37,7 @@ const right = () => {
:icon="['far', 'lightbulb']"
class="text-yellow-200 p-2 content-center"
/>
<h1 class="inline text-text w-1/2 font-xl p-2 m-1">提示</h1>
<h1 class="inline text-text w-1/2 font-xl p-2 m-1">提示 {{ length }}</h1>
<div v-if="current !== -1" class="flex w-full justify-end items-end">
<button
class="p-2 m-1 w-1/8"
@@ -49,13 +54,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">/</p>
<p class="inline text-text font-xl w-1/14 p-2 m-1">
{{ sessionStore.session.hints.length }}
{{ length }}
</p>
<button
class="p-2 m-1 w-1/8"
id="Rbutton"
@click="right"
:disabled="current === sessionStore.session.hints.length - 1"
:disabled="disabled"
>
<font-awesome-icon :icon="['fas', 'arrow-right']" class="text-text" />
</button>
@@ -64,7 +69,7 @@ const right = () => {
<div id="hint" class="bg-bg p-8 overflow-y-auto">
<ul>
<li
v-for="response in sessionStore.session.responses[current]"
v-for="response in sessionStore.session?.responses[current]"
:key="response.type"
class="text-text font-xl whitespace-pre-wrap"
>
@@ -73,7 +78,7 @@ const right = () => {
</div>
</li>
<li
v-for="hint in sessionStore.session.hints[current]"
v-for="hint in sessionStore.session?.hints[current]"
:key="hint"
class="text-text-primary font-xl whitespace-pre-wrap"
>
+3 -2
View File
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import MarkdownText from '../MarkdownText.vue'
import sessionStore from '../../store/session'
import useSession from '../../store/session'
import { Task } from '../../types'
const sessionStore = useSession()
const tasks = computed(() => {
return sessionStore.session.tasks.map((task: Task) => {
return sessionStore.session?.tasks.map((task: Task) => {
const prefix = task.completed ? '✓' : '➤'
const color = task.completed ? 'text-text-primary' : 'text-text'
return {
+37
View File
@@ -0,0 +1,37 @@
<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>
+4 -1
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import FileNode from '../FileNode.vue'
import sessionStore from '../../store/session'
import useSession from '../../store/session'
const sessionStore = useSession()
</script>
<template>
@@ -8,6 +10,7 @@ import sessionStore from '../../store/session'
<ul>
<li>
<FileNode
v-if="sessionStore.session"
:node="sessionStore.session.graph"
:pwd="sessionStore.session.pwd"
/>
@@ -4,12 +4,11 @@ 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'
import useSession from '../../store/session'
import useUserProfile from '../../store/userProfile'
const userStore = useUserProfile()
const sessionStore = useSession()
const router = useRouter()
const toast = useToast()
@@ -17,8 +16,8 @@ const toast = useToast()
const handleLogout = async () => {
try {
await logout()
resetUserProfile()
reset()
userStore.resetUserProfile()
sessionStore.reset()
router.push({ name: 'login' })
} catch (err) {
toast.error('登出失敗')
@@ -28,7 +27,7 @@ const handleLogout = async () => {
onMounted(async () => {
try {
await loadUserProfile()
await userStore.loadUserProfile()
} catch (err) {
toast.error('無法讀取使用者資料')
console.error(err)
@@ -59,7 +58,7 @@ onMounted(async () => {
class="text-text inline-block whitespace-nowrap px-1.5"
style="font-size: 2vh"
>
{{ userProfileStore.username }}
{{ userStore.username }}
</p>
<a
title="Survey"
+3 -1
View File
@@ -19,6 +19,7 @@ import {
faTrophy,
faFileInvoice,
faEnvelope,
faPlay,
} from '@fortawesome/free-solid-svg-icons'
import {
faLightbulb,
@@ -54,5 +55,6 @@ library.add(
faGoogle,
faFileInvoice,
faFacebook,
faEnvelope
faEnvelope,
faPlay
)
+4
View File
@@ -1,4 +1,6 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
/* import font awesome icon component */
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
@@ -13,9 +15,11 @@ import App from './App.vue'
import router from './router'
const app = createApp(App)
const pinia = createPinia()
app.component('font-awesome-icon', FontAwesomeIcon)
app.use(pinia)
app.use(router)
const options = {
+78 -138
View File
@@ -1,156 +1,96 @@
import { reactive } from 'vue'
import { useToast } from 'vue-toastification'
import { defineStore } from 'pinia'
import { FileGraph, FileObject } from '@linux-odyssey/file-graph'
import { IQuest } from '@linux-odyssey/models'
import api from '../utils/api'
import { createSession, getActiveSession, getQuest } from '../utils/api'
import Socket from '../utils/socket'
import SocketTerminal from '../utils/terminal'
import { LoadQuestError, LoadSessionError } from '../utils/errors'
import { Session, StageResponse } from '../types'
const toast = useToast()
function newSession() {
const graph = new FileGraph({
path: '/',
type: 'folder',
discovered: false,
})
return {
_id: '',
status: 'inactive',
graph,
pwd: '',
hints: [],
tasks: [],
responses: [],
}
}
const socket = new Socket()
const term = new SocketTerminal(40, 80)
let hasSetup = false
interface Store {
session: Session
session: Session | null
questId: string
quest: IQuest | null
}
const store = reactive<Store>({
session: newSession(),
questId: '',
quest: null,
const useSession = defineStore('session', {
state: (): Store => ({
session: null,
questId: '',
quest: null,
}),
actions: {
async setQuest(questId: string) {
this.quest = await getQuest(questId)
this.questId = questId
},
async setSession(session: Session) {
this.session = session
this.session.graph = new FileGraph(session.graph)
term.reset()
await socket.connect(session)
term.focus()
},
async createSession() {
const session = await createSession(this.questId)
await this.setSession(session)
socket.emit('terminal', 'echo start\n')
},
async getActiveSession() {
const session = await getActiveSession(this.questId)
if (session) {
this.setSession(session)
}
},
updateGraph(event: { discover?: FileObject[]; pwd?: string }) {
if (!this.session) return
if (event.discover) {
this.session.graph.discover(event.discover)
}
if (event.pwd) {
this.session.pwd = event.pwd
}
},
newResponse(response: StageResponse) {
if (!this.session) return
this.session.responses.push(response.responses)
this.session.hints.push(response.hints)
this.session.tasks = response.tasks
this.session.status = response.status
},
reset() {
socket.reset()
term.reset()
this.$reset()
},
setup() {
if (hasSetup) return
socket.on('terminal', (data: string) => {
term.write(data)
})
term.onData((data: string) => {
socket.emit('terminal', data)
})
socket.on('graph', (event: { discover: FileObject[]; pwd: string }) => {
this.updateGraph(event)
})
socket.on('response', (response: StageResponse) => {
this.newResponse(response)
})
hasSetup = true
},
setStatus(status: string) {
if (!this.session) return
this.session.status = status
},
},
})
const socket = new Socket()
const term = new SocketTerminal(40, 80)
function setQuest(questId: string) {
return api.get(`/quests/${questId}`).then((res) => {
store.questId = res.data._id
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()
await socket.connect(session)
term.focus()
} catch (err: any) {
console.error(err)
if (err instanceof Error) {
toast.error(err.message)
} else {
toast.error('Failed to connect to the session. Please try again later.')
}
throw err
}
}
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) {
store.session.graph.discover(event.discover)
}
if (event.pwd) {
store.session.pwd = event.pwd
}
}
function newResponse(response: StageResponse) {
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()
term.reset()
store.session = newSession()
store.quest = null
}
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) => {
term.write(data)
})
term.onData((data: string) => {
socket.emit('terminal', data)
})
socket.on('graph', (event: { discover: FileObject[]; pwd: string }) => {
updateGraph(event)
})
socket.on('response', (response: StageResponse) => {
newResponse(response)
})
}
setup()
export default store
export default useSession
+32 -27
View File
@@ -1,30 +1,35 @@
import { reactive } from 'vue'
import api from '../utils/api'
import { defineStore } from 'pinia'
import { getUserProfile } from '../utils/api'
const store = reactive({
username: '',
email: '',
progress: {},
interface IProgress {
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: '',
email: '',
progress: new Map<string, IProgress>(),
}),
actions: {
async loadUserProfile() {
const res = await getUserProfile()
this.$patch(res)
},
async resetUserProfile() {
this.$reset()
},
},
})
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
export default useUserProfile
+35
View File
@@ -1,4 +1,8 @@
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({
baseURL: '/api/v1',
@@ -8,3 +12,34 @@ const api = axios.create({
withCredentials: true,
})
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,7 +46,6 @@ class SocketTerminal {
mount(reference: HTMLElement): void {
this.term.open(reference)
this.term.focus()
this.resizeScreen()
}
+11 -22
View File
@@ -1,20 +1,20 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useToast } from 'vue-toastification'
import sessionStore, { init } from '../store/session'
import useSession from '../store/session'
import HeaderPart from '../components/header/HeaderPart.vue'
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
// import CommandlistPart from '../components/CommandlistPart.vue'
import HintPart from '../components/game/HintPart.vue'
import QuestPart from '../components/game/QuestPart.vue'
import TerminalPart from '../components/game/TerminalPart.vue'
import VisualizationPart from '../components/game/VisualizationPart.vue'
import ControlPalette from '../components/game/ControlPalette.vue'
import CompleteModal from '../components/game/CompleteModal.vue'
import { LoadQuestError, LoadSessionError } from '../utils/errors'
import StartButton from '../components/game/StartButton.vue'
const sessionStore = useSession()
const completed = computed(() => {
return sessionStore.session.status === 'finished'
return sessionStore.session?.status === 'finished'
})
const props = defineProps({
@@ -24,23 +24,11 @@ const props = defineProps({
},
})
const toast = useToast()
onMounted(async () => {
try {
await init(props.questId)
} catch (err: any) {
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)
}
sessionStore.reset()
sessionStore.setup()
await sessionStore.setQuest(props.questId)
await sessionStore.getActiveSession()
})
</script>
@@ -56,7 +44,7 @@ onMounted(async () => {
<input
type="hidden"
id="currentStatus"
:value="sessionStore.session.status"
:value="sessionStore.session?.status"
/>
<!-- main -->
<div id="main" class="h-full pt-10 w-full flex p-3 space-x-3">
@@ -72,6 +60,7 @@ onMounted(async () => {
</div>
<!-- Terminal and Hint -->
<div class="bg-bg h-full rounded-lg w-1/2">
<StartButton :questId="$props.questId" />
<section id="hint" class="h-3/5">
<HintPart />
</section>
-95
View File
@@ -1,95 +0,0 @@
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
@@ -0,0 +1,23 @@
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
+10 -3
View File
@@ -1,3 +1,5 @@
import { z } from 'zod'
export const passwordPolicy = {
minLength: 8,
minNumbers: 1,
@@ -6,6 +8,11 @@ export const passwordPolicy = {
minSymbols: 0,
}
export default {
passwordPolicy,
}
export const getSessionsRequestSchema = z.object({
questId: z.string().optional(),
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,5 +10,8 @@
"scripts": {
"build": "tsc",
"dev": "tsc -w"
},
"dependencies": {
"zod": "^3.23.8"
}
}
@@ -3,7 +3,6 @@ import type { Express, Request, Response } from 'express'
import { User, Session, ISession } from '@linux-odyssey/models'
import {
createNewSession,
getOrCreateActiveSession,
isQuestUnlocked,
} from '../../models/sessionManager.js'
import { asyncHandler } from '../../middleware/error.js'
@@ -56,7 +55,6 @@ export const createSession = asyncHandler(
export const getSessionById = asyncHandler(
async (req: Request, res: Response) => {
const { sessionId } = matchedData(req)
console.log(sessionId)
const session = await Session.findOne({
_id: sessionId,
user: (req.user as Express.ExistingUser).id,
@@ -71,7 +69,7 @@ export const getSessionById = asyncHandler(
}
)
export const getOrCreateSession = asyncHandler(
export const getActiveSessionHandler = asyncHandler(
async (req: Request, res: Response) => {
const { questId } = matchedData(req)
const user = await User.findById((req.user as Express.ExistingUser).id)
@@ -79,8 +77,12 @@ export const getOrCreateSession = asyncHandler(
user &&
((await isQuestUnlocked(user, questId)) || config.testing.enabled)
) {
const session = await getOrCreateActiveSession(user, questId)
res.json(sessionDetail(session))
const session = await Session.findOne({
user: user.id,
quest: questId,
status: 'active',
})
res.json(session ? sessionDetail(session) : null)
} else {
res.status(403).json({ message: 'Quest is locked.' })
}
+2 -2
View File
@@ -17,10 +17,10 @@ router.post(
sessionRateLimit,
sessionController.createSession
)
router.post(
router.get(
'/active',
activeSessionValidator,
sessionController.getOrCreateSession
sessionController.getActiveSessionHandler
)
router.get(
'/:sessionId',
+1 -3
View File
@@ -8,7 +8,6 @@ import SessionMiddleware from '../middleware/session.js'
import { genJWT } from '../utils/auth.js'
import logger from '../utils/logger.js'
// eslint-disable-next-line no-unused-vars
type SocketCallback = (event: string, ...args: any[]) => void
const sessions = new Map<string, SocketCallback[]>()
@@ -44,7 +43,6 @@ async function connectContainer(socket: Socket, next: (err?: Error) => void) {
next(new Error('User not found.'))
return
}
// console.log(socket.handshake)
const { sessionId } = socket.handshake.query
if (
!sessionId ||
@@ -79,7 +77,7 @@ async function connectContainer(socket: Socket, next: (err?: Error) => void) {
stream,
}
next()
} catch (err) {
} catch {
next(new Error('Failed to start container.'))
}
}
+1 -1
View File
@@ -82,7 +82,7 @@ export async function deleteContainer(id: string) {
}
try {
await container.stop()
} catch (error) {
} catch {
logger.warn('Failed to stop container', id)
}
await container.remove()
+1 -1
View File
@@ -112,7 +112,7 @@ export default class CommandHandler extends SessionHandler {
)
)
return checks.every((c) => c === true)
} catch (e) {
} catch {
return false
}
}
-15
View File
@@ -79,21 +79,6 @@ export async function createNewSession(
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(
session: HydratedDocument<ISession>
): Promise<void> {
+13
View File
@@ -37,6 +37,19 @@ paths:
schema:
type: string
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:
'200':
description: list of existing session
+23
View File
@@ -2479,6 +2479,11 @@
"@vue/compiler-dom" "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":
version "6.6.1"
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.6.1.tgz#7c14346383751d9f6ad4bea0963245b30220ef83"
@@ -6773,6 +6778,14 @@ pify@^2.2.0, pify@^2.3.0:
resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
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:
version "4.0.6"
resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9"
@@ -8160,6 +8173,11 @@ vite@^4.5.3:
optionalDependencies:
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:
version "9.4.2"
resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-9.4.2.tgz#02ffcce82042b082292f2d1672514615f0d95b6d"
@@ -8457,6 +8475,11 @@ yocto-queue@^0.1.0:
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
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:
version "5.5.0"
resolved "https://registry.yarnpkg.com/zrender/-/zrender-5.5.0.tgz#54d0d6c4eda81a96d9f60a9cd74dc48ea026bc1e"