mirror of
https://github.com/linux-odyssey/linux-odyssey.git
synced 2026-11-06 03:20:13 +08:00
55 files changed
+744
-351
No files matched your search
@@ -27,5 +27,6 @@ module.exports = {
|
||||
'no-var': 'error',
|
||||
'no-plusplus': 'off',
|
||||
'prettier/prettier': ['error', { endOfLine: 'auto', semi: false }],
|
||||
'import/prefer-default-export': 'off',
|
||||
},
|
||||
}
|
||||
@@ -32,10 +32,14 @@ Run Cypress locally, using the current development containers:
|
||||
|
||||
Run Cypress in container, create brand-new containers along with it:
|
||||
|
||||
docker compose -f docker-compose.testing.yml run cypress
|
||||
./docker-scripts.sh test
|
||||
|
||||
# or with building
|
||||
|
||||
./docker-scripts.sh test --build
|
||||
|
||||
# tearing off the containers
|
||||
docker compose -f docker-compose.testing.yml down
|
||||
./docker-scripts.sh down
|
||||
|
||||
### Deployment
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ COPY package.json yarn.lock ./
|
||||
COPY app/package.json ./app/package.json
|
||||
COPY packages/file-graph/package.json ./packages/file-graph/package.json
|
||||
COPY packages/utils/package.json ./packages/utils/package.json
|
||||
COPY packages/constants/package.json ./packages/constants/package.json
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
# Dev stage
|
||||
@@ -16,6 +17,7 @@ CMD [ "yarn", "dev:docker", "--host", "0.0.0.0" ]
|
||||
FROM base as build
|
||||
COPY packages/file-graph ./packages/file-graph
|
||||
COPY packages/utils ./packages/utils
|
||||
COPY packages/constants ./packages/constants
|
||||
COPY app ./app
|
||||
RUN yarn build
|
||||
|
||||
|
||||
@@ -5,12 +5,6 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + Vue</title>
|
||||
<script
|
||||
async
|
||||
defer
|
||||
data-domain="linuxodyssey.xyz"
|
||||
src="https://analytics.indie.tw/js/script.js"
|
||||
></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
+4
-2
@@ -11,15 +11,17 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@linux-odyssey/file-graph": "*",
|
||||
"@linux-odyssey/utils": "*",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.4.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.4.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.0",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.3",
|
||||
"@linux-odyssey/file-graph": "*",
|
||||
"@linux-odyssey/utils": "*",
|
||||
"@linux-odyssey/constants": "*",
|
||||
"axios": "^1.4.0",
|
||||
"socket.io-client": "^4.7.1",
|
||||
"validator": "^13.11.0",
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.4",
|
||||
"xterm": "^5.2.1",
|
||||
|
||||
@@ -128,6 +128,7 @@ const handleChange = () => {
|
||||
class="my-4 bg-background-primary text-text-primary bg- rounded-md block w-full px-3 h-10 shadow-sm focus:outline-none placeholder:text-text-line focus:ring-2 focus:ring-text-primary ring-1 ring-background-secondary"
|
||||
:placeholder="type === 'login' ? 'Email / Username' : 'Username'"
|
||||
v-model="username"
|
||||
required
|
||||
@input="handleChange()"
|
||||
autocomplete="username"
|
||||
/>
|
||||
@@ -147,6 +148,7 @@ const handleChange = () => {
|
||||
class="my-4 bg-background-primary text-text-primary bg- rounded-md block w-full px-3 h-10 shadow-sm focus:outline-none placeholder:text-text-line focus:ring-2 focus:ring-text-primary ring-1 ring-background-secondary"
|
||||
placeholder="Email"
|
||||
v-model="email"
|
||||
required
|
||||
@input="handleChange()"
|
||||
autocomplete="email"
|
||||
/>
|
||||
@@ -158,6 +160,7 @@ const handleChange = () => {
|
||||
class="my-4 bg-background-primary text-text-primary bg- rounded-md block w-full px-3 h-10 shadow-sm focus:outline-none placeholder:text-text-line focus:ring-2 focus:ring-text-primary ring-1 ring-background-secondary"
|
||||
placeholder="Password"
|
||||
v-model="password"
|
||||
required
|
||||
@input="handleChange()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -30,13 +30,9 @@
|
||||
|
||||
<script setup>
|
||||
import sessionManager from '../utils/session'
|
||||
import socket from '../utils/socket'
|
||||
import socketTerminal from '../utils/terminal'
|
||||
|
||||
const reset = async () => {
|
||||
console.log('resetting...')
|
||||
await sessionManager.createSession()
|
||||
socket.connect(sessionManager.getSession())
|
||||
socketTerminal.clear()
|
||||
}
|
||||
</script>
|
||||
@@ -10,12 +10,12 @@
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import socketTerminal from '../utils/terminal'
|
||||
import sessionManager from '../utils/session'
|
||||
|
||||
const terminal = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
socketTerminal.mount(terminal.value)
|
||||
window.addEventListener('resize', () => socketTerminal.resizeScreen())
|
||||
sessionManager.term.mount(terminal.value)
|
||||
window.addEventListener('resize', () => sessionManager.term.resizeScreen())
|
||||
})
|
||||
</script>
|
||||
+67
-15
@@ -1,31 +1,83 @@
|
||||
import api from './api'
|
||||
|
||||
import {
|
||||
TooManyRequestsError,
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
} from './errors'
|
||||
|
||||
export async function isLoggedIn() {
|
||||
const res = await api.get('/auth/check-session')
|
||||
const { loggedIn } = res.data
|
||||
return loggedIn
|
||||
}
|
||||
|
||||
export const login = async (username, password) => {
|
||||
try {
|
||||
await api.post('/auth/login', { username, password })
|
||||
return true
|
||||
} catch (err) {
|
||||
if (err.response?.status === 401) {
|
||||
return false
|
||||
}
|
||||
throw err
|
||||
function handleRequestError(err) {
|
||||
if (!err.response) throw err // Not an axios error
|
||||
const { status } = err.response
|
||||
if (status === 429) {
|
||||
throw new TooManyRequestsError(err.response.data.message)
|
||||
}
|
||||
if (status === 401) {
|
||||
throw new UnauthorizedError(err.response.data.message)
|
||||
}
|
||||
if (status === 400) {
|
||||
const { errors } = err.response.data
|
||||
if (Array.isArray(errors) && errors.length > 0) {
|
||||
const { path, msg } = errors[0]
|
||||
switch (path) {
|
||||
case 'username':
|
||||
throw new ValidationError('Username is already taken')
|
||||
case 'email':
|
||||
throw new ValidationError('Email is already taken')
|
||||
case 'password':
|
||||
throw new ValidationError('Password is too weak')
|
||||
default:
|
||||
throw new ValidationError(msg)
|
||||
}
|
||||
}
|
||||
throw new ValidationError(err.response.data.message)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
export const login = (username, password) => {
|
||||
return api
|
||||
.post('/auth/login', { username, password })
|
||||
.then(() => true)
|
||||
.catch(handleRequestError)
|
||||
}
|
||||
|
||||
export const register = (username, email, password) => {
|
||||
return api.post('/auth/register', {
|
||||
username,
|
||||
password,
|
||||
email,
|
||||
})
|
||||
return api
|
||||
.post('/auth/register', {
|
||||
username,
|
||||
password,
|
||||
email,
|
||||
})
|
||||
.then(() => true)
|
||||
.catch(handleRequestError)
|
||||
}
|
||||
|
||||
export const checkUsername = async (username) => {
|
||||
return api
|
||||
.get('/auth/check-username', {
|
||||
params: { username },
|
||||
})
|
||||
.then(() => true)
|
||||
.catch(handleRequestError)
|
||||
}
|
||||
|
||||
export const chooseUsername = async (username) => {
|
||||
return api
|
||||
.post('/auth/register-from-session', { username })
|
||||
.then(() => true)
|
||||
.catch(handleRequestError)
|
||||
}
|
||||
|
||||
export const logout = () => {
|
||||
return api.post('/auth/logout')
|
||||
return api
|
||||
.post('/auth/logout')
|
||||
.then(() => true)
|
||||
.catch(handleRequestError)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
export class TooManyRequestsError extends Error {
|
||||
constructor(message) {
|
||||
super(message)
|
||||
this.name = 'TooManyRequestsError'
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends Error {
|
||||
constructor(message) {
|
||||
super(message)
|
||||
this.name = 'ValidationError'
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends Error {
|
||||
constructor(message) {
|
||||
super(message)
|
||||
this.name = 'UnauthorizedError'
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
TooManyRequestsError,
|
||||
ValidationError,
|
||||
UnauthorizedError,
|
||||
}
|
||||
+40
-26
@@ -1,6 +1,8 @@
|
||||
import { ref } from 'vue'
|
||||
import { FileGraph } from '@linux-odyssey/file-graph'
|
||||
import api from './api'
|
||||
import Socket from './socket'
|
||||
import SocketTerminal from './terminal'
|
||||
|
||||
class SessionManager {
|
||||
constructor(questId) {
|
||||
@@ -16,17 +18,44 @@ class SessionManager {
|
||||
)
|
||||
this.pwd = ref('')
|
||||
this.status = ref('inactive')
|
||||
this.socket = new Socket()
|
||||
this.term = new SocketTerminal(40, 80)
|
||||
}
|
||||
|
||||
init() {
|
||||
this.socket.on('terminal', (data) => {
|
||||
this.term.write(data)
|
||||
})
|
||||
this.term.onData((data) => {
|
||||
this.socket.emit('terminal', data)
|
||||
})
|
||||
this.socket.on('graph', (event) => {
|
||||
this.handleGraphUpdate(event)
|
||||
})
|
||||
this.socket.on('hints', (event) => {
|
||||
this.handleHintUpdate(event)
|
||||
})
|
||||
this.socket.on('tasks', (tasks) => {
|
||||
this.setTasks(tasks)
|
||||
})
|
||||
this.socket.on('status', (event) => {
|
||||
this.handleStatusUpdate(event)
|
||||
})
|
||||
return this.getActiveSession()
|
||||
}
|
||||
|
||||
getSession() {
|
||||
return this.session.value
|
||||
}
|
||||
|
||||
setSession(session) {
|
||||
async setSession(session) {
|
||||
this.session.value = session
|
||||
this.graph.value = new FileGraph(session.graph)
|
||||
this.hints.value = session.hints
|
||||
this.status.value = session.status
|
||||
this.term.reset()
|
||||
await this.socket.connect(session)
|
||||
this.term.focus()
|
||||
}
|
||||
|
||||
handleGraphUpdate(event) {
|
||||
@@ -58,33 +87,18 @@ class SessionManager {
|
||||
this.setSession(data)
|
||||
}
|
||||
|
||||
async getSessionList() {
|
||||
const res = await api.get('/sessions', {
|
||||
params: { quest_id: this.questId },
|
||||
async getActiveSession() {
|
||||
const res = await api.post('/sessions/active', {
|
||||
quest_id: this.questId,
|
||||
})
|
||||
const { data } = res
|
||||
return data
|
||||
}
|
||||
|
||||
async lastSession() {
|
||||
const sessions = await this.getSessionList()
|
||||
if (sessions.length === 0) {
|
||||
return
|
||||
const session = res.data
|
||||
console.log(session)
|
||||
try {
|
||||
await this.setSession(session)
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
this.createSession().catch(console.error)
|
||||
}
|
||||
const { _id: sessionId } = sessions[sessions.length - 1]
|
||||
const { data: session } = await api.get(`/sessions/${sessionId}`)
|
||||
console.log(
|
||||
`Last Session ID: ${session._id}, Quest: ${session.quest}, Created At: ${session.createdAt}`
|
||||
)
|
||||
this.setSession(session)
|
||||
}
|
||||
|
||||
async lastOrCreate() {
|
||||
await this.lastSession()
|
||||
if (!this.session.value) {
|
||||
await this.createSession()
|
||||
}
|
||||
console.log(this.session.value)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
-15
@@ -7,22 +7,28 @@ class SocketWrapper {
|
||||
}
|
||||
|
||||
connect(session) {
|
||||
if (this.socket) {
|
||||
this.socket.off()
|
||||
this.socket.disconnect()
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.socket) {
|
||||
this.socket.off()
|
||||
this.socket.disconnect()
|
||||
}
|
||||
|
||||
this.socket = io('', {
|
||||
query: {
|
||||
session_id: session._id,
|
||||
},
|
||||
this.socket = io('', {
|
||||
query: {
|
||||
session_id: session._id,
|
||||
},
|
||||
})
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
console.log('Socket connected to session', session._id)
|
||||
})
|
||||
|
||||
this.socket.once('terminal', () => {
|
||||
resolve()
|
||||
})
|
||||
this.socket.once('error', reject)
|
||||
this.bindListeners()
|
||||
})
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
console.log('Socket connected to session', session._id)
|
||||
})
|
||||
|
||||
this.bindListeners()
|
||||
}
|
||||
|
||||
on(event, callback) {
|
||||
@@ -32,6 +38,12 @@ class SocketWrapper {
|
||||
}
|
||||
}
|
||||
|
||||
once(event, callback) {
|
||||
if (this.socket) {
|
||||
this.socket.once(event, callback)
|
||||
}
|
||||
}
|
||||
|
||||
bindListeners() {
|
||||
this.listeners.forEach(({ event, callback }) => {
|
||||
this.socket.on(event, callback)
|
||||
@@ -47,4 +59,4 @@ class SocketWrapper {
|
||||
}
|
||||
}
|
||||
|
||||
export default new SocketWrapper()
|
||||
export default SocketWrapper
|
||||
+15
-11
@@ -1,6 +1,5 @@
|
||||
import { Terminal } from 'xterm'
|
||||
import { FitAddon } from 'xterm-addon-fit'
|
||||
import socket from './socket'
|
||||
import 'xterm/css/xterm.css'
|
||||
|
||||
class SocketTerminal {
|
||||
@@ -24,13 +23,18 @@ class SocketTerminal {
|
||||
this.fitAddon = new FitAddon()
|
||||
this.term.loadAddon(this.fitAddon)
|
||||
this.fitAddon.fit()
|
||||
this.socket = socket
|
||||
this.socket.on('terminal', (message) => {
|
||||
this.term.write(message)
|
||||
})
|
||||
this.term.onData((key) => {
|
||||
this.socket.emit('terminal', key)
|
||||
})
|
||||
}
|
||||
|
||||
write(message) {
|
||||
this.term.write(message)
|
||||
}
|
||||
|
||||
onData(callback) {
|
||||
this.term.onData(callback)
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.term.focus()
|
||||
}
|
||||
|
||||
resizeScreen() {
|
||||
@@ -43,9 +47,9 @@ class SocketTerminal {
|
||||
this.resizeScreen()
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.term.clear()
|
||||
reset() {
|
||||
this.term.reset()
|
||||
}
|
||||
}
|
||||
|
||||
export default new SocketTerminal(40, 100)
|
||||
export default SocketTerminal
|
||||
@@ -2,44 +2,46 @@
|
||||
import { isValidUsername } from '@linux-odyssey/utils'
|
||||
import AuthForm from '../components/AuthForm.vue'
|
||||
import Background from '../components/DynamicBackground.vue'
|
||||
import { TooManyRequestsError, ValidationError } from '../utils/errors'
|
||||
import { checkUsername, chooseUsername } from '../utils/auth'
|
||||
|
||||
import api from '../utils/api'
|
||||
|
||||
async function checkUsername({ username, error }) {
|
||||
if (username && !isValidUsername(username)) {
|
||||
error('Invalid username.')
|
||||
return
|
||||
}
|
||||
if (username.length > 3) {
|
||||
try {
|
||||
const { data } = await api.get('/auth/check-username', {
|
||||
params: { username },
|
||||
})
|
||||
|
||||
const { available } = data
|
||||
if (!available) {
|
||||
error('Username is already taken.')
|
||||
}
|
||||
} catch {
|
||||
async function check({ username, error }) {
|
||||
if (username) {
|
||||
if (!isValidUsername(username)) {
|
||||
error('Invalid username.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await checkUsername(username)
|
||||
} catch (err) {
|
||||
if (err instanceof TooManyRequestsError) {
|
||||
error('Too many requests. Try again in 2 minutes.')
|
||||
return
|
||||
}
|
||||
if (err instanceof ValidationError) {
|
||||
error(err.message)
|
||||
return
|
||||
}
|
||||
error('Something went wrong. Please try again later.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit({ username, success, error }) {
|
||||
api
|
||||
.post('/auth/register-from-session', {
|
||||
username,
|
||||
})
|
||||
.then(success)
|
||||
.catch((err) => {
|
||||
if (err.response?.status === 409) {
|
||||
error('Username is already taken.')
|
||||
} else {
|
||||
console.error(err.message)
|
||||
error('Something went wrong.')
|
||||
}
|
||||
})
|
||||
async function handleSubmit({ username, success, error }) {
|
||||
try {
|
||||
await chooseUsername(username)
|
||||
success()
|
||||
} catch (err) {
|
||||
if (err instanceof TooManyRequestsError) {
|
||||
error('Too many requests. Try again in 2 minutes.')
|
||||
return
|
||||
}
|
||||
if (err instanceof ValidationError) {
|
||||
error(err.message)
|
||||
return
|
||||
}
|
||||
error(err.message)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
@@ -51,7 +53,7 @@ function handleSubmit({ username, success, error }) {
|
||||
<div class="h-2/3 w-1/3">
|
||||
<AuthForm
|
||||
@onSubmit="handleSubmit"
|
||||
@onChange="checkUsername"
|
||||
@onChange="check"
|
||||
type="username"
|
||||
:socialLogin="false"
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import socket from '../utils/socket'
|
||||
import sessionManager from '../utils/session'
|
||||
import GameHeaderPart from '../components/GameHeaderPart.vue'
|
||||
import CommandlistPart from '../components/CommandlistPart.vue'
|
||||
@@ -16,27 +15,7 @@ const completed = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await sessionManager.lastOrCreate()
|
||||
socket.connect(sessionManager.getSession())
|
||||
socket.on('connect', function open() {
|
||||
console.log('Connected to the server.')
|
||||
})
|
||||
socket.on('disconnect', function close() {
|
||||
console.log('Disconnected from the server.')
|
||||
})
|
||||
socket.on('graph', (event) => {
|
||||
sessionManager.handleGraphUpdate(event)
|
||||
})
|
||||
socket.on('hints', (event) => {
|
||||
sessionManager.handleHintUpdate(event)
|
||||
})
|
||||
socket.on('tasks', (tasks) => {
|
||||
sessionManager.setTasks(tasks)
|
||||
})
|
||||
socket.on('message', console.log)
|
||||
socket.on('status', (event) => {
|
||||
sessionManager.handleStatusUpdate(event)
|
||||
})
|
||||
await sessionManager.init()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,15 +2,32 @@
|
||||
import AuthForm from '../components/AuthForm.vue'
|
||||
import Background from '../components/DynamicBackground.vue'
|
||||
|
||||
import {
|
||||
TooManyRequestsError,
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
} from '../utils/errors'
|
||||
import { login } from '../utils/auth'
|
||||
|
||||
const handleLogin = async ({ username, password, success, error }) => {
|
||||
try {
|
||||
const isSuccess = await login(username, password)
|
||||
if (isSuccess) success()
|
||||
else error('Wrong username or password.')
|
||||
} catch (err) {
|
||||
if (err instanceof TooManyRequestsError) {
|
||||
error('Too many requests. Try again in 2 minutes.')
|
||||
return
|
||||
}
|
||||
if (err instanceof UnauthorizedError) {
|
||||
error('Wrong username or password.')
|
||||
return
|
||||
}
|
||||
if (err instanceof ValidationError) {
|
||||
error('Invalid username or password.')
|
||||
return
|
||||
}
|
||||
console.error(err)
|
||||
error('Something went wrong.')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,55 +1,62 @@
|
||||
<script setup>
|
||||
import { isValidEmail, isValidUsername } from '@linux-odyssey/utils'
|
||||
import validator from 'validator'
|
||||
import { isValidUsername } from '@linux-odyssey/utils'
|
||||
import { passwordPolicy } from '@linux-odyssey/constants'
|
||||
import AuthForm from '../components/AuthForm.vue'
|
||||
import Background from '../components/DynamicBackground.vue'
|
||||
|
||||
import api from '../utils/api'
|
||||
import { register } from '../utils/auth'
|
||||
import { checkUsername, register } from '../utils/auth'
|
||||
import {
|
||||
UnauthorizedError,
|
||||
TooManyRequestsError,
|
||||
ValidationError,
|
||||
} from '../utils/errors'
|
||||
|
||||
function handleRegister({ username, email, password, success, error }) {
|
||||
register(username, email, password)
|
||||
.then(success)
|
||||
.catch((err) => {
|
||||
if (err.response?.status === 409) {
|
||||
// Handle username or email already exists error here
|
||||
if (err.response.data.type === 'username') {
|
||||
error('Username already exists.')
|
||||
} else if (err.response.data.type === 'email') {
|
||||
error('Email already exists.')
|
||||
} else {
|
||||
error('Something went wrong.')
|
||||
}
|
||||
} else if (err.response?.status === 400) {
|
||||
error('Invalid username or email.')
|
||||
} else {
|
||||
console.error(err)
|
||||
if (err instanceof TooManyRequestsError) {
|
||||
error('Too many requests. Try again in 2 minutes.')
|
||||
return
|
||||
}
|
||||
if (err instanceof UnauthorizedError) {
|
||||
error('Invalid username or password.')
|
||||
return
|
||||
}
|
||||
if (err instanceof ValidationError) {
|
||||
error(err.message)
|
||||
return
|
||||
}
|
||||
console.error(err)
|
||||
error('Something went wrong.')
|
||||
})
|
||||
}
|
||||
|
||||
async function check({ username, email, error }) {
|
||||
async function check({ username, email, password, error }) {
|
||||
if (username && !isValidUsername(username)) {
|
||||
error('Invalid username.')
|
||||
return
|
||||
}
|
||||
if (email && !isValidEmail(email)) {
|
||||
if (email && !validator.isEmail(email)) {
|
||||
error('Invalid email.')
|
||||
return
|
||||
}
|
||||
if (!username && !email) {
|
||||
if (password && !validator.isStrongPassword(password, passwordPolicy)) {
|
||||
error(
|
||||
'Your password must be 8+ characters with at least one number, one upper and one lower case letter.'
|
||||
)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await api.get('/auth/check-username', {
|
||||
params: { username },
|
||||
})
|
||||
const { type, available } = res.data
|
||||
if (!available) {
|
||||
error(`${type} already exists.`)
|
||||
}
|
||||
await checkUsername(username)
|
||||
} catch (err) {
|
||||
if (err.response?.status === 400) {
|
||||
error('Invalid username or email.')
|
||||
if (err instanceof ValidationError) {
|
||||
error(err.message)
|
||||
return
|
||||
}
|
||||
if (err instanceof TooManyRequestsError) {
|
||||
error('Too many requests. Try again in 2 minutes.')
|
||||
return
|
||||
}
|
||||
console.error(err)
|
||||
|
||||
@@ -9,11 +9,13 @@ export default defineConfig({
|
||||
'/api': {
|
||||
target: process.env.API_TARGET || 'http://localhost:3000',
|
||||
changeOrigin: false,
|
||||
xfwd: true,
|
||||
},
|
||||
'/socket.io': {
|
||||
target: process.env.API_TARGET || 'ws://localhost:3000',
|
||||
changeOrigin: false,
|
||||
ws: true,
|
||||
xfwd: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ server {
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location /api-docs/ {
|
||||
@@ -18,6 +19,7 @@ server {
|
||||
location /socket.io/ {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
|
||||
+13
-6
@@ -2,6 +2,8 @@ version: '3'
|
||||
services:
|
||||
app:
|
||||
image: lancatlin/linux-odyssey:app
|
||||
depends_on:
|
||||
- backend
|
||||
build:
|
||||
context: .
|
||||
dockerfile: app/Dockerfile
|
||||
@@ -12,6 +14,8 @@ services:
|
||||
- 8080:80
|
||||
backend:
|
||||
image: lancatlin/linux-odyssey:backend
|
||||
depends_on:
|
||||
- db
|
||||
restart: on-failure
|
||||
build:
|
||||
context: .
|
||||
@@ -25,8 +29,10 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- MONGO_URL=mongodb://user:user-password@db:27017/odyssey-test
|
||||
- MONGO_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@db:27017/${MONGO_DB}
|
||||
- DOCKER_NETWORK=linux-odyssey-players
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- TRUSTED_PROXIES=${TRUSTED_PROXIES}
|
||||
|
||||
analytics:
|
||||
image: lancatlin/linux-odyssey:analytics
|
||||
@@ -41,7 +47,7 @@ services:
|
||||
ports:
|
||||
- '3001:3001'
|
||||
environment:
|
||||
- MONGO_URL=mongodb://user:user-password@db:27017/odyssey-test
|
||||
- MONGO_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@db:27017/${MONGO_DB}
|
||||
|
||||
db:
|
||||
image: mongo
|
||||
@@ -56,10 +62,10 @@ services:
|
||||
environment:
|
||||
- BASE_URL=${BASE_URL}
|
||||
- MONGO_INITDB_ROOT_USERNAME=admin
|
||||
- MONGO_INITDB_ROOT_PASSWORD=admin-password
|
||||
- MONGO_USER=user
|
||||
- MONGO_PASS=user-password
|
||||
- MONGO_DB=odyssey-test
|
||||
- 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}
|
||||
@@ -81,6 +87,7 @@ networks:
|
||||
internal:
|
||||
players:
|
||||
name: linux-odyssey-players
|
||||
internal: true
|
||||
|
||||
volumes:
|
||||
mongo:
|
||||
@@ -42,6 +42,7 @@ services:
|
||||
- BASE_URL=http://localhost:5173
|
||||
- MONGO_URL=mongodb://user:user-password@db-test:27017/odyssey-test
|
||||
- DOCKER_NETWORK=linux-odyssey-players
|
||||
- SECRET_KEY=b1d2e917-17b9-4fce-9055-082dee6ec5d6 # Only for testing
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
|
||||
@@ -76,3 +77,4 @@ networks:
|
||||
internal:
|
||||
players:
|
||||
name: linux-odyssey-players
|
||||
internal: true
|
||||
@@ -36,6 +36,8 @@ services:
|
||||
- BASE_URL=http://localhost:5173
|
||||
- MONGO_URL=mongodb://db:27017/odyssey-test
|
||||
- DOCKER_NETWORK=linux-odyssey-players
|
||||
- SECRET_KEY=b1d2e917-17b9-4fce-9055-082dee6ec5d6 # Only for testing
|
||||
- TRUSTED_PROXIES=${TRUSTED_PROXIES}
|
||||
- HOST_PWD=${PWD}
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||
@@ -93,3 +95,4 @@ networks:
|
||||
internal:
|
||||
players:
|
||||
name: linux-odyssey-players
|
||||
internal: true
|
||||
@@ -18,4 +18,15 @@ clean() {
|
||||
docker rm -f $containers
|
||||
}
|
||||
|
||||
test() {
|
||||
if [ "$1" = "--build" ]; then
|
||||
docker compose -f docker-compose.testing.yml build
|
||||
fi
|
||||
docker compose -f docker-compose.testing.yml run --rm cypress
|
||||
}
|
||||
|
||||
down() {
|
||||
docker compose -f docker-compose.testing.yml down
|
||||
}
|
||||
|
||||
$@
|
||||
@@ -22,6 +22,7 @@
|
||||
"test": "yarn node --experimental-vm-modules $(yarn bin jest)",
|
||||
"test:e2e": "yarn workspace tests test",
|
||||
"analytics": "yarn workspace analytics dev",
|
||||
"setup": "yarn workspace server setup",
|
||||
"clean": "yarn workspace server clean"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export const passwordPolicy = {
|
||||
minLength: 8,
|
||||
minNumbers: 1,
|
||||
minLowercase: 1,
|
||||
minUppercase: 1,
|
||||
minSymbols: 0,
|
||||
}
|
||||
|
||||
export default {
|
||||
passwordPolicy,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@linux-odyssey/constants",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "index.js",
|
||||
"exports": "./index.js",
|
||||
"license": "GPL"
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
export function isValidUsername(username) {
|
||||
// check if the username is between 1 and 32 characters
|
||||
if (username.length > 32) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Define a regular expression pattern for a valid Linux username.
|
||||
const usernamePattern = /^[a-z][a-z0-9_-]*$/
|
||||
|
||||
// Test the username against the pattern.
|
||||
if (!usernamePattern.test(username)) return false
|
||||
|
||||
// check if the username is between 1 and 32 characters
|
||||
if (username.length < 4 || username.length > 32) {
|
||||
return false
|
||||
}
|
||||
|
||||
const reservedWords = [
|
||||
'root',
|
||||
'admin',
|
||||
|
||||
+3
-1
@@ -2,10 +2,11 @@ FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY ./package.json yarn.lock ./
|
||||
COPY server/package.json ./server/package.json
|
||||
COPY packages/file-graph/package.json ./packages/file-graph/package.json
|
||||
COPY packages/utils/package.json ./packages/utils/package.json
|
||||
COPY packages/models/package.json ./packages/models/package.json
|
||||
COPY packages/constants/package.json ./packages/constants/package.json
|
||||
COPY server/package.json ./server/package.json
|
||||
RUN yarn install --frozen-lockfile --production
|
||||
# Install docker cli
|
||||
COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/
|
||||
@@ -20,6 +21,7 @@ FROM build as prod
|
||||
COPY packages/file-graph ./packages/file-graph
|
||||
COPY packages/utils ./packages/utils
|
||||
COPY packages/models ./packages/models
|
||||
COPY packages/constants ./packages/constants
|
||||
COPY ./server ./server
|
||||
COPY quests ./quests
|
||||
|
||||
|
||||
+6
-1
@@ -7,18 +7,22 @@
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"test": "yarn node --experimental-vm-modules $(yarn bin jest)",
|
||||
"setup": "node src/cli/setup.js",
|
||||
"clean": "node src/cli/clean.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@linux-odyssey/constants": "*",
|
||||
"@linux-odyssey/file-graph": "*",
|
||||
"@linux-odyssey/utils": "*",
|
||||
"@linux-odyssey/models": "*",
|
||||
"@linux-odyssey/utils": "*",
|
||||
"bcrypt": "^5.1.1",
|
||||
"commander": "^11.0.0",
|
||||
"connect-mongo": "^5.0.0",
|
||||
"dockerode": "^3.3.5",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.3",
|
||||
"express-session": "^1.17.3",
|
||||
"express-validator": "^7.0.1",
|
||||
"jsonwebtoken": "^9.0.1",
|
||||
"minimist": "^1.2.8",
|
||||
"mongoose": "^7.4.0",
|
||||
@@ -30,6 +34,7 @@
|
||||
"passport-local": "^1.0.0",
|
||||
"socket.io": "^4.7.1",
|
||||
"swagger-ui-express": "^5.0.0",
|
||||
"validator": "^13.11.0",
|
||||
"yaml": "^2.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isValidEmail, isValidUsername } from '@linux-odyssey/utils'
|
||||
import { matchedData } from 'express-validator'
|
||||
import { User } from '@linux-odyssey/models'
|
||||
import { genJWT, hashPassword } from '../../utils/auth.js'
|
||||
|
||||
@@ -12,67 +12,8 @@ export async function issueToken(req, res) {
|
||||
res.json({ token })
|
||||
}
|
||||
|
||||
async function invalidUsername(username) {
|
||||
return !isValidUsername(username) || User.exists({ username })
|
||||
}
|
||||
|
||||
async function invalidEmail(email) {
|
||||
return !isValidEmail(email) || User.exists({ email })
|
||||
}
|
||||
|
||||
export async function checkUsername(req, res) {
|
||||
const { username, email } = req.query
|
||||
const cred = username || email
|
||||
if (!cred) {
|
||||
res.status(400).json({
|
||||
message: 'username or email is required',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (isValidEmail(cred)) {
|
||||
res.json({
|
||||
type: 'email',
|
||||
available: !(await User.exists({ email: cred })),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (isValidUsername(cred)) {
|
||||
res.json({
|
||||
type: 'username',
|
||||
available: !(await User.exists({ username: cred })),
|
||||
})
|
||||
return
|
||||
}
|
||||
res.status(400).json({
|
||||
message: 'invalid username or email',
|
||||
})
|
||||
}
|
||||
|
||||
export async function register(req, res, next) {
|
||||
const { username, password, email } = req.body
|
||||
if (!(username && password && email)) {
|
||||
res.status(400).json({
|
||||
message: 'username, password, and email are required',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (await invalidUsername(username)) {
|
||||
res.status(409).json({
|
||||
type: 'username',
|
||||
message: 'Invalid username',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (await invalidEmail(email)) {
|
||||
res.status(409).json({
|
||||
type: 'email',
|
||||
message: 'Invalid email',
|
||||
})
|
||||
return
|
||||
}
|
||||
const { username, password, email } = matchedData(req)
|
||||
|
||||
const user = new User({
|
||||
username,
|
||||
@@ -81,7 +22,6 @@ export async function register(req, res, next) {
|
||||
try {
|
||||
user.hashedPassword = await hashPassword(password)
|
||||
await user.save()
|
||||
req.user = user
|
||||
req.login(user, (err) => {
|
||||
if (err) {
|
||||
next(err)
|
||||
@@ -131,24 +71,16 @@ export async function socialLogin(req, res) {
|
||||
}
|
||||
|
||||
export async function registerFromSession(req, res) {
|
||||
const { username } = req.body
|
||||
const { username } = matchedData(req)
|
||||
const { newUser } = req.session
|
||||
|
||||
if (!newUser) {
|
||||
res.status(400).json({
|
||||
res.status(401).json({
|
||||
message: 'no new user found',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (await invalidUsername(username)) {
|
||||
res.status(409).json({
|
||||
type: 'username',
|
||||
message: 'Invalid username',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
newUser.username = username
|
||||
const user = new User(newUser)
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Session, Quest } from '@linux-odyssey/models'
|
||||
import { createContainer, deleteContainer } from '../../containers/docker.js'
|
||||
import SessionHandler from '../../game/sessionHandler.js'
|
||||
import { matchedData } from 'express-validator'
|
||||
import { Session } from '@linux-odyssey/models'
|
||||
import {
|
||||
createNewSession,
|
||||
getOrCreateActiveSession,
|
||||
} from '../../game/sessionManager.js'
|
||||
|
||||
function sessionSummary(session) {
|
||||
return {
|
||||
@@ -37,53 +40,14 @@ export async function getSessionList(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
async function deactivateSessions(user, quest) {
|
||||
const sessions = await Session.find({
|
||||
user,
|
||||
quest,
|
||||
status: 'active',
|
||||
})
|
||||
return Promise.all(
|
||||
sessions.map((session) => {
|
||||
return deleteContainer(session.containerId).then(() => {
|
||||
session.status = 'inactive'
|
||||
session.containerId = null
|
||||
return session.save()
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function createSession(req, res) {
|
||||
const quest = await Quest.findById(req.body.quest_id)
|
||||
if (!quest) {
|
||||
res.status(400).json({ message: 'Quest not found.' })
|
||||
return
|
||||
}
|
||||
|
||||
// deactivate all active sessions
|
||||
deactivateSessions(req.user._id, quest._id).catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
|
||||
const { quest_id } = matchedData(req)
|
||||
try {
|
||||
const container = await createContainer(
|
||||
`quest-${quest.id}-${req.user.username}-${Date.now()}`
|
||||
)
|
||||
const newSession = new Session({
|
||||
user: req.user,
|
||||
quest,
|
||||
containerId: container.id,
|
||||
})
|
||||
|
||||
const session = new SessionHandler(newSession)
|
||||
session.addNewTasks()
|
||||
|
||||
await newSession.save()
|
||||
res.status(201).json(sessionDetail(newSession))
|
||||
const session = await createNewSession(req.user, quest_id)
|
||||
res.status(201).json(sessionDetail(session))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
res.status(400).json({ message: err.message })
|
||||
res.status(500).json({ message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,3 +86,14 @@ export async function deleteSessionById(req, res) {
|
||||
res.status(500).json({ message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrCreateSession(req, res) {
|
||||
const { quest_id } = matchedData(req)
|
||||
try {
|
||||
const session = await getOrCreateActiveSession(req.user, quest_id)
|
||||
res.json(sessionDetail(session))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
res.status(500).json({ message: err.message })
|
||||
}
|
||||
}
|
||||
@@ -5,29 +5,38 @@ import enabledMethods from '../../auth/passport.js'
|
||||
|
||||
import {
|
||||
register,
|
||||
checkUsername,
|
||||
checkSession,
|
||||
logout,
|
||||
socialLogin,
|
||||
registerFromSession,
|
||||
} from '../controllers/authController.js'
|
||||
import {
|
||||
checkUsernameValidators,
|
||||
loginValidators,
|
||||
registerFromSessionValidators,
|
||||
registerValidators,
|
||||
} from '../validators/authValidator.js'
|
||||
import { authenticateRateLimit } from '../../middleware/rateLimiter.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post(
|
||||
'/login',
|
||||
authenticateRateLimit,
|
||||
loginValidators,
|
||||
passport.authenticate('local', { failureMessage: true }),
|
||||
(req, res) => {
|
||||
res.json({ message: 'success' })
|
||||
}
|
||||
)
|
||||
|
||||
router.post('/register', register)
|
||||
router.post('/register', authenticateRateLimit, registerValidators, register)
|
||||
|
||||
router.post('/logout', logout)
|
||||
|
||||
router.get('/check-username', checkUsername)
|
||||
router.post('/logout', authenticateRateLimit, logout)
|
||||
|
||||
router.get('/check-username', checkUsernameValidators, (req, res) =>
|
||||
res.json({ available: true })
|
||||
)
|
||||
router.get('/check-session', checkSession)
|
||||
|
||||
router.get('/available-methods', (req, res) => {
|
||||
@@ -36,14 +45,29 @@ router.get('/available-methods', (req, res) => {
|
||||
|
||||
if (enabledMethods.google) {
|
||||
router.get('/google', passport.authenticate('google'))
|
||||
router.get('/google/callback', passport.authenticate('google'), socialLogin)
|
||||
router.get(
|
||||
'/google/callback',
|
||||
authenticateRateLimit,
|
||||
passport.authenticate('google'),
|
||||
socialLogin
|
||||
)
|
||||
}
|
||||
|
||||
if (enabledMethods.github) {
|
||||
router.get('/github', passport.authenticate('github'))
|
||||
router.get('/github/callback', passport.authenticate('github'), socialLogin)
|
||||
router.get(
|
||||
'/github/callback',
|
||||
authenticateRateLimit,
|
||||
passport.authenticate('github'),
|
||||
socialLogin
|
||||
)
|
||||
}
|
||||
|
||||
router.post('/register-from-session', registerFromSession)
|
||||
router.post(
|
||||
'/register-from-session',
|
||||
authenticateRateLimit,
|
||||
registerFromSessionValidators,
|
||||
registerFromSession
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -5,12 +5,19 @@ import quests from './quests.js'
|
||||
import commands from './commands.js'
|
||||
import authRouter from './auth.js'
|
||||
import authRequired from '../../middleware/authRequired.js'
|
||||
import config from '../../config.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
res.send('Hello API!')
|
||||
})
|
||||
if (!config.isProduction) {
|
||||
router.get('/', (req, res) => {
|
||||
res.send('Hello API!')
|
||||
})
|
||||
|
||||
router.get('/ip', (req, res) => {
|
||||
res.json({ ip: req.ip, forwarded: req.headers['x-forwarded-for'] })
|
||||
})
|
||||
}
|
||||
|
||||
router.use('/auth', authRouter)
|
||||
router.use('/quests', quests)
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import { Router } from 'express'
|
||||
import * as sessionController from '../controllers/sessionController.js'
|
||||
import { sessionRateLimit } from '../../middleware/rateLimiter.js'
|
||||
import {
|
||||
createSessionValidator,
|
||||
activeSessionValidator,
|
||||
} from '../validators/sessionValidator.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', sessionController.getSessionList)
|
||||
router.post('/', sessionController.createSession)
|
||||
router.post(
|
||||
'/',
|
||||
createSessionValidator,
|
||||
sessionRateLimit,
|
||||
sessionController.createSession
|
||||
)
|
||||
router.post(
|
||||
'/active',
|
||||
activeSessionValidator,
|
||||
sessionController.getOrCreateSession
|
||||
)
|
||||
router.get('/:id', sessionController.getSessionById)
|
||||
router.delete('/:id', sessionController.deleteSessionById)
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ async function connectClient(socket) {
|
||||
}
|
||||
const sessionId = socket.handshake.query.session_id
|
||||
if (!sessionId) {
|
||||
socket.send('Session ID not found.')
|
||||
socket.emit('error', 'Session ID not found.')
|
||||
socket.disconnect()
|
||||
return
|
||||
}
|
||||
@@ -46,7 +46,7 @@ async function connectClient(socket) {
|
||||
status: 'active',
|
||||
})
|
||||
if (!session) {
|
||||
socket.send('Session not found.')
|
||||
socket.emit('error', 'Session not found.')
|
||||
socket.disconnect()
|
||||
return
|
||||
}
|
||||
@@ -61,7 +61,7 @@ async function connectClient(socket) {
|
||||
container = await getAndStartContainer(session.containerId)
|
||||
stream = await attachContainer(container, { token })
|
||||
} catch (err) {
|
||||
socket.send(err.message)
|
||||
socket.emit('error', 'Failed to start container.')
|
||||
socket.disconnect()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { check } from 'express-validator'
|
||||
|
||||
import { isValidUsername, isValidEmail } from '@linux-odyssey/utils'
|
||||
import { User } from '@linux-odyssey/models'
|
||||
import { passwordPolicy } from '@linux-odyssey/constants'
|
||||
import { noError } from '../../middleware/validator.js'
|
||||
|
||||
const checkUsername = () =>
|
||||
check('username')
|
||||
.notEmpty()
|
||||
.isString()
|
||||
.custom((username) => {
|
||||
if (!isValidUsername(username)) {
|
||||
throw new Error('Invalid username')
|
||||
}
|
||||
return true
|
||||
})
|
||||
.trim()
|
||||
|
||||
const checkNewUsername = () =>
|
||||
checkUsername().custom(async (username) => {
|
||||
if (await User.exists({ username })) {
|
||||
throw new Error('Username taken')
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const checkEmail = () => check('email').notEmpty().isString().isEmail().trim()
|
||||
|
||||
const checkNewEmail = () =>
|
||||
checkEmail().custom(async (email) => {
|
||||
if (await User.exists({ email })) {
|
||||
throw new Error('Email taken')
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const checkPassword = () =>
|
||||
check('password').notEmpty().isStrongPassword(passwordPolicy)
|
||||
|
||||
const checkLogin = () =>
|
||||
check('username')
|
||||
.isString()
|
||||
.notEmpty()
|
||||
.trim()
|
||||
.custom((login) => {
|
||||
if (!isValidUsername(login) && !isValidEmail(login)) {
|
||||
throw new Error('Invalid login')
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
export const registerValidators = [
|
||||
checkNewUsername(),
|
||||
checkNewEmail(),
|
||||
checkPassword(),
|
||||
noError,
|
||||
]
|
||||
|
||||
export const registerFromSessionValidators = [checkNewUsername(), noError]
|
||||
|
||||
export const loginValidators = [checkLogin(), noError]
|
||||
|
||||
export const checkUsernameValidators = [checkNewUsername(), noError]
|
||||
@@ -0,0 +1,8 @@
|
||||
import { check } from 'express-validator'
|
||||
import { noError } from '../../middleware/validator.js'
|
||||
|
||||
const checkQuestId = () =>
|
||||
check('quest_id').notEmpty().isString().isLowercase().isLength({ max: 64 })
|
||||
|
||||
export const createSessionValidator = [checkQuestId(), noError]
|
||||
export const activeSessionValidator = [checkQuestId(), noError]
|
||||
@@ -3,7 +3,7 @@ import config from '../config.js'
|
||||
|
||||
const jwtOptions = {
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
secretOrKey: config.jwtSecret,
|
||||
secretOrKey: config.secret,
|
||||
}
|
||||
|
||||
function verifyJwt(jwtPayload, done) {
|
||||
|
||||
@@ -8,7 +8,7 @@ async function verifyPassword(username, password, done) {
|
||||
$or: [{ username }, { email: username }],
|
||||
})
|
||||
if (!user) {
|
||||
return done(null, false, { message: 'Incorrect username.' })
|
||||
return done(null, false, { message: 'Incorrect username or password.' })
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(password, user.hashedPassword)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { genSecret } from '../utils/env.js'
|
||||
|
||||
function setup() {
|
||||
console.log('Setting up .env file...')
|
||||
const secret = genSecret()
|
||||
console.log(`SECRET_KEY=${secret}`)
|
||||
}
|
||||
|
||||
setup()
|
||||
+13
-1
@@ -1,10 +1,21 @@
|
||||
import validator from 'validator'
|
||||
import { get } from './utils/env.js'
|
||||
|
||||
function getTrustProxies(key) {
|
||||
if (!process.env[key]) {
|
||||
return []
|
||||
}
|
||||
return process.env[key]
|
||||
.split(',')
|
||||
.map((p) => validator.trim(p))
|
||||
.filter((p) => validator.isIPRange(p) || validator.isIP(p))
|
||||
}
|
||||
|
||||
const config = {
|
||||
host: get('HOST', 'localhost'),
|
||||
port: get('PORT', 3000),
|
||||
db: get('MONGO_URL', 'mongodb://localhost:27017/odyssey-test'),
|
||||
jwtSecret: get('JWT_SECRET', 'secret'),
|
||||
secret: get('SECRET_KEY', ''),
|
||||
dockerNetwork: get('DOCKER_NETWORK', 'linux-odyssey-players'),
|
||||
isProduction: process.env.NODE_ENV === 'production',
|
||||
hostPwd: get('HOST_PWD', ''),
|
||||
@@ -18,6 +29,7 @@ const config = {
|
||||
clientID: get('GITHUB_CLIENT_ID', ''),
|
||||
clientSecret: get('GITHUB_CLIENT_SECRET', ''),
|
||||
},
|
||||
trustedProxies: getTrustProxies('TRUSTED_PROXIES'),
|
||||
}
|
||||
|
||||
config.baseUrl = get('BASE_URL', `http://${config.host}:${config.port}`)
|
||||
|
||||
@@ -12,6 +12,7 @@ const containerOptions = {
|
||||
OpenStdin: true,
|
||||
StdinOnce: false,
|
||||
HostConfig: {
|
||||
NetworkMode: config.dockerNetwork,
|
||||
Binds:
|
||||
!config.isProduction && config.hostPwd
|
||||
? [
|
||||
@@ -22,20 +23,21 @@ const containerOptions = {
|
||||
},
|
||||
}
|
||||
|
||||
const network = engine.getNetwork(config.dockerNetwork)
|
||||
// const network = engine.getNetwork(config.dockerNetwork)
|
||||
|
||||
export async function createContainer(name) {
|
||||
const container = await engine.createContainer({
|
||||
export function createContainer(name) {
|
||||
return engine.createContainer({
|
||||
...containerOptions,
|
||||
name,
|
||||
})
|
||||
await network.connect({ Container: container.id })
|
||||
return container
|
||||
}
|
||||
|
||||
export async function getAndStartContainer(id) {
|
||||
console.log(`Getting container: ${id}`)
|
||||
const container = engine.getContainer(id)
|
||||
if (!container) {
|
||||
throw new Error(`Container ${id} not found`)
|
||||
}
|
||||
if (!(await container.inspect()).State.Running) {
|
||||
await container.start()
|
||||
}
|
||||
|
||||
@@ -15,7 +15,13 @@ export async function removeExpired() {
|
||||
await Promise.all(
|
||||
sessions.map(async (session) => {
|
||||
console.log('Expiring session: ', session._id, session.containerId)
|
||||
await deleteContainer(session.containerId)
|
||||
try {
|
||||
await deleteContainer(session.containerId)
|
||||
} catch (err) {
|
||||
if (err.statusCode !== 404) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
if (session.status === 'active') {
|
||||
session.status = 'inactive'
|
||||
}
|
||||
@@ -25,7 +31,7 @@ export async function removeExpired() {
|
||||
)
|
||||
console.log('Done checking for expired sessions')
|
||||
} catch (err) {
|
||||
console.error(err.message)
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@ export default class SessionHandler {
|
||||
this.quest = session.quest
|
||||
}
|
||||
|
||||
getSession() {
|
||||
return this.session
|
||||
}
|
||||
|
||||
getTask(stage) {
|
||||
return this.session.tasks.find((t) => t.id === stage.id)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Session, Quest } from '@linux-odyssey/models'
|
||||
import { createContainer, deleteContainer } from '../containers/docker.js'
|
||||
import SessionHandler from './sessionHandler.js'
|
||||
|
||||
async function deactivateSessions(user, quest) {
|
||||
const sessions = await Session.find({
|
||||
user,
|
||||
quest,
|
||||
status: 'active',
|
||||
})
|
||||
return Promise.all(
|
||||
sessions.map((session) => {
|
||||
return deleteContainer(session.containerId)
|
||||
.catch(console.error)
|
||||
.finally(() => {
|
||||
session.status = 'inactive'
|
||||
session.containerId = null
|
||||
return session.save()
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function createNewSession(user, questId) {
|
||||
const quest = await Quest.findById(questId)
|
||||
if (!quest) {
|
||||
throw new Error(`Quest ${questId} not found`)
|
||||
}
|
||||
|
||||
// deactivate all active sessions
|
||||
deactivateSessions(user._id, quest._id).catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
|
||||
const container = await createContainer(
|
||||
`quest-${quest.id}-${user.username}-${Date.now()}`
|
||||
)
|
||||
|
||||
const sessionHandler = new SessionHandler(
|
||||
new Session({
|
||||
user,
|
||||
quest,
|
||||
containerId: container.id,
|
||||
})
|
||||
)
|
||||
|
||||
sessionHandler.addNewTasks()
|
||||
|
||||
const session = sessionHandler.getSession()
|
||||
await session.save()
|
||||
return session
|
||||
}
|
||||
|
||||
export async function getOrCreateActiveSession(user, questId) {
|
||||
const session = await Session.findOne({
|
||||
user,
|
||||
quest: questId,
|
||||
status: 'active',
|
||||
})
|
||||
if (session) {
|
||||
return session
|
||||
}
|
||||
return createNewSession(user, questId)
|
||||
}
|
||||
+18
-4
@@ -12,11 +12,19 @@ import apiRouter from './api/routes/index.js'
|
||||
import loadAndUpdateQuests from './utils/quest.js'
|
||||
import config from './config.js'
|
||||
import errorHandler from './middleware/error.js'
|
||||
import { globalRateLimit } from './middleware/rateLimiter.js'
|
||||
import sessionMiddleware from './middleware/session.js'
|
||||
import expiryRemovalScheduler from './containers/expiryChecker.js'
|
||||
import { createTestUser } from './utils/auth.js'
|
||||
|
||||
async function main() {
|
||||
if (!config.secret) {
|
||||
console.warn(
|
||||
'No SECRET_KEY found in .env! To set up a persistent key, please run the setup script:'
|
||||
)
|
||||
console.warn('yarn setup')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
try {
|
||||
await connectDB(config.db)
|
||||
console.log('Connected to MongoDB')
|
||||
@@ -25,8 +33,6 @@ async function main() {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await createTestUser()
|
||||
|
||||
await loadAndUpdateQuests()
|
||||
expiryRemovalScheduler()
|
||||
|
||||
@@ -36,7 +42,14 @@ async function main() {
|
||||
const app = express()
|
||||
const server = http.createServer(app)
|
||||
socketServer(server)
|
||||
app.use(errorHandler)
|
||||
app.set('trust proxy', [
|
||||
'loopback',
|
||||
'linklocal',
|
||||
'uniquelocal',
|
||||
...config.trustedProxies,
|
||||
])
|
||||
console.log('Trusted proxies:', config.trustedProxies)
|
||||
app.use(globalRateLimit)
|
||||
app.use(sessionMiddleware)
|
||||
app.use(passport.session())
|
||||
app.use(
|
||||
@@ -53,6 +66,7 @@ async function main() {
|
||||
})
|
||||
|
||||
app.use('/api/v1', apiRouter)
|
||||
app.use(errorHandler)
|
||||
|
||||
server.listen(config.port, config.host, () => {
|
||||
console.log(`Server listening at ${config.baseUrl}`)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
export default function errorHandler(err, req, res, next) {
|
||||
console.error(err.stack) // Log error stack trace to the console
|
||||
res.status(500).send({ message: err.message }) // Send error response
|
||||
res.status(500).send({ message: 'Something broke!' }) // Send error response
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import rateLimit from 'express-rate-limit'
|
||||
|
||||
export const globalRateLimit = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 100,
|
||||
})
|
||||
|
||||
export const authenticateRateLimit = rateLimit({
|
||||
windowMs: 2 * 60 * 1000,
|
||||
max: 5,
|
||||
})
|
||||
|
||||
export const sessionRateLimit = rateLimit({
|
||||
windowMs: 10 * 1000,
|
||||
max: 1,
|
||||
keyGenerator: (req) => {
|
||||
return req.user ? req.user._id : req.ip
|
||||
},
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import MongoStore from 'connect-mongo'
|
||||
import config from '../config.js'
|
||||
|
||||
export default session({
|
||||
secret: config.jwtSecret,
|
||||
secret: config.secret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
rolling: true,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { validationResult } from 'express-validator'
|
||||
|
||||
export function noError(req, res, next) {
|
||||
const result = validationResult(req)
|
||||
if (result.isEmpty()) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
res.status(400).json({
|
||||
message: 'Validation failed',
|
||||
errors: result.array(),
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { User } from '@linux-odyssey/models'
|
||||
import bcrypt from 'bcrypt'
|
||||
import config from '../config.js'
|
||||
|
||||
@@ -7,23 +6,11 @@ export function hashPassword(password) {
|
||||
return bcrypt.hash(password, 10)
|
||||
}
|
||||
|
||||
export async function createTestUser() {
|
||||
let user = await User.findOne({ username: 'alex' })
|
||||
if (!user) {
|
||||
user = new User({
|
||||
username: 'alex',
|
||||
email: 'alex@example.com',
|
||||
})
|
||||
user.hashedPassword = await hashPassword('123456')
|
||||
await user.save()
|
||||
}
|
||||
}
|
||||
|
||||
export function genJWT(payload) {
|
||||
return new Promise((resolve, reject) => {
|
||||
jwt.sign(
|
||||
payload,
|
||||
config.jwtSecret,
|
||||
config.secret,
|
||||
{ expiresIn: config.containerExpiry },
|
||||
(err, token) => {
|
||||
if (err) {
|
||||
@@ -38,7 +25,7 @@ export function genJWT(payload) {
|
||||
|
||||
export function verifyJWT(token) {
|
||||
return new Promise((resolve, reject) => {
|
||||
jwt.verify(token, config.jwtSecret, (err, decoded) => {
|
||||
jwt.verify(token, config.secret, (err, decoded) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import crypto from 'crypto'
|
||||
|
||||
export function get(key, defaultValue) {
|
||||
const value = process.env[key]
|
||||
if (value != null) {
|
||||
@@ -6,3 +7,7 @@ export function get(key, defaultValue) {
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
export function genSecret() {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
@@ -80,6 +80,37 @@ paths:
|
||||
message:
|
||||
type: string
|
||||
|
||||
/sessions/active:
|
||||
post:
|
||||
summary: 'Get or create an active session.'
|
||||
tags:
|
||||
- signed-in
|
||||
description: 'This endpoint returns the latest active session for the user, or creates a new one if no active session exists.'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: 'object'
|
||||
required:
|
||||
- quest_id
|
||||
properties:
|
||||
quest_id:
|
||||
type: 'string'
|
||||
description: 'The ID of the quest.'
|
||||
example: 'helloworld'
|
||||
responses:
|
||||
200:
|
||||
description: 'Return a active session.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Session'
|
||||
400:
|
||||
description: 'Bad Request. The request is invalid or malformed.'
|
||||
500:
|
||||
description: 'Internal Server Error.'
|
||||
|
||||
/sessions/{session_id}:
|
||||
get:
|
||||
tags:
|
||||
@@ -488,6 +519,20 @@ paths:
|
||||
description: Invalid request body
|
||||
'401':
|
||||
description: Unauthorized
|
||||
/ip:
|
||||
get:
|
||||
summary: Get client IP address
|
||||
responses:
|
||||
'200':
|
||||
description: IP address
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ip:
|
||||
type: string
|
||||
example: '127.0.0.1'
|
||||
|
||||
components:
|
||||
schemas:
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
/// <reference types="cypress" />
|
||||
describe('example helloworld app', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/')
|
||||
})
|
||||
|
||||
it('open login page', () => {
|
||||
console.log('baseUrl', Cypress.config('baseUrl'))
|
||||
cy.visit('/register')
|
||||
cy.get('#username').type('alex')
|
||||
cy.get('#password').type('123456{enter}')
|
||||
cy.get('#email').type('alex@gmail.com')
|
||||
cy.get('#password').type('123456As{enter}')
|
||||
})
|
||||
})
|
||||
@@ -2695,6 +2695,11 @@ express-handlebars@^7.1.2:
|
||||
graceful-fs "^4.2.11"
|
||||
handlebars "^4.7.8"
|
||||
|
||||
express-rate-limit@^7.1.3:
|
||||
version "7.1.3"
|
||||
resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-7.1.3.tgz#0eae6c7733316f3d9403a71ad488e31e94ca0aa4"
|
||||
integrity sha512-BDes6WeNYSGRRGQU8QDNwUnwqaBro28HN/TTweM3RlxXRHDld8RLoH7tbfCxAc0hamQyn6aL0KrfR45+ZxknYg==
|
||||
|
||||
express-session@^1.17.3:
|
||||
version "1.17.3"
|
||||
resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.17.3.tgz#14b997a15ed43e5949cb1d073725675dd2777f36"
|
||||
@@ -2709,6 +2714,14 @@ express-session@^1.17.3:
|
||||
safe-buffer "5.2.1"
|
||||
uid-safe "~2.1.5"
|
||||
|
||||
express-validator@^7.0.1:
|
||||
version "7.0.1"
|
||||
resolved "https://registry.yarnpkg.com/express-validator/-/express-validator-7.0.1.tgz#435fac6b5fa838763f78eca05d2206317b92106e"
|
||||
integrity sha512-oB+z9QOzQIE8FnlINqyIFA8eIckahC6qc8KtqLdLJcU3/phVyuhXH3bA4qzcrhme+1RYaCSwrq+TlZ/kAKIARA==
|
||||
dependencies:
|
||||
lodash "^4.17.21"
|
||||
validator "^13.9.0"
|
||||
|
||||
express@^4.18.2:
|
||||
version "4.18.2"
|
||||
resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz#3fabe08296e930c796c19e3c516979386ba9fd59"
|
||||
@@ -6079,6 +6092,11 @@ v8-to-istanbul@^9.0.1:
|
||||
"@types/istanbul-lib-coverage" "^2.0.1"
|
||||
convert-source-map "^1.6.0"
|
||||
|
||||
validator@^13.11.0, validator@^13.9.0:
|
||||
version "13.11.0"
|
||||
resolved "https://registry.yarnpkg.com/validator/-/validator-13.11.0.tgz#23ab3fd59290c61248364eabf4067f04955fbb1b"
|
||||
integrity sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==
|
||||
|
||||
vary@^1, vary@~1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
|
||||
|
||||
Reference in new issue
Block a user