Object Preview service

This commit is contained in:
Cristian Balas
2021-04-29 15:11:22 +03:00
committed by GitHub
parent fb96e49852
commit 93edc65f66
30 changed files with 18312 additions and 5 deletions
+6 -3
View File
@@ -27,13 +27,13 @@ workflows:
only: /^v.*/
branches:
ignore: /.*/
test-test:
cristi-ci-test:
jobs:
- docker_build_and_deploy:
context: main-builds
filters:
branches:
only: cristi/ci-test-deployment
only: cristi/ci-test
jobs:
test_server:
@@ -86,7 +86,10 @@ jobs:
name: Build Server
command: env SPECKLE_SERVER_PACKAGE=server ./.circleci/build.sh
- run:
name: Deploy Frontend and Server
name: Build Object Preview Service
command: env SPECKLE_SERVER_PACKAGE=preview-service ./.circleci/build.sh
- run:
name: Deploy
command: ./.circleci/deploy.sh
- run:
name: Test deployment
+14
View File
@@ -28,6 +28,13 @@ echo "$K8S_CLUSTER_CERTIFICATE" | base64 --decode > k8s_cert.crt
--token=$K8S_TOKEN \
set image deployment/$TARGET_SPECKLE_DEPLOYMENT-server main=$DOCKER_IMAGE_TAG-server:$IMAGE_VERSION_TAG
./kubectl \
--kubeconfig=/dev/null \
--server=$K8S_SERVER \
--certificate-authority=k8s_cert.crt \
--token=$K8S_TOKEN \
set image deployment/$TARGET_SPECKLE_DEPLOYMENT-preview-service main=$DOCKER_IMAGE_TAG-preview-service:$IMAGE_VERSION_TAG
# Wait for rollout to complete
./kubectl \
@@ -43,3 +50,10 @@ echo "$K8S_CLUSTER_CERTIFICATE" | base64 --decode > k8s_cert.crt
--certificate-authority=k8s_cert.crt \
--token=$K8S_TOKEN \
rollout status -w deployment/$TARGET_SPECKLE_DEPLOYMENT-server --timeout=1m
./kubectl \
--kubeconfig=/dev/null \
--server=$K8S_SERVER \
--certificate-authority=k8s_cert.crt \
--token=$K8S_TOKEN \
rollout status -w deployment/$TARGET_SPECKLE_DEPLOYMENT-preview-service --timeout=1m
+41
View File
@@ -0,0 +1,41 @@
{
"env": {
"browser": true,
"commonjs": true,
"es2020": true
},
"parserOptions": {
"ecmaVersion": 11
},
"ignorePatterns": ["node_modules/*"],
"rules": {
"arrow-spacing": [
2,
{
"before": true,
"after": true
}
],
"array-bracket-spacing": [2, "always"],
"object-curly-spacing": [1, "always"],
"block-spacing": [2, "always"],
"camelcase": [
1,
{
"properties": "always"
}
],
"space-in-parens": [2, "always"],
"keyword-spacing": 2,
"semi": [1, "never"],
"quotes": [1, "single"],
"indent": ["error", 2],
"space-unary-ops": [
2,
{
"words": true,
"nonwords": false
}
]
}
}
+44
View File
@@ -0,0 +1,44 @@
# NOTE: Docker context should be set to git root directory, to include the viewer
FROM node:14.16.0-buster-slim as node
RUN apt-get update && apt-get install -y \
tini \
&& rm -rf /var/lib/apt/lists/*
# chromium dependencies
RUN apt-get update && apt-get install -y \
ca-certificates fonts-liberation libappindicator3-1 libasound2 libatk-bridge2.0-0 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 lsb-release wget xdg-utils \
&& rm -rf /var/lib/apt/lists/*
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}
WORKDIR /app
WORKDIR /opt/objectloader
COPY packages/objectloader/package*.json ./
RUN npm install --production=false
COPY packages/objectloader .
# RUN npm run build
WORKDIR /opt/viewer
COPY packages/viewer/package*.json ./
RUN npm install ../objectloader
# Install dependencies and devDependencies
RUN npm install --production=false
COPY packages/viewer .
RUN npm run build
WORKDIR /opt/preview-service
COPY packages/preview-service/package*.json ./
RUN npm install ../objectloader
RUN npm install ../viewer
RUN npm ci --production=false
COPY packages/preview-service .
RUN npm run build-fe
ENTRYPOINT [ "tini", "--" ]
CMD ["node", "bin/www"]
+40
View File
@@ -0,0 +1,40 @@
'use strict'
var createError = require( 'http-errors' )
var express = require( 'express' )
var path = require( 'path' )
var cookieParser = require( 'cookie-parser' )
var logger = require( 'morgan' )
var indexRouter = require( './routes/index' )
var previewRouter = require( './routes/preview' )
var objectsRouter = require( './routes/objects' )
var app = express()
app.use( logger( 'dev' ) )
app.use( express.json() )
app.use( express.urlencoded( { extended: false } ) )
app.use( cookieParser() )
app.use( express.static( path.join( __dirname, 'public' ) ) )
app.use( '/', indexRouter )
app.use( '/preview', previewRouter )
app.use( '/objects', objectsRouter )
// catch 404 and forward to error handler
app.use( function( req, res, next ) {
next( createError( 404 ) )
} )
// error handler
app.use( function( err, req, res, next ) {
let errorText = err.message
if ( req.app.get( 'env' ) === 'development' ) {
errorText = `<html><body><pre>${err.message}: ${err.status}\n${err.stack}</pre></body></html>`
}
res.status( err.status || 500 )
res.send( errorText )
} )
module.exports = app
@@ -0,0 +1,86 @@
'use strict'
const crypto = require( 'crypto' )
const knex = require( '../knex' )
const fetch = require( 'node-fetch' )
const ObjectPreview = ( ) => knex( 'object_preview' )
const Previews = ( ) => knex( 'previews' )
async function startTask() {
let { rows } = await knex.raw( `
UPDATE object_preview
SET
"previewStatus" = 1,
"lastUpdate" = NOW()
FROM (
SELECT "streamId", "objectId" FROM object_preview
WHERE "previewStatus" = 0 OR ("previewStatus" = 1 AND "lastUpdate" < NOW() - INTERVAL '1 HOUR')
ORDER BY "priority" ASC, "lastUpdate" ASC
LIMIT 1
) as task
WHERE object_preview."streamId" = task."streamId" AND object_preview."objectId" = task."objectId"
RETURNING object_preview."streamId", object_preview."objectId"
` )
return rows[0]
}
async function doTask( task ) {
let previewUrl = `http://127.0.0.1:3001/preview/${task.streamId}/${task.objectId}`
let res = await fetch( previewUrl )
res = await res.json()
// let imgBuffer = await res.buffer() // this gets the binary response body
let metadata = {}
for ( let angle in res ) {
const imgBuffer = new Buffer.from( res[angle].replace( /^data:image\/\w+;base64,/, '' ), 'base64' )
let previewId = crypto.createHash( 'md5' ).update( imgBuffer ).digest( 'hex' )
// Save preview image
let insertionObject = { id: previewId, data: imgBuffer }
//await Previews().insert( insertionObject )
//let dbQuery = Previews().insert( insertionObject ).toString( ) + ' on conflict do nothing'
await knex.raw( 'INSERT INTO "previews" (id, data) VALUES (?, ?) ON CONFLICT DO NOTHING', [ previewId, imgBuffer ] )
metadata[angle] = previewId
}
// Update preview metadata
await knex.raw( `
UPDATE object_preview
SET
"previewStatus" = 2,
"lastUpdate" = NOW(),
"preview" = ?
WHERE "streamId" = ? AND "objectId" = ?
`, [ metadata, task.streamId, task.objectId ] )
}
async function tick() {
try {
let task = await startTask()
if ( !task ) {
setTimeout( tick, 1000 )
return
}
doTask( task )
// Check for another task very soon
setTimeout( tick, 10 )
} catch ( err ) {
console.log( 'Error executing task: ', err )
setTimeout( tick, 5000 )
}
}
async function startPreviewService() {
console.log( '📸 Started Preview Service' )
tick()
}
module.exports = { startPreviewService }
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env node
/**
* Module dependencies.
*/
var app = require( '../app' )
var debug = require( 'debug' )( 'preview-service:server' )
var http = require( 'http' )
var { startPreviewService } = require( '../bg_service' )
/**
* Get port from environment and store in Express.
*/
var port = normalizePort( process.env.PORT || '3001' )
app.set( 'port', port )
/**
* Create HTTP server.
*/
var server = http.createServer( app )
/**
* Listen on provided port, on all network interfaces.
*/
server.listen( port, '127.0.0.1' )
server.on( 'error', onError )
server.on( 'listening', onListening )
/**
* Normalize a port into a number, string, or false.
*/
function normalizePort( val ) {
var port = parseInt( val, 10 )
if ( isNaN( port ) ) {
// named pipe
return val
}
if ( port >= 0 ) {
// port number
return port
}
return false
}
/**
* Event listener for HTTP server "error" event.
*/
function onError( error ) {
if ( error.syscall !== 'listen' ) {
throw error
}
var bind = typeof port === 'string'
? 'Pipe ' + port
: 'Port ' + port
// handle specific listen errors with friendly messages
switch ( error.code ) {
case 'EACCES':
console.error( bind + ' requires elevated privileges' )
process.exit( 1 )
break
case 'EADDRINUSE':
console.error( bind + ' is already in use' )
process.exit( 1 )
break
default:
throw error
}
}
/**
* Event listener for HTTP server "listening" event.
*/
function onListening() {
var addr = server.address()
var bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr.port
debug( 'Listening on ' + bind )
startPreviewService()
}
+8
View File
@@ -0,0 +1,8 @@
'use strict'
module.exports = require( 'knex' )( {
client: 'pg',
connection: process.env.PG_CONNECTION_STRING || 'postgres://speckle:speckle@localhost/speckle',
pool: { min: 1, max: 1 }
// migrations are in managed in the server package
} )
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
{
"name": "@speckle/preview-service",
"version": "2.0.0",
"description": "Generate PNG previews of Speckle objects by using a headless viewer",
"main": "index.js",
"homepage": "https://speckle.systems",
"repository": {
"type": "git",
"url": "https://github.com/specklesystems/speckle-server.git",
"directory": "packages/preview-service"
},
"scripts": {
"dev": "DEBUG='preview-service:*' nodemon ./bin/www",
"build-fe": "webpack --env dev --config webpack.config.render_page.js && webpack --env build --config webpack.config.render_page.js"
},
"dependencies": {
"@speckle/objectloader": "file:../objectloader",
"@speckle/viewer": "file:../viewer",
"cookie-parser": "~1.4.4",
"crypto": "^1.0.1",
"debug": "~2.6.9",
"express": "~4.16.1",
"file-type": "^16.3.0",
"http-errors": "~1.6.3",
"jade": "~1.11.0",
"knex": "^0.95.4",
"morgan": "~1.9.1",
"node-fetch": "^2.6.1",
"pg": "^8.6.0",
"pg-query-stream": "^4.1.0",
"puppeteer": "^9.0.0",
"zlib": "^1.0.5"
},
"devDependencies": {
"@babel/core": "^7.13.16",
"babel-loader": "^8.2.2",
"clean-webpack-plugin": "^4.0.0-alpha.0",
"html-webpack-plugin": "^5.3.1",
"nodemon": "^2.0.7",
"webpack": "^5.35.0",
"webpack-cli": "^4.6.0",
"webpack-dev-server": "^3.11.2"
}
}
@@ -0,0 +1 @@
Speckle Object Preview Service
@@ -0,0 +1,12 @@
<!doctype html><html><head><meta charset="utf-8"/><title>Speckle Viewer</title><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{
font-family: 'Space Mono', monospace !important;
margin: 0px;
}
button {
font-family: 'Space Mono', monospace !important;
border-color: #0A66FF;
}
#renderer{
height: 400px;
width: 700px;
}</style><script defer="defer" src="viewer.min.js"></script></head><body><div id="renderer"></div></body></html>
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
/*! For license information please see Speckle.js.LICENSE.txt */
File diff suppressed because one or more lines are too long
View File
@@ -0,0 +1,19 @@
import { Viewer, Converter } from '@speckle/viewer'
import ObjectLoader from '@speckle/objectloader'
let v = new Viewer( { container: document.getElementById( 'renderer' ), showStats: false } )
// v.on( 'load-progress', args => console.log( args ) )
window.v = v
window.LoadData = async function LoadData( url ) {
await v.loadObject( url, token )
}
window.onload = (event) => {
let testUrl = window.location.hash.substr(1);
if (testUrl) {
LoadData(testUrl);
};
};
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Speckle Viewer</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<!--
<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/skeleton/2.0.4/skeleton.min.css" integrity="sha512-EZLkOqwILORob+p0BXZc+Vm3RgJBOe1Iq/0fiI7r/wJgzOFZMlsqTa29UEl6v6U6gsV4uIpsNZoV32YZqrCRCQ==" crossorigin="anonymous" />
-->
<style type="text/css">
body{
font-family: 'Space Mono', monospace !important;
margin: 0px;
}
button {
font-family: 'Space Mono', monospace !important;
border-color: #0A66FF;
}
#renderer{
height: 400px;
width: 700px;
}
</style>
</head>
<body>
<div id="renderer"></div>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
'use strict'
var express = require( 'express' )
var router = express.Router()
router.get( '/', function( req, res, next ) {
res.send( 'Speckle Object Preview Service' )
} )
module.exports = router
+103
View File
@@ -0,0 +1,103 @@
'use strict'
const zlib = require( 'zlib' )
var express = require( 'express' )
var { getObject, getObjectChildrenStream } = require( './services/objects_utils' )
var router = express.Router()
// This method was copy-pasted from the server method, without authentication/authorization (this web service is an internal one)
router.get( '/:streamId/:objectId', async function( req, res, next ) {
// Populate first object (the "commit")
let obj = await getObject( { streamId: req.params.streamId, objectId: req.params.objectId } )
if ( !obj ) {
return res.status( 404 ).send( `Failed to find object ${req.params.objectId}.` )
}
obj = obj.data
let simpleText = req.headers.accept === 'text/plain'
let dbStream = await getObjectChildrenStream( { streamId: req.params.streamId, objectId: req.params.objectId } )
let currentChunkSize = 0
let maxChunkSize = 50000
let chunk = simpleText ? '' : [ ]
let isFirst = true
res.writeHead( 200, { 'Content-Encoding': 'gzip', 'Content-Type': simpleText ? 'text/plain' : 'application/json' } )
const gzip = zlib.createGzip( )
if ( !simpleText ) gzip.write( '[' )
// helper func to flush the gzip buffer
const writeBuffer = ( addTrailingComma ) => {
// console.log( `writing buff ${currentChunkSize}` )
if ( simpleText ) {
gzip.write( chunk )
} else {
gzip.write( chunk.join( ',' ) )
if ( addTrailingComma ) {
gzip.write( ',' )
}
}
gzip.flush( )
chunk = simpleText ? '' : [ ]
}
var objString = JSON.stringify( obj )
if ( simpleText ) {
chunk += `${obj.id}\t${objString}\n`
} else {
chunk.push( objString )
}
writeBuffer( true )
let k = 0
let requestDropped = false
dbStream.on( 'data', row => {
try {
let data = JSON.stringify( row.data )
currentChunkSize += Buffer.byteLength( data, 'utf8' )
if ( simpleText ) {
chunk += `${row.data.id}\t${data}\n`
} else {
chunk.push( data )
}
if ( currentChunkSize >= maxChunkSize ) {
currentChunkSize = 0
writeBuffer( true )
}
k++
} catch ( e ) {
requestDropped = true
debug( 'speckle:error' )( `'Failed to find object, or object is corrupted.' ${req.params.objectId}` )
return
}
} )
dbStream.on( 'error', err => {
console.log("DB ERROR ",err)
debug( 'speckle:error' )( `Error in streaming object children for ${req.params.objectId}: ${err}` )
requestDropped = true
return
} )
dbStream.on( 'end', ( ) => {
if ( currentChunkSize !== 0 ) {
writeBuffer( false )
if ( !simpleText ) gzip.write( ']' )
}
gzip.end( )
} )
// 🚬
gzip.pipe( res )
} )
module.exports = router
+113
View File
@@ -0,0 +1,113 @@
'use strict'
var express = require( 'express' )
var router = express.Router()
const puppeteer = require( 'puppeteer' )
function sleep( ms ) {
return new Promise( ( resolve ) => {
setTimeout( resolve, ms )
} )
}
async function getScreenshot( objectUrl ) {
const browser = await puppeteer.launch( { args: [ '--no-sandbox', '--disable-setuid-sandbox' ] } )
const page = await browser.newPage()
await page.goto( 'http://127.0.0.1:3001/render/' )
console.log("Page loaded")
console.time( 'lo' )
const scr = await page.evaluate( async ( objectUrl ) => {
waitForAnimation = async ( ms=70 ) => await new Promise( ( resolve ) => {
setTimeout( resolve, ms )
} )
let scr = {}
let stepAngle = 0.261799
v.postprocessing = false
v.sceneManager.skipPostLoad = true
await v.loadObject( objectUrl, '' )
v.interactions.zoomExtents( 0.95, false )
await waitForAnimation()
scr['0'] = v.interactions.screenshot()
for ( let i = 1; i < 3; i++ ) {
v.interactions.rotateCamera( stepAngle, undefined, false )
await waitForAnimation()
scr[( -1 * i ) + ''] = v.interactions.screenshot()
}
v.interactions.rotateCamera( -2 * stepAngle, undefined, false )
await waitForAnimation()
for ( let i = 1; i < 3; i++ ) {
v.interactions.rotateCamera( -1 * stepAngle, undefined, false )
await waitForAnimation()
scr[i + ''] = v.interactions.screenshot()
}
/*
v.interactions.rotateCamera( 2 * stepAngle, transition=false )
await waitForAnimation( 500 )
let dirArray = [ 'top', 'bottom', 'front', 'back', 'left', 'right' ]
for ( let i in dirArray ) {
let d = dirArray[i]
v.interactions.rotateTo( d )
await waitForAnimation()
scr[d] = v.interactions.screenshot()
}
*/
return scr
}, objectUrl )
return scr
return `
<html><body>
<img height="200px" src="${scr['-2']}" /><br />
<img height="200px" src="${scr['-1']}" /><br />
<img height="200px" src="${scr['0']}" /><br />
<img height="200px" src="${scr['1']}" /><br />
<img height="200px" src="${scr['2']}" /><br />
</body></html>
`
const imageBuffer = new Buffer.from( b64Image.replace( /^data:image\/\w+;base64,/, '' ), 'base64' )
console.timeEnd( 'lo' )
// await page.waitForTimeout(500);
//var response = await page.screenshot({
// type: 'png',
// clip: {x: 0, y: 0, width: 800, height: 800}
//});
// Don't await for cleanup
browser.close()
return imageBuffer
};
router.get( '/:streamId/:objectId', async function( req, res, next ) {
let objectUrl = `http://127.0.0.1:3001/streams/${req.params.streamId}/objects/${req.params.objectId}`
/*
let authToken = ''
let authorizationHeader = req.header( 'Authorization' )
if ( authorizationHeader && authorizationHeader.toLowerCase().startsWith( 'bearer ' ) ) {
authToken = authorizationHeader.Substring( 'Bearer '.Length ).Trim()
}
// useful for testing (not the recommended way of passing the auth token)
if ( req.query.authToken ) {
authToken = req.query.authToken
}
*/
console.log( objectUrl )
console.time( 'test' )
let scr = await getScreenshot( objectUrl )
console.timeEnd( 'test' )
// res.setHeader( 'content-type', 'image/png' )
res.send( scr )
} )
module.exports = router
@@ -0,0 +1,32 @@
'use strict'
let debug = require( 'debug' )( 'speckle:services' )
const knex = require( '../../knex' )
const Objects = ( ) => knex( 'objects' )
const Closures = ( ) => knex( 'object_children_closure' )
module.exports = {
async getObject( { streamId, objectId } ) {
let res = await Objects( ).where( { streamId: streamId, id: objectId } ).select( '*' ).first( )
if ( !res ) return null
res.data.totalChildrenCount = res.totalChildrenCount
delete res.streamId
return res
},
async getObjectChildrenStream( { streamId, objectId } ) {
let q = Closures( )
q.select( 'id' )
q.select( 'data' )
q.rightJoin( 'objects', function() {
this.on( 'objects.streamId', '=', 'object_children_closure.streamId' )
.andOn( 'objects.id', '=', 'object_children_closure.child' )
} )
.where( knex.raw( 'object_children_closure."streamId" = ? AND parent = ?', [ streamId, objectId ] ) )
.orderBy( 'objects.id' )
return q.stream( )
}
}
@@ -0,0 +1,57 @@
/* global __dirname, require, module*/
const HtmlWebpackPlugin = require( 'html-webpack-plugin' )
const { CleanWebpackPlugin } = require( 'clean-webpack-plugin' )
const path = require( 'path' )
const yargs = require( 'yargs' )
const env = yargs.argv.env
let filename = 'viewer'
let outputFile, mode
if ( env === 'build' ) {
mode = 'production'
outputFile = filename + '.min.js'
} else {
mode = 'development'
outputFile = filename + '.js'
}
const config = {
mode: mode,
entry: path.resolve( __dirname + '/render_page/src/app.js' ),
target: 'web',
devtool: 'source-map',
output: {
path: path.resolve( __dirname + '/public/render' ) ,
filename: outputFile,
},
module: {
rules: [
{
test: /(\.jsx|\.js|\.ts|\.tsx)$/,
use: {
loader: 'babel-loader',
},
exclude: /(node_modules|bower_components)/,
},
],
},
plugins: [
new CleanWebpackPlugin( { cleanStaleWebpackAssets: false } ),
new HtmlWebpackPlugin( { title: 'Speckle Viewer Example', template: 'render_page/src/example.html', filename: 'index.html' } )
],
resolve: {
modules: [ path.resolve( './node_modules' ), path.resolve( '.render_page/src' ) ],
extensions: [ '.json', '.js' ],
},
devServer: {
contentBase: path.join( __dirname, 'example' ),
compress: false,
port: 9000,
serveIndex: true,
writeToDisk: true
}
}
module.exports = config
+2 -1
View File
@@ -56,6 +56,7 @@ module.exports = {
connection: connectionUri,
migrations: {
directory: migrationDirs
}
},
pool: { min: 2, max: 4 }
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ const { scalarResolvers, scalarSchemas } = require( './core/graph/scalars' )
exports.init = async ( app ) => {
let dirs = fs.readdirSync( `${appRoot}/modules` )
let moduleDirs = [ './core', './auth', './apiexplorer', './emails', './pwdreset', './serverinvites' ] // TODO: add './invites'
let moduleDirs = [ './core', './auth', './apiexplorer', './emails', './pwdreset', './serverinvites', './previews' ] // TODO: add './invites'
// Stage 1: initialise all modules
for ( let dir of moduleDirs ){
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

+102
View File
@@ -0,0 +1,102 @@
/* istanbul ignore file */
'use strict'
const debug = require( 'debug' )
const express = require( 'express' )
const appRoot = require( 'app-root-path' )
const cors = require( 'cors' )
const { matomoMiddleware } = require( `${appRoot}/logging/matomoHelper` )
const { contextMiddleware, validateScopes, authorizeResolver } = require( `${appRoot}/modules/shared` )
const { getStream } = require( '../core/services/streams' )
const { getObject } = require( '../core/services/objects' )
const { getPreviewImage, createObjectPreview, getObjectPreviewInfo } = require( './services/previews' )
exports.init = ( app, options ) => {
if ( process.env.DISABLE_PREVIEWS ) {
debug( 'speckle:modules' )( '📸 Object preview module is DISABLED' )
} else {
debug( 'speckle:modules' )( '📸 Init object preview module' )
}
let sendObjectPreview = async ( req, res, streamId, objectId ) => {
if ( process.env.DISABLE_PREVIEWS ) {
return res.sendFile( `${appRoot}/modules/previews/assets/no_preview.png` )
}
// Check if objectId is valid
const dbObj = await getObject( { streamId, objectId } )
if ( !dbObj ) {
return res.sendFile( `${appRoot}/modules/previews/assets/preview_error.png` )
}
// Get existing preview metadata
let previewInfo = await getObjectPreviewInfo( { streamId, objectId } )
if ( !previewInfo ) {
await createObjectPreview( { streamId, objectId, priority: 0 } )
}
let timestampStart = Date.now()
// Try for 10 sec of wall-clock to get the image (wait for preview generation)
while ( Date.now() < timestampStart + 10*1000 ) {
previewInfo = await getObjectPreviewInfo( { streamId, objectId } )
if ( previewInfo.previewStatus == 2 && previewInfo.preview ) {
break
}
await new Promise( ( resolve ) => {
setTimeout( resolve, 500 )
} )
}
if ( previewInfo.previewStatus != 2 || !previewInfo.preview ) {
return res.sendFile( `${appRoot}/modules/previews/assets/no_preview.png` )
}
let previewImgId = previewInfo.preview[req.params.angle]
if ( !previewImgId ) {
debug( 'speckle:errors' )( `Error: Preview angle '${req.params.angle}' not found for object ${streamId}:${objectId}` )
return res.sendFile( `${appRoot}/modules/previews/assets/preview_error.png` )
}
let previewImg = await getPreviewImage( { previewId: previewImgId } )
if ( !previewImg ) {
debug( 'speckle:errors' )( `Error: Preview image not found: ${previewImgId}` )
return res.sendFile( `${appRoot}/modules/previews/assets/preview_error.png` )
}
res.contentType( 'image/png' )
res.send( previewImg )
}
app.get( '/preview/:streamId/objects/:objectId/:angle', contextMiddleware, matomoMiddleware, async ( req, res ) => {
const stream = await getStream( { streamId: req.params.streamId, userId: req.context.userId } )
if ( !stream ) {
return res.status( 404 ).end()
}
if ( !stream.isPublic && req.context.auth === false ) {
return res.status( 401 ).end( )
}
if ( !stream.isPublic ) {
try {
await validateScopes( req.context.scopes, 'streams:read' )
} catch ( err ) {
return res.status( 401 ).end( )
}
try {
await authorizeResolver( req.context.userId, req.params.streamId, 'stream:reviewer' )
} catch ( err ) {
return res.status( 401 ).end( )
}
}
return sendObjectPreview( req, res, req.params.streamId, req.params.objectId )
} )
}
exports.finalize = () => {}
@@ -0,0 +1,25 @@
/* istanbul ignore file */
'use strict'
exports.up = async knex => {
await knex.schema.createTable( 'object_preview', table => {
table.string( 'streamId', 10 ).references( 'id' ).inTable( 'streams' ).onDelete( 'cascade' )
table.string( 'objectId' ).notNullable( )
table.integer( 'previewStatus' ).notNullable( ).defaultTo( 0 )
table.integer( 'priority' ).notNullable( ).defaultTo( 1 )
table.timestamp( 'lastUpdate' ).notNullable( ).defaultTo( knex.fn.now( ) )
table.jsonb( 'preview' )
table.primary( [ 'streamId', 'objectId' ] )
table.index( [ 'previewStatus', 'priority', 'lastUpdate' ] )
} )
await knex.schema.createTable( 'previews', table => {
table.string( 'id' ).primary( )
table.binary( 'data' )
} )
}
exports.down = async knex => {
await knex.schema.dropTableIfExists( 'object_preview' )
await knex.schema.dropTableIfExists( 'previews' )
}
@@ -0,0 +1,35 @@
/* istanbul ignore file */
'use strict'
const appRoot = require( 'app-root-path' )
const { async } = require('crypto-random-string')
const knex = require( `${appRoot}/db/knex` )
const ObjectPreview = ( ) => knex( 'object_preview' )
const Previews = ( ) => knex( 'previews' )
module.exports = {
async getObjectPreviewInfo( { streamId, objectId } ) {
return await ObjectPreview( ).select( '*' ).where( { streamId, objectId } ).first( )
},
async createObjectPreview ( { streamId, objectId, priority } ) {
let insertionObject = {
streamId,
objectId,
priority,
previewStatus: 0
}
let sqlQuery = ObjectPreview( ).insert( insertionObject ).toString( ) + ' on conflict do nothing'
await knex.raw( sqlQuery )
},
async getPreviewImage( { previewId } ) {
let previewRow = await Previews( ).where( { id: previewId } ).first( ).select( '*' )
if ( !previewRow ) {
return null
}
return previewRow.data
}
}