mirror of
https://github.com/linux-odyssey/linux-odyssey.git
synced 2026-11-06 03:20:13 +08:00
Compare commits
91
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e37b13d99 | ||
|
|
b2a251576c | ||
|
|
8cc006e3a4 | ||
|
|
d6ae6d818d | ||
|
|
6692ae2b9b | ||
|
|
fab02d03a9 | ||
|
|
9e44ebe415 | ||
|
|
019caf58e4 | ||
|
|
97ad1a7b50 | ||
|
|
cb4b9f429a | ||
|
|
5e11d2da11 | ||
|
|
a901ce005c | ||
|
|
4dd2a7cbe1 | ||
|
|
5cf375905d | ||
|
|
d7c5c756f3 | ||
|
|
44ecd3b87a | ||
|
|
1888781a47 | ||
|
|
860b257ea7 | ||
|
|
ba251fb933 | ||
|
|
84bd2620d8 | ||
|
|
b449d84c54 | ||
|
|
91ebaec22c | ||
|
|
58354b7cd8 | ||
|
|
31301419ad | ||
|
|
251139c159 | ||
|
|
a4ed4cf533 | ||
|
|
247f03b71a | ||
|
|
be22a121c8 | ||
|
|
261c6fc029 | ||
|
|
03b2993b56 | ||
|
|
2cc348687a | ||
|
|
2a3429106c | ||
|
|
a6720cfe39 | ||
|
|
4c292fd6ba | ||
|
|
5ba0021c37 | ||
|
|
19897e607f | ||
|
|
08e9191c4b | ||
|
|
aa95eda23c | ||
|
|
6bc37900d8 | ||
|
|
98f18627e5 | ||
|
|
9b4f50ebbd | ||
|
|
5fd24190d2 | ||
|
|
d2bb079f50 | ||
|
|
d959ab22fe | ||
|
|
9c8260a411 | ||
|
|
3c92c9d143 | ||
|
|
5208d87bd6 | ||
|
|
634464065d | ||
|
|
4c90793fc6 | ||
|
|
169587c54d | ||
|
|
4dd5f94c60 | ||
|
|
14bcb3b0d5 | ||
|
|
8281a9d882 | ||
|
|
dcf72d046c | ||
|
|
b73607fb8d | ||
|
|
b64d707d16 | ||
|
|
7dcac306f9 | ||
|
|
eddbda23d7 | ||
|
|
124c5cc401 | ||
|
|
21d08b8156 | ||
|
|
3745011504 | ||
|
|
3f08452ea4 | ||
|
|
acfb09c6ca | ||
|
|
c2c100e6e6 | ||
|
|
4033eefa0d | ||
|
|
334b34ef65 | ||
|
|
ac89d521fc | ||
|
|
60641ba0ad | ||
|
|
dd67bcb1bd | ||
|
|
d002b21aa8 | ||
|
|
033c808c02 | ||
|
|
1be4f73e7e | ||
|
|
9ddfcd2a86 | ||
|
|
ca36f6391a | ||
|
|
1572b2ea06 | ||
|
|
8f6914d46c | ||
|
|
517ab43e8c | ||
|
|
74c49e1df7 | ||
|
|
cb0f3cc3b0 | ||
|
|
5605b27127 | ||
|
|
a7368110dd | ||
|
|
bdb7def73a | ||
|
|
b74866e50f | ||
|
|
d577656eb1 | ||
|
|
6400aa4a56 | ||
|
|
887d205ec1 | ||
|
|
8597c22b9f | ||
|
|
d8c0ee4b20 | ||
|
|
8180e9a0cf | ||
|
|
6daa11bff7 | ||
|
|
976dc6363c |
No files matched your search
@@ -0,0 +1,50 @@
|
|||||||
|
HOST=localhost
|
||||||
|
PORT=3000
|
||||||
|
BASE_URL=https://example.com
|
||||||
|
SECRET_KEY=YOU_SHOULD_CHANGE_THIS
|
||||||
|
BACKEND_URL=http://backend:3000
|
||||||
|
DOCKER_NETWORK=linux-odyssey-players
|
||||||
|
|
||||||
|
MONGO_ROOT_PASSWORD=adminpassword
|
||||||
|
MONGO_USER=commander
|
||||||
|
MONGO_PASS=commander
|
||||||
|
MONGO_DB=linux-odyssey
|
||||||
|
MONGO_URL=mongodb://commander:commander@db:27017/linux-odyssey
|
||||||
|
|
||||||
|
# Admin user credentials for analytics
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=admin
|
||||||
|
ADMIN_HOST=0.0.0.0
|
||||||
|
ADMIN_PORT=3001
|
||||||
|
# ADMIN_BASE_URL=http://0.0.0.0:3001
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
# CONTAINER_EXPIRY=60 # 60 minutes
|
||||||
|
# SESSION_MAX_AGE=168 # 7 days
|
||||||
|
# TRUSTED_PROXIES=1.2.3.4
|
||||||
|
# valid configurations see isIP & isIPRange in https://github.com/validatorjs/validator.js?tab=readme-ov-file#validators
|
||||||
|
|
||||||
|
# GOOGLE_CLIENT_ID=
|
||||||
|
# GOOGLE_CLIENT_SECRET=
|
||||||
|
# GITHUB_CLIENT_ID=
|
||||||
|
# GITHUB_CLIENT_SECRET=
|
||||||
|
# SURVEY_URL=
|
||||||
|
# BUG_REPORT_URL=
|
||||||
|
|
||||||
|
# SURVEY_URL=
|
||||||
|
# BUG_REPORT_URL=
|
||||||
|
|
||||||
|
# Docker related config
|
||||||
|
# DOCKER_NETWORK=host
|
||||||
|
# QUEST_IMAGE=linuxodyssey/quest-base
|
||||||
|
# DOCKER_PREFIX=linuxodyssey/quest-
|
||||||
|
|
||||||
|
# To mount quest folder from host into container, for development purpose
|
||||||
|
# MOUNT_QUEST=true
|
||||||
|
|
||||||
|
# LOG_PATH=
|
||||||
|
|
||||||
|
# Testing, create a user with the following credentials, disable rate limiting
|
||||||
|
# TESTING=true
|
||||||
|
# TESTING_USERNAME=alex
|
||||||
|
# TESTING_PASSWORD=Alex1234
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
HOST=localhost
|
||||||
|
PORT=3000
|
||||||
|
BASE_URL=http://localhost:5173
|
||||||
|
MONGO_URL=mongodb://localhost:27017/odyssey-test
|
||||||
|
SECRET_KEY=sample-secret
|
||||||
|
DOCKER_NETWORK=linux-odyssey-players-dev
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
# BACKEND_URL=http://host.docker.internal:3000
|
||||||
|
# CONTAINER_EXPIRY=60 # 60 minutes
|
||||||
|
# SESSION_MAX_AGE=168 # 7 days
|
||||||
|
# TRUSTED_PROXIES=1.2.3.4
|
||||||
|
# valid configurations see isIP & isIPRange in https://github.com/validatorjs/validator.js?tab=readme-ov-file#validators
|
||||||
|
|
||||||
|
# GOOGLE_CLIENT_ID=
|
||||||
|
# GOOGLE_CLIENT_SECRET=
|
||||||
|
# GITHUB_CLIENT_ID=
|
||||||
|
# GITHUB_CLIENT_SECRET=
|
||||||
|
# SURVEY_URL=
|
||||||
|
# BUG_REPORT_URL=
|
||||||
|
|
||||||
|
# SURVEY_URL=
|
||||||
|
# BUG_REPORT_URL=
|
||||||
|
|
||||||
|
# Docker related config
|
||||||
|
# DOCKER_NETWORK=host
|
||||||
|
# QUEST_IMAGE=linuxodyssey/quest-base
|
||||||
|
# DOCKER_PREFIX=linuxodyssey/quest-
|
||||||
|
|
||||||
|
# To mount quest folder from host into container, for development purpose
|
||||||
|
# MOUNT_QUEST=true
|
||||||
|
|
||||||
|
# LOG_PATH=
|
||||||
|
|
||||||
|
# Testing, create a user with the following credentials, disable rate limiting
|
||||||
|
# TESTING=true
|
||||||
|
# TESTING_USERNAME=alex
|
||||||
|
# TESTING_PASSWORD=Alex1234
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
config
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
@@ -85,6 +87,7 @@ built
|
|||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
.env.local
|
.env.local
|
||||||
|
.env.prod
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
.cache
|
.cache
|
||||||
|
|||||||
@@ -5,9 +5,15 @@
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- NodeJS
|
- NodeJS v20+
|
||||||
- Docker
|
- Docker
|
||||||
- Yarn
|
- Yarn: v1.22
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
- MongoDB requires x86 architecture to run for v5+ versions. You can track [this issue](https://github.com/linux-odyssey/linux-odyssey/issues/166) for updates.
|
||||||
|
|
||||||
|
Windows user please refer to [docs/windows-setup.md](docs/windows-setup.md) for setup instructions.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -15,30 +21,66 @@
|
|||||||
cd linux-odyssey
|
cd linux-odyssey
|
||||||
yarn install
|
yarn install
|
||||||
|
|
||||||
## Development
|
## Project Structures
|
||||||
|
|
||||||
Run mongoDB first:
|
1. **app**: frontend
|
||||||
|
2. **server**: backend
|
||||||
|
3. **quests**: tutorials scripts for each quest
|
||||||
|
4. **packages**: other packages
|
||||||
|
1. **analytics**: admin dashboard to analyze collected data
|
||||||
|
2. **constants**: some shared config values for app and server
|
||||||
|
3. **container**: the script run in container to capture users' command
|
||||||
|
4. **file-graph**: the algorithm to construct and modify file graph, used in both app and server
|
||||||
|
5. **models**: definitions of MondoDB collections (data schema)
|
||||||
|
6. **utils**: other utility code shared between packages
|
||||||
|
7. **game (in the future)**: core logic of game playing, will be extracted from server
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The configuration is stored in the `.env` file. You can copy from the sample file.
|
||||||
|
|
||||||
|
cp .env.sample .env
|
||||||
|
|
||||||
|
The backend will use a SSH key pair to login to the player's container. The private key is on the host, and the public key is copied to the player's container.
|
||||||
|
|
||||||
|
The key pair is stored in `./config/ssh_key` and `./config/ssh_key.pub`. If it doesn't exist, the backend will generate a new pair and store them.
|
||||||
|
|
||||||
|
## Docker Setup
|
||||||
|
|
||||||
|
### Docker compose files explaination
|
||||||
|
|
||||||
|
1. **docker-compose.yml**: The one used for development. It sets up MongoDB and builds base quest image.
|
||||||
|
2. **docker-compose.prod.yml**: The one used for production deployment. It has a Nginx service, a backend, a database, an analytics service, and creates isolated networks for player containers.
|
||||||
|
3. **docker-compose.testing.yml**: Similar to production config, but sets up an one-time environment for end-to-end testing.
|
||||||
|
|
||||||
|
### Common Development Setup
|
||||||
|
|
||||||
|
This sets up a development database, and running development server for frontend and backend.
|
||||||
|
|
||||||
|
Pull the latest images:
|
||||||
|
|
||||||
|
docker compose pull
|
||||||
|
|
||||||
|
Start the containers:
|
||||||
|
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
The db will automatically restarts by Docker.
|
This will start the db. it will automatically restarts by Docker.
|
||||||
|
|
||||||
Build up dependencies:
|
Building quest images:
|
||||||
|
|
||||||
yarn build:deps
|
docker compose build base
|
||||||
|
yarn build:quests
|
||||||
|
|
||||||
Run dev server (frontend, backend):
|
### Build Quest Images in Details
|
||||||
|
|
||||||
yarn dev
|
In each quest folder, there is a `game.yml` file, which is the quest definition.
|
||||||
|
|
||||||
If you want to enable social login, you should have OAuth client token in a `.env` file:
|
If you want to include additional files, put them in a `home` folder. The files in `home` will be copied to the user's home on container creation automatically.
|
||||||
|
|
||||||
### Build Quest Images
|
|
||||||
|
|
||||||
In each quest folder, there is a `game.yml` file, which is the quest definition. And a optional `Dockerfile` to overwrite the base image. If you want to include additional files, put them in a `home` folder. The files in `home` will **not** be copied to the image automatically, you have to copy them in the `Dockerfile`.
|
|
||||||
|
|
||||||
quests/
|
quests/
|
||||||
├── Dockerfile # Base image
|
├── Dockerfile # Base image
|
||||||
|
├── entrypoint.sh # Entrypoint for the container
|
||||||
├── discover
|
├── discover
|
||||||
│ └── game.yml
|
│ └── game.yml
|
||||||
├── helloworld
|
├── helloworld
|
||||||
@@ -54,14 +96,55 @@ In each quest folder, there is a `game.yml` file, which is the quest definition.
|
|||||||
└── home
|
└── home
|
||||||
└── ancient_scroll.txt
|
└── ancient_scroll.txt
|
||||||
|
|
||||||
|
An optional `Dockerfile` can overwrite the base image. You can use this to install additional dependencies or modify the environment.
|
||||||
|
|
||||||
|
Customized `entrypoint.sh` for each quest is not yet supported.
|
||||||
|
|
||||||
To build all quests:
|
To build all quests:
|
||||||
|
|
||||||
yarn build:quests
|
yarn build:quests
|
||||||
|
|
||||||
The base image is built with docker-compose, so you can use `docker compose build base` to rebuild it. Once you rebuild the base image, you have to rebuild all quests.
|
The base image is built with docker-compose, so you can use `docker compose build base` to rebuild it. Once you rebuild the base image, you have to rebuild all quests.
|
||||||
|
|
||||||
|
### Optional: Mounting Host Directory
|
||||||
|
|
||||||
|
To improve the development experience, we can mount the host directory to the container in the pattern:
|
||||||
|
|
||||||
|
/path/to/linux-odyssey/quests/${quest_id}/home:/home/commander
|
||||||
|
/path/to/linux-odyssey/packages/container:/usr/local/lib/container
|
||||||
|
|
||||||
|
You can edit the files on your host, and the changes will be reflected in the container. This is disabled by default. To enable it, you can set the following environment variables:
|
||||||
|
|
||||||
|
MOUNT_QUEST=true
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Copy `.env.sample` to `.env` and customize it for your needs.
|
||||||
|
|
||||||
|
Build the whole project for the first time:
|
||||||
|
|
||||||
|
yarn build
|
||||||
|
|
||||||
|
This will build the typescript for each package and the server.
|
||||||
|
|
||||||
|
Run dev server (frontend, backend):
|
||||||
|
|
||||||
|
yarn dev
|
||||||
|
|
||||||
|
**IMPORTANT NOTE**: only the app and server have hot-reloading, other packages require manually running `yarn dev` in each package.
|
||||||
|
|
||||||
|
To develop analytics dashboard:
|
||||||
|
|
||||||
|
yarn analytics
|
||||||
|
|
||||||
|
The default username and password is `alex` and `Alex1234`.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
Unit tests:
|
||||||
|
|
||||||
|
yarn test:unit
|
||||||
|
|
||||||
Run Cypress locally, using the current development containers:
|
Run Cypress locally, using the current development containers:
|
||||||
|
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
@@ -78,76 +161,22 @@ Run Cypress in container, create brand-new containers along with it:
|
|||||||
# tearing off the containers
|
# tearing off the containers
|
||||||
./docker-scripts.sh down
|
./docker-scripts.sh down
|
||||||
|
|
||||||
### Deployment
|
## Deployment
|
||||||
|
|
||||||
|
# Build the images (only allowed for maintainers)
|
||||||
docker compose -f docker-compose.prod.yml build
|
docker compose -f docker-compose.prod.yml build
|
||||||
docker compose -f docker-compose.prod.yml push
|
docker compose -f docker-compose.prod.yml push
|
||||||
|
|
||||||
# Server side
|
# Server side
|
||||||
docker compose -f docker-compose.prod.yml pull
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
yarn build:quests
|
||||||
docker compose -f docker-compose.prod.yml up -d
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
### Necessary Configuration
|
||||||
|
|
||||||
To enable social login, you should have OAuth client token in a `.env` file:
|
To enable social login, you should have OAuth client token in a `.env` file:
|
||||||
You can copy from `example.env`
|
You can copy from `.env.sample`
|
||||||
|
|
||||||
# .env
|
**YOU MUST CHANGE THE SECRET_KEY** in `.env` file.
|
||||||
BASE_URL=https://example.com
|
|
||||||
GOOGLE_CLIENT_ID=...
|
|
||||||
GOOGLE_CLIENT_SECRET=...
|
|
||||||
|
|
||||||
To enable it:
|
SECRET_KEY=your-secret-key
|
||||||
|
|
||||||
docker compose --env-file .env -f docker-compose.prod.yml up -d
|
|
||||||
|
|
||||||
## Standalone App (without Docker)
|
|
||||||
|
|
||||||
Source: `app/`
|
|
||||||
|
|
||||||
# Connect to dev server (https://odyssey.wancat.cc)
|
|
||||||
yarn app
|
|
||||||
|
|
||||||
# Connect to localhost (http://localhost:3000)
|
|
||||||
yarn app:local
|
|
||||||
|
|
||||||
# Connect to anyhost
|
|
||||||
API_ENDPOINT=http://example.com yarn app:local
|
|
||||||
|
|
||||||
## Swagger
|
|
||||||
|
|
||||||
docker compose up -d swagger
|
|
||||||
|
|
||||||
Open http://localhost:8080 to open Swagger
|
|
||||||
|
|
||||||
## CLI terminal client (Docker not required)
|
|
||||||
|
|
||||||
yarn cli
|
|
||||||
Usage: client [options] [command]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
-s, --session <string> Session ID
|
|
||||||
-c, --create Create a new session
|
|
||||||
-h, --host <string> Server host (default: "http://localhost:3000")
|
|
||||||
--help display help for command
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
list List all sessions
|
|
||||||
|
|
||||||
connet to recent session:
|
|
||||||
|
|
||||||
yarn cli
|
|
||||||
|
|
||||||
connect to new session:
|
|
||||||
|
|
||||||
yarn cli -c
|
|
||||||
|
|
||||||
connect to specific session:
|
|
||||||
|
|
||||||
yarn cli -s 64d0de0367459c13004bc83f
|
|
||||||
|
|
||||||
connect to other host:
|
|
||||||
|
|
||||||
yarn cli -h https://example.com
|
|
||||||
|
|
||||||
list sessions:
|
|
||||||
|
|
||||||
yarn cli list
|
|
||||||
+1
-1
@@ -10,6 +10,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@formbricks/js": "^2.1.1",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "^6.4.0",
|
"@fortawesome/free-brands-svg-icons": "^6.4.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^6.4.0",
|
"@fortawesome/free-regular-svg-icons": "^6.4.0",
|
||||||
@@ -22,7 +23,6 @@
|
|||||||
"@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",
|
|
||||||
"marked": "^10.0.0",
|
"marked": "^10.0.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"socket.io-client": "^4.7.1",
|
"socket.io-client": "^4.7.1",
|
||||||
|
|||||||
File renamed without changes.
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
@@ -20,7 +20,21 @@ function nodeClass() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<a href="#" :class="nodeClass()">{{ node.discovered ? node.name : '???' }}</a>
|
<a href="#" :class="nodeClass()">
|
||||||
|
<font-awesome-icon
|
||||||
|
v-if="node.type === 'folder'"
|
||||||
|
:icon="['far', 'folder']"
|
||||||
|
class="text-text-primary size-8"
|
||||||
|
/>
|
||||||
|
<font-awesome-icon
|
||||||
|
v-else
|
||||||
|
:icon="['far', 'file']"
|
||||||
|
class="text-text-primary size-8"
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
{{ node.discovered ? node.name : '???' }}
|
||||||
|
{{ node.name === '' && node.discovered ? '/' : '' }}
|
||||||
|
</a>
|
||||||
<ul :class="nodeClass()" v-if="node.children && node.children.length > 0">
|
<ul :class="nodeClass()" v-if="node.children && node.children.length > 0">
|
||||||
<li :class="nodeClass()" v-for="child in node.children" :key="child.path">
|
<li :class="nodeClass()" v-for="child in node.children" :key="child.path">
|
||||||
<FileNode :node="child" :pwd="pwd" />
|
<FileNode :node="child" :pwd="pwd" />
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
import { ref, defineProps, onMounted } from 'vue'
|
||||||
|
import { getQuest } from '../utils/api'
|
||||||
|
import MarkdownText from './MarkdownText.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
questTitle: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
questId: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
questUnlocked: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['closeIntro'])
|
||||||
|
const showIntro = ref(true) // Controls whether the modal is visible or not
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
const { questTitle, questId, questUnlocked } = props
|
||||||
|
const questInstruction = ref('')
|
||||||
|
const questColor = ref('')
|
||||||
|
const questTextColor = ref('')
|
||||||
|
const closeModal = () => {
|
||||||
|
showIntro.value = false
|
||||||
|
emit('closeIntro', true)
|
||||||
|
router.push({ name: 'map' })
|
||||||
|
}
|
||||||
|
const handleQuests = (id: string) => {
|
||||||
|
if (questUnlocked) {
|
||||||
|
router.push({ name: 'game', params: { questId: id } })
|
||||||
|
} else {
|
||||||
|
toast.warning('你還沒完成前一個關卡!')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleColour = () => {
|
||||||
|
if (questUnlocked) {
|
||||||
|
questColor.value = '#00ff00'
|
||||||
|
questTextColor.value = '#000000'
|
||||||
|
} else {
|
||||||
|
questColor.value = '#505050'
|
||||||
|
questTextColor.value = '#a0a0a0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(async () => {
|
||||||
|
const quest = await getQuest(questId)
|
||||||
|
questInstruction.value = quest.instruction
|
||||||
|
handleColour()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="showIntro" class="modal">
|
||||||
|
<div class="modal-content bg-bg-secondary rounded-lg">
|
||||||
|
<span class="close text-text content-between" @click="closeModal"
|
||||||
|
>×</span
|
||||||
|
>
|
||||||
|
|
||||||
|
<h2
|
||||||
|
id="QuestTitle"
|
||||||
|
class="text-2xl text-text mb-5 w-full inline-flex justify-center"
|
||||||
|
>
|
||||||
|
{{ questTitle }}
|
||||||
|
</h2>
|
||||||
|
<div class="w-full h-3/5 p-8">
|
||||||
|
<div class="px-5 whitespace-pre-wrap">
|
||||||
|
<MarkdownText
|
||||||
|
class="text-text markdown-content"
|
||||||
|
:content="questInstruction"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
id="StartAdventure"
|
||||||
|
target="_blank"
|
||||||
|
@click="handleQuests(questId)"
|
||||||
|
:style="{ backgroundColor: questColor, color: questTextColor }"
|
||||||
|
class="inline-flex justify-center rounded-lg text-base font-black py-3 mt-5 w-full text-lg"
|
||||||
|
>開始冒險</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
position: relative;
|
||||||
|
margin: 10% auto;
|
||||||
|
padding: 20px;
|
||||||
|
width: 60%;
|
||||||
|
height: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 10px;
|
||||||
|
font-size: 30px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.markdown-content {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+232
-159
@@ -1,181 +1,254 @@
|
|||||||
<!-- eslint-disable no-console -->
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, watch, computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { use, init } from 'echarts/core'
|
|
||||||
import { GraphChart } from 'echarts/charts'
|
|
||||||
import { SVGRenderer } from 'echarts/renderers'
|
|
||||||
import {
|
|
||||||
TitleComponent,
|
|
||||||
TooltipComponent,
|
|
||||||
DataZoomComponent,
|
|
||||||
} from 'echarts/components'
|
|
||||||
import { DAG } from '@linux-odyssey/utils'
|
import { DAG } from '@linux-odyssey/utils'
|
||||||
import api from '../utils/api'
|
import { getQuests } from '../utils/api'
|
||||||
import { NodeImage } from '../img/svg.js'
|
|
||||||
import useUserProfile from '../store/userProfile'
|
import useUserProfile from '../store/userProfile'
|
||||||
|
import QuestIntro from './QuestIntro.vue'
|
||||||
|
|
||||||
const store = useUserProfile()
|
const store = useUserProfile()
|
||||||
const marginX = 500
|
|
||||||
const marginY = 300
|
|
||||||
const chartContainer = ref(null)
|
|
||||||
let chartInstance = null
|
|
||||||
const fullwidth = window.screen.width
|
|
||||||
|
|
||||||
use([
|
|
||||||
GraphChart,
|
|
||||||
SVGRenderer,
|
|
||||||
TitleComponent,
|
|
||||||
TooltipComponent,
|
|
||||||
DataZoomComponent,
|
|
||||||
])
|
|
||||||
|
|
||||||
async function getQuests() {
|
|
||||||
try {
|
|
||||||
const res = await api.get('/quests')
|
|
||||||
return res.data
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const genOption = (nodes: any, edges: any) => ({
|
|
||||||
renderer: 'svg',
|
|
||||||
tooltip: {},
|
|
||||||
animationDurationUpdate: 1500,
|
|
||||||
animationEasingUpdate: 'quinticInOut',
|
|
||||||
// dataZoom: [
|
|
||||||
// {
|
|
||||||
// type: 'slider', // This type means "slider data zoom" which provides a slider bar for zooming
|
|
||||||
// orient: 'vertical', // This makes the data zoom component vertical
|
|
||||||
// start: 0, // The starting percentage of the window out of the data extent
|
|
||||||
// end: 100, // The ending percentage of the window out of the data extent
|
|
||||||
// show: true,
|
|
||||||
// },
|
|
||||||
// ],
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
type: 'graph',
|
|
||||||
layout: 'none',
|
|
||||||
symbol: () => {
|
|
||||||
return NodeImage
|
|
||||||
},
|
|
||||||
symbolSize: [fullwidth / 13, fullwidth / 30],
|
|
||||||
roam: 'move',
|
|
||||||
zoom: 1.5,
|
|
||||||
center: ['120%', '30%'],
|
|
||||||
label: {
|
|
||||||
show: true,
|
|
||||||
fontSize: fullwidth / 90,
|
|
||||||
},
|
|
||||||
edgeSymbol: ['pin', 'arrow'],
|
|
||||||
edgeSymbolSize: [2, 15],
|
|
||||||
edgeLabel: {
|
|
||||||
fontSize: 25,
|
|
||||||
},
|
|
||||||
data: nodes,
|
|
||||||
// links: [],
|
|
||||||
links: edges,
|
|
||||||
lineStyle: {
|
|
||||||
width: 3,
|
|
||||||
color: 'gray',
|
|
||||||
opacity: 4,
|
|
||||||
curveness: 0.08,
|
|
||||||
},
|
|
||||||
itemStyle: {
|
|
||||||
color: (params: any) => {
|
|
||||||
const {
|
|
||||||
data: { completed, unlocked },
|
|
||||||
} = params
|
|
||||||
if (completed) {
|
|
||||||
return '#00ff00'
|
|
||||||
}
|
|
||||||
if (unlocked) {
|
|
||||||
return '#ADADB5'
|
|
||||||
}
|
|
||||||
return '#505050'
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
function getOption(quests: any, progress: any) {
|
|
||||||
const dag = new DAG(quests)
|
|
||||||
|
|
||||||
const nodes = dag.getNodes().map((node) => ({
|
|
||||||
id: node._id,
|
|
||||||
name: node.title,
|
|
||||||
y: marginX * node.index - (marginX * dag.getLayer(node._id)) / 2,
|
|
||||||
x: marginY * node.layer * 2,
|
|
||||||
completed: progress[node._id]?.completed || false,
|
|
||||||
unlocked: node.requirements.every((req: any) => progress[req]?.completed),
|
|
||||||
}))
|
|
||||||
const lineAppearence = (node: any) => {
|
|
||||||
try {
|
|
||||||
const { unlocked } = node
|
|
||||||
return {
|
|
||||||
type: unlocked ? 'line' : 'dashed',
|
|
||||||
color: unlocked ? '#ADADB5' : '#454552',
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const edges = dag.getEdgesArray().map((edge: any) => ({
|
|
||||||
source: edge[0],
|
|
||||||
target: edge[1],
|
|
||||||
lineStyle: lineAppearence(nodes.find((node) => node.id === edge[1])),
|
|
||||||
}))
|
|
||||||
return genOption(nodes, edges)
|
|
||||||
}
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
function initChart(option: any) {
|
const svgWidth = ref(window.innerWidth)
|
||||||
chartInstance = init(chartContainer.value)
|
const svgHeight = ref(window.innerHeight)
|
||||||
chartInstance.setOption(option)
|
|
||||||
chartInstance.on('click', (params: any) => {
|
|
||||||
const {
|
|
||||||
data: { id, unlocked },
|
|
||||||
} = params
|
|
||||||
|
|
||||||
if (unlocked) {
|
const marginX = 120
|
||||||
router.push({ name: 'game', params: { questId: id } })
|
const marginY = 120
|
||||||
} else {
|
|
||||||
toast.warning('你還沒完成前一個關卡!')
|
const nodeWidth = 160
|
||||||
}
|
const nodeHeight = 50
|
||||||
})
|
const opened = ref<Node | null>(null)
|
||||||
|
type Node = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
index: number
|
||||||
|
completed: boolean
|
||||||
|
unlocked: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type Edge = {
|
||||||
|
source: Node
|
||||||
|
target: Node
|
||||||
|
}
|
||||||
|
|
||||||
|
const graphData = ref<{ nodes: Node[]; edges: Edge[] }>({
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const dragStartX = ref(0)
|
||||||
|
const dragStartY = ref(0)
|
||||||
|
const offsetX = ref(0)
|
||||||
|
const offsetY = ref(0)
|
||||||
|
|
||||||
|
function startDrag(event: MouseEvent) {
|
||||||
|
isDragging.value = true
|
||||||
|
dragStartX.value = event.clientX - offsetX.value
|
||||||
|
dragStartY.value = event.clientY - offsetY.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function drag(event: MouseEvent) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
offsetX.value = event.clientX - dragStartX.value
|
||||||
|
offsetY.value = event.clientY - dragStartY.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag() {
|
||||||
|
isDragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function computeGraphData() {
|
||||||
|
if (!store.progress) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const quests = await getQuests()
|
||||||
|
const dag = new DAG(quests)
|
||||||
|
const nodesValues = dag.getNodes()
|
||||||
|
|
||||||
|
const nodes = nodesValues.map((node) => ({
|
||||||
|
id: node._id,
|
||||||
|
title: node.title,
|
||||||
|
x: marginX * node.layer * 2,
|
||||||
|
y:
|
||||||
|
marginY * node.index -
|
||||||
|
(marginX * dag.getLayer(node._id)) / 2 +
|
||||||
|
svgHeight.value / 2,
|
||||||
|
completed: store.progress[node._id]?.completed || false,
|
||||||
|
unlocked: node.requirements.every(
|
||||||
|
(req: string) => store.progress[req]?.completed
|
||||||
|
),
|
||||||
|
index: node.index,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const edges = dag
|
||||||
|
.getEdgesArray()
|
||||||
|
.map((edge) => ({
|
||||||
|
source: nodes.find((n) => n.id === edge[0]),
|
||||||
|
target: nodes.find((n) => n.id === edge[1]),
|
||||||
|
}))
|
||||||
|
.filter(
|
||||||
|
(edge): edge is Edge =>
|
||||||
|
edge.source !== undefined && edge.target !== undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
graphData.value = { nodes, edges }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error computing graph data:', error)
|
||||||
|
toast.error('Failed to load quest data')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleNodeClick(node: Node) {
|
||||||
|
opened.value = node
|
||||||
|
}
|
||||||
|
function closeIntro(close: boolean) {
|
||||||
|
if (close) {
|
||||||
|
opened.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await store.loadUserProfile()
|
await store.loadUserProfile()
|
||||||
const quests = await getQuests()
|
await computeGraphData()
|
||||||
const option = getOption(quests, store.progress)
|
})
|
||||||
initChart(option)
|
|
||||||
|
watch(() => store.progress, computeGraphData, { deep: true })
|
||||||
|
|
||||||
|
const curvedPath = computed(() => {
|
||||||
|
return (source: Node, target: Node) => {
|
||||||
|
// const dx = target.x - source.x
|
||||||
|
// const dy = target.y - source.y
|
||||||
|
// const dr = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
const dr = 0
|
||||||
|
const sweep = target.index > source.index ? 0 : 1 // Determines if the arc should curve up or down based on node index
|
||||||
|
return `M${source.x},${source.y}A${dr},${dr} 0 0,${sweep} ${target.x},${target.y}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const nodeStyle = computed(() => {
|
||||||
|
return (node: Node) => {
|
||||||
|
if (node.completed) return 'completed'
|
||||||
|
if (node.unlocked) return 'unlocked'
|
||||||
|
return 'locked'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const edgeStyle = computed(() => {
|
||||||
|
return (edge: Edge) => {
|
||||||
|
return edge.target.unlocked ? 'unlocked' : 'locked'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative bg-black w-full h-full flex">
|
<div class="relative bg-black w-full h-full flex">
|
||||||
<img
|
<div
|
||||||
src="../img/catelogbg.svg"
|
class="flex place-content-center w-full bg-catelogbg bg-cover bg-scroll"
|
||||||
alt="bg"
|
|
||||||
class="p-2 stroke-2 scale-y-120 justify-center items-center"
|
|
||||||
/>
|
|
||||||
<h1
|
|
||||||
class="p-10 absolute w-fit z-2 font-mono flex flax-wrap text-xl"
|
|
||||||
style="width: 30%; height: 6%; font-size: 3vh; color: #00ff00"
|
|
||||||
>
|
>
|
||||||
踏上你的Linux冒險之旅吧!
|
<h1
|
||||||
</h1>
|
class="p-10 absolute w-fit z-2 font-mono flex flax-wrap text-xl"
|
||||||
<div class="flex flex-wrap absolute w-full h-full z-1">
|
style="width: 30%; height: 6%; font-size: 3vh; color: #00ff00"
|
||||||
<div ref="chartContainer" class="w-full h-full flex flex-wrap"></div>
|
>
|
||||||
|
踏上你的Linux冒險之旅吧!
|
||||||
|
</h1>
|
||||||
|
<svg
|
||||||
|
:width="svgWidth"
|
||||||
|
:height="svgHeight"
|
||||||
|
class="absolute"
|
||||||
|
@mousedown="startDrag"
|
||||||
|
@mousemove="drag"
|
||||||
|
@mouseup="endDrag"
|
||||||
|
@mouseleave="endDrag"
|
||||||
|
>
|
||||||
|
<g :transform="`translate(${offsetX}, ${offsetY})`">
|
||||||
|
<g
|
||||||
|
v-for="edge in graphData.edges"
|
||||||
|
:key="`${edge.source.id}-${edge.target.id}`"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
:d="curvedPath(edge.source, edge.target)"
|
||||||
|
:class="['edge', edgeStyle(edge)]"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
v-for="node in graphData.nodes"
|
||||||
|
:key="node.id"
|
||||||
|
@click="handleNodeClick(node)"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
:x="node.x - nodeWidth / 2"
|
||||||
|
:y="node.y - nodeHeight / 2"
|
||||||
|
:width="nodeWidth"
|
||||||
|
:height="nodeHeight"
|
||||||
|
rx="10"
|
||||||
|
ry="10"
|
||||||
|
:class="['node', nodeStyle(node)]"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
:x="node.x"
|
||||||
|
:y="node.y"
|
||||||
|
text-anchor="middle"
|
||||||
|
alignment-baseline="middle"
|
||||||
|
:class="['node-text', nodeStyle(node)]"
|
||||||
|
font-size="18"
|
||||||
|
>
|
||||||
|
{{ node.title }}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<QuestIntro
|
||||||
|
:questTitle="opened.title"
|
||||||
|
:questId="opened.id"
|
||||||
|
:questUnlocked="opened.unlocked"
|
||||||
|
v-if="opened"
|
||||||
|
@close-intro="closeIntro"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
svg {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node {
|
||||||
|
&.completed {
|
||||||
|
fill: #6cf76c;
|
||||||
|
}
|
||||||
|
&.unlocked {
|
||||||
|
fill: #8c8c92;
|
||||||
|
}
|
||||||
|
&.locked {
|
||||||
|
fill: #505050;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-text {
|
||||||
|
&.completed {
|
||||||
|
fill: #1d1d1d;
|
||||||
|
}
|
||||||
|
&.unlocked {
|
||||||
|
fill: #ffffff;
|
||||||
|
}
|
||||||
|
&.locked {
|
||||||
|
fill: #a0a0a0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge {
|
||||||
|
stroke-width: 3;
|
||||||
|
fill: none;
|
||||||
|
&.unlocked {
|
||||||
|
stroke: #adadb5;
|
||||||
|
stroke-dasharray: none;
|
||||||
|
}
|
||||||
|
&.locked {
|
||||||
|
stroke: #454552;
|
||||||
|
stroke-dasharray: 5, 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -9,9 +9,11 @@ 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()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
showModal.value = false
|
showModal.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const defineComponent = () => ({
|
const defineComponent = () => ({
|
||||||
components: {
|
components: {
|
||||||
Vue3Lottie,
|
Vue3Lottie,
|
||||||
@@ -22,6 +24,7 @@ const defineComponent = () => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const backtoMap = async () => {
|
const backtoMap = async () => {
|
||||||
try {
|
try {
|
||||||
router.push({ name: 'map' })
|
router.push({ name: 'map' })
|
||||||
@@ -67,6 +70,21 @@ setTimeout(() => {
|
|||||||
>回到地圖</a
|
>回到地圖</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="position: relative; height: 100vh; overflow: auto">
|
||||||
|
<iframe
|
||||||
|
src="https://app.formbricks.com/s/clyffdm0c0be2wh6335clle0l"
|
||||||
|
frameborder="0"
|
||||||
|
style="
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
</iframe>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
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 useSession from '../../store/session'
|
||||||
import { surveyUrl } from '../../config'
|
import { openBugReport, openQuestSurvey } from '../../utils/formbricks'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const sessionStore = useSession()
|
const sessionStore = useSession()
|
||||||
@@ -21,31 +21,39 @@ const continuePlay = async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section
|
<section class="mt-2 flex flex-row gap-3 justify-center">
|
||||||
class="h-fit w-fit px-1 py-1 grid grid-cols-4 gap-2 place-content-between"
|
<button
|
||||||
>
|
id="survey"
|
||||||
<button id="survey" class="bg-bg-secondary rounded-lg p-2">
|
class="bg-bg-secondary rounded-lg p-2"
|
||||||
<a title="Survey" :href="surveyUrl" target="_blank" class="h-5 w-5">
|
@click="openQuestSurvey"
|
||||||
<font-awesome-icon
|
>
|
||||||
:icon="['fas', 'file-invoice']"
|
<font-awesome-icon
|
||||||
class="text-text-primary px-1"
|
:icon="['fas', 'file-invoice']"
|
||||||
/>
|
class="text-text-primary px-1"
|
||||||
<p class="text-text-primary inline ml-1 md:flex">問卷</p>
|
/>
|
||||||
</a>
|
<p class="text-text-primary inline md:flex">問卷</p>
|
||||||
</button>
|
</button>
|
||||||
<button disabled id="solution" class="bg-bg-disabled rounded-lg p-2">
|
<button
|
||||||
|
title="Bug Report"
|
||||||
|
@click="openBugReport"
|
||||||
|
class="bg-bg-secondary rounded-lg p-2"
|
||||||
|
>
|
||||||
|
<font-awesome-icon :icon="['fas', 'bug']" class="text-text-primary" />
|
||||||
|
<p class="text-text-primary inline ml-1 md:flex">回報</p>
|
||||||
|
</button>
|
||||||
|
<!-- <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 px-1"
|
||||||
/>
|
/>
|
||||||
<p class="text-text-disabled inline ml-1 md:flex">解答</p>
|
<p class="text-text-disabled inline ml-1 md:flex">解答</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 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>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
id="continue"
|
id="continue"
|
||||||
@@ -56,7 +64,7 @@ const continuePlay = async () => {
|
|||||||
:icon="['far', 'circle-right']"
|
:icon="['far', 'circle-right']"
|
||||||
class="text-text-primary px-1"
|
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>
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -48,14 +48,14 @@ const sessionStore = useSession()
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 50%;
|
right: 50%;
|
||||||
border-top: 1px solid #00ff00;
|
border-top: 2px solid #00ff00;
|
||||||
width: 50%;
|
width: 50%;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
}
|
}
|
||||||
.tree li::after {
|
.tree li::after {
|
||||||
right: auto;
|
right: auto;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
border-left: 1px solid #00ff00;
|
border-left: 2px solid #00ff00;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree li:only-child::after,
|
.tree li:only-child::after,
|
||||||
@@ -73,7 +73,7 @@ const sessionStore = useSession()
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tree li:last-child::before {
|
.tree li:last-child::before {
|
||||||
border-right: 1px solid #00ff00;
|
border-right: 2px solid #00ff00;
|
||||||
border-radius: 0 5px 0 0;
|
border-radius: 0 5px 0 0;
|
||||||
-webkit-border-radius: 0 5px 0 0;
|
-webkit-border-radius: 0 5px 0 0;
|
||||||
-moz-border-radius: 0 5px 0 0;
|
-moz-border-radius: 0 5px 0 0;
|
||||||
@@ -89,7 +89,7 @@ const sessionStore = useSession()
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
border-left: 1px solid #00ff00;
|
border-left: 2px solid #00ff00;
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,7 @@ const sessionStore = useSession()
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-family: arial, verdana, tahoma;
|
font-family: arial, verdana, tahoma;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
@@ -112,18 +112,10 @@ const sessionStore = useSession()
|
|||||||
-moz-transition: all 0.5s;
|
-moz-transition: all 0.5s;
|
||||||
}
|
}
|
||||||
|
|
||||||
a.discovered {
|
.tree li a.pwd {
|
||||||
background: #526b4e;
|
border: 3px solid #00ff00;
|
||||||
color: #000;
|
background: #008080;
|
||||||
border: 1px solid #94a0b4;
|
font-weight: bold;
|
||||||
}
|
font-size: 13px;
|
||||||
|
|
||||||
ul.discovered,
|
|
||||||
li.discovered {
|
|
||||||
border-color: #94a0b4;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.pwd {
|
|
||||||
box-shadow: 0 0 40px rgba(255, 255, 0, 20);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -3,7 +3,8 @@ import { onMounted } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { logout } from '../../utils/auth'
|
import { logout } from '../../utils/auth'
|
||||||
import { bugReportUrl, surveyUrl } from '../../config'
|
import { surveyUrl } from '../../config'
|
||||||
|
import { openBugReport } from '../../utils/formbricks'
|
||||||
import useSession from '../../store/session'
|
import useSession from '../../store/session'
|
||||||
import useUserProfile from '../../store/userProfile'
|
import useUserProfile from '../../store/userProfile'
|
||||||
|
|
||||||
@@ -84,15 +85,14 @@ onMounted(async () => {
|
|||||||
<font-awesome-icon :icon="['fas', 'map']" class="text-text-primary" />
|
<font-awesome-icon :icon="['fas', 'map']" class="text-text-primary" />
|
||||||
<span class="text-text sm:hidden px-2">Map</span>
|
<span class="text-text sm:hidden px-2">Map</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<a
|
<button
|
||||||
title="Bug Report"
|
title="Bug Report"
|
||||||
:href="bugReportUrl"
|
@click="openBugReport"
|
||||||
target="_blank"
|
|
||||||
class="md:h-5 md:w-5 px-1.5 w-auto"
|
class="md:h-5 md:w-5 px-1.5 w-auto"
|
||||||
>
|
>
|
||||||
<font-awesome-icon :icon="['fas', 'bug']" class="text-text-primary" />
|
<font-awesome-icon :icon="['fas', 'bug']" class="text-text-primary" />
|
||||||
<span class="text-text sm:hidden px-2">Bug Report</span>
|
<span class="text-text sm:hidden px-2">Bug Report</span>
|
||||||
</a>
|
</button>
|
||||||
<button
|
<button
|
||||||
title="Sign Out"
|
title="Sign Out"
|
||||||
@click="handleLogout"
|
@click="handleLogout"
|
||||||
|
|||||||
+32
-28
@@ -3,28 +3,30 @@ import { library } from '@fortawesome/fontawesome-svg-core'
|
|||||||
|
|
||||||
/* import specific icons */
|
/* import specific icons */
|
||||||
import {
|
import {
|
||||||
faBars,
|
|
||||||
faArrowLeft,
|
faArrowLeft,
|
||||||
faList,
|
|
||||||
faGear,
|
|
||||||
faTriangleExclamation,
|
|
||||||
faExpand,
|
|
||||||
faTerminal,
|
|
||||||
faArrowRotateLeft,
|
|
||||||
faArrowRightFromBracket,
|
|
||||||
faArrowRight,
|
faArrowRight,
|
||||||
|
faArrowRightFromBracket,
|
||||||
|
faArrowRotateLeft,
|
||||||
|
faBars,
|
||||||
faBug,
|
faBug,
|
||||||
faCircleExclamation,
|
faCircleExclamation,
|
||||||
faMap,
|
|
||||||
faTrophy,
|
|
||||||
faFileInvoice,
|
|
||||||
faEnvelope,
|
faEnvelope,
|
||||||
|
faExpand,
|
||||||
|
faFileInvoice,
|
||||||
|
faGear,
|
||||||
|
faList,
|
||||||
|
faMap,
|
||||||
faPlay,
|
faPlay,
|
||||||
|
faTerminal,
|
||||||
|
faTriangleExclamation,
|
||||||
|
faTrophy,
|
||||||
} from '@fortawesome/free-solid-svg-icons'
|
} from '@fortawesome/free-solid-svg-icons'
|
||||||
import {
|
import {
|
||||||
faLightbulb,
|
|
||||||
faCircleQuestion,
|
faCircleQuestion,
|
||||||
faCircleRight,
|
faCircleRight,
|
||||||
|
faFile,
|
||||||
|
faFolder,
|
||||||
|
faLightbulb,
|
||||||
} from '@fortawesome/free-regular-svg-icons'
|
} from '@fortawesome/free-regular-svg-icons'
|
||||||
import {
|
import {
|
||||||
faFacebook,
|
faFacebook,
|
||||||
@@ -34,27 +36,29 @@ import {
|
|||||||
|
|
||||||
/* add icons to the library */
|
/* add icons to the library */
|
||||||
library.add(
|
library.add(
|
||||||
faBars,
|
|
||||||
faArrowLeft,
|
faArrowLeft,
|
||||||
faList,
|
|
||||||
faGear,
|
|
||||||
faTriangleExclamation,
|
|
||||||
faExpand,
|
|
||||||
faTerminal,
|
|
||||||
faArrowRotateLeft,
|
|
||||||
faArrowRightFromBracket,
|
|
||||||
faArrowRight,
|
faArrowRight,
|
||||||
|
faArrowRightFromBracket,
|
||||||
|
faArrowRotateLeft,
|
||||||
|
faBars,
|
||||||
faBug,
|
faBug,
|
||||||
faMap,
|
|
||||||
faTrophy,
|
|
||||||
faCircleExclamation,
|
faCircleExclamation,
|
||||||
faLightbulb,
|
faEnvelope,
|
||||||
|
faExpand,
|
||||||
|
faFileInvoice,
|
||||||
|
faGear,
|
||||||
|
faList,
|
||||||
|
faMap,
|
||||||
|
faPlay,
|
||||||
|
faTerminal,
|
||||||
|
faTriangleExclamation,
|
||||||
|
faTrophy,
|
||||||
faCircleQuestion,
|
faCircleQuestion,
|
||||||
faCircleRight,
|
faCircleRight,
|
||||||
faGithub,
|
faFile,
|
||||||
faGoogle,
|
faFolder,
|
||||||
faFileInvoice,
|
faLightbulb,
|
||||||
faFacebook,
|
faFacebook,
|
||||||
faEnvelope,
|
faGithub,
|
||||||
faPlay
|
faGoogle
|
||||||
)
|
)
|
||||||
+7
-1
@@ -10,9 +10,9 @@ import Toast, { POSITION } from 'vue-toastification'
|
|||||||
/* import Vue Lottie */
|
/* import Vue Lottie */
|
||||||
import Vue3Lottie from 'vue3-lottie'
|
import Vue3Lottie from 'vue3-lottie'
|
||||||
import 'vue-toastification/dist/index.css'
|
import 'vue-toastification/dist/index.css'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import formbricks from './utils/formbricks'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
@@ -22,6 +22,12 @@ app.component('font-awesome-icon', FontAwesomeIcon)
|
|||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
|
||||||
|
router.afterEach(() => {
|
||||||
|
if (typeof formbricks !== 'undefined') {
|
||||||
|
formbricks.registerRouteChange()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
position: POSITION.TOP_RIGHT,
|
position: POSITION.TOP_RIGHT,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,8 +61,15 @@ const useSession = defineStore('session', {
|
|||||||
if (response.tasks) {
|
if (response.tasks) {
|
||||||
this.session.tasks = response.tasks
|
this.session.tasks = response.tasks
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
response.status === 'finished' &&
|
||||||
|
this.session.status !== 'finished'
|
||||||
|
) {
|
||||||
|
this.finish()
|
||||||
|
}
|
||||||
this.session.status = response.status
|
this.session.status = response.status
|
||||||
},
|
},
|
||||||
|
finish() {},
|
||||||
reset() {
|
reset() {
|
||||||
socket.reset()
|
socket.reset()
|
||||||
term.reset()
|
term.reset()
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ interface IProgress {
|
|||||||
export interface UserProfile {
|
export interface UserProfile {
|
||||||
username: string
|
username: string
|
||||||
email: string
|
email: string
|
||||||
progress: Map<string, IProgress>
|
progress: Record<string, IProgress>
|
||||||
}
|
}
|
||||||
|
|
||||||
const useUserProfile = defineStore('userProfile', {
|
const useUserProfile = defineStore('userProfile', {
|
||||||
state: (): UserProfile => ({
|
state: (): UserProfile => ({
|
||||||
username: '',
|
username: '',
|
||||||
email: '',
|
email: '',
|
||||||
progress: new Map<string, IProgress>(),
|
progress: {},
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
async loadUserProfile() {
|
async loadUserProfile() {
|
||||||
|
|||||||
@@ -43,3 +43,8 @@ export async function getActiveSession(
|
|||||||
})
|
})
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getQuests(): Promise<IQuest[]> {
|
||||||
|
const res = await api.get<IQuest[]>('/quests')
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import formbricks from '@formbricks/js/website'
|
||||||
|
import useSession from '../store/session'
|
||||||
|
import useUserProfile from '../store/userProfile'
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
formbricks.init({
|
||||||
|
environmentId: 'clzcdqqn0000kadvtqykdo0pc',
|
||||||
|
apiHost: 'https://app.formbricks.com',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openQuestSurvey() {
|
||||||
|
const sessionStore = useSession()
|
||||||
|
const userStore = useUserProfile()
|
||||||
|
|
||||||
|
formbricks.track('quest_completed', {
|
||||||
|
hiddenFields: {
|
||||||
|
quest: sessionStore.questId,
|
||||||
|
session: sessionStore.session?._id,
|
||||||
|
username: userStore.username,
|
||||||
|
email: userStore.email,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openBugReport() {
|
||||||
|
const sessionStore = useSession()
|
||||||
|
const userStore = useUserProfile()
|
||||||
|
|
||||||
|
formbricks.track('bug_report', {
|
||||||
|
hiddenFields: {
|
||||||
|
quest: sessionStore.questId,
|
||||||
|
session: sessionStore.session?._id,
|
||||||
|
username: userStore.username,
|
||||||
|
email: userStore.email,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default formbricks
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted, watch } from 'vue'
|
||||||
import useSession from '../store/session'
|
import useSession from '../store/session'
|
||||||
|
import useUserProfile from '../store/userProfile'
|
||||||
import HeaderPart from '../components/header/HeaderPart.vue'
|
import HeaderPart from '../components/header/HeaderPart.vue'
|
||||||
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
|
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
|
||||||
import DescriptionPart from '../components/game/DescriptionPart.vue'
|
import DescriptionPart from '../components/game/DescriptionPart.vue'
|
||||||
@@ -10,13 +11,22 @@ import VisualizationPart from '../components/game/VisualizationPart.vue'
|
|||||||
import ControlPalette from '../components/game/ControlPalette.vue'
|
import ControlPalette from '../components/game/ControlPalette.vue'
|
||||||
import CompleteModal from '../components/game/CompleteModal.vue'
|
import CompleteModal from '../components/game/CompleteModal.vue'
|
||||||
import StartButton from '../components/game/StartButton.vue'
|
import StartButton from '../components/game/StartButton.vue'
|
||||||
|
import { openQuestSurvey } from '../utils/formbricks'
|
||||||
|
|
||||||
const sessionStore = useSession()
|
const sessionStore = useSession()
|
||||||
|
const userStore = useUserProfile()
|
||||||
|
|
||||||
const completed = computed(() => {
|
const completed = computed(() => {
|
||||||
return sessionStore.session?.status === 'finished'
|
return sessionStore.session?.status === 'finished'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(completed, (newValue, oldValue) => {
|
||||||
|
if (newValue && !oldValue) {
|
||||||
|
console.log('finish', userStore)
|
||||||
|
openQuestSurvey()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
questId: {
|
questId: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export default {
|
|||||||
DEFAULT: '#FF0000',
|
DEFAULT: '#FF0000',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
catelogbg: "url('/catelogbg.svg')",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
|
|||||||
+8
-23
@@ -1,4 +1,3 @@
|
|||||||
version: '3'
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: linuxodyssey/frontend
|
image: linuxodyssey/frontend
|
||||||
@@ -29,15 +28,14 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
|
- ./config:/app/config
|
||||||
|
- ./quests:/app/quests:ro
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- MONGO_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@db:27017/${MONGO_DB}
|
|
||||||
- DOCKER_NETWORK=linux-odyssey-players
|
- DOCKER_NETWORK=linux-odyssey-players
|
||||||
- SECRET_KEY=${SECRET_KEY}
|
|
||||||
- TRUSTED_PROXIES=${TRUSTED_PROXIES}
|
|
||||||
- BACKEND_URL=http://backend:3000
|
- BACKEND_URL=http://backend:3000
|
||||||
- BASE_URL=${BASE_URL}
|
- HOST_PROJECT_ROOT=${PWD}
|
||||||
|
|
||||||
analytics:
|
analytics:
|
||||||
image: linuxodyssey/analytics
|
image: linuxodyssey/analytics
|
||||||
@@ -52,13 +50,11 @@ services:
|
|||||||
- internal
|
- internal
|
||||||
ports:
|
ports:
|
||||||
- '3001:3001'
|
- '3001:3001'
|
||||||
environment:
|
env_file:
|
||||||
- MONGO_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@db:27017/${MONGO_DB}
|
- .env
|
||||||
- ADMIN_USERNAME=${ADMIN_USERNAME}
|
|
||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: mongo
|
image: mongo:6
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
@@ -67,19 +63,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- mongo:/data/db
|
- mongo:/data/db
|
||||||
- ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro
|
- ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro
|
||||||
environment:
|
env_file:
|
||||||
- BASE_URL=${BASE_URL}
|
- .env
|
||||||
- MONGO_INITDB_ROOT_USERNAME=admin
|
|
||||||
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
|
|
||||||
- MONGO_USER=${MONGO_USER}
|
|
||||||
- MONGO_PASS=${MONGO_PASSWORD}
|
|
||||||
- MONGO_DB=${MONGO_DB}
|
|
||||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
|
||||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
|
||||||
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
|
|
||||||
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
|
|
||||||
- SURVEY_URL=${SURVEY_URL}
|
|
||||||
- BUG_REPORT_URL=${BUG_REPORT_URL}
|
|
||||||
|
|
||||||
base:
|
base:
|
||||||
image: linuxodyssey/quest-base
|
image: linuxodyssey/quest-base
|
||||||
|
|||||||
+8
-4
@@ -1,7 +1,7 @@
|
|||||||
version: '3'
|
version: '3'
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: mongo
|
image: mongo:6
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
@@ -12,12 +12,16 @@ services:
|
|||||||
|
|
||||||
base:
|
base:
|
||||||
image: linuxodyssey/quest-base
|
image: linuxodyssey/quest-base
|
||||||
|
networks:
|
||||||
|
- players-dev
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./quests/Dockerfile
|
dockerfile: ./quests/Dockerfile
|
||||||
|
extra_hosts:
|
||||||
|
- 'host.docker.internal:host-gateway'
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
internal:
|
internal:
|
||||||
players:
|
players-dev:
|
||||||
name: linux-odyssey-players
|
name: linux-odyssey-players-dev
|
||||||
internal: true
|
internal: false
|
||||||
+4
-3
@@ -32,15 +32,16 @@ down() {
|
|||||||
dump() {
|
dump() {
|
||||||
# Define service name and dump directory name
|
# Define service name and dump directory name
|
||||||
SERVICE_NAME="db"
|
SERVICE_NAME="db"
|
||||||
|
COMPOSE_FILE="docker-compose.prod.yml"
|
||||||
DUMP_NAME="mongodump-$(date --iso).archive"
|
DUMP_NAME="mongodump-$(date --iso).archive"
|
||||||
CONTAINER_ID=$(docker compose ps -q $SERVICE_NAME)
|
CONTAINER_ID=$(docker compose -f $COMPOSE_FILE ps -q $SERVICE_NAME)
|
||||||
|
|
||||||
# Fetch credentials from Docker environment variables
|
# Fetch credentials from Docker environment variables
|
||||||
MONGO_URL=$(docker compose exec -T backend printenv MONGO_URL)
|
MONGO_URL=$(docker compose -f $COMPOSE_FILE exec -T backend printenv MONGO_URL)
|
||||||
echo mongo uri: $MONGO_URL
|
echo mongo uri: $MONGO_URL
|
||||||
|
|
||||||
# Dump MongoDB data using credentials
|
# Dump MongoDB data using credentials
|
||||||
docker compose exec -T $SERVICE_NAME sh -c "mongodump --gzip --archive=/tmp/$DUMP_NAME -d odyssey-test" &&
|
docker compose -f $COMPOSE_FILE exec -T $SERVICE_NAME sh -c "mongodump --gzip --archive=/tmp/$DUMP_NAME --uri=$MONGO_URL" &&
|
||||||
|
|
||||||
# Copy the dump from the container to the host
|
# Copy the dump from the container to the host
|
||||||
# Note: You will need to know the exact container name for this step
|
# Note: You will need to know the exact container name for this step
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Windows Environment Setup
|
||||||
|
|
||||||
|
## Install WSL and Ubuntu
|
||||||
|
|
||||||
|
Please refer to the Official [Setup WSL](https://learn.microsoft.com/en-us/windows/wsl/setup/environment) on Microsoft's website.
|
||||||
|
|
||||||
|
Run the following command in PowerShell as Administrator:
|
||||||
|
|
||||||
|
wsl --install
|
||||||
|
|
||||||
|
Then restart the computer. After restarting, you can open Ubuntu terminal by searching "Ubuntu" in Start menu. You will be prompted to create a new user account. Once you have done that, you can continue with the following steps.
|
||||||
|
|
||||||
|
## Setup Environment (In Ubuntu terminal)
|
||||||
|
|
||||||
|
1. Update and upgrade
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Install Git
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo apt-get install git
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Install Node.js (Version 20.x)
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||||
|
sudo apt-get install nodejs
|
||||||
|
sudo apt install build-essential
|
||||||
|
sudo npm install -g yarn
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install Docker
|
||||||
|
|
||||||
|
(Refer to [get.docker.com](https://get.docker.com/))
|
||||||
|
|
||||||
|
To install the latest stable versions of Docker CLI, Docker Engine, and their
|
||||||
|
dependencies:
|
||||||
|
|
||||||
|
1. Download the script
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -fsSL https://get.docker.com -o install-docker.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
1. verify the script's content
|
||||||
|
|
||||||
|
```
|
||||||
|
cat install-docker.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
1. run the script with --dry-run to verify the steps it executes
|
||||||
|
|
||||||
|
```
|
||||||
|
sh install-docker.sh --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
**NOTE: the script will recommend you to use Docker Desktop instead, but we need to use Docker inside WSL for the backend to work.** Just ignore it and wait for the installation to finish.
|
||||||
|
|
||||||
|
1. run the script either as root, or using sudo to perform the installation.
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo sh install-docker.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Verify the installation
|
||||||
|
|
||||||
|
```
|
||||||
|
docker --version
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Add your user to the docker group
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
```
|
||||||
|
|
||||||
|
**NOTE: You need to create a new WSL terminal to apply the changes.**
|
||||||
|
|
||||||
|
1. Verify the installation
|
||||||
|
|
||||||
|
```
|
||||||
|
docker ps
|
||||||
|
```
|
||||||
|
This should return something like:
|
||||||
|
|
||||||
|
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||||
|
|
||||||
|
## Run the Project (In Ubuntu terminal)
|
||||||
|
|
||||||
|
1. Clone the repository
|
||||||
|
|
||||||
|
```
|
||||||
|
git clone https://github.com/lancatlin/linux-odyssey.git
|
||||||
|
cd linux-odyssey
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional: To open the project in VSCode, run the following command inside the project folder:
|
||||||
|
|
||||||
|
```
|
||||||
|
code .
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Install dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
yarn install
|
||||||
|
```
|
||||||
|
It is ok if there are some warnings and `gyp: ERR` errors, just verify there is `done` at the end.
|
||||||
|
|
||||||
|
1. Copy the environment variables
|
||||||
|
|
||||||
|
```
|
||||||
|
cp .env.sample .env
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Docker setup
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose pull
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d db
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Check folder ./config exist and is empty -->
|
||||||
|
<!-- Check .env file exist -->
|
||||||
|
|
||||||
|
1. Run the project
|
||||||
|
|
||||||
|
```
|
||||||
|
yarn build
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Check ./config/ssh_key.pub is a file not a folder -->
|
||||||
|
|
||||||
|
Enjoy!
|
||||||
+3
-1
@@ -54,6 +54,8 @@
|
|||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"latest": "^0.2.0",
|
||||||
"typescript-eslint": "^7.4.0"
|
"typescript-eslint": "^7.4.0"
|
||||||
}
|
},
|
||||||
|
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||||
}
|
}
|
||||||
@@ -21,14 +21,16 @@ interface Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
host: process.env.HOST || 'localhost',
|
host: process.env.ADMIN_HOST || 'localhost',
|
||||||
port: process.env.PORT ? parseInt(process.env.PORT, 10) : 3001,
|
port: process.env.ADMIN_PORT ? parseInt(process.env.ADMIN_PORT, 10) : 3001,
|
||||||
db: process.env.MONGO_URL || 'mongodb://localhost:27017/odyssey-test',
|
db: process.env.MONGO_URL || 'mongodb://localhost:27017/odyssey-test',
|
||||||
username: getOrFailed('ADMIN_USERNAME'),
|
username: getOrFailed('ADMIN_USERNAME'),
|
||||||
password: getOrFailed('ADMIN_PASSWORD'),
|
password: getOrFailed('ADMIN_PASSWORD'),
|
||||||
baseUrl:
|
baseUrl:
|
||||||
process.env.BASE_URL ||
|
process.env.ADMIN_BASE_URL ||
|
||||||
`http://${process.env.HOST || 'localhost'}:${process.env.PORT ? parseInt(process.env.PORT, 10) : 3001}`,
|
`http://${process.env.ADMIN_HOST || 'localhost'}:${
|
||||||
|
process.env.ADMIN_PORT ? parseInt(process.env.ADMIN_PORT, 10) : 3001
|
||||||
|
}`,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default config
|
export default config
|
||||||
@@ -5,12 +5,12 @@ interface Node {
|
|||||||
|
|
||||||
export default class DAG {
|
export default class DAG {
|
||||||
private nodes = new Map()
|
private nodes = new Map()
|
||||||
private edges = new Set()
|
private edges = new Set<[string, string]>()
|
||||||
private layers: number[] = []
|
private layers: number[] = []
|
||||||
|
|
||||||
constructor(nodes: Node[]) {
|
constructor(nodes: Node[]) {
|
||||||
this.nodes = new Map(nodes.map((node) => [node._id, node]))
|
this.nodes = new Map(nodes.map((node) => [node._id, node]))
|
||||||
this.edges = new Set()
|
this.edges = new Set<[string, string]>()
|
||||||
this.nodes.forEach((_, id) => {
|
this.nodes.forEach((_, id) => {
|
||||||
this.setLayer(id)
|
this.setLayer(id)
|
||||||
this.addEdges(id)
|
this.addEdges(id)
|
||||||
@@ -42,7 +42,7 @@ export default class DAG {
|
|||||||
if (!node) {
|
if (!node) {
|
||||||
throw new Error(`Node ${id} not found`)
|
throw new Error(`Node ${id} not found`)
|
||||||
}
|
}
|
||||||
return this.layers[node.layer - 1]
|
return this.layers[node.layer - 1] || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private setLayer(id: string) {
|
private setLayer(id: string) {
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# This is the sshd server system-wide configuration file. See
|
||||||
|
# sshd_config(5) for more information.
|
||||||
|
|
||||||
|
# This sshd was compiled with PATH=/usr/local/bin:/usr/bin:/bin:/usr/games
|
||||||
|
|
||||||
|
# The strategy used for options in the default sshd_config shipped with
|
||||||
|
# OpenSSH is to specify options with their default value where
|
||||||
|
# possible, but leave them commented. Uncommented options override the
|
||||||
|
# default value.
|
||||||
|
|
||||||
|
Include /etc/ssh/sshd_config.d/*.conf
|
||||||
|
|
||||||
|
#Port 22
|
||||||
|
#AddressFamily any
|
||||||
|
#ListenAddress 0.0.0.0
|
||||||
|
#ListenAddress ::
|
||||||
|
|
||||||
|
#HostKey /etc/ssh/ssh_host_rsa_key
|
||||||
|
#HostKey /etc/ssh/ssh_host_ecdsa_key
|
||||||
|
#HostKey /etc/ssh/ssh_host_ed25519_key
|
||||||
|
|
||||||
|
# Ciphers and keying
|
||||||
|
#RekeyLimit default none
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
#SyslogFacility AUTH
|
||||||
|
#LogLevel INFO
|
||||||
|
|
||||||
|
# Authentication:
|
||||||
|
|
||||||
|
#LoginGraceTime 2m
|
||||||
|
#PermitRootLogin prohibit-password
|
||||||
|
#StrictModes yes
|
||||||
|
#MaxAuthTries 6
|
||||||
|
#MaxSessions 10
|
||||||
|
|
||||||
|
PubkeyAuthentication yes
|
||||||
|
|
||||||
|
# Expect .ssh/authorized_keys2 to be disregarded by default in future.
|
||||||
|
AuthorizedKeysFile /etc/ssh/authorized_keys/%u
|
||||||
|
|
||||||
|
#AuthorizedPrincipalsFile none
|
||||||
|
|
||||||
|
#AuthorizedKeysCommand none
|
||||||
|
#AuthorizedKeysCommandUser nobody
|
||||||
|
|
||||||
|
# For this to work you will also need host keys in /etc/ssh/ssh_known_hosts
|
||||||
|
#HostbasedAuthentication no
|
||||||
|
# Change to yes if you don't trust ~/.ssh/known_hosts for
|
||||||
|
# HostbasedAuthentication
|
||||||
|
#IgnoreUserKnownHosts no
|
||||||
|
# Don't read the user's ~/.rhosts and ~/.shosts files
|
||||||
|
#IgnoreRhosts yes
|
||||||
|
|
||||||
|
# To disable tunneled clear text passwords, change to no here!
|
||||||
|
#PasswordAuthentication yes
|
||||||
|
#PermitEmptyPasswords no
|
||||||
|
|
||||||
|
# Change to yes to enable challenge-response passwords (beware issues with
|
||||||
|
# some PAM modules and threads)
|
||||||
|
KbdInteractiveAuthentication no
|
||||||
|
|
||||||
|
# Kerberos options
|
||||||
|
#KerberosAuthentication no
|
||||||
|
#KerberosOrLocalPasswd yes
|
||||||
|
#KerberosTicketCleanup yes
|
||||||
|
#KerberosGetAFSToken no
|
||||||
|
|
||||||
|
# GSSAPI options
|
||||||
|
#GSSAPIAuthentication no
|
||||||
|
#GSSAPICleanupCredentials yes
|
||||||
|
#GSSAPIStrictAcceptorCheck yes
|
||||||
|
#GSSAPIKeyExchange no
|
||||||
|
|
||||||
|
# Set this to 'yes' to enable PAM authentication, account processing,
|
||||||
|
# and session processing. If this is enabled, PAM authentication will
|
||||||
|
# be allowed through the KbdInteractiveAuthentication and
|
||||||
|
# PasswordAuthentication. Depending on your PAM configuration,
|
||||||
|
# PAM authentication via KbdInteractiveAuthentication may bypass
|
||||||
|
# the setting of "PermitRootLogin prohibit-password".
|
||||||
|
# If you just want the PAM account and session checks to run without
|
||||||
|
# PAM authentication, then enable this but set PasswordAuthentication
|
||||||
|
# and KbdInteractiveAuthentication to 'no'.
|
||||||
|
UsePAM yes
|
||||||
|
|
||||||
|
#AllowAgentForwarding yes
|
||||||
|
#AllowTcpForwarding yes
|
||||||
|
#GatewayPorts no
|
||||||
|
X11Forwarding no
|
||||||
|
#X11DisplayOffset 10
|
||||||
|
#X11UseLocalhost yes
|
||||||
|
#PermitTTY yes
|
||||||
|
#PrintMotd yes
|
||||||
|
#PrintLastLog yes
|
||||||
|
#TCPKeepAlive yes
|
||||||
|
#PermitUserEnvironment no
|
||||||
|
#Compression delayed
|
||||||
|
#ClientAliveInterval 0
|
||||||
|
#ClientAliveCountMax 3
|
||||||
|
#UseDNS no
|
||||||
|
#PidFile /run/sshd.pid
|
||||||
|
#MaxStartups 10:30:100
|
||||||
|
#PermitTunnel no
|
||||||
|
#ChrootDirectory none
|
||||||
|
#VersionAddendum none
|
||||||
|
|
||||||
|
# no default banner path
|
||||||
|
#Banner none
|
||||||
|
|
||||||
|
# Allow client to pass locale environment variables
|
||||||
|
AcceptEnv LANG LC_* NODE_PATH ZDOTDIR API_ENDPOINT TOKEN
|
||||||
|
|
||||||
|
# override default of no subsystems
|
||||||
|
Subsystem sftp /usr/lib/openssh/sftp-server
|
||||||
|
|
||||||
|
# Example of overriding settings on a per-user basis
|
||||||
|
#Match User anoncvs
|
||||||
|
# X11Forwarding no
|
||||||
|
# AllowTcpForwarding no
|
||||||
|
# PermitTTY no
|
||||||
|
# ForceCommand cvs server
|
||||||
+29
-8
@@ -1,24 +1,39 @@
|
|||||||
PROMPT='%F{2}%n%f:%F{6}%~%f $ '
|
PROMPT='%F{2}%n%f:%F{6}%~%f $ '
|
||||||
unsetopt CASE_GLOB
|
unsetopt CASE_GLOB
|
||||||
setopt CHASE_LINKS
|
setopt CHASE_LINKS
|
||||||
emulate bash
|
# emulate bash
|
||||||
|
|
||||||
set +o prompt_cr
|
set +o prompt_cr
|
||||||
|
|
||||||
preexec() {
|
preexec() {
|
||||||
export CMD_START_TIME=$(date +%s%N) # capture start time
|
export CMD_START_TIME=$(date +%s%N) # capture start time
|
||||||
export CMD_NAME=$1 # capture command
|
export CMD_NAME="$1" # capture command
|
||||||
|
export CMD_PID=$$
|
||||||
|
|
||||||
# A list of interactive commands
|
# # A list of interactive commands
|
||||||
interactive_commands=("vim" "nano" "top" "ssh" "less" "zsh" "bash")
|
# readlink /proc/$PPID/exe
|
||||||
|
interactive_commands=("vim" "nano" "top" "ssh" "less" "zsh" "bash" "script" "exec")
|
||||||
|
# # if [[ $CMD_EXECUTED = 1 ]]; then
|
||||||
|
# # export CMD_EXECUTED=0
|
||||||
|
# # return
|
||||||
|
# # fi
|
||||||
|
|
||||||
# Check if the command is in the list of interactive commands
|
# # Check if the command is in the list of interactive commands
|
||||||
if [[ " ${interactive_commands[@]} " =~ " $1 " ]]; then
|
if [[ " ${interactive_commands[@]} " =~ " $1 " ]]; then
|
||||||
# If it is, just execute it normally
|
# If it is, just execute it normally
|
||||||
exec
|
# exec
|
||||||
|
# echo IF
|
||||||
|
return
|
||||||
else
|
else
|
||||||
# If it's not, execute it and redirect its output to a file
|
# If it's not, execute it and redirect its output to a file
|
||||||
exec > >(tee /tmp/cmd.out) 2> >(tee /tmp/cmd.error >&2)
|
# exec > >(tee /tmp/cmd.out) 2> >(tee /tmp/cmd.error >&2)
|
||||||
|
# echo ELSE
|
||||||
|
# export CMD_EXECUTED=1
|
||||||
|
# eval >&1 >/tmp/cmd.out
|
||||||
|
# exec >&1
|
||||||
|
# exec
|
||||||
|
script --quiet -O /tmp/cmd.out -c "$CMD_NAME"
|
||||||
|
return 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +44,12 @@ precmd() {
|
|||||||
export CMD_ERROR_FILE=/tmp/cmd.error
|
export CMD_ERROR_FILE=/tmp/cmd.error
|
||||||
|
|
||||||
# Now run your Node.js script with the result as an argument
|
# Now run your Node.js script with the result as an argument
|
||||||
node /usr/local/lib/container/cli.js; rm /tmp/cmd.out /tmp/cmd.error 2>/dev/null
|
node /usr/local/lib/container/cli.js
|
||||||
|
if [[ -n $CMD_PID ]]; then
|
||||||
|
# echo $CMD_PID
|
||||||
|
# kill $CMD_PID
|
||||||
|
fi
|
||||||
|
# rm /tmp/cmd.out /tmp/cmd.error 2>/dev/null
|
||||||
|
|
||||||
# Reset variables
|
# Reset variables
|
||||||
unset CMD_START_TIME
|
unset CMD_START_TIME
|
||||||
@@ -38,5 +58,6 @@ precmd() {
|
|||||||
unset CMD_OUTPUT_FILE
|
unset CMD_OUTPUT_FILE
|
||||||
unset CMD_ERROR_FILE
|
unset CMD_ERROR_FILE
|
||||||
unset CMD_NAME
|
unset CMD_NAME
|
||||||
|
export CMD_EXECUTED=0
|
||||||
}
|
}
|
||||||
|
|
||||||
+18
-12
@@ -1,26 +1,32 @@
|
|||||||
FROM alpine:3.18
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
LABEL maintainer="wancat@wancat.cc"
|
LABEL maintainer="wancat@wancat.cc"
|
||||||
|
|
||||||
RUN apk add --no-cache \
|
RUN apt-get update && apt-get install -y \
|
||||||
vim \
|
vim \
|
||||||
zsh \
|
zsh \
|
||||||
nodejs \
|
curl \
|
||||||
yarn
|
openssh-server
|
||||||
|
|
||||||
|
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x -o nodesource_setup.sh
|
||||||
|
RUN bash nodesource_setup.sh
|
||||||
|
RUN apt-get install -y nodejs
|
||||||
|
RUN npm install -g yarn
|
||||||
|
RUN node -v && yarn -v
|
||||||
|
|
||||||
WORKDIR /usr/local/lib/container
|
WORKDIR /usr/local/lib/container
|
||||||
|
|
||||||
COPY ./packages/container/package.json ./yarn.lock ./
|
COPY ./packages/container/package.json ./yarn.lock ./
|
||||||
RUN yarn install --modules-folder /usr/src/node_modules
|
RUN yarn install --modules-folder /usr/src/node_modules
|
||||||
ENV NODE_PATH=/usr/src/node_modules
|
ENV NODE_PATH=/usr/src/node_modules
|
||||||
COPY ./packages/container /usr/local/lib/container
|
COPY ./packages/container /usr/local/lib/container
|
||||||
COPY ./quests/.includes/zshrc /etc/zsh/zshrc
|
COPY ./quests/.includes/zshrc /etc/zsh/zshrc
|
||||||
|
RUN rm -f /etc/motd /etc/issue /etc/issue.net /etc/update-motd.d/*
|
||||||
|
RUN rm -rf /etc/skel && mkdir -p /etc/skel
|
||||||
|
RUN mkdir -p /etc/ssh/authorized_keys && chmod 755 /etc/ssh/authorized_keys
|
||||||
|
COPY ./quests/.includes/sshd_config /etc/ssh/sshd_config
|
||||||
|
COPY ./quests/entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
ENV USERNAME=commander
|
EXPOSE 22
|
||||||
|
|
||||||
RUN adduser --disabled-password --gecos '' --shell /bin/zsh ${USERNAME}
|
ENTRYPOINT ["/bin/sh", "/entrypoint.sh"]
|
||||||
|
|
||||||
USER ${USERNAME}
|
|
||||||
|
|
||||||
WORKDIR /home/${USERNAME}
|
|
||||||
CMD [ "/bin/zsh" ]
|
|
||||||
@@ -1,16 +1,14 @@
|
|||||||
title: 輸出文字
|
title: 輸出文字
|
||||||
instruction: |
|
instruction: |
|
||||||
在這個關卡中,你將會學到:
|
在這個關卡中,你將會學到:
|
||||||
1. 如何使用 `echo` 指令
|
1. 如何使用 `echo` 在終端機上顯示文字。
|
||||||
|
|
||||||
按下中間的 `start` 按鈕來開始這個關卡。
|
|
||||||
|
|
||||||
requirements:
|
requirements:
|
||||||
- get-started
|
- get-started
|
||||||
|
|
||||||
stages:
|
stages:
|
||||||
- id: start
|
- id: start
|
||||||
task: 按下 `start` 按鈕開始
|
task: 按下 `開始` 按鈕開始
|
||||||
condition:
|
condition:
|
||||||
command:
|
command:
|
||||||
- ^echo start$
|
- ^echo start$
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
echo start
|
||||||
|
echo next
|
||||||
|
ls
|
||||||
|
cd folder03
|
||||||
|
pwd
|
||||||
|
cd ../folder02
|
||||||
|
ls ../folder01
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
title: 測驗一
|
||||||
|
instruction: |
|
||||||
|
在這個關卡中,將會考驗你是否理解以下內容:
|
||||||
|
1. `echo`:用來輸出文字
|
||||||
|
2. `ls`:用來查看資料夾的內容
|
||||||
|
3. `cd`:用來在資料夾之間移動
|
||||||
|
4. `pwd`:用來顯示目前的路徑
|
||||||
|
|
||||||
|
requirements:
|
||||||
|
- path
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- id: start
|
||||||
|
task: 按下 `開始` 按鈕開始
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^echo start$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content: |
|
||||||
|
這個關卡將會測試你是否理解之前的教學內容。
|
||||||
|
因此不會告訴你要輸入什麼指令。
|
||||||
|
請思考並應用正確的指令來完成任務。
|
||||||
|
|
||||||
|
現在,請讓終端機輸出 `next` 來繼續。
|
||||||
|
|
||||||
|
- id: next
|
||||||
|
task: 讓終端機輸出 `next`
|
||||||
|
requirements:
|
||||||
|
- start
|
||||||
|
condition:
|
||||||
|
output:
|
||||||
|
- ^next$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content: |
|
||||||
|
現在請你查看目前資料夾的內容。
|
||||||
|
|
||||||
|
- id: ls-~
|
||||||
|
task: 查看目前資料夾的內容
|
||||||
|
requirements:
|
||||||
|
- next
|
||||||
|
condition:
|
||||||
|
pwd:
|
||||||
|
- ^/home/commander$
|
||||||
|
command:
|
||||||
|
- ^ls$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content: |
|
||||||
|
請進入 `folder03` 資料夾。
|
||||||
|
|
||||||
|
- id: cd-folder03
|
||||||
|
task: 進入 `folder03` 資料夾
|
||||||
|
requirements:
|
||||||
|
- ls-~
|
||||||
|
condition:
|
||||||
|
pwd:
|
||||||
|
- ^/home/commander/folder03$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content: |
|
||||||
|
請在終端機顯示目前的路徑。
|
||||||
|
|
||||||
|
- id: pwd-folder03
|
||||||
|
task: 顯示目前的路徑
|
||||||
|
requirements:
|
||||||
|
- cd-folder03
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^pwd$
|
||||||
|
output:
|
||||||
|
- ^/home/commander/folder03$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content: |
|
||||||
|
現在請你進入 `folder02` 資料夾,
|
||||||
|
並查看 `folder02` 資料夾的內容。
|
||||||
|
|
||||||
|
- id: ls-folder02
|
||||||
|
task: 進入並查看 `folder02` 資料夾的內容
|
||||||
|
requirements:
|
||||||
|
- pwd-folder03
|
||||||
|
condition:
|
||||||
|
pwd:
|
||||||
|
- ^/home/commander/folder02$
|
||||||
|
command:
|
||||||
|
- ^ls$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content: |
|
||||||
|
最後,請你留在 `folder02` 資料夾,
|
||||||
|
並查看 `folder01` 資料夾的內容。
|
||||||
|
|
||||||
|
- id: END
|
||||||
|
requirements:
|
||||||
|
- ls-folder02
|
||||||
|
task: 留在 `folder02` 資料夾,查看 `folder01` 資料夾的內容。
|
||||||
|
condition:
|
||||||
|
pwd:
|
||||||
|
- ^/home/commander/folder02$
|
||||||
|
command:
|
||||||
|
- ^ls
|
||||||
|
output:
|
||||||
|
- file1.txt
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content: |
|
||||||
|
恭喜你通過了這個測驗!
|
||||||
|
你可以繼續學習之後的教學內容了。
|
||||||
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,7 @@
|
|||||||
|
echo start
|
||||||
|
echo next
|
||||||
|
ls
|
||||||
|
cat hello.txt
|
||||||
|
cat pneumonoultramicroscopicsilicovolcanoconiosis.txt
|
||||||
|
mkdir folder
|
||||||
|
touch folder/file.txt
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
title: 測驗二
|
||||||
|
instruction: |
|
||||||
|
在這個關卡中,將會考驗你是否理解以下內容:
|
||||||
|
1. `cat`:用來查看檔案的內容
|
||||||
|
2. `touch`:用來建立檔案
|
||||||
|
3. `mkdir`:用來建立資料夾
|
||||||
|
4. 用 `tab` 鍵來自動補全
|
||||||
|
5. 之前測驗包含的內容
|
||||||
|
|
||||||
|
requirements:
|
||||||
|
- create
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- id: start
|
||||||
|
task: 按下 `開始` 按鈕開始
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^echo start$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content: |
|
||||||
|
這個關卡將會測試你是否理解之前的教學內容。
|
||||||
|
因此不會告訴你要輸入什麼指令。
|
||||||
|
請思考並應用正確的指令來完成任務。
|
||||||
|
|
||||||
|
現在,請讓終端機輸出 `next` 來繼續。
|
||||||
|
|
||||||
|
- id: next
|
||||||
|
task: 讓終端機輸出 `next`
|
||||||
|
requirements:
|
||||||
|
- start
|
||||||
|
condition:
|
||||||
|
output:
|
||||||
|
- ^next$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content: |
|
||||||
|
現在請你查看目前資料夾的內容。
|
||||||
|
|
||||||
|
- id: ls-~
|
||||||
|
task: 查看目前資料夾的內容
|
||||||
|
requirements:
|
||||||
|
- next
|
||||||
|
condition:
|
||||||
|
pwd:
|
||||||
|
- ^/home/commander$
|
||||||
|
command:
|
||||||
|
- ^ls$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content: |
|
||||||
|
請查看 `hello.txt` 檔案的內容。
|
||||||
|
|
||||||
|
- id: cat-hello
|
||||||
|
task: 查看 `hello.txt` 檔案內容
|
||||||
|
requirements:
|
||||||
|
- ls-~
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^cat hello.txt$
|
||||||
|
output:
|
||||||
|
- ^This is `hello.txt`.$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content: |
|
||||||
|
請查看另一個有著很長檔名的檔案內容。
|
||||||
|
|
||||||
|
- id: cat-long-filename
|
||||||
|
task: 查看另一個檔案的內容
|
||||||
|
requirements:
|
||||||
|
- cat-hello
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^cat pneumonoultramicroscopicsilicovolcanoconiosis.txt$
|
||||||
|
output:
|
||||||
|
- ^A name that has been invented for a lung disease caused by breathing in very small pieces of ash or dust.$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content: |
|
||||||
|
現在請你建立一個名稱為 `folder` 的資料夾。
|
||||||
|
|
||||||
|
- id: mkdir-folder
|
||||||
|
task: 建立名稱為 `folder` 的資料夾
|
||||||
|
requirements:
|
||||||
|
- cat-long-filename
|
||||||
|
condition:
|
||||||
|
files:
|
||||||
|
- path: /home/commander/folder
|
||||||
|
type: folder
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content: |
|
||||||
|
最後,請你在 `folder` 資料夾中,
|
||||||
|
建立檔名為 `file.txt` 的檔案。
|
||||||
|
|
||||||
|
- id: END
|
||||||
|
requirements:
|
||||||
|
- mkdir-folder
|
||||||
|
task: 在 `folder` 中建立檔名為 `file.txt` 的檔案
|
||||||
|
condition:
|
||||||
|
files:
|
||||||
|
- path: /home/commander/folder/file.txt
|
||||||
|
type: file
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content: |
|
||||||
|
恭喜你通過了這個測驗!
|
||||||
|
你可以繼續學習之後的教學內容了。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
This is `hello.txt`.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
A name that has been invented for a lung disease caused by breathing in very small pieces of ash or dust.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
echo start
|
||||||
|
echo next
|
||||||
|
ls
|
||||||
|
cd folder
|
||||||
|
cp hello.txt world.txt
|
||||||
|
rm hello.txt
|
||||||
|
mv world.txt ..
|
||||||
|
cd ..
|
||||||
|
mv world.txt hello.txt
|
||||||
|
cat hello.txt
|
||||||
|
rmdir folder
|
||||||
|
rm -r trash_can
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
title: 測驗三
|
||||||
|
instruction: |
|
||||||
|
在這個關卡中,將會考驗你是否理解以下內容:
|
||||||
|
1. `cp`:用來複製檔案和資料夾
|
||||||
|
2. `mv`:用來移動檔案和資料夾或更改名稱
|
||||||
|
3. `rm`:用來刪除檔案和資料夾
|
||||||
|
4. `rmdir`:用來刪除空的資料夾
|
||||||
|
5. 透過選項讓指令達成不同的功能,例如 `rm -r`
|
||||||
|
6. 之前測驗包含的內容
|
||||||
|
|
||||||
|
requirements:
|
||||||
|
- remove
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- id: start
|
||||||
|
task: 按下 `開始` 按鈕開始
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^echo start$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content: |
|
||||||
|
這個關卡將會測試你是否理解之前的教學內容。
|
||||||
|
因此不會告訴你要輸入什麼指令。
|
||||||
|
請思考並應用正確的指令來完成任務。
|
||||||
|
|
||||||
|
現在,請讓終端機輸出 `next` 來繼續。
|
||||||
|
|
||||||
|
- id: next
|
||||||
|
task: 讓終端機輸出 `next`
|
||||||
|
requirements:
|
||||||
|
- start
|
||||||
|
condition:
|
||||||
|
output:
|
||||||
|
- ^next$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content: |
|
||||||
|
現在請你查看目前資料夾的內容。
|
||||||
|
|
||||||
|
- id: ls-~
|
||||||
|
task: 查看目前資料夾的內容
|
||||||
|
requirements:
|
||||||
|
- next
|
||||||
|
condition:
|
||||||
|
pwd:
|
||||||
|
- ^/home/commander$
|
||||||
|
command:
|
||||||
|
- ^ls$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content: |
|
||||||
|
請複製 `folder` 資料夾中的檔案到 `folder` 中,並命名為 `world.txt` 。
|
||||||
|
|
||||||
|
- id: cp-hello-world
|
||||||
|
task: 複製 `folder` 中的檔案並命名為 `world.txt`
|
||||||
|
requirements:
|
||||||
|
- ls-~
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^cp .*hello.txt .*world.txt$
|
||||||
|
files:
|
||||||
|
- path: /home/commander/folder/hello.txt
|
||||||
|
type: file
|
||||||
|
- path: /home/commander/folder/world.txt
|
||||||
|
type: file
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content: |
|
||||||
|
請刪除 `hello.txt` 檔案。
|
||||||
|
|
||||||
|
- id: rm-hello
|
||||||
|
task: 刪除 `hello.txt`
|
||||||
|
requirements:
|
||||||
|
- cp-hello-world
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^rm .*hello.txt$
|
||||||
|
files:
|
||||||
|
- path: /home/commander/folder/hello.txt
|
||||||
|
type: file
|
||||||
|
exists: false
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content: |
|
||||||
|
現在請將 `world.txt` 移出 `folder` 資料夾。
|
||||||
|
(移到 `commander` 資料夾中)
|
||||||
|
|
||||||
|
- id: mv-world-..
|
||||||
|
task: 將 `world.txt` 移出 `folder` 資料夾
|
||||||
|
requirements:
|
||||||
|
- rm-hello
|
||||||
|
condition:
|
||||||
|
files:
|
||||||
|
- path: /home/commander/folder/world.txt
|
||||||
|
type: file
|
||||||
|
exists: false
|
||||||
|
- path: /home/commander/world.txt
|
||||||
|
type: file
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content: |
|
||||||
|
將 `world.txt` 的名稱改回 `hello.txt` ,
|
||||||
|
並查看檔案的內容。
|
||||||
|
|
||||||
|
- id: cat-hello
|
||||||
|
task: 將 `world.txt` 的名稱改回 `hello.txt` 後查看檔案內容
|
||||||
|
requirements:
|
||||||
|
- mv-world-..
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^cat hello.txt$
|
||||||
|
files:
|
||||||
|
- path: /home/commander/world.txt
|
||||||
|
type: file
|
||||||
|
exists: false
|
||||||
|
- path: /home/commander/hello.txt
|
||||||
|
type: file
|
||||||
|
output:
|
||||||
|
- ^This is `hello.txt`.$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content: |
|
||||||
|
刪除 `folder` 資料夾。
|
||||||
|
|
||||||
|
- id: rmdir-folder
|
||||||
|
task: 刪除 `folder` 資料夾
|
||||||
|
requirements:
|
||||||
|
- cat-hello
|
||||||
|
condition:
|
||||||
|
files:
|
||||||
|
- path: /home/commander/folder
|
||||||
|
type: folder
|
||||||
|
exists: false
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content: |
|
||||||
|
刪除 `trash_can` 資料夾。
|
||||||
|
|
||||||
|
- id: END
|
||||||
|
requirements:
|
||||||
|
- rmdir-folder
|
||||||
|
task: 刪除 `trash_can` 資料夾
|
||||||
|
condition:
|
||||||
|
files:
|
||||||
|
- path: /home/commander/trash_can
|
||||||
|
type: folder
|
||||||
|
exists: false
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content: |
|
||||||
|
恭喜你通過了這個測驗!
|
||||||
|
你可以繼續學習之後的教學內容了。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
This is `hello.txt`.
|
||||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1 @@
|
|||||||
|
aHR0cHM6Ly9yZXVybC5jYy9YUjZZMDA=
|
||||||
Whitespace-only changes.
@@ -1,3 +0,0 @@
|
|||||||
FROM linuxodyssey/quest-base
|
|
||||||
|
|
||||||
COPY home ${HOME}
|
|
||||||
@@ -2,7 +2,7 @@ echo start
|
|||||||
ls
|
ls
|
||||||
cat hello.txt
|
cat hello.txt
|
||||||
cat world.txt
|
cat world.txt
|
||||||
echo continue
|
echo next
|
||||||
cp hello.txt hello2.txt
|
cp hello.txt hello2.txt
|
||||||
cat hello2.txt
|
cat hello2.txt
|
||||||
cp world.txt world2.txt
|
cp world.txt world2.txt
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
title: 複製檔案
|
title: 複製檔案
|
||||||
instruction: |
|
instruction: |
|
||||||
在這個關卡中,你將會學到:
|
在這個關卡中,你將會學到:
|
||||||
1. 如何用 `cp` 指令複製檔案
|
1. 如何使用 `cp` 複製檔案
|
||||||
|
|
||||||
按下中間的 `start` 按鈕來開始這個關卡。
|
|
||||||
|
|
||||||
requirements:
|
requirements:
|
||||||
- create
|
- create
|
||||||
|
|
||||||
stages:
|
stages:
|
||||||
- id: start
|
- id: start
|
||||||
task: 按下 `start` 按鈕開始
|
task: 按下 `開始` 按鈕開始
|
||||||
condition:
|
condition:
|
||||||
command:
|
command:
|
||||||
- ^echo start$
|
- ^echo start$
|
||||||
@@ -46,7 +44,8 @@ stages:
|
|||||||
請使用 `cd /home/commander` 來返回 `/home/commander` 資料夾。
|
請使用 `cd /home/commander` 來返回 `/home/commander` 資料夾。
|
||||||
responses:
|
responses:
|
||||||
- type: narrative
|
- type: narrative
|
||||||
content: 可以看到現在資料夾裡有 `hello.txt` 跟 `world.txt`。
|
content: |
|
||||||
|
可以看到現在資料夾裡有 `hello.txt` 跟 `world.txt`。
|
||||||
請使用 `cat` 來查看它們的內容。
|
請使用 `cat` 來查看它們的內容。
|
||||||
看完之後,使用 `echo next` 來繼續吧!
|
看完之後,使用 `echo next` 來繼續吧!
|
||||||
hints:
|
hints:
|
||||||
@@ -165,7 +164,7 @@ stages:
|
|||||||
command:
|
command:
|
||||||
- ^cp hello.txt hello2.txt$
|
- ^cp hello.txt hello2.txt$
|
||||||
files:
|
files:
|
||||||
- path: hello2.txt
|
- path: /home/commander/hello2.txt
|
||||||
type: file
|
type: file
|
||||||
exceptions:
|
exceptions:
|
||||||
- condition:
|
- condition:
|
||||||
@@ -225,7 +224,7 @@ stages:
|
|||||||
command:
|
command:
|
||||||
- ^cp world.txt world2.txt$
|
- ^cp world.txt world2.txt$
|
||||||
files:
|
files:
|
||||||
- path: world2.txt
|
- path: /home/commander/world2.txt
|
||||||
type: file
|
type: file
|
||||||
exceptions:
|
exceptions:
|
||||||
- condition:
|
- condition:
|
||||||
@@ -293,9 +292,9 @@ stages:
|
|||||||
command:
|
command:
|
||||||
- ^cp
|
- ^cp
|
||||||
files:
|
files:
|
||||||
- path: notes2
|
- path: /home/commander/notes2
|
||||||
type: directory
|
type: directory
|
||||||
- path: notes2/note.txt
|
- path: /home/commander/notes2/note.txt
|
||||||
type: file
|
type: file
|
||||||
exceptions:
|
exceptions:
|
||||||
- condition:
|
- condition:
|
||||||
|
|||||||
+8
-10
@@ -4,14 +4,12 @@ instruction: |
|
|||||||
1. 如何使用 `touch` 建立檔案
|
1. 如何使用 `touch` 建立檔案
|
||||||
2. 如何使用 `mkdir` 建立資料夾
|
2. 如何使用 `mkdir` 建立資料夾
|
||||||
|
|
||||||
按下中間的 `start` 按鈕來開始這個關卡。
|
|
||||||
|
|
||||||
requirements:
|
requirements:
|
||||||
- read
|
- tab
|
||||||
|
|
||||||
stages:
|
stages:
|
||||||
- id: start
|
- id: start
|
||||||
task: 按下 `start` 按鈕開始
|
task: 按下 `開始` 按鈕開始
|
||||||
condition:
|
condition:
|
||||||
command:
|
command:
|
||||||
- ^echo start$
|
- ^echo start$
|
||||||
@@ -46,12 +44,12 @@ stages:
|
|||||||
- ls
|
- ls
|
||||||
condition:
|
condition:
|
||||||
files:
|
files:
|
||||||
- path: hello.txt
|
- path: /home/commander/hello.txt
|
||||||
type: file
|
type: file
|
||||||
exceptions:
|
exceptions:
|
||||||
- condition:
|
- condition:
|
||||||
files:
|
files:
|
||||||
- path: hello$
|
- path: /home/commander/hello
|
||||||
type: file
|
type: file
|
||||||
responses:
|
responses:
|
||||||
- type: narrative
|
- type: narrative
|
||||||
@@ -109,12 +107,12 @@ stages:
|
|||||||
- ls-touch
|
- ls-touch
|
||||||
condition:
|
condition:
|
||||||
files:
|
files:
|
||||||
- path: world.txt
|
- path: /home/commander/world.txt
|
||||||
type: file
|
type: file
|
||||||
exceptions:
|
exceptions:
|
||||||
- condition:
|
- condition:
|
||||||
files:
|
files:
|
||||||
- path: world$
|
- path: /home/commander/world
|
||||||
type: file
|
type: file
|
||||||
responses:
|
responses:
|
||||||
- type: narrative
|
- type: narrative
|
||||||
@@ -156,7 +154,7 @@ stages:
|
|||||||
- ls-2
|
- ls-2
|
||||||
condition:
|
condition:
|
||||||
files:
|
files:
|
||||||
- path: notes
|
- path: /home/commander/notes
|
||||||
type: folder
|
type: folder
|
||||||
responses:
|
responses:
|
||||||
- type: narrative
|
- type: narrative
|
||||||
@@ -205,7 +203,7 @@ stages:
|
|||||||
- cd-notes
|
- cd-notes
|
||||||
condition:
|
condition:
|
||||||
files:
|
files:
|
||||||
- path: notes/note.txt
|
- path: /home/commander/notes/note.txt
|
||||||
type: file
|
type: file
|
||||||
responses:
|
responses:
|
||||||
- type: narrative
|
- type: narrative
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
USERNAME=commander
|
||||||
|
|
||||||
|
echo "Initializing container for $USERNAME"
|
||||||
|
|
||||||
|
if ! id -u ${USERNAME} >/dev/null 2>&1; then
|
||||||
|
echo "Adding user $USERNAME"
|
||||||
|
adduser --disabled-password --gecos '' --shell /bin/zsh ${USERNAME} && \
|
||||||
|
find / -name ".gitkeep" -type f -exec rm -f {} +
|
||||||
|
|
||||||
|
echo "Adding public key for $USERNAME"
|
||||||
|
echo "Public key: $PUB_KEY"
|
||||||
|
PUB_KEY_PATH=/etc/ssh/authorized_keys/${USERNAME}
|
||||||
|
echo $PUB_KEY > ${PUB_KEY_PATH}
|
||||||
|
chmod 644 ${PUB_KEY_PATH}
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p /run/sshd
|
||||||
|
echo "Starting SSH server"
|
||||||
|
/usr/sbin/sshd -D -e
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# 執行檔案
|
||||||
|
|
||||||
|
## Outline
|
||||||
|
|
||||||
|
1. 什麼是可執行檔
|
||||||
|
2. 使用 `which` 指令查看執行檔的位置
|
||||||
|
3. 如何執行可執行檔
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
在我們先前的課程裡,已經使用了許多不同的指令,像是 `ls`, `cat`, `cp`, `mv`, `rm` 等。
|
||||||
|
這些指令,其實都是一個**可執行檔**,在你打指令的時候被作業系統執行。
|
||||||
|
所以大部分的指令,我們其實都能找到它在檔案系統裡的原始位置。
|
||||||
|
要查看一個指令它的檔案位置,可以使用 `which` 指令。
|
||||||
|
輸入 `which [指令名稱]` 就能夠查看指令的檔案位置了。
|
||||||
|
|
||||||
|
> 使用 `which ls` 來查看 `ls` 的路徑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
你看到終端機顯示出 `/bin/ls` 這個路徑,它其實就是 `ls` 指令它檔案存在的位置。
|
||||||
|
`/bin` 是一個系統資料夾,裡面會儲存許多系統內建的程式,像是 `cat`, `cp` 等指令也都被存在這裡。
|
||||||
|
|
||||||
|
> 使用 `which cat` 查看 `cat` 的路徑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
我們要執行一個可執行檔時,只要直接輸入它的路徑就可以執行它。
|
||||||
|
我們來試看看使用執行檔的路徑來執行指令。
|
||||||
|
|
||||||
|
> 輸入 `/bin/ls` 來查看當前的資料夾
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
你會看到它列出了我們資料夾內的檔案,你可以自己比較看看,跟單純打 `ls` 的結果會一模一樣。
|
||||||
|
它一樣可以使用參數。我們來用 `cat` 試試看。
|
||||||
|
|
||||||
|
> 輸入 `/bin/cat hello.txt` 來顯示 `hello.txt` 的內容
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
你會看到了 `hello.txt` 的內容,效果跟單純用 `cat` 一模一樣。
|
||||||
|
事實上,這是必然的,因為 `cat` 的檔案位置就是 `/bin/cat`,我們執行了同一個程式,當然會有同樣的結果。
|
||||||
|
其實指令,就是由 **一個執行檔** + **參數** 所構成。
|
||||||
|
我們之所以不用打 `/bin/ls`,而是只要打 `ls` 就能執行,是因為有 `PATH` 這個環境變數,幫我們預設好了終端機要去哪些*資料夾*找檔案。
|
||||||
|
但因為這超出了本次課程的範圍,關於 `PATH` 以及環境變數的議題,我們會在未來的課程再討論。
|
||||||
|
|
||||||
|
我們也可以執行不是內建的指令,像是現在資料夾中的 `dog` 檔案。
|
||||||
|
|
||||||
|
> 使用 `cat dog` 來查看 `dog` 的內容
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
你會看到這是一個用 `echo` 和 `cat` 所組成的簡單程式。
|
||||||
|
詳細的內容不用了解沒關係,功能是和 `cat` 一樣,用來印出檔案內容的程式。
|
||||||
|
但是這支程式,在沒有給要印出的檔案時,不會停留在輸入狀態。
|
||||||
|
|
||||||
|
當我們要執行一個檔案時,就是用指定路徑的方式。
|
||||||
|
那因為 `dog` 位在家目錄底下,所以牠的絕對路徑就是 `/home/commander/dog`,我們也可以用簡寫 `~/dog`。
|
||||||
|
|
||||||
|
> 輸入 `~/dog` 來執行 `dog` 檔案
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
你看到了終端機輸出這隻傲嬌的狗。
|
||||||
|
除了使用絕對路徑,很多時候,我們會處在跟程式相同的資料夾,這時使用相對路徑會更為便利。
|
||||||
|
但跟平常列出檔案時不一樣,我們為了區別它不是內建的指令,需要加上 `./` 在檔名前面。
|
||||||
|
`.` 在路徑中,代表的就是當前的資料夾。
|
||||||
|
那我們再試試看使用相對路徑,並讓牠接收一個檔案作為參數。
|
||||||
|
|
||||||
|
> 輸入 `./dog hello.txt` 來印出 `hello.txt` 的內容
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
你看到這隻狗在講了幾句話後也印出了 `hello.txt` 的內容了。
|
||||||
|
這個範例,其實就是一個簡單的自訂指令。
|
||||||
|
我們可以把一大串指令,寫在一個檔案裡讓它自動執行,幫我們處理一些複雜或是重複性的工作。
|
||||||
|
我們在使用別人寫的程式時,也常會用到這個技巧。
|
||||||
|
|
||||||
|
最後留一個小挑戰給你,請試著移動到 `commands` 資料夾後,再使用相對路徑執行裡面的 `lucky` 檔案。
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
title: 執行檔案
|
||||||
|
instruction: |
|
||||||
|
在這個關卡中,你將會學到:
|
||||||
|
1. 什麼是可執行檔
|
||||||
|
2. 使用 `which` 指令查看執行檔的位置
|
||||||
|
3. 如何執行可執行檔
|
||||||
|
|
||||||
|
requirements:
|
||||||
|
- remove
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- id: start
|
||||||
|
task: 按下開始按鈕
|
||||||
|
type: start
|
||||||
|
condition:
|
||||||
|
command: ^echo start$
|
||||||
|
responses:
|
||||||
|
- content: |
|
||||||
|
在我們先前的課程裡,已經使用了許多不同的指令,像是 `ls`, `cat`, `cp`, `mv`, `rm` 等。
|
||||||
|
這些指令,其實都是一個**可執行檔**,在你打指令的時候被作業系統執行。
|
||||||
|
所以大部分的指令,我們其實都能找到它在檔案系統裡的原始位置。
|
||||||
|
要查看一個指令它的檔案位置,可以使用 `which` 指令。
|
||||||
|
輸入 `which [指令名稱]` 就能夠查看指令的檔案位置了。
|
||||||
|
|
||||||
|
hints:
|
||||||
|
- ➤ 使用 `which ls` 來查看 `ls` 的路徑
|
||||||
|
|
||||||
|
- id: which-ls
|
||||||
|
requirements:
|
||||||
|
- start
|
||||||
|
task: 使用 `which` 指令查看 `ls` 的路徑
|
||||||
|
condition:
|
||||||
|
command: ^which ls$
|
||||||
|
responses:
|
||||||
|
- content: |
|
||||||
|
你看到終端機顯示出 `/bin/ls` 這個路徑,它其實就是 `ls` 指令它檔案存在的位置。
|
||||||
|
`/bin` 是一個系統資料夾,裡面會儲存許多系統內建的程式,像是 `cat`, `cp` 等指令也都被存在這裡。
|
||||||
|
hints:
|
||||||
|
- ➤ 使用 `which cat` 查看 `cat` 的路徑
|
||||||
|
|
||||||
|
- id: which-cat
|
||||||
|
requirements:
|
||||||
|
- which-ls
|
||||||
|
task: 使用 `which` 指令查看 `cat` 的路徑
|
||||||
|
condition:
|
||||||
|
command: ^which cat$
|
||||||
|
responses:
|
||||||
|
- content: |
|
||||||
|
你看到了 `/bin/cat` 這個路徑,它就是 `cat` 指令它檔案存在的位置。
|
||||||
|
我們要執行一個可執行檔時,只要 **直接輸入它的路徑** 就可以執行它。
|
||||||
|
我們來試看看使用執行檔的路徑來執行指令。
|
||||||
|
|
||||||
|
在此之前,我們先試試看一般執行 `ls` 指令的結果。
|
||||||
|
hints:
|
||||||
|
- ➤ 輸入 `ls` 來查看當前的資料夾
|
||||||
|
|
||||||
|
- id: ls
|
||||||
|
requirements:
|
||||||
|
- which-cat
|
||||||
|
task: 使用 `ls` 指令查看當前的資料夾
|
||||||
|
condition:
|
||||||
|
command: ^ls$
|
||||||
|
responses:
|
||||||
|
- content: |
|
||||||
|
你看到了當前的資料夾內有 `commands` 資料夾和 `dog`, `hello.txt` 這兩個檔案。
|
||||||
|
我們再來試看看用 `ls` 的路徑來執行它。
|
||||||
|
hints:
|
||||||
|
- ➤ 輸入 `/bin/ls` 來查看當前的資料夾
|
||||||
|
|
||||||
|
- id: bin-ls
|
||||||
|
requirements:
|
||||||
|
- ls
|
||||||
|
task: 使用執行檔的絕對路徑來執行 `ls`
|
||||||
|
condition:
|
||||||
|
command: ^/bin/ls$
|
||||||
|
responses:
|
||||||
|
- content: |
|
||||||
|
你會看到它列出了我們資料夾內的檔案,你會發現它的輸出跟單純打 `ls` 的結果一模一樣。
|
||||||
|
它一樣可以使用參數。我們來用 `cat` 試試看。
|
||||||
|
|
||||||
|
hints:
|
||||||
|
- ➤ 輸入 `/bin/cat hello.txt` 來顯示 `hello.txt` 的內容
|
||||||
|
|
||||||
|
- id: bin-cat
|
||||||
|
requirements:
|
||||||
|
- bin-ls
|
||||||
|
task: 使用執行檔的絕對路徑來執行 `cat`
|
||||||
|
condition:
|
||||||
|
command: ^/bin/cat hello.txt$
|
||||||
|
responses:
|
||||||
|
- content: |
|
||||||
|
你看到了 `hello.txt` 的內容,跟單純使用 `cat` 一模一樣。
|
||||||
|
事實上,這是必然的,因為 `cat` 的檔案位置就是 `/bin/cat`,我們執行了同一個程式,當然會有同樣的結果。
|
||||||
|
|
||||||
|
因為一個指令,就是由 `執行檔 參數1 參數2 ...` 所構成。
|
||||||
|
`執行檔` 就是我們要執行的程式,像是 `ls`, `cat`, `which` 等。
|
||||||
|
`參數` 則是我們要提供給程式的資訊,像是接在 `cat` 後面的 `hello.txt`。
|
||||||
|
|
||||||
|
我們之所以不用打 `/bin/ls`,而是只要打 `ls` 就能執行,是因為有 `PATH` 這個環境變數,幫我們預設好了終端機要去哪些*資料夾*找檔案。
|
||||||
|
但因為這超出了本次課程的範圍,關於 `PATH` 以及環境變數的議題,我們會在未來的課程再討論。
|
||||||
|
|
||||||
|
接著我們來試著執行不是內建的指令。
|
||||||
|
現在資料夾中的 `dog` 檔案,是一個在這門課程所提供的自訂指令。
|
||||||
|
我們先來看看它是怎麼寫的。
|
||||||
|
|
||||||
|
hints:
|
||||||
|
- ➤ 使用 `cat dog` 來查看 `dog` 的內容
|
||||||
|
|
||||||
|
- id: cat-dog
|
||||||
|
requirements:
|
||||||
|
- bin-cat
|
||||||
|
task: 使用 `cat` 指令查看 `dog` 的內容
|
||||||
|
condition:
|
||||||
|
command: ^cat dog$
|
||||||
|
responses:
|
||||||
|
- content: |
|
||||||
|
你看到了這是一個用 `echo` 和 `sleep` 所組成的簡單程式。
|
||||||
|
詳細的內容不用了解沒關係,只是輸出一些文字而已。
|
||||||
|
|
||||||
|
當我們要執行一個檔案時,就是用指定路徑的方式。
|
||||||
|
也就是說 `執行檔` 就是 `dog` 的路徑,`參數` 在這個範例中不會使用到。
|
||||||
|
那因為 `dog` 位在家目錄底下,所以牠的絕對路徑就是 `/home/commander/dog`。
|
||||||
|
那在 `執行檔 參數` 這個格式下,我們要打的指令就會是
|
||||||
|
```
|
||||||
|
/home/commander/dog
|
||||||
|
```
|
||||||
|
|
||||||
|
hints:
|
||||||
|
- ➤ 輸入 `/home/commander/dog` 來執行 `dog` 檔案
|
||||||
|
|
||||||
|
- id: dog-abs
|
||||||
|
requirements:
|
||||||
|
- cat-dog
|
||||||
|
task: 使用絕對路徑來執行 `dog`
|
||||||
|
condition:
|
||||||
|
or:
|
||||||
|
- command: ^/home/commander/dog$
|
||||||
|
- command: ^~/dog$
|
||||||
|
responses:
|
||||||
|
- content: |
|
||||||
|
你看到了終端機輸出這隻傲嬌的狗。
|
||||||
|
除了使用絕對路徑,很多時候,我們會處在跟程式相同的資料夾,這時使用相對路徑會更為便利。
|
||||||
|
但跟平常列出檔案時不一樣,我們為了區別它不是內建的指令,需要加上 `./` 在檔名前面。
|
||||||
|
`.` 在路徑中,代表的就是當前的資料夾。
|
||||||
|
那我們再試試看使用相對路徑來執行 `dog` 檔案。
|
||||||
|
|
||||||
|
hints:
|
||||||
|
- ➤ 輸入 `./dog` 來執行 `dog` 檔案
|
||||||
|
|
||||||
|
- id: dog-rel
|
||||||
|
requirements:
|
||||||
|
- dog-abs
|
||||||
|
task: 使用相對路徑來執行 `dog`,並加上參數
|
||||||
|
condition:
|
||||||
|
pwd: /home/commander
|
||||||
|
command: ^./dog$
|
||||||
|
responses:
|
||||||
|
- content: |
|
||||||
|
你看到這隻狗執行了跟剛才一樣的結果。
|
||||||
|
這個範例,其實就是一個簡單的自訂指令。
|
||||||
|
我們可以把一大串指令,寫在一個檔案裡讓它自動執行,幫我們處理一些複雜或是重複性的工作。
|
||||||
|
我們在使用別人寫的程式時,也常會用到這個技巧。
|
||||||
|
|
||||||
|
最後留一個小挑戰給你,請試著先進入 `commands` 資料夾後,再使用相對路徑執行裡面的 `lucky` 檔案。
|
||||||
|
hints:
|
||||||
|
- ➤ 使用 `cd` 來進入資料夾
|
||||||
|
- ➤ 使用 `ls` 查看檔案
|
||||||
|
- ➤ 輸入 `./檔案名稱` 來執行檔案
|
||||||
|
|
||||||
|
- id: END
|
||||||
|
requirements:
|
||||||
|
- dog-rel
|
||||||
|
task: 使用相對路徑來執行 `lucky`
|
||||||
|
condition:
|
||||||
|
pwd: /home/commander/commands
|
||||||
|
command: ^./lucky$
|
||||||
|
responses:
|
||||||
|
- content: |
|
||||||
|
你看到了終端機輸出 `You are lucky!` 這個字串。
|
||||||
|
恭喜你,你已經完成了這個課程!
|
||||||
|
exceptions:
|
||||||
|
condition:
|
||||||
|
command: ^echo help$
|
||||||
|
responses:
|
||||||
|
- content: |
|
||||||
|
你需要先移動到 `commands` 資料夾,再使用相對路徑執行 `lucky` 檔案。
|
||||||
|
請先輸入 `cd commands` 來移動到 `commands` 資料夾,再使用相對路徑 `./lucky` 來執行 `lucky` 檔案。
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo "I am SO LUCKY!!!"
|
||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo "你執行了 dog 檔案"
|
||||||
|
echo "汪汪汪"
|
||||||
|
sleep 1
|
||||||
|
echo "你知道嗎?"
|
||||||
|
sleep 1
|
||||||
|
echo "我比貓還厲害"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Hello! This is a file hello.txt
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
FROM linuxodyssey/quest-base
|
|
||||||
|
|
||||||
COPY home ${HOME}
|
|
||||||
@@ -4,14 +4,12 @@ instruction: |
|
|||||||
1. 如何使用 `ls` 查看資料夾的內容
|
1. 如何使用 `ls` 查看資料夾的內容
|
||||||
2. 如何使用 `cd` 在資料夾之間移動
|
2. 如何使用 `cd` 在資料夾之間移動
|
||||||
|
|
||||||
按下中間的 `start` 按鈕來開始這個關卡。
|
|
||||||
|
|
||||||
requirements:
|
requirements:
|
||||||
- get-started
|
- get-started
|
||||||
|
|
||||||
stages:
|
stages:
|
||||||
- id: start
|
- id: start
|
||||||
task: 按下 `start` 按鈕開始教程
|
task: 按下 `開始` 按鈕開始教程
|
||||||
condition:
|
condition:
|
||||||
command:
|
command:
|
||||||
- ^echo start$
|
- ^echo start$
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ instruction: |
|
|||||||
1. 如何使用這個網站
|
1. 如何使用這個網站
|
||||||
2. 如何使用終端機
|
2. 如何使用終端機
|
||||||
|
|
||||||
按下中間的 `start` 按鈕來開始這個關卡。
|
|
||||||
|
|
||||||
stages:
|
stages:
|
||||||
- id: start
|
- id: start
|
||||||
task: 按下 `start` 按鈕開始
|
task: 按下 `開始` 按鈕開始
|
||||||
condition:
|
condition:
|
||||||
command:
|
command:
|
||||||
- ^echo start$
|
- ^echo start$
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
FROM linuxodyssey/quest-base
|
|
||||||
|
|
||||||
COPY home ${HOME}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
FROM linuxodyssey/quest-base
|
|
||||||
|
|
||||||
COPY --chown=${USERNAME} home ${HOME}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
echo start
|
echo start
|
||||||
echo continue
|
echo next
|
||||||
mv hello.txt notes
|
mv hello.txt notes
|
||||||
ls
|
ls
|
||||||
ls notes
|
ls notes
|
||||||
echo continue
|
echo next
|
||||||
mv world.txt wow.txt
|
mv world.txt wow.txt
|
||||||
ls
|
ls
|
||||||
mv notes docs
|
mv notes docs
|
||||||
|
|||||||
+10
-12
@@ -1,16 +1,14 @@
|
|||||||
title: 移動檔案
|
title: 移動檔案
|
||||||
instruction: |
|
instruction: |
|
||||||
在這個關卡中,你將會學到:
|
在這個關卡中,你將會學到:
|
||||||
1. 如何用 `mv` 移動檔案與資料夾
|
1. 如何使用 `mv` 移動檔案與資料夾
|
||||||
|
|
||||||
按下中間的 `start` 按鈕來開始這個關卡。
|
|
||||||
|
|
||||||
requirements:
|
requirements:
|
||||||
- create
|
- create
|
||||||
|
|
||||||
stages:
|
stages:
|
||||||
- id: start
|
- id: start
|
||||||
task: 按下 `start` 按鈕開始
|
task: 按下 `開始` 按鈕開始
|
||||||
condition:
|
condition:
|
||||||
command:
|
command:
|
||||||
- ^echo start$
|
- ^echo start$
|
||||||
@@ -51,9 +49,9 @@ stages:
|
|||||||
command:
|
command:
|
||||||
- ^mv hello.txt notes$
|
- ^mv hello.txt notes$
|
||||||
files:
|
files:
|
||||||
- path: notes/hello.txt
|
- path: /home/commander/notes/hello.txt
|
||||||
type: file
|
type: file
|
||||||
- path: hello.txt
|
- path: /home/commander/hello.txt
|
||||||
exists: false
|
exists: false
|
||||||
responses:
|
responses:
|
||||||
- type: narrative
|
- type: narrative
|
||||||
@@ -108,9 +106,9 @@ stages:
|
|||||||
command:
|
command:
|
||||||
- ^mv
|
- ^mv
|
||||||
files:
|
files:
|
||||||
- path: wow.txt
|
- path: /home/commander/wow.txt
|
||||||
type: file
|
type: file
|
||||||
- path: world.txt
|
- path: /home/commander/world.txt
|
||||||
exists: false
|
exists: false
|
||||||
responses:
|
responses:
|
||||||
- type: narrative
|
- type: narrative
|
||||||
@@ -150,9 +148,9 @@ stages:
|
|||||||
command:
|
command:
|
||||||
- ^mv
|
- ^mv
|
||||||
files:
|
files:
|
||||||
- path: docs
|
- path: /home/commander/docs
|
||||||
type: directory
|
type: directory
|
||||||
- path: notes
|
- path: /home/commander/notes
|
||||||
exists: false
|
exists: false
|
||||||
responses:
|
responses:
|
||||||
- type: narrative
|
- type: narrative
|
||||||
@@ -189,9 +187,9 @@ stages:
|
|||||||
command:
|
command:
|
||||||
- ^mv
|
- ^mv
|
||||||
files:
|
files:
|
||||||
- path: hi.txt
|
- path: /home/commander/hi.txt
|
||||||
type: file
|
type: file
|
||||||
- path: docs/hello.txt
|
- path: /home/commander/docs/hello.txt
|
||||||
exists: false
|
exists: false
|
||||||
exceptions:
|
exceptions:
|
||||||
- condition:
|
- condition:
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
FROM linuxodyssey/quest-base
|
|
||||||
|
|
||||||
COPY home ${HOME}
|
|
||||||
@@ -4,15 +4,13 @@ instruction: |
|
|||||||
1. 絕對路徑和相對路徑
|
1. 絕對路徑和相對路徑
|
||||||
2. 如何使用 `pwd` 顯示目前的路徑
|
2. 如何使用 `pwd` 顯示目前的路徑
|
||||||
|
|
||||||
按下中間的 `start` 按鈕來開始這個關卡。
|
|
||||||
|
|
||||||
requirements:
|
requirements:
|
||||||
- cast
|
- cast
|
||||||
- explore
|
- explore
|
||||||
|
|
||||||
stages:
|
stages:
|
||||||
- id: start
|
- id: start
|
||||||
task: 按下 `start` 按鈕開始教程
|
task: 按下 `開始` 按鈕開始教程
|
||||||
condition:
|
condition:
|
||||||
command:
|
command:
|
||||||
- ^echo start$
|
- ^echo start$
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
FROM linuxodyssey/quest-base
|
|
||||||
|
|
||||||
COPY home ${HOME}
|
|
||||||
@@ -3,14 +3,12 @@ instruction: |
|
|||||||
在這個關卡中,你將會學到:
|
在這個關卡中,你將會學到:
|
||||||
1. 如何使用 `cat` 查看檔案的內容
|
1. 如何使用 `cat` 查看檔案的內容
|
||||||
|
|
||||||
按下中間的 `start` 按鈕來開始這個關卡。
|
|
||||||
|
|
||||||
requirements:
|
requirements:
|
||||||
- path
|
- path
|
||||||
|
|
||||||
stages:
|
stages:
|
||||||
- id: start
|
- id: start
|
||||||
task: 按下 `start` 按鈕開始教程
|
task: 按下 `開始` 按鈕開始教程
|
||||||
condition:
|
condition:
|
||||||
command:
|
command:
|
||||||
- ^echo start$
|
- ^echo start$
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
FROM linuxodyssey/quest-base
|
|
||||||
|
|
||||||
COPY --chown=${USERNAME} home ${HOME}
|
|
||||||
RUN mkdir empty
|
|
||||||
+14
-17
@@ -1,12 +1,9 @@
|
|||||||
title: 刪除檔案與資料夾
|
title: 刪除檔案與資料夾
|
||||||
instruction: |
|
instruction: |
|
||||||
在這個關卡中,你將會學到:
|
在這個關卡中,你將會學到:
|
||||||
1. 用 `rm` 指令刪除檔案
|
1. 如何使用 `rm` 指令刪除檔案
|
||||||
2. 用 `rmdir` 與資料夾
|
2. 如何使用 `rmdir` 刪除空資料夾
|
||||||
3. 用 `rm -r` 刪除資料夾與它的所有檔案
|
3. 如何使用 `rm -r` 刪除資料夾與其中的所有檔案
|
||||||
現在資料夾裡有一些檔案與資料夾,請你使用 `ls` 跟 `cat` 來查看它們的內容。
|
|
||||||
|
|
||||||
按下中間的 `start` 按鈕來開始這個關卡。
|
|
||||||
|
|
||||||
requirements:
|
requirements:
|
||||||
- copy
|
- copy
|
||||||
@@ -14,7 +11,7 @@ requirements:
|
|||||||
|
|
||||||
stages:
|
stages:
|
||||||
- id: start
|
- id: start
|
||||||
task: 按下 `start` 按鈕開始
|
task: 按下 `開始` 按鈕開始
|
||||||
condition:
|
condition:
|
||||||
command:
|
command:
|
||||||
- ^echo start$
|
- ^echo start$
|
||||||
@@ -47,7 +44,7 @@ stages:
|
|||||||
command:
|
command:
|
||||||
- ^rm\s
|
- ^rm\s
|
||||||
files:
|
files:
|
||||||
- path: hello.txt
|
- path: /home/commander/hello.txt
|
||||||
exists: false
|
exists: false
|
||||||
responses:
|
responses:
|
||||||
- content: |
|
- content: |
|
||||||
@@ -61,7 +58,7 @@ stages:
|
|||||||
command:
|
command:
|
||||||
- ^ls$
|
- ^ls$
|
||||||
files:
|
files:
|
||||||
- path: hello.txt
|
- path: /home/commander/hello.txt
|
||||||
exists: false
|
exists: false
|
||||||
responses:
|
responses:
|
||||||
- content: |
|
- content: |
|
||||||
@@ -81,9 +78,9 @@ stages:
|
|||||||
command:
|
command:
|
||||||
- ^rm
|
- ^rm
|
||||||
files:
|
files:
|
||||||
- path: world.txt
|
- path: /home/commander/world.txt
|
||||||
exists: false
|
exists: false
|
||||||
- path: wow.txt
|
- path: /home/commander/wow.txt
|
||||||
exists: false
|
exists: false
|
||||||
responses:
|
responses:
|
||||||
- content: |
|
- content: |
|
||||||
@@ -114,7 +111,7 @@ stages:
|
|||||||
command:
|
command:
|
||||||
- ^rmdir empty$
|
- ^rmdir empty$
|
||||||
files:
|
files:
|
||||||
- path: empty
|
- path: /home/commander/empty
|
||||||
exists: false
|
exists: false
|
||||||
responses:
|
responses:
|
||||||
- content: |
|
- content: |
|
||||||
@@ -142,7 +139,7 @@ stages:
|
|||||||
command:
|
command:
|
||||||
- ^rmdir notes$
|
- ^rmdir notes$
|
||||||
files:
|
files:
|
||||||
- path: notes
|
- path: /home/commander/notes
|
||||||
type: folder
|
type: folder
|
||||||
exists: true
|
exists: true
|
||||||
responses:
|
responses:
|
||||||
@@ -164,10 +161,10 @@ stages:
|
|||||||
command:
|
command:
|
||||||
- ^rm
|
- ^rm
|
||||||
files:
|
files:
|
||||||
- path: notes
|
- path: /home/commander/notes
|
||||||
type: folder
|
type: folder
|
||||||
exists: true
|
exists: true
|
||||||
- path: notes/note.txt
|
- path: /home/commander/notes/note.txt
|
||||||
exists: false
|
exists: false
|
||||||
exceptions:
|
exceptions:
|
||||||
- condition:
|
- condition:
|
||||||
@@ -206,7 +203,7 @@ stages:
|
|||||||
task: 使用 `rmdir` 刪除 `notes` 資料夾
|
task: 使用 `rmdir` 刪除 `notes` 資料夾
|
||||||
condition:
|
condition:
|
||||||
files:
|
files:
|
||||||
- path: notes
|
- path: /home/commander/notes
|
||||||
exists: false
|
exists: false
|
||||||
responses:
|
responses:
|
||||||
- content: |
|
- content: |
|
||||||
@@ -241,7 +238,7 @@ stages:
|
|||||||
command:
|
command:
|
||||||
- ^rm -r trash$
|
- ^rm -r trash$
|
||||||
files:
|
files:
|
||||||
- path: trash
|
- path: /home/commander/trash
|
||||||
exists: false
|
exists: false
|
||||||
responses:
|
responses:
|
||||||
- content: |
|
- content: |
|
||||||
|
|||||||
Whitespace-only changes.
@@ -0,0 +1,196 @@
|
|||||||
|
title: Tab 自動補全
|
||||||
|
instruction: |
|
||||||
|
在這個關卡,你將會學到
|
||||||
|
|
||||||
|
1. 如何使用 Tab 自動補全路徑
|
||||||
|
2. 出現衝突時如何處理
|
||||||
|
|
||||||
|
按下中間的按鈕來開始這個關卡。
|
||||||
|
|
||||||
|
requirements:
|
||||||
|
- read
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- id: start
|
||||||
|
task: 按下開始按鈕開始關卡
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^echo start$
|
||||||
|
responses:
|
||||||
|
- content: |
|
||||||
|
在終端機裡,所有的操作都是用文字進行。
|
||||||
|
然而,打字很花時間,而且只要一個字打錯,指令就會執行失敗。
|
||||||
|
因些終端機提供「自動補全」的功能,自動幫你完成檔案路徑、指令名稱、參數名稱,節省你的時間,也避免輸入錯誤。
|
||||||
|
|
||||||
|
在開始練習之前,我們先來看看家目錄下有哪些檔案。
|
||||||
|
|
||||||
|
hints:
|
||||||
|
- ➤ 使用 `ls` 查看檔案列表
|
||||||
|
|
||||||
|
- id: ls
|
||||||
|
task: 使用 `ls` 查看檔案列表
|
||||||
|
requirements:
|
||||||
|
- start
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^ls$
|
||||||
|
responses:
|
||||||
|
- content: |
|
||||||
|
你看到了這個資料夾中充滿了超級長和有錯字的檔案名稱,如果這些都要我們一個一個字輸入,肯定會很痛苦。
|
||||||
|
但不要緊,只要使用自動補全,檔名不論多長都能輕鬆輸入。
|
||||||
|
|
||||||
|
使用自動補全的方法是,**先輸入部分的檔名,再按下 Tab**
|
||||||
|
(Tab 是 Q 左邊的按鍵)
|
||||||
|
例如要補全 `SuperSecretPlansForWorldDomination.txt`,你可以先輸入 `Su` 再按下 Tab,終端機就會幫你打完剩下的檔名了。
|
||||||
|
|
||||||
|
我們試看看印出 `SuperSecretPlansForWorldDomination.txt` 這個檔案的內容。
|
||||||
|
|
||||||
|
hints:
|
||||||
|
- ➤ 使用 `cat SuperSecretPlansForWorldDomination.txt` 來印出檔案內容
|
||||||
|
- ➤ 在輸入 `cat Su` 之後按下 Tab 來自動補全檔名,完成後按 Enter 就能執行指令
|
||||||
|
|
||||||
|
- id: cat_super_secret
|
||||||
|
task: 使用 `cat` 印出 SuperSecretPlansForWorldDomination.txt 的內容
|
||||||
|
requirements:
|
||||||
|
- ls
|
||||||
|
condition:
|
||||||
|
pwd:
|
||||||
|
- ^/home/commander$
|
||||||
|
command:
|
||||||
|
- ^cat SuperSecretPlansForWorldDomination.txt$
|
||||||
|
responses:
|
||||||
|
- content: |
|
||||||
|
你看到了終端機自動幫你補全了 `SuperSecretPlansForWorldDomination.txt`,就算是很長的檔名,也不用怕打錯字。
|
||||||
|
開頭不論你打了幾個字都無所謂,你可以只打 `S`、`Su`、`Sup`、`Super` 或甚至完全不打字都沒關係。
|
||||||
|
但是你打的字越少,就越容易跟其他的檔案重複,這時候就要做衝突處理。
|
||||||
|
關於衝突處理,我們會在待會練習。
|
||||||
|
|
||||||
|
自動補全也可以用來對付拼字錯誤,像是 `ThsisAFlieNmaeWthAloOfTpyosThatisRleayHdToRaed.txt` 這個檔案充滿了錯字,任誰來打都難保不會失手。
|
||||||
|
這一次,請你一樣用 `cat` 來試著印出 `ThsisAFlieNmaeWthAloOfTpyosThatisRleayHdToRaed.txt` 的內容。
|
||||||
|
|
||||||
|
hints:
|
||||||
|
- ➤ 輸入 `cat ThsisAFlieNmaeWthAloOfTpyosThatisRleayHdToRaed.txt` 來印出檔案內容
|
||||||
|
- ➤ 輸入 `cat Thsi` 之後按下 Tab 做自動補全
|
||||||
|
- ➤ 試試看輸入更少的字元就按 Tab 會有什麼樣的結果
|
||||||
|
|
||||||
|
- id: cat_typo
|
||||||
|
task: 使用 `cat` 印出 ThsisAFlieNmaeWthAloOfTpyosThatisRleayHdToRaed.txt 的內容
|
||||||
|
requirements:
|
||||||
|
- cat_super_secret
|
||||||
|
condition:
|
||||||
|
pwd:
|
||||||
|
- ^/home/commander$
|
||||||
|
command:
|
||||||
|
- ^cat ThsisAFlieNmaeWthAloOfTpyosThatisRleayHdToRaed.txt$
|
||||||
|
responses:
|
||||||
|
- content: |
|
||||||
|
你看到了這個充滿錯字的檔案了。
|
||||||
|
只要使用自動補全,不管是錯字、大小寫變化、還是隨機亂碼都不再是困擾了。
|
||||||
|
讓我們再練習一個,這次請你查看 `ThingsIWillOrganizeLaterWhenIHaveTime` 資料夾中的檔案。
|
||||||
|
一樣,輸入前幾個字,然後按 Tab 自動補全。
|
||||||
|
|
||||||
|
hints:
|
||||||
|
- ➤ 輸入 `ls ThingsIWillOrganizeLaterWhenIHaveTime` 查看資料夾中的檔案
|
||||||
|
- ➤ 輸入 `ls Thing` 之後按下 Tab 做自動補全
|
||||||
|
|
||||||
|
- id: ls_things
|
||||||
|
task: 使用 `ls` 查看 ThingsIWillOrganizeLaterWhenIHaveTime 資料夾中的檔案
|
||||||
|
requirements:
|
||||||
|
- cat_typo
|
||||||
|
condition:
|
||||||
|
pwd:
|
||||||
|
- ^/home/commander$
|
||||||
|
command:
|
||||||
|
- ^ls ThingsIWillOrganizeLaterWhenIHaveTime$
|
||||||
|
responses:
|
||||||
|
- content: |
|
||||||
|
你看到了這個資料夾裡有著三個檔案:一個如何做三明治的教學跟兩個在半夜隨機下載的東西。
|
||||||
|
假如我們要打開三明治教學 `HowToProperlyMakeASandwich.txt`,但是它位在資料夾中,我們沒辦法只按一次 Tab 就補全它的檔名。
|
||||||
|
這次我們就要來練習使用**連續的自動補全**。
|
||||||
|
|
||||||
|
像這樣檔案位在其他資料夾的情況,我們只要先輸入資料夾名稱的開頭,按一次 Tab,再輸入檔案的開頭,再按一次 Tab,就能夠補全 `資料夾/檔名` 了。
|
||||||
|
而且當你補全的對象是資料夾時,終端機還會自動幫你在結尾加上 `/`,省去你自己輸入的時間。
|
||||||
|
那麼我們就來打開 `HowToProperlyMakeASandwich.txt`。
|
||||||
|
|
||||||
|
hints:
|
||||||
|
- ➤ 輸入 `cat ThingsIWillOrganizeLaterWhenIHaveTime/HowToProperlyMakeASandwich.txt` 來印出檔案內容
|
||||||
|
- ➤ 先輸入 `cat Thin` 按 Tab 補全至 `cat ThingsIWillOrganizeLaterWhenIHaveTime/`
|
||||||
|
- ➤ 再輸入 `cat ThingsIWillOrganizeLaterWhenIHaveTime/How` 按 Tab 補全至完整路徑
|
||||||
|
|
||||||
|
- id: cat_sandwich
|
||||||
|
task: 使用 `cat` 印出 ThingsIWillOrganizeLaterWhenIHaveTime/HowToProperlyMakeASandwich.txt 的內容
|
||||||
|
requirements:
|
||||||
|
- ls_things
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^cat ThingsIWillOrganizeLaterWhenIHaveTime/HowToProperlyMakeASandwich.txt$
|
||||||
|
responses:
|
||||||
|
- content: |
|
||||||
|
你看到了這份嚴謹的三明治製作教學。
|
||||||
|
那我們剩下來要看的就是資料夾裡的兩個檔案:`RandomStuffThatIDownloadedAt3AM.txt` 和 `RandomStuffThatIDownloadedAt4AM.txt`
|
||||||
|
兩個檔名非常相似,只有最後一個是 3AM 另一個是 4AM。
|
||||||
|
這樣我們只打前面幾個字的時候,沒有辦法分辦到底選的是哪一個檔案。
|
||||||
|
這就是**發生衝突**。
|
||||||
|
|
||||||
|
遇到衝突的解決方法,其實跟使用資料夾是一樣的。
|
||||||
|
在輸入 `Ran` 按下 Tab 後,終端機會補全到衝突發生前的位置,也就是 `RandomStuffThatIDownloadedAt`。
|
||||||
|
我們只要根據我們想開啟的檔案,輸入 `3` 或是 `4`,再按一下 Tab,就能夠補全剩下的檔名了。
|
||||||
|
|
||||||
|
_如果我們把完整的路徑視作補全的對象,那資料夾其實就是一種衝突的情況。_
|
||||||
|
|
||||||
|
hints:
|
||||||
|
- ➤ 印出 `ThingsIWillOrganizeLaterWhenIHaveTime/RandomStuffThatIDownloadedAt3AM.txt` 的檔案內容
|
||||||
|
- ➤ 先輸入 `cat Thin` 按 Tab 補全至 `cat ThingsIWillOrganizeLaterWhenIHaveTime/`
|
||||||
|
- ➤ 再接續輸入 `Ran` 按 Tab 補全至 `cat ThingsIWillOrganizeLaterWhenIHaveTime/RandomStuffThatIDownloadedAt`
|
||||||
|
- ➤ 再接續輸入 `3` 後按 Tab 補全至完整的路徑。
|
||||||
|
|
||||||
|
- id: cat_3am
|
||||||
|
task: 使用 `cat` 印出 ThingsIWillOrganizeLaterWhenIHaveTime/RandomStuffThatIDownloadedAt3AM.txt 的內容
|
||||||
|
requirements:
|
||||||
|
- cat_sandwich
|
||||||
|
condition:
|
||||||
|
pwd:
|
||||||
|
- ^/home/commander$
|
||||||
|
command:
|
||||||
|
- ^cat ThingsIWillOrganizeLaterWhenIHaveTime/RandomStuffThatIDownloadedAt3AM.txt$
|
||||||
|
responses:
|
||||||
|
- content: |
|
||||||
|
你看到了一份凌晨三點下載的檔案列表。嗯…我今天網路用的夠多了。
|
||||||
|
最後留下一個挑戰給你,請先進入到 `ThingsIWillOrganizeLaterWhenIHaveTime` 資料夾後,再印出 `RandomStuffThatIDownloadedAt4AM.txt`。
|
||||||
|
練習使用今天教的自動補全來完成任務。
|
||||||
|
|
||||||
|
hints:
|
||||||
|
- ➤ 先使用 `cd` 進入 ThingsIWillOrganizeLaterWhenIHaveTime 資料夾
|
||||||
|
- ➤ 再使用 `cat` 印出 RandomStuffThatIDownloadedAt4AM.txt 的內容
|
||||||
|
- ➤ 記得使用 Tab 自動補全來節省時間和避免錯誤
|
||||||
|
|
||||||
|
- id: cd_things
|
||||||
|
task: 使用 `cd` 進入 ThingsIWillOrganizeLaterWhenIHaveTime 資料夾
|
||||||
|
requirements:
|
||||||
|
- cat_3am
|
||||||
|
condition:
|
||||||
|
pwd:
|
||||||
|
- ^/home/commander/ThingsIWillOrganizeLaterWhenIHaveTime$
|
||||||
|
responses:
|
||||||
|
- content: |
|
||||||
|
很好,你已經成功進入 ThingsIWillOrganizeLaterWhenIHaveTime 資料夾了。
|
||||||
|
現在請使用 `cat` 指令來印出 `RandomStuffThatIDownloadedAt4AM.txt` 的內容。
|
||||||
|
記得運用 Tab 自動補全功能!
|
||||||
|
|
||||||
|
- id: END
|
||||||
|
task: 使用 `cat` 印出 RandomStuffThatIDownloadedAt4AM.txt 的內容
|
||||||
|
requirements:
|
||||||
|
- cd_things
|
||||||
|
condition:
|
||||||
|
pwd:
|
||||||
|
- ^/home/commander/ThingsIWillOrganizeLaterWhenIHaveTime$
|
||||||
|
command:
|
||||||
|
- ^cat RandomStuffThatIDownloadedAt4AM.txt$
|
||||||
|
responses:
|
||||||
|
- content: |
|
||||||
|
恭喜你!你已經成功完成了所有的任務,並且熟練地運用了 Tab 自動補全功能。
|
||||||
|
這個技能將會在你未來的終端機操作中帶來極大的便利。
|
||||||
|
記住,不論是長檔名、複雜路徑,還是難以拼寫的檔案名稱,Tab 自動補全都是你的好幫手!
|
||||||
|
|
||||||
|
hints:
|
||||||
|
- ➤ 你已經完成了所有任務!可以繼續探索或是結束這個關卡。
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
1. 收集全世界的襪子,讓所有人都找不到另一支襪子。
|
||||||
|
2. 訓練一支企鵝軍隊,用可愛征服世界。
|
||||||
|
3. 發明一種能讓所有人講話都變成雞叫聲的裝置。
|
||||||
|
4. 把所有的咖啡替換成無咖啡因的,看著世界陷入混亂。
|
||||||
|
5. 用巨大的橡皮擦擦掉所有國家的邊界,宣布地球是一個巨大的遊樂場。
|
||||||
|
6. 讓所有政治家在演講時必須穿著小丑服裝。
|
||||||
|
7. 把所有的數學課本改成烹飪食譜,看看會發生什麼。
|
||||||
|
8. 發明一種能讓人說出真心話的香水,然後在聯合國大會上噴灑。
|
||||||
|
9. 用巨大的磁鐵吸走地球上所有的金屬,建造一個太空迪士尼樂園。
|
||||||
|
10. 最後,如果以上都失敗了,就用可愛貓咪影片轟炸全世界的社交媒體,直到所有人都投降為止。
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
如何"正確"製作一個三明治:
|
||||||
|
|
||||||
|
1. 用力拍醒你的麵包。它們整天都在發懶!
|
||||||
|
2. 仔細選擇配料,確保每種都有自己的個性。例如,挑選一片特別愛說笑話的火腿。
|
||||||
|
3. 用尺精確測量每片配料的厚度,誤差不得超過1毫米。
|
||||||
|
4. 在組裝三明治時,必須戴上白手套和實驗室防護鏡,以確保最高級別的衛生。
|
||||||
|
5. 完成後,用放大鏡仔細檢查每一寸三明治,確保完美無缺。
|
||||||
|
|
||||||
|
記住:製作三明治是一門精確的科學,絕不容許有任何差錯或創意!
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
我在凌晨 3 點開始下載的東西,但我一直沒有時間去整理。
|
||||||
|
|
||||||
|
1. 一個可以把所有食物變成披薩的魔法棒設計圖
|
||||||
|
2. 如何在五分鐘內學會十種外星語言的秘密指南
|
||||||
|
3. 用舊襪子製作時尚帽子的教程視頻(連結已失效)
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
我在凌晨 4 點開始下載的東西,但我一直沒有時間去整理。
|
||||||
|
|
||||||
|
1. 一份詳細解釋為什麼貓咪才是地球真正統治者的論文
|
||||||
|
2. 如何用口香糖和迴紋針修理國際太空站的WikiHow頁面
|
||||||
|
3. 訓練水母成為水下偵探的完整課程大綱
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Congrats! You fond a flie namd by somone who cleerly had a strok. If you can raed this, you mght be a geenius or jsut realy gud at Wheel of Fortnue.
|
||||||
|
|
||||||
|
Remeber: freinds don't let freinds name files aftr drinkng too much. But hey, at lest you used tab compltion to get here!
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# 使用 Tab 自動補全
|
||||||
|
|
||||||
|
在這個關卡,你將會學到
|
||||||
|
|
||||||
|
1. 如何使用 Tab 自動補全路徑
|
||||||
|
2. 出現衝突時如何處理
|
||||||
|
|
||||||
|
按下中間的按鈕來開始這個關卡。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
在終端機裡,所有的操作都是用文字進行。
|
||||||
|
然而,打字很花時間,而且只要一個字打錯,指令就會執行失敗。
|
||||||
|
因此終端機提供「自動補全」的功能,自動幫你完成檔案路徑、指令名稱、參數名稱,節省你的時間,也避免輸入錯誤。
|
||||||
|
|
||||||
|
在開始練習之前,我們先來看看家目錄下有哪些檔案。
|
||||||
|
|
||||||
|
> 使用 `ls` 查看檔案列表
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
你看到了這個資料夾中充滿了超級長和有錯字的檔案名稱,如果這些都要我們一個一個字輸入,肯定會很痛苦。
|
||||||
|
但不要緊,只要使用自動補全,檔名不論多長都能輕鬆輸入。
|
||||||
|
|
||||||
|
使用自動補全的方法是,**先輸入部分的檔名,再按下 Tab**
|
||||||
|
(Tab 是 Q 左邊的按鍵)
|
||||||
|
例如要補全 `SuperSecretPlansForWorldDomination.txt`,你可以先輸入 `Su` 再按下 Tab,終端機就會幫你打完剩下的檔名了。
|
||||||
|
|
||||||
|
我們試看看印出 `SuperSecretPlansForWorldDomination.txt` 這個檔案的內容。
|
||||||
|
|
||||||
|
> 使用 `cat SuperSecretPlansForWorldDomination.txt` 來印出檔案內容
|
||||||
|
> 在輸入 `cat Su` 之後按下 Tab 來自動補全檔名,完成後按 Enter 就能執行指令
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
你看到了終端機自動幫你補全了 `SuperSecretPlansForWorldDomination.txt`,就算是很長的檔名,也不用怕打錯字。
|
||||||
|
開頭不論你打了幾個字都無所謂,你可以只打 `S`、`Su`、`Sup`、`Super` 或甚至完全不打字都沒關係。
|
||||||
|
但是你打的字越少,就越容易跟其他的檔案重複,這時候就要做衝突處理。
|
||||||
|
關於衝突處理,我們會在待會練習。
|
||||||
|
|
||||||
|
自動補全也可以用來對付拼字錯誤,像是 `ThsisAFlieNmaeWthAloOfTpyosThatisRleayHdToRaed.txt` 這個檔案充滿了錯字,任誰來打都難保不會失手。
|
||||||
|
這一次,請你一樣用 `cat` 來試著印出 `ThsisAFlieNmaeWthAloOfTpyosThatisRleayHdToRaed.txt` 的內容。
|
||||||
|
|
||||||
|
> 輸入 `cat ThsisAFlieNmaeWthAloOfTpyosThatisRleayHdToRaed.txt` 來印出檔案內容
|
||||||
|
> 輸入 `cat Thsi` 之後按下 Tab 做自動補全
|
||||||
|
> 試試看輸入更少的字元就按 Tab 會有什麼樣的結果
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
你看到了這個充滿錯字的檔案了。
|
||||||
|
只要使用自動補全,不管是錯字、大小寫變化、還是隨機亂碼都不再是困擾了。
|
||||||
|
讓我們再練習一個,這次請你查看 `ThingsIWillOrganizeLaterWhenIHaveTime` 資料夾中的檔案。
|
||||||
|
一樣,輸入前幾個字,然後按 Tab 自動補全。
|
||||||
|
|
||||||
|
> 輸入 `ls ThingsIWillOrganizeLaterWhenIHaveTime` 查看資料夾中的檔案
|
||||||
|
> 輸入 `ls Thing` 之後按下 Tab 做自動補全
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
你看到了這個資料夾裡有著三個檔案:一個如何做三明治的教學跟兩個在半夜隨機下載的東西。
|
||||||
|
假如我們要打開三明治教學 `HowToProperlyMakeASandwich.txt`,但是它位在資料夾中,我們沒辦法只按一次 Tab 就補全它的檔名。
|
||||||
|
這次我們就要來練習使用**連續的自動補全**。
|
||||||
|
|
||||||
|
像這樣檔案位在其他資料夾的情況,我們只要先輸入資料夾名稱的開頭,按一次 Tab,再輸入檔案的開頭,再按一次 Tab,就能夠補全 `資料夾/檔名` 了。
|
||||||
|
而且當你補全的對象是資料夾時,終端機還會自動幫你在結尾加上 `/`,省去你自己輸入的時間。
|
||||||
|
那麼我們就來打開 `HowToProperlyMakeASandwich.txt`。
|
||||||
|
|
||||||
|
> 輸入 `cat ThingsIWillOrganizeLaterWhenIHaveTime/HowToProperlyMakeASandwich.txt` 來印出檔案內容
|
||||||
|
> 先輸入 `cat Thin` 按 Tab 補全至 `cat ThingsIWillOrganizeLaterWhenIHaveTime/`
|
||||||
|
> 再輸入 `cat ThingsIWillOrganizeLaterWhenIHaveTime/How` 按 Tab 補全至完整路徑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
你看到了這份嚴謹的三明治製作教學。
|
||||||
|
那我們剩下來要看的就是資料夾裡的兩個檔案:`RandomStuffThatIDownloadedAt3AM.txt` 和 `RandomStuffThatIDownloadedAt4AM.txt`
|
||||||
|
兩個檔名非常相似,只有最後一個是 3AM 另一個是 4AM。
|
||||||
|
這樣我們只打前面幾個字的時候,沒有辦法分辦到底選的是哪一個檔案。
|
||||||
|
這就是**發生衝突**。
|
||||||
|
|
||||||
|
遇到衝突的解決方法,其實跟使用資料夾是一樣的。
|
||||||
|
在輸入 `Ran` 按下 Tab 後,終端機會補全到衝突發生前的位置,也就是 `RandomStuffThatIDownloadedAt`。
|
||||||
|
我們只要根據我們想開啟的檔案,輸入 `3` 或是 `4`,再按一下 Tab,就能夠補全剩下的檔名了。
|
||||||
|
|
||||||
|
_如果我們把完整的路徑視作補全的對象,那資料夾其實就是一種衝突的情況。_
|
||||||
|
|
||||||
|
> 輸入 `cat ThingsIWillOrganizeLaterWhenIHaveTime/RandomStuffThatIDownloadedAt3AM.txt` 來印出檔案內容
|
||||||
|
> 先輸入 `cat Thin` 按 Tab 補全至 `cat ThingsIWillOrganizeLaterWhenIHaveTime/`
|
||||||
|
> 再接續輸入 `Ran` 按 Tab 補全至 `cat ThingsIWillOrganizeLaterWhenIHaveTime/RandomStuffThatIDownloadedAt`
|
||||||
|
> 再接續輸入 `3` 後按 Tab 補全至完整的路徑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
你看到了一份凌晨三點下載的檔案列表。嗯…我今天網路用的夠多了。
|
||||||
|
最後留下一個挑戰給你,請先進入到 `ThingsIWillOrganizeLaterWhenIHaveTime` 資料夾後,再印出 `RandomStuffThatIDownloadedAt4AM.txt`。
|
||||||
|
練習使用今天教的自動補全來完成任務。
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"socket.io": "^4.7.1",
|
"socket.io": "^4.7.1",
|
||||||
|
"ssh2": "^1.16.0",
|
||||||
"swagger-ui-express": "^5.0.0",
|
"swagger-ui-express": "^5.0.0",
|
||||||
"validator": "^13.11.0",
|
"validator": "^13.11.0",
|
||||||
"winston": "^3.11.0",
|
"winston": "^3.11.0",
|
||||||
@@ -61,6 +62,7 @@
|
|||||||
"@types/passport-local": "^1.0.38",
|
"@types/passport-local": "^1.0.38",
|
||||||
"@types/passport-oauth2": "^1.4.15",
|
"@types/passport-oauth2": "^1.4.15",
|
||||||
"@types/source-map-support": "^0",
|
"@types/source-map-support": "^0",
|
||||||
|
"@types/ssh2": "^1.15.1",
|
||||||
"@types/swagger-ui-express": "^4.1.6",
|
"@types/swagger-ui-express": "^4.1.6",
|
||||||
"@types/validator": "^13.11.9",
|
"@types/validator": "^13.11.9",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { asyncHandler } from '../../middleware/error.js'
|
|||||||
|
|
||||||
export const newCommand = asyncHandler(async (req: Request, res: Response) => {
|
export const newCommand = asyncHandler(async (req: Request, res: Response) => {
|
||||||
const { command, pwd, output, error, params } = matchedData(req)
|
const { command, pwd, output, error, params } = matchedData(req)
|
||||||
|
console.log(command, output, error)
|
||||||
|
|
||||||
const { sessionId } = req.user as any
|
const { sessionId } = req.user as any
|
||||||
const session = await Session.findById(sessionId)
|
const session = await Session.findById(sessionId)
|
||||||
|
|||||||
@@ -43,7 +43,10 @@ export const createSession = 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)
|
||||||
if (user && (await isQuestUnlocked(user, questId))) {
|
if (
|
||||||
|
user &&
|
||||||
|
((await isQuestUnlocked(user, questId)) || config.testing.enabled)
|
||||||
|
) {
|
||||||
const session = await createNewSession(user, questId)
|
const session = await createNewSession(user, questId)
|
||||||
res.status(201).json(sessionDetail(session))
|
res.status(201).json(sessionDetail(session))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Server } from 'socket.io'
|
import { Server } from 'socket.io'
|
||||||
import type { Socket } from 'socket.io'
|
import type { Socket } from 'socket.io'
|
||||||
|
import { Duplex } from 'stream'
|
||||||
import validator from 'validator'
|
import validator from 'validator'
|
||||||
import type { Server as HttpServer } from 'http'
|
import type { Server as HttpServer } from 'http'
|
||||||
import { Session } from '@linux-odyssey/models'
|
import { ISession, Session } from '@linux-odyssey/models'
|
||||||
import { getAndStartContainer, attachContainer } from '../containers/docker.js'
|
import { getAndStartContainer, attachContainer } from '../containers/docker.js'
|
||||||
import SessionMiddleware from '../middleware/session.js'
|
import SessionMiddleware from '../middleware/session.js'
|
||||||
import { genJWT } from '../utils/auth.js'
|
import { genJWT } from '../utils/auth.js'
|
||||||
@@ -77,30 +78,34 @@ async function connectContainer(socket: Socket, next: (err?: Error) => void) {
|
|||||||
stream,
|
stream,
|
||||||
}
|
}
|
||||||
next()
|
next()
|
||||||
} catch {
|
} catch (err) {
|
||||||
next(new Error('Failed to start container.'))
|
logger.error(err)
|
||||||
|
next(new Error('Failed to connect to container.'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onConnect(socket: Socket) {
|
function onConnect(socket: Socket) {
|
||||||
const { session, stream } = socket.data.context
|
const { session, stream } = socket.data.context as {
|
||||||
stream.socket.on('data', (chunk: Buffer) => {
|
session: ISession
|
||||||
|
stream: Duplex
|
||||||
|
}
|
||||||
|
stream.on('data', (chunk: Buffer) => {
|
||||||
socket.emit('terminal', chunk.toString())
|
socket.emit('terminal', chunk.toString())
|
||||||
})
|
})
|
||||||
|
|
||||||
socket.on('terminal', function incoming(message) {
|
socket.on('terminal', function incoming(message) {
|
||||||
stream.socket.write(message)
|
stream.write(message)
|
||||||
})
|
})
|
||||||
|
|
||||||
const socketCallback = (event: string, ...data: any[]) => {
|
const socketCallback = (event: string, ...data: any[]) => {
|
||||||
socket.emit(event, ...data)
|
socket.emit(event, ...data)
|
||||||
}
|
}
|
||||||
|
|
||||||
listenToSession(session.id, socketCallback)
|
listenToSession(session._id.toString(), socketCallback)
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
stream.socket.write('exit\n')
|
stream.write('exit\n')
|
||||||
removeFromSession(session.id, socketCallback)
|
removeFromSession(session._id.toString(), socketCallback)
|
||||||
stream.destroy()
|
stream.destroy()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-8
@@ -1,6 +1,9 @@
|
|||||||
import validator from 'validator'
|
import validator from 'validator'
|
||||||
import dotenv from 'dotenv'
|
import dotenv from 'dotenv'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import path from 'path'
|
||||||
import { get } from './utils/env.js'
|
import { get } from './utils/env.js'
|
||||||
|
import { loadOrCreateKeyPair } from './utils/crypto.js'
|
||||||
|
|
||||||
// Import dotenv and load ../.env
|
// Import dotenv and load ../.env
|
||||||
dotenv.config({ path: '../.env' })
|
dotenv.config({ path: '../.env' })
|
||||||
@@ -29,22 +32,31 @@ function getUrl(key: string): string {
|
|||||||
|
|
||||||
function createConfig() {
|
function createConfig() {
|
||||||
const isProduction = process.env.NODE_ENV === 'production'
|
const isProduction = process.env.NODE_ENV === 'production'
|
||||||
const host = get('HOST', 'localhost')
|
const host = get('HOST', '0.0.0.0')
|
||||||
const port = Number(get('PORT', 3000))
|
const port = Number(get('PORT', 3000))
|
||||||
const baseUrl = get('BASE_URL', `http://${host}:${port}`)
|
const baseUrl = get('BASE_URL', `http://${host}:${port}`)
|
||||||
|
const backendUrl = get('BACKEND_URL', `http://host.docker.internal:${port}`)
|
||||||
const url = new URL(baseUrl)
|
const url = new URL(baseUrl)
|
||||||
|
const projectRoot = getProjectRoot()
|
||||||
|
const keypairPath = get(
|
||||||
|
'KEYPAIR_PATH',
|
||||||
|
path.join(projectRoot, 'config', 'ssh_key')
|
||||||
|
)
|
||||||
|
const keypair = loadOrCreateKeyPair(keypairPath)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
domain: url.hostname,
|
domain: url.hostname,
|
||||||
protocol: url.protocol,
|
protocol: url.protocol,
|
||||||
backendUrl: get('BACKEND_URL', baseUrl),
|
backendUrl,
|
||||||
db: get('MONGO_URL', 'mongodb://localhost:27017/odyssey-test'),
|
db: get('MONGO_URL', 'mongodb://localhost:27017/odyssey-test'),
|
||||||
secret: get('SECRET_KEY', ''),
|
secret: get('SECRET_KEY', ''),
|
||||||
isProduction: process.env.NODE_ENV === 'production',
|
isProduction: process.env.NODE_ENV === 'production',
|
||||||
containerExpiry: get('EXPIRY', 1000 * 60 * 60),
|
containerExpiry: Number(process.env.CONTAINER_EXPIRY ?? 60) * 1000 * 60,
|
||||||
sessionMaxAge: get('SESSION_MAX_AGE', 1000 * 60 * 60 * 24 * 7),
|
sessionMaxAge:
|
||||||
|
Number(process.env.SESSION_MAX_AGE ?? 7 * 24) * 1000 * 60 * 60,
|
||||||
google: {
|
google: {
|
||||||
clientID: get('GOOGLE_CLIENT_ID', ''),
|
clientID: get('GOOGLE_CLIENT_ID', ''),
|
||||||
clientSecret: get('GOOGLE_CLIENT_SECRET', ''),
|
clientSecret: get('GOOGLE_CLIENT_SECRET', ''),
|
||||||
@@ -57,16 +69,20 @@ function createConfig() {
|
|||||||
surveyUrl: getUrl('SURVEY_URL'),
|
surveyUrl: getUrl('SURVEY_URL'),
|
||||||
bugReportUrl: getUrl('BUG_REPORT_URL'),
|
bugReportUrl: getUrl('BUG_REPORT_URL'),
|
||||||
|
|
||||||
|
projectRoot,
|
||||||
|
|
||||||
docker: {
|
docker: {
|
||||||
network: get('DOCKER_NETWORK', 'host'),
|
network: get('DOCKER_NETWORK', 'linux-odyssey-players-dev'),
|
||||||
defaultImage: get('QUEST_IMAGE', 'linuxodyssey/quest-base'),
|
defaultImage: get('QUEST_IMAGE', 'linuxodyssey/quest-base'),
|
||||||
imagePrefix: get('DOCKER_PREFIX', 'linuxodyssey/quest-'),
|
imagePrefix: get('DOCKER_PREFIX', 'linuxodyssey/quest-'),
|
||||||
mountQuest: get('MOUNT_QUEST', ''),
|
mountQuest: process.env.MOUNT_QUEST === 'true',
|
||||||
hostPwd: get('HOST_PWD', ''),
|
hostProjectRoot: get('HOST_PROJECT_ROOT', projectRoot),
|
||||||
|
keypairPath,
|
||||||
|
keypair,
|
||||||
},
|
},
|
||||||
|
|
||||||
log: {
|
log: {
|
||||||
path: get('LOG_PATH', '../logs'),
|
path: get('LOG_PATH', path.join(projectRoot, 'logs')),
|
||||||
},
|
},
|
||||||
testing: {
|
testing: {
|
||||||
enabled: !isProduction && process.env.TESTING === 'true',
|
enabled: !isProduction && process.env.TESTING === 'true',
|
||||||
@@ -76,9 +92,16 @@ function createConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getProjectRoot(): string {
|
||||||
|
const filename = fileURLToPath(import.meta.url)
|
||||||
|
const root = path.join(path.dirname(filename), '..', '..')
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
const config = createConfig()
|
const config = createConfig()
|
||||||
|
|
||||||
export function getQuestImage(id: string): string {
|
export function getQuestImage(id: string): string {
|
||||||
|
// TODO: handle staging images (add version tag)
|
||||||
return `${config.docker.imagePrefix}${id}`
|
return `${config.docker.imagePrefix}${id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +1,80 @@
|
|||||||
|
import fs from 'fs/promises'
|
||||||
|
import { Duplex } from 'stream'
|
||||||
import Docker from 'dockerode'
|
import Docker from 'dockerode'
|
||||||
import config, { getQuestImage } from '../config.js'
|
import config, { getQuestImage } from '../config.js'
|
||||||
import logger from '../utils/logger.js'
|
import logger from '../utils/logger.js'
|
||||||
|
import { connectToSSH } from './ssh.js'
|
||||||
|
|
||||||
const engine = new Docker()
|
const engine = new Docker()
|
||||||
|
|
||||||
const containerOptions = {
|
const newContainerOptions = (
|
||||||
AttachStdin: true,
|
name: string,
|
||||||
AttachStdout: true,
|
imageId: string,
|
||||||
AttachStderr: true,
|
options: {
|
||||||
Tty: true,
|
binds?: string[]
|
||||||
OpenStdin: true,
|
} = {}
|
||||||
StdinOnce: false,
|
): Docker.ContainerCreateOptions => ({
|
||||||
|
name,
|
||||||
|
Image: getQuestImage(imageId),
|
||||||
HostConfig: {
|
HostConfig: {
|
||||||
NetworkMode: config.docker.network,
|
NetworkMode: config.docker.network,
|
||||||
|
Binds: options.binds,
|
||||||
|
ExtraHosts: ['host.docker.internal:host-gateway'],
|
||||||
},
|
},
|
||||||
} as Docker.ContainerCreateOptions
|
ExposedPorts: {
|
||||||
|
'22/tcp': {},
|
||||||
|
},
|
||||||
|
Env: [`PUB_KEY=${config.docker.keypair.publicKey}`],
|
||||||
|
})
|
||||||
|
|
||||||
export function createContainer(
|
export async function createContainer(
|
||||||
name: string,
|
name: string,
|
||||||
questId: string
|
questId: string,
|
||||||
|
imageId: string
|
||||||
): Promise<Docker.Container> {
|
): Promise<Docker.Container> {
|
||||||
const option = {
|
let binds: string[] = []
|
||||||
...containerOptions,
|
binds.push(
|
||||||
name,
|
`${config.docker.hostProjectRoot}/quests/.includes/zshrc:/etc/zsh/zshrc`
|
||||||
Image: getQuestImage(questId),
|
)
|
||||||
|
if (await questHomeExists(questId)) {
|
||||||
|
logger.info('Mounting quest home', questId)
|
||||||
|
binds.push(
|
||||||
|
`${config.docker.hostProjectRoot}/quests/${questId}/home:/etc/skel:ro`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
const { hostPwd, mountQuest } = config.docker
|
if (!config.isProduction && config.docker.mountQuest && imageId !== 'base') {
|
||||||
if (!config.isProduction && hostPwd && mountQuest === questId) {
|
logger.info('Mounting quest folder', questId)
|
||||||
logger.info('Mounting quest folder', mountQuest)
|
binds = [
|
||||||
option.HostConfig!.Binds = [
|
`${config.docker.hostProjectRoot}/quests/${questId}/home:/home/commander`,
|
||||||
`${hostPwd}/quests/${mountQuest}/home:/home/commander`,
|
`${config.docker.hostProjectRoot}/packages/container:/usr/local/lib/container`,
|
||||||
`${hostPwd}/packages/container:/usr/local/lib/container`,
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
const option = newContainerOptions(name, imageId, { binds })
|
||||||
|
await createNetworkIfNotExists(config.docker.network)
|
||||||
return engine.createContainer(option)
|
return engine.createContainer(option)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createNetworkIfNotExists(network: string) {
|
||||||
|
const dockerNetworks = await engine.listNetworks({
|
||||||
|
filters: {
|
||||||
|
name: [network],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (dockerNetworks.length === 0) {
|
||||||
|
logger.log('Creating player network', network)
|
||||||
|
await engine.createNetwork({ Name: network })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function questHomeExists(imageId: string) {
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(`${config.projectRoot}/quests/${imageId}/home`)
|
||||||
|
return stat.isDirectory()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getAndStartContainer(
|
export async function getAndStartContainer(
|
||||||
id: string
|
id: string
|
||||||
): Promise<Docker.Container> {
|
): Promise<Docker.Container> {
|
||||||
@@ -53,26 +92,26 @@ export async function getAndStartContainer(
|
|||||||
export async function attachContainer(
|
export async function attachContainer(
|
||||||
container: Docker.Container,
|
container: Docker.Container,
|
||||||
{ token }: { token: string }
|
{ token }: { token: string }
|
||||||
) {
|
): Promise<Duplex> {
|
||||||
const exec = await container.exec({
|
const containerIp = (await container.inspect()).NetworkSettings.Networks[
|
||||||
AttachStdin: true,
|
config.docker.network
|
||||||
AttachStdout: true,
|
].IPAddress
|
||||||
AttachStderr: true,
|
|
||||||
Cmd: ['/bin/zsh'],
|
|
||||||
Tty: true,
|
|
||||||
Env: [
|
|
||||||
`TOKEN=${token}`,
|
|
||||||
`API_ENDPOINT=${config.backendUrl}`,
|
|
||||||
'ZDOTDIR=/etc/zsh',
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
const execOutput = await exec.start({
|
let error: Error | null = null
|
||||||
Detach: false,
|
for (let i = 0; i < 10; i++) {
|
||||||
Tty: true,
|
try {
|
||||||
})
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const stream = await connectToSSH(containerIp, { token })
|
||||||
return execOutput
|
return stream
|
||||||
|
} catch (err) {
|
||||||
|
error = err as Error
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 1000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Failed to connect to SSH', { cause: error })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteContainer(id: string) {
|
export async function deleteContainer(id: string) {
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Client } from 'ssh2'
|
||||||
|
import { Duplex } from 'stream'
|
||||||
|
import config from '../config.js'
|
||||||
|
|
||||||
|
export async function connectToSSH(
|
||||||
|
containerIp: string,
|
||||||
|
{ token }: { token: string }
|
||||||
|
): Promise<Duplex> {
|
||||||
|
const conn = new Client()
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
conn
|
||||||
|
.on('ready', () => {
|
||||||
|
conn.shell(
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
TOKEN: token,
|
||||||
|
API_ENDPOINT: config.backendUrl,
|
||||||
|
ZDOTDIR: '/etc/zsh',
|
||||||
|
NODE_PATH: '/usr/src/node_modules',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
stream.on('close', () => {
|
||||||
|
conn.end()
|
||||||
|
})
|
||||||
|
resolve(stream)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.on('error', (err) => {
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
|
.connect({
|
||||||
|
host: containerIp,
|
||||||
|
port: 22,
|
||||||
|
username: 'commander',
|
||||||
|
privateKey: config.docker.keypair.privateKey,
|
||||||
|
timeout: 5000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
+1
-1
@@ -72,7 +72,7 @@ async function main() {
|
|||||||
app.use('/api/v1', apiRouter)
|
app.use('/api/v1', apiRouter)
|
||||||
app.use(errorHandler)
|
app.use(errorHandler)
|
||||||
|
|
||||||
server.listen(config.port, config.host, () => {
|
server.listen(config.port, '0.0.0.0', () => {
|
||||||
logger.info(`Server listening at ${config.baseUrl}`)
|
logger.info(`Server listening at ${config.baseUrl}`)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export async function createNewSession(
|
|||||||
|
|
||||||
const container = await createContainer(
|
const container = await createContainer(
|
||||||
`quest-${quest.id}-${user.username}-${Date.now()}`,
|
`quest-${quest.id}-${user.username}-${Date.now()}`,
|
||||||
|
quest.id,
|
||||||
quest.image
|
quest.image
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import ssh2 from 'ssh2'
|
||||||
|
|
||||||
|
const {
|
||||||
|
utils: { generateKeyPairSync },
|
||||||
|
} = ssh2
|
||||||
|
|
||||||
|
type KeyPair = {
|
||||||
|
privateKey: string
|
||||||
|
publicKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadOrCreateKeyPair(privateKeyPath: string): KeyPair {
|
||||||
|
const publicKeyPath = `${privateKeyPath}.pub`
|
||||||
|
if (fs.existsSync(privateKeyPath) && fs.existsSync(publicKeyPath)) {
|
||||||
|
const privateKey = fs.readFileSync(privateKeyPath, 'utf8')
|
||||||
|
const publicKey = fs.readFileSync(publicKeyPath, 'utf8')
|
||||||
|
return { privateKey, publicKey }
|
||||||
|
}
|
||||||
|
const dir = path.dirname(privateKeyPath)
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
const keyPair = generateKeyPair()
|
||||||
|
fs.writeFileSync(privateKeyPath, keyPair.privateKey, {
|
||||||
|
mode: 0o600,
|
||||||
|
})
|
||||||
|
fs.writeFileSync(publicKeyPath, keyPair.publicKey, {
|
||||||
|
mode: 0o600,
|
||||||
|
})
|
||||||
|
return keyPair
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateKeyPair(): KeyPair {
|
||||||
|
const { private: privateKey, public: publicKey } =
|
||||||
|
generateKeyPairSync('ed25519')
|
||||||
|
return { privateKey, publicKey }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { generateKeyPair } from '../../src/utils/crypto'
|
||||||
|
|
||||||
|
describe('generateKeyPair', () => {
|
||||||
|
it('should generate a key pair', () => {
|
||||||
|
const keyPair = generateKeyPair()
|
||||||
|
expect(keyPair).toHaveProperty('privateKey')
|
||||||
|
expect(keyPair).toHaveProperty('publicKey')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in new issue
Block a user