Browse Source

First commit

master
Logan Koester 3 years ago
commit
d7a1e75735
No known key found for this signature in database GPG Key ID: EBA7BD5CB8B7CB94
  1. 1
      .dockerignore
  2. 59
      .drone.yml
  3. 4
      .gitignore
  4. 43
      Dockerfile
  5. 21
      LICENSE.md
  6. 3
      README.md
  7. 12
      config/default.js
  8. 32
      config/dragonruby.js
  9. 7
      config/index.js
  10. 36
      package.json
  11. 274
      src/index.js
  12. 75
      waypoint.hcl
  13. 2293
      yarn.lock

1
.dockerignore

@ -0,0 +1 @@
node_modules/

59
.drone.yml

@ -0,0 +1,59 @@
---
kind: pipeline
type: docker
name: Waypoint
environment:
WAYPOINT_SERVER_ADDR: waypoint.smaug.dev:9701
WAYPOINT_SERVER_TLS: 1
WAYPOINT_SERVER_TLS_SKIP_VERIFY: 1
steps:
- name: build
image: registry.gitlab.com/gitlab-org/waypoint-images:latest
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- waypoint init
- waypoint build
environment:
WAYPOINT_SERVER_TOKEN:
from_secret: WAYPOINT_SERVER_TOKEN
- name: deploy
image: registry.gitlab.com/gitlab-org/waypoint-images:latest
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- waypoint deploy -local=false -release=false
environment:
WAYPOINT_SERVER_TOKEN:
from_secret: WAYPOINT_SERVER_TOKEN
- name: release
image: registry.gitlab.com/gitlab-org/waypoint-images:latest
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- waypoint release -local=false -prune-retain=0
environment:
WAYPOINT_SERVER_TOKEN:
from_secret: WAYPOINT_SERVER_TOKEN
volumes:
- name: dockersock
host:
path: /var/run/docker.sock
trigger:
branch:
- master
- main
event:
- push
- pull_request
- custom

4
.gitignore

@ -0,0 +1,4 @@
.idea/
node_modules/
static/
.env

43
Dockerfile

@ -0,0 +1,43 @@
FROM node:16-slim
RUN apt-get update && apt-get install --no-install-recommends -yq \
libgconf-2-4 libxss1 libxtst6 ca-certificates wget curl gnupg2 python \
gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 \
libexpat1 libfontconfig1 libgcc1 libgdk-pixbuf2.0-0 libglib2.0-0 \
libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 \
libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 \
libxfixes3 libxi6 libxrandr2 libxrender1 \
fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils \
libatk-bridge2.0-0 ffmpeg
ENV APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE 1
RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub |\
apt-key add -
RUN echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list
RUN apt-get update && apt-get install -y google-chrome-unstable git \
fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst --no-install-recommends \
&& rm -rf /var/lib/apt/lists/* /src/*.deb
RUN mkdir -p /usr/share/fonts/emoji \
&& curl --location --silent --show-error --out \
/usr/share/fonts/emoji/emojione-android.ttf \
https://github.com/emojione/emojione-assets/releases/download/3.1.2/emojione-android.ttf \
&& chmod -R +rx /usr/share/fonts/ \
&& fc-cache -fv
RUN rm /bin/sh && ln -s /bin/bash /bin/sh
RUN mkdir /app
COPY package.json yarn.lock app/
COPY src app/src
COPY config app/config
WORKDIR /app
RUN yarn
WORKDIR /app
COPY static ./static
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true
RUN mkdir -p /home/node/Downloads \
&& chown -R node:node /home/node \
&& chown -R node:node /app
USER node
CMD yarn start

21
LICENSE.md

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Logan Koester <logan@logankoester.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

3
README.md

@ -0,0 +1,3 @@
# Page Recorder
> A service for recording videos of a webpage

12
config/default.js

@ -0,0 +1,12 @@
export default {
viewport: {
width: 1280,
height: 720,
},
fps: 60,
start: 0,
duration: 5,
quiet: true,
output: 'static/output.webm',
launchArguments: ['--disable-setuid-sandbox', '--no-sandbox'],
};

32
config/dragonruby.js

@ -0,0 +1,32 @@
export default {
viewport: {
width: 1280,
height: 720,
},
fps: 60,
start: 1,
duration: 5,
quiet: true,
output: 'static/output.webm',
launchArguments: ['--disable-setuid-sandbox', '--no-sandbox'],
selector: 'canvas',
preparePage: async function(page) {
await page.evaluate(async () => {
document.getElementById('clicktoplaydiv').hidden = true;
function playWhenReady() {
setTimeout(function () {
if (window.gtk && window.gtk.play) {
if (window.gtk.module.clickedToPlay) {
return false;
} else {
window.gtk.play();
}
} else {
playWhenReady();
}
}, 1000);
}
await playWhenReady();
});
},
};

7
config/index.js

@ -0,0 +1,7 @@
import defaultOptions from './default.js'
import dragonrubyOptions from './dragonruby.js'
export default {
default: defaultOptions,
dragonruby: dragonrubyOptions,
}

36
package.json

@ -0,0 +1,36 @@
{
"name": "page-recorder",
"version": "1.0.0",
"description": "A service for recording videos of a webpage",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js"
},
"author": {
"name": "Logan Koester",
"url": "https://ldk.dev",
"email": "logan@logankoester.com"
},
"license": "MIT",
"type": "module",
"dependencies": {
"@hapi/catbox-redis": "^6.0.2",
"@hapi/hapi": "^20.2.1",
"@hapi/inert": "^6.0.4",
"@hapi/vision": "^6.1.0",
"filenamify-url": "^3.0.0",
"hapi-auth-jwt2": "^10.2.0",
"hapi-dev-errors": "^4.0.0",
"hapi-pino": "^9.1.0",
"hapi-swagger": "^14.2.4",
"moment": "^2.29.1",
"pino-pretty": "^7.3.0",
"puppeteer": "^10.4.0",
"timecut": "^0.3.1"
},
"devDependencies": {
"@types/node": "^16.11.1",
"nodemon": "^2.0.15"
}
}

274
src/index.js

@ -0,0 +1,274 @@
import Hapi from '@hapi/hapi';
import Boom from '@hapi/boom';
import inert from '@hapi/inert';
import CatboxRedis from '@hapi/catbox-redis';
import vision from '@hapi/vision';
import devErrors from 'hapi-dev-errors';
import hapiPino from 'hapi-pino';
import HapiSwagger from 'hapi-swagger';
import HapiAuthJwt2 from 'hapi-auth-jwt2';
import jwt from 'jsonwebtoken';
import filenamifyUrl from 'filenamify-url';
import moment from 'moment';
import Joi from 'joi';
import timecut from 'timecut';
import path from 'path';
import { readFile } from 'fs/promises';
import environments from '../config/index.js';
const Package = JSON.parse(await readFile(new URL('../package.json', import.meta.url)));
const defaultOptions = (environment = 'default') => (
environments[environment]
);
const init = async () => {
const server = Hapi.server({
port: process.env.PORT || 8080,
host: process.env.HOST || 'localhost',
debug: false,
cache: [
{
name: 'capture_service_cache',
provider: {
constructor: CatboxRedis,
options: {
//partition: 'my_cached_data',
host: (process.env.REDIS_HOST || 'localhost'),
port: (process.env.REDIS_PORT || 6379),
database: 0,
//tls: {},
},
},
}
],
});
const swaggerOptions = {
info: {
title: Package.name,
description: Package.description,
version: Package.version,
contact: Package.author,
},
schemes: ['http', 'https'],
host: `${process.env.HOST || 'localhost'}:${process.env.PORT || 8080}`,
basePath: '/',
debug: false,
documentationPath: '/',
expanded: 'full',
tryItOutEnabled: false,
securityDefinitions: {
jwt: {
type: 'apiKey',
name: 'Authorization',
in: 'header',
},
},
security: [{ jwt: [] }],
};
const apiClients = {
'default': {
id: 'default',
secret: 'letmein',
}
};
const privateKey = (process.env.PRIVATE_KEY || '5oauN6YdPF');
const validate = decoded => {
if (!apiClients[decoded.id]) {
return { isValid: false };
} else {
return { isValid: true };
}
};
await server.register(inert);
await server.register({
plugin: devErrors,
options: {
showErrors: process.env.NODE_ENV !== 'production'
}
});
await server.register({
plugin: hapiPino,
options: {
logQueryParams: false,
logRequestComplete: false,
redact: ['req.headers.authorization'],
getChildBindings: (request) => ({ }),
transport: {
target: 'pino-pretty',
options: {
colorize: true,
minimumLevel: 'info',
levelFirst: true,
messageFormat: true,
timestampKey: 'time',
translateTime: true,
singleLine: false,
mkdir: true,
append: true,
},
},
}
});
await server.register([
HapiAuthJwt2,
inert,
vision,
{
plugin: HapiSwagger,
options: swaggerOptions,
},
]);
server.auth.strategy('jwt', 'jwt', {
key: privateKey,
validate,
verifyOptions: { algorithms: ['HS256'] }
});
server.auth.default('jwt');
const createVideo = async (opts={}) => {
const environment = opts['environment'] || 'default';
const options = Object.assign(defaultOptions(environment), opts);
server.logger.info('Recording video for %s', options.url);
try {
await timecut(options);
server.logger.info('Video written to %s', options.output);
} catch (e) {
throw Error(e);
}
return options.output;
};
server.method('video.create', createVideo, {
cache: {
cache: 'capture_service_cache',
staleIn: 60 * 60 * 1000,
staleTimeout: 1,
expiresIn: 24 * 60 * 60 * 1000,
generateTimeout: 5 * 60 * 1000,
pendingGenerateTimeout: 60 * 1000,
getDecoratedValue: true,
},
generateKey: (options) => options.url,
});
server.route({
method: 'GET',
path: '/video',
handler: async (request, h) => {
const { url, fps, start, duration, selector, environment } = request.query;
const output = path.join('./', 'static', `${filenamifyUrl(url)}.webm`);
const { cached, value, report } = await server.methods.video.create({
url,
output,
fps,
start,
duration,
selector,
environment,
});
if (cached) {
request.logger.info(
'Cache is %s for %s (expiring %s)',
(cached.isStale ? 'stale' : 'fresh'),
cached.item,
moment.duration(cached.ttl).humanize(true, { h: 48 }),
);
}
return h.file(value).header('Content-Disposition', 'inline; filename=video.webm');
},
options: {
tags: ['api'],
auth: false, // THIS DISABLES JWT AUTH!
description: 'Fetch a video recording',
plugins: {
'hapi-swagger': {
produces: ['video/webm'],
responses: {
200: {
description: 'A WebM video file',
schema: Joi.binary().meta({ swaggerType: 'file' }),
},
},
},
},
validate: {
query: Joi.object({
url: Joi.string().uri().required().description('The URL of the target page'),
selector: Joi.string().description('Crop each frame to the bounding box of the first item found by the specified CSS selector'),
fps: Joi.number().integer().min(1).max(60).default(60).description('Framerate to record as video'),
start: Joi.number().integer().min(0).default(0).description('Seconds to wait before saving any frames'),
duration: Joi.number().integer().min(1).default(5).description('Duration to capture (in seconds)'),
environment: Joi.string().default('default').description('Choose a set of default options from a file at config/*.js'),
}),
},
},
});
server.route({
method: 'GET',
path: '/token/{id}/{secret}',
options: {
auth: false,
tags: ['api'],
description: 'Request a token for a given API key',
handler: (request, h) => {
const { id, secret } = request.params;
if (!apiClients[id]) {
throw Boom.badRequest(`API client "${id}" does not exist`);
}
if (apiClients[id].secret != secret) {
throw Boom.unauthorized(`Secret does not match the expected value`);
}
const token = jwt.sign({ id: id }, privateKey, { algorithm: 'HS256' });
return { token: token };
},
validate: {
params: Joi.object({
id: Joi.string().required().description('A registered API client'),
secret: Joi.string().required().description('A matching client secret'),
}),
},
},
});
server.ext('onPreResponse', (request, h) => {
const { url } = request.query;
const response = request.response;
if (response.isBoom &&
response.output.statusCode === 404) {
request.logger.debug(request);
if (request.route.method === 'get' &&
request.route.path === '/video') {
request.logger.warn(
'Dropping cache key %s (%s)',
url,
response.output.payload.error
);
server.methods.video.create.cache.drop({ url });
return h.response().redirect(request.url);
}
}
return h.continue;
});
await server.start();
server.logger.info('Server running on %s', server.info.uri);
};
process.on('unhandledRejection', (err) => {
console.log(err);
process.exit(1);
});
init();

75
waypoint.hcl

@ -0,0 +1,75 @@
project = "page-recorder"
runner {
enabled = true
data_source "git" {
url = "git@git.smaug.dev:ereborstudios/page-recorder.git"
ref = "master"
}
}
# Labels can be specified for organizational purposes.
# labels = { "foo" = "bar" }
# An application to deploy.
app "page-recorder" {
config {
env = {
REDIS_HOST = var.redis_host
REDIS_PORT = var.redis_port
}
}
labels = {
"service" = var.name
}
# Build specifies how an application should be deployed. In this case,
# we'll build using a Dockerfile and keeping it in a local registry.
build {
use "docker" {
}
}
# Deploy to Docker
deploy {
use "docker" {
service_port = 3090
static_environment = {
"NODE_ENV": "production"
}
labels = {
"traefik.http.routers.${var.name}.rule" = "Host(`${var.host}`, `${var.name}.waypoint.smaug.dev`)"
"traefik.http.routers.${var.name}.entrypoints" = "websecure"
"traefik.http.routers.${var.name}.tls.certresolver" = "myresolver"
"traefik.http.services.${var.name}.loadbalancer.server.port" = "3090"
}
}
}
}
variable "name" {
type = string
default = "page-recorder"
description = "Project name"
}
variable "host" {
type = string
default = "preview.smaug.dev"
description = "Project host"
}
variable "redis_host" {
type = string
default = "redis.smaug.dev"
description = "Redis host"
}
variable "redis_port" {
type = string
default = 6379
description = "Redis port"
}

2293
yarn.lock

File diff suppressed because it is too large
Loading…
Cancel
Save