Parcourir la source

Merge branch 'release/v0.0.4'

Jérôme BUISINE il y a 5 ans
Parent
commit
9d15987b4b

+ 18 - 24
README.md

@@ -15,39 +15,46 @@ Use the following environment variables to configure the application.
 | ----------- | ------------- | ----------- | :------: | :------: |
 | `PORT` | `5000` | The port used by the started application |  ✅  | ✅ |
 | `SERVE_CLIENT` | `true` | Should the server serve client (Fully local application) |  ✅  | ⬜️ |
+| `IMAGES_PATH` | `./images` | The directory where the images are stored (absolute path if changed ⚠️) |  ✅  | ⬜️ |
+
+Configure more deeply the way the app works by modifying *[config.js](config.js)*.
+
+| Option      | Default value | Description |
+| ----------- | ------------- | ----------- |
+| apiPrefix | `/api` | The url prefix for the API |
+| imageServedUrl | `/api/images` | The url prefix from where the images are served |
+| serverPort | `5000` | The port used by the server |
+| imagesPath | `./images` | The directory where the images are stored |
+| serveClient | `true` | Should the server serve client files from the `/dist` directory |
+| fileNameConvention | `/^(.*)?_([0-9]{2,})\.(.*)$/` | File name convention for images |
+| sceneFileNameBlackList | `['config', 'seuilExpe']` | Files to ignore in scenes |
 
 ### Run server + client
 Linux
 ```sh
-PORT=8080 SERVE_CLIENT=true docker-compose up
+PORT=8080 SERVE_CLIENT=true IMAGE_PATH=/var/images docker-compose up
 ```
 Windows
 ```bat
 SET PORT=8080
 SET SERVE_CLIENT=true
+SET IMAGE_PATH=C:\my\images
 docker-compose up
 ```
 
 ### Run server only
 Linux
 ```sh
-PORT=8080 SERVE_CLIENT=false docker-compose up
+SERVE_CLIENT=false docker-compose up
 ```
 Windows
 ```bat
-SET PORT=8080
 SET SERVE_CLIENT=false
 docker-compose up
 ```
 
 ### Run client only
-Linux
 ```sh
-PORT=8080 docker-compose -f docker-compose.frontapp_only.yml up
-```
-Windows
-```bat
-SET PORT=8080
 docker-compose -f docker-compose.frontapp_only.yml up
 ```
 
@@ -64,7 +71,7 @@ docker-compose -f docker-compose.frontapp_only.yml build
 
 ### Using Windows
 When using Windows, it may happen that you can't properly run the containers because of Windows's path system being different. To circumvant this problem, you can do the [following steps](https://github.com/docker/compose/issues/4303#issuecomment-379563170).
-> 1. On **Command Line**: "_set COMPOSE_CONVERT_WINDOWS_PATHS=1_";
+> 1. On **Command Line**: "set COMPOSE_CONVERT_WINDOWS_PATHS=1";
 > 2. Restart **Docker for Windows**;
 > 3. Go to **Docker for Windows** settings **>** Shared Drives **>** Reset credentials **>** select drive **>** Apply;
 > 4. Reopen **Command Line**;
@@ -87,21 +94,8 @@ export NODE_ENV=production
 Windows
 ```bat
 SET NODE_ENV=production
-SET SERVE_CLIENT=false
 ```
 
-### Configuration 
-Configure the project by modifying *[config.js](config.js)*.
-
-#### Configuration options
-| Option      | Default value | Description |
-| ----------- | ------------- | ----------- |
-| apiPrefix | `/api` | The url prefix for the API |
-| serverPort | `5000` | The port used by the server |
-| imagesPath | `images` | The directory where the images are stored |
-| serveClient | `true` | Should the server serve client files from the `/dist` directory |
-
-
 ### API
 #### Run the server
 ```sh
@@ -133,7 +127,7 @@ yarn run app:dev
 
 
 #### Automatically fix the client code syntax with ESLint
-```
+```sh
 yarn run app:lint
 ```
 

+ 12 - 3
config.js

@@ -8,14 +8,23 @@ export const PRODUCTION_MODE = process.env.NODE_ENV === 'production'
 // The url prefix for the API
 export const apiPrefix = '/api'
 
+// The url prefix from where the images are served
+export const imageServedUrl = apiPrefix + '/images'
+
 // The port used by the server
 export const serverPort = parseInt(process.env.PORT, 10) || 5000
 
-// The directory where the images are stored (do not edit it)
-export const imagesPath = path.resolve(__dirname, 'images')
+// The directory where the images are stored
+export const imagesPath = process.env.IMAGES_PATH || path.resolve(__dirname, 'images')
 
 // Should the server serve client files from the `/dist` directory
-export const serveClient = process.env.SERVE_CLIENT === 'true'
+export const serveClient = process.env.SERVE_CLIENT === 'true' || true
+
+// File name convention for images
+export const fileNameConvention = /^(.*)?_([0-9]{2,})\.(.*)$/
+
+// Files to ignore in scenes
+export const sceneFileNameBlackList = ['config', 'seuilExpe']
 
 // Logger configuration
 export const logger = winston.createLogger({

+ 3 - 3
docker-compose.yml

@@ -11,7 +11,7 @@ services:
           - "${PORT}:${PORT}"
       environment:
           NODE_ENV: production
-          SERVE_CLIENT: $SERVE_CLIENT
-          PORT: $PORT
+          SERVE_CLIENT: "${SERVE_CLIENT:-true}"
+          PORT: "${PORT:-5000}"
       volumes:
-          - ./images:/usr/src/app/images
+          - "${IMAGES_PATH:-./images}:/usr/src/app/images"

+ 116 - 2
server/functions.js

@@ -1,13 +1,15 @@
 'use strict'
 
+import { promises as fs } from 'fs'
+import path from 'path'
 import boom from 'boom'
-import { logger } from '../config'
+import { logger, imagesPath, fileNameConvention, sceneFileNameBlackList } from '../config'
 
 /**
  * Call the error handler if a middleware function throw an error
  *
  * @param {Function} fn original middleware function of the route
- * @returns {Function} the same middleware function of the route but error handled
+ * @returns {Promise<Function>} the same middleware function of the route but error handled
  */
 export const asyncMiddleware = fn => (req, res, next) => {
   Promise.resolve(fn(req, res, next)).catch(err => {
@@ -29,6 +31,7 @@ export const errorHandler = (err, req, res, next) => {
   let logMsg = `Error ${payload.statusCode} - ${payload.error}` +
     ` - Message :\n${payload.message}`
   if (err.data) logMsg += `\nData : \n${JSON.stringify(err.data, null, 2) || err.data}`
+  logMsg += `\nStack trace : \n${err.stack}`
 
   logger.error(logMsg)
 
@@ -48,8 +51,119 @@ export const errorHandler = (err, req, res, next) => {
  * @param {string[]} requiredParameters list of all required parameters
  * @param {object} parameters parameters provided in the request (req.query)
  * @returns {void}
+ * @throws missing parameters
  */
 export const checkRequiredParameters = (requiredParameters, parameters) => {
   if (!requiredParameters.every(aRequiredParameter => Object.keys(parameters).includes(aRequiredParameter)))
     throw boom.badRequest(`Missing parameter(s). Required parameters : ${requiredParameters.join(', ')}.`)
 }
+
+/**
+ * Check a scene name is valid
+ * (Not trying to go back in the file system tree by using `/../`)
+ *
+ * @param {string} sceneName the scene name to check
+ * @returns {void}
+ * @throws invalid scene name
+ */
+export const checkSceneName = sceneName => {
+  if (!/^(?!.*\.\.).*$/.test(sceneName))
+    throw boom.conflict(`The requested scene name "${sceneName}" is not valid.`)
+}
+
+/**
+ * Check a file name is valid with configure convention
+ *
+ * @param {string} fileName the file name to check
+ * @returns {void}
+ * @throws file name does not match convention
+ */
+export const checkFileName = fileName => {
+  if (!fileNameConvention.test(fileName))
+    throw new Error(`The file name does not match convention (scene_000150.ext - ${fileNameConvention.toString()}) : "${fileName}"`)
+}
+
+/**
+ * Get all files in a scene
+ *
+ * @param {string} sceneName the scene name
+ * @returns {string[]} the list of all files in the scene
+ * @throws scene directory is not accessible
+ */
+export const getSceneFiles = sceneName => {
+  // Check the scene name is valid
+  checkSceneName(sceneName)
+
+  // Path to the scene directory
+  const scenePath = path.resolve(imagesPath, sceneName)
+
+  return fs.readdir(scenePath).catch(() => {
+    throw boom.badRequest(`Can't access the "${scenePath}" directory. Check it exists and you have read permission on it.`)
+  })
+}
+
+/** Image data type definition (do no remove)
+ * @typedef {object} ImageData
+ * @property {string} prefix prefix of image
+ * @property {number} quality quality of image
+ * @property {string} ext extension of image
+ */
+/**
+ * Get image data from every files in a scene (exclude blacklisted ones)
+ * @typedef {string} filename path to the image
+ * @param {string} sceneName the scene name
+ * @returns {Promise<Map<filename, ImageData>>} the data for all images in a scene (Map key = file name)
+ * @throws some file names could not be parsed
+ */
+export const getSceneFilesData = async sceneName => {
+  // Get scene files
+  const files = await getSceneFiles(sceneName)
+
+  // A list of all fails parsing scene file names
+  let failList = []
+
+  // Parse file name to get qualities
+  const data = files.reduce((acc, image) => {
+    // Go to next file if its name contains a blacklisted word
+    if (!sceneFileNameBlackList.every(x => image !== x))
+      return acc
+
+    // Check file name is valid
+    try {
+      checkFileName(image)
+    }
+    catch (err) {
+      failList.push(err.message)
+      return acc
+    }
+
+    // Parse file name
+    try {
+      const regexRes = fileNameConvention.exec(image)
+      // Check valid file name
+      if (regexRes.length !== 4) return acc
+
+      const fileData = {
+        prefix: regexRes[1],
+        quality: parseInt(regexRes[2], 10),
+        ext: regexRes[3]
+      }
+
+      // Check valid quality
+      if (isNaN(fileData.quality)) return acc
+
+      // Data is valid, set it
+      acc.set(regexRes[0], fileData)
+    }
+    catch (err) {
+      failList.push(`Failed to parse file name : ${image}`)
+    }
+    return acc
+  }, new Map())
+
+  // Check if the parse fail list is empty
+  if (failList.length > 0)
+    throw boom.conflict(`Failed to parse file names in the "${sceneName}"'s scene directory.`, failList)
+
+  return data
+}

+ 7 - 7
server/index.js

@@ -8,7 +8,7 @@ import helmet from 'helmet'
 import cors from 'cors'
 import routes from './routes'
 import { errorHandler } from './functions'
-import { apiPrefix, serverPort, serveClient, imagesPath, logger } from '../config'
+import { apiPrefix, imageServedUrl, serverPort, serveClient, imagesPath, logger } from '../config'
 const morgan = require('morgan')
 
 const app = express()
@@ -22,6 +22,11 @@ app.use(compression())
 // Enhance the app security by setting some HTTP headers
 app.use(helmet())
 
+// Serve images. "serve-static" is used because it caches images ("express.static" doesn't)
+app.use(imageServedUrl, serveStatic(imagesPath))
+
+// Load all the API routes in the server
+app.use(apiPrefix, routes)
 
 if (serveClient) {
   // Serve client files (Client is local)
@@ -31,14 +36,9 @@ else {
   // Don't serve client files (Client is remote)
   // Turn "Cross-origin resource sharing" on to allow the remote client to connect to the API
   app.use(cors())
+  app.get('*', (req, res) => res.status(404).send('Client is not served.'))
 }
 
-// Serve images. "serve-static" is used because it caches images ("express.static" doesn't)
-app.use(apiPrefix + '/images', serveStatic(imagesPath))
-
-// Load all the API routes in the server
-app.use(apiPrefix, routes)
-
 // Error handler (Middleware called when throwing in another middleware)
 app.use(errorHandler)
 

+ 33 - 7
server/routes/getImage.js

@@ -1,12 +1,10 @@
 'use strict'
 
 import express from 'express'
-import { promises } from 'fs'
+import boom from 'boom'
 
-import { imagesPath } from '../../config'
-import { asyncMiddleware, checkRequiredParameters } from '../functions'
-
-const fs = promises
+import { imageServedUrl } from '../../config'
+import { asyncMiddleware, checkSceneName, checkRequiredParameters, getSceneFilesData } from '../functions'
 
 const router = express.Router()
 
@@ -14,8 +12,36 @@ router.get('/', asyncMiddleware(async (req, res) => {
   // Check the request contains all the required parameters
   checkRequiredParameters(['sceneName', 'imageQuality'], req.query)
 
-  const dirContent = await fs.readdir(imagesPath)
-  res.json({ msg: 'Not ready yet' })
+  const { sceneName, imageQuality } = req.query
+
+  let errorList = []
+
+  // Check the scene name is valid
+  try {
+    checkSceneName(sceneName)
+  }
+  catch (err) {
+    errorList.push(err)
+  }
+
+  // Check `imageQuality` is an integer
+  const qualityInt = parseInt(imageQuality, 10)
+  if (isNaN(qualityInt)) errorList.push('The specified quality is not an integer.')
+
+  // Check there is no errors with parameters
+  if (errorList.length > 0)
+    throw boom.badRequest('Invalid query parameter(s).', errorList)
+
+
+  const sceneData = await getSceneFilesData(sceneName)
+
+  // Search an image with the requested quality in the scene
+  for (const [imageName, imageData] of sceneData.entries()) {
+    if (qualityInt === imageData.quality)
+      return res.json({ link: `${imageServedUrl}/${sceneName}/${imageName}` })
+  }
+
+  throw boom.notFound(`The requested quality (${imageQuality}) was not found for the requested scene (${sceneName}).`)
 }))
 
 export default router

+ 5 - 55
server/routes/listSceneQualities.js

@@ -1,11 +1,7 @@
 'use strict'
 
 import express from 'express'
-import { promises as fs } from 'fs'
-import path from 'path'
-import boom from 'boom'
-import { imagesPath } from '../../config'
-import { asyncMiddleware, checkRequiredParameters } from '../functions'
+import { asyncMiddleware, checkRequiredParameters, getSceneFilesData } from '../functions'
 
 const router = express.Router()
 
@@ -14,56 +10,10 @@ router.get('/', asyncMiddleware(async (req, res) => {
   // Check the request contains all the required parameters
   checkRequiredParameters(['sceneName'], req.query)
 
-  const sceneName = req.query.sceneName
-
-  // Check the scene name is valid (Not trying to go back in the file system tree by using `/../`)
-  if (!/^(?!.*\.\.).*$/.test(sceneName))
-    throw boom.conflict(`The requested scene name "${sceneName}" is not valid.`)
-
-  // Path to the scene directory
-  const scenePath = path.resolve(imagesPath, sceneName)
-
-  // Get the list of all images in the selected scene
-  const images = await fs.readdir(scenePath)
-    .catch(() => {
-      // The images directory does not exist or is not accessible
-      throw boom.badRequest(`Can't access the "${scenePath}" directory. Check it exists and you have read permission on it.`)
-    })
-
-  // List of blacklisted words from image names
-  const blackList = ['config', 'seuilExpe']
-
-  // A list of all fails parsing file names
-  let failList = []
-  // Parse file name to get qualities
-  const qualities = images.reduce((acc, image) => {
-    // Go to next file if its name contains a blacklisted word
-    if (!blackList.every(x => image !== x))
-      return acc
-
-    // Check if file name contains "_"
-    if (!/^.*?_[0-9]{5}\..*$/.test(image)) {
-      failList.push(`The file name does not match convention (scene_00150.ext) : "${image}"`)
-      return acc
-    }
-    try {
-      const sp = image.split('_')
-      const end = sp[sp.length - 1] // 000650.png
-      const qualityString = end.replace(/\..*/g, '') // 000650
-      const qualityInteger = parseInt(qualityString, 10) // 650
-      acc.push(qualityInteger)
-    }
-    catch (err) {
-      failList.push(`Failed to parse file name : ${image}`)
-    }
-    return acc
-  }, [])
-
-  // Check if the parse fail list is empty
-  if (failList.length > 0)
-    throw boom.conflict(`Failed to parse file names in the "${sceneName}"'s scene directory.`, failList)
-
-  res.json(qualities)
+  const { sceneName } = req.query
+  const sceneData = await getSceneFilesData(sceneName)
+  const data = Array.from(sceneData.values()).map(x => x.quality)
+  res.json(data)
 }))
 
 export default router

+ 14 - 8
server/routes/listScenes.js

@@ -3,21 +3,27 @@
 import express from 'express'
 import { promises as fs } from 'fs'
 import boom from 'boom'
-import { imagesPath } from '../../config'
 import { asyncMiddleware } from '../functions'
+import { imagesPath } from '../../config'
 
 const router = express.Router()
 
-// Route which returns a list of all available scenes in the `imagesPath` directory
-router.get('/', asyncMiddleware(async (req, res) => {
+/**
+ * Get the list of all files in the images directory
+ *
+ * @returns {string[]} the list of files
+ * @throws the directory does not exist or is not accessible
+ */
+export const getSceneList = () => {
   try {
-    // Return the list of all files in the images directory
-    res.json(await fs.readdir(imagesPath))
+    return fs.readdir(imagesPath)
   }
   catch (err) {
-    // The images directory does not exist or is not accessible
-    throw boom.badRequest(`Can't access the "${imagesPath}" directory. Check it exists and you have read permission on it.`)
+    throw boom.conflict(`Can't access the "${imagesPath}" directory. Check it exists and you have read permission on it.`)
   }
-}))
+}
+
+// Route which returns a list of all available scenes in the `imagesPath` directory
+router.get('/', asyncMiddleware(async (req, res) => res.json(await getSceneList())))
 
 export default router

+ 149 - 0
webhook_deploy_gogs.js

@@ -0,0 +1,149 @@
+/**
+ * A server that listens on a port for Gogs's webhooks.
+ * It will check for a push event on the master branch, then deploy the project on the machine.
+ * The webhook's secret is check to ensure no malicious request from unknown sources.
+ *
+ * Usage :
+ * Set the "WEBHOOK_SECRET" environment variable with the webhook's secret.
+ *
+ * @see https://gogs.io/docs/features/webhook
+ *
+ *
+ * @author Antoine Sauvage <contact@asauvage.fr>
+ * @license MIT 2019 - https://opensource.org/licenses/MIT
+ * @see https://gist.github.com/rigwild/4238a13cb3501c6e85065b403a71b475
+ */
+
+'use strict'
+
+const fs = require('fs')
+const http = require('http')
+const path = require('path')
+const { promisify } = require('util')
+const exec = promisify(require('child_process').exec)
+
+// The port which this script will listen on
+const port = 12345
+
+// The path to the project directory
+const projectPath = path.resolve('.')
+
+// The webhook secret to check the origin of the webhook event
+// Check the "WEBHOOK_SECRET" environment variable is set
+if (!process.env.WEBHOOK_SECRET && process.env.WEBHOOK_SECRET !== '') {
+  console.error(`${new Date().toLocaleString()} - The "WEBHOOK_SECRET" environment variable is not set.`)
+  process.exit(1)
+}
+const webhookSecret = process.env.WEBHOOK_SECRET
+
+// Check whether the project path exists and script has read access
+console.log(`${new Date().toLocaleString()} - Configured project path : ${projectPath}\n`)
+try {
+  fs.accessSync(projectPath, fs.constants.W_OK)
+  console.log(`${new Date().toLocaleString()} - The project's directory exists and script has write permission.`)
+}
+catch (err) {
+  console.error(`${new Date().toLocaleString()} - The project's directory does not exist or script has not write permission.`, err)
+  process.exit(1)
+}
+
+
+// Check the "PORT" environment variable is set to a valid integer
+if (!process.env.PORT || !parseInt(process.env.PORT, 10)) {
+  console.error(`${new Date().toLocaleString()} - The "PORT" environment variable is not set or is not an integer.`)
+  process.exit(1)
+}
+
+// Check the "SERVE_CLIENT" environment variable is set to 'true' or 'false'
+if (!process.env.SERVE_CLIENT || !['true', 'false'].some(x => x === process.env.SERVE_CLIENT)) {
+  console.error(`${new Date().toLocaleString()} - The "SERVE_CLIENT" environment variable is not set or is not 'true' or 'false'`)
+  process.exit(1)
+}
+
+const env = {
+  PORT: parseInt(process.env.PORT, 10),
+  SERVE_CLIENT: process.env.SERVE_CLIENT,
+  IMAGES_PATH: process.env.IMAGES_PATH
+}
+// Recap used environment variables
+Object.keys(env).forEach(x => console.log(`${x}=${env[x]}`))
+
+// The script that will be executed by the machine
+const deployScript = `cd ${projectPath}` +
+  ' && git reset --hard HEAD' +
+  ' && git pull origin master' +
+  ' && docker-compose down' +
+  ' && docker-compose build' +
+  ' && docker-compose up -d'
+
+console.log('\nConfiguration is valid. Starting the webhook-listener server ...')
+
+const deploy = async () => {
+  try {
+    console.log(`${new Date().toLocaleString()} - Deploying project ...`)
+    const startTime = process.hrtime()
+    const { stdout, stderr } = await exec(
+      deployScript,
+      {
+        cwd: projectPath,
+        env
+      }
+    )
+    const endTime = process.hrtime(startTime)
+
+    // Logs received from the deploy script are sent in stdout :
+    // git fetch and docker-compose build/up are writing their success logs in stderr...
+    // A deploy fail will be printed in stderr (in the catch)
+    console.log('stdout :\n', stdout)
+    console.log('stderr :\n', stderr)
+    console.log(`\n${new Date().toLocaleString()} - Project successfully deployed with Docker.`)
+    console.log(`Total deploy time : ${endTime[0]}s and ${endTime[1] / 1000000}ms.`)
+  }
+  catch (err) {
+    console.error(`\n${new Date().toLocaleString()} - Error deploying project.\n`, err)
+  }
+}
+
+// Configuration is fine, start the server
+http.createServer((req, res) => {
+  // Check the method is POST
+  if (req.method !== 'POST') {
+    res.statusCode = 400
+    res.write('Wrong HTTP method.')
+    res.end()
+    return
+  }
+
+  // Check the event is a push
+  if (req.headers['x-gogs-event'] !== 'push') {
+    res.statusCode = 200
+    res.write('OK. Not a push event.')
+    res.end()
+    return
+  }
+
+  // Answer OK and close the connection
+  res.statusCode = 200
+  res.write('OK')
+  res.end()
+
+  let body = []
+  req.on('data', chunk => body.push(chunk))
+  req.on('end', () => {
+    try {
+      body = JSON.parse(Buffer.concat(body).toString())
+      // Check if the event was on master
+      if (!body.ref || body.ref !== 'refs/heads/master') return
+
+      // Check if secret matches
+      if (!body.secret || body.secret !== webhookSecret) return
+
+      console.log(`${new Date()} - Valid webhook event. Push on master was sent. Deployement process starts.`)
+      deploy()
+    }
+    catch (err) {
+      console.error(`${new Date()} - Invalid JSON was received`)
+    }
+  })
+}).listen(port)
+console.log(`${new Date().toLocaleString()} - Server is listening on http://localhost:${port}/`)