Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/reuse-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ jobs:

- uses: actions/setup-node@v3
with:
node-version: 22
node-version: 24
cache: 'npm'

- name: Install gifsicle
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends gifsicle

- name: Install dependencies
run: npm ci

Expand Down
23 changes: 17 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,32 @@ RUN echo "Building for architecture $TARGETARCH"

# Install chrome and fonts to support major charsets (Chinese, Japanese, Arabic, Hebrew, Thai and a few others)
RUN apt-get update && \
apt-get upgrade -y && \
apt-get install -y wget gnupg ca-certificates && \
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/googlechrome-linux-keyring.gpg && \
sh -c 'echo "deb [arch=amd64 signed-by=/usr/share/keyrings/googlechrome-linux-keyring.gpg] https://dl-ssl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' && \
apt-get update && \
apt-get install -y google-chrome-stable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-khmeros fonts-kacst-one fonts-freefont-ttf libxss1 dbus dbus-x11 --no-install-recommends && \
apt-get install -y google-chrome-stable gifsicle fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-khmeros fonts-kacst-one fonts-freefont-ttf libxss1 dbus dbus-x11 --no-install-recommends && \
service dbus start

ENV DBUS_SESSION_BUS_ADDRESS autolaunch:
ENV DBUS_SESSION_BUS_ADDRESS=autolaunch:

# It's a good idea to use dumb-init to help prevent zombie chrome processes.
ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.2/dumb-init_1.2.2_$TARGETARCH /usr/local/bin/dumb-init
RUN chmod +x /usr/local/bin/dumb-init
# releases since 1.2.3 name the binaries by uname -m, not by docker's TARGETARCH
RUN case "$TARGETARCH" in \
amd64) DUMB_INIT_ARCH=x86_64 ;; \
arm64) DUMB_INIT_ARCH=aarch64 ;; \
*) echo "unsupported architecture $TARGETARCH" && exit 1 ;; \
esac && \
wget -q -O /usr/local/bin/dumb-init "https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_$DUMB_INIT_ARCH" && \
chmod +x /usr/local/bin/dumb-init

# cleanup
RUN apt-get clean
RUN apt-get purge -y --auto-remove gnupg apt-transport-https

# skip the browser download when installing puppeteer
ENV PUPPETEER_SKIP_DOWNLOAD true
ENV PUPPETEER_SKIP_DOWNLOAD=true

######################################
# Stage: nodejs dependencies and build
Expand All @@ -47,7 +54,7 @@ ADD package-lock.json .
# use clean-modules on the same line as npm ci to be lighter in the cache
RUN npm i -g clean-modules@2.0.6
RUN npm ci --omit=dev &&\
clean-modules --yes --exclude exceljs/lib/doc/ --exclude "**/*.mustache"
clean-modules --yes --exclude "**/*.mustache"

##################################
# Stage: main nodejs service stage
Expand All @@ -56,6 +63,10 @@ LABEL org.opencontainers.image.vendor="Koumoul"
LABEL org.opencontainers.image.authors="contact@koumoul.com"
LABEL org.opencontainers.image.licenses="AGPL-3.0-only"

# npm and corepack are only needed by the builder stage, and they carry their own
# vulnerabilities; the service runs with plain node.
RUN rm -rf /usr/local/lib/node_modules /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack

# Add user so we don't need --no-sandbox.
RUN groupadd -r pptruser && useradd -r -g pptruser -G audio,video pptruser \
&& mkdir -p /home/pptruser/Downloads \
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Ased as a companion service for [data-fair](https://koumoul-dev.github.io/data-f

## Developper

To run locally you will need to install google-chrome-unstable for your system.
To run locally you will need to install google-chrome-unstable and gifsicle for your system.

Install dependencies without downloading chromium:

Expand Down
2 changes: 2 additions & 0 deletions api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import eventPromise from '@data-fair/lib-utils/event-promise.js'
import { createHttpTerminator } from 'http-terminator'
import app from './app.ts'
import * as pageUtils from './utils/page.ts'
import * as gifsicle from './utils/gifsicle.ts'
import config from '#config'

const server = createServer(app)
Expand All @@ -17,6 +18,7 @@ server.keepAliveTimeout = (60 * 1000) + 1000
server.headersTimeout = (60 * 1000) + 2000

export const start = async () => {
await gifsicle.start()
await pageUtils.start()
if (config.observer.active) await startObserver(config.observer.port)
if (config.privateDirectoryUrl) {
Expand Down
22 changes: 12 additions & 10 deletions api/utils/animation.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import { file } from 'tmp-promise'
import config from '#config'
import fs from 'fs'
import { promisify } from 'util'
import stream from 'stream'
import fs from 'node:fs'
import { promisify } from 'node:util'
import { pipeline } from 'node:stream/promises'
import GifEncoder from 'gif-encoder'
import getPixelsCb from 'get-pixels'
import imageminGifsicle from 'imagemin-gifsicle'
import { optimizeGif } from './gifsicle.ts'
import debug from 'debug'
import { type Page } from 'puppeteer'

const pipeline = promisify(stream.pipeline)
const getPixels = promisify(getPixelsCb)

export const capture = async (target: string, page: Page, width: number, height: number) => {
Expand All @@ -27,20 +26,23 @@ export const capture = async (target: string, page: Page, width: number, height:
// @ts-ignore
return window.animateCaptureFrame()
})
let buffer
let buffer: Uint8Array | undefined
await Promise.race([
page.screenshot().then(b => { buffer = b }),
// frames are decoded to raw pixels right away, so spending time on png compression is wasted
page.screenshot({ optimizeForSpeed: true }).then(b => { buffer = b }),
new Promise(resolve => setTimeout(resolve, config.screenshotTimeout))
])
if (!buffer) throw new Error(`Failed to capture animation frame of page "${target}" before timeout`)
const pixels = await getPixels(buffer, 'image/png')
// puppeteer returns a plain Uint8Array, but get-pixels switches on Buffer.isBuffer to tell
// raw image data from a file path, and would treat the frame as a filename otherwise
const frame = Buffer.from(buffer.buffer, buffer.byteOffset, buffer.byteLength)
const pixels = await getPixels(frame, 'image/png')
gif.addFrame(pixels.data)
}
gif.finish()
await pipelinePromise
debug(`[${target}] gif screenshot is taken`)
const rawBuffer = await fs.promises.readFile(path)
cleanup()
const compressedBuffer = await imageminGifsicle({ optimizationLevel: 2 })(rawBuffer)
return compressedBuffer
return await optimizeGif(rawBuffer, 2)
}
44 changes: 44 additions & 0 deletions api/utils/gifsicle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { spawn } from 'node:child_process'

/**
* Optimize a GIF buffer using the gifsicle binary provided by the system.
*
* This replaces the imagemin-gifsicle package, whose only role was to spawn the
* same binary: it pulled in the abandoned bin-wrapper/download/decompress chain
* (used solely to fetch the binary at install time) and with it the bulk of this
* project's dependency vulnerabilities. The binary now comes from the distro.
*/
/**
* Fail at startup rather than on the first animated capture: gifsicle used to be
* bundled by imagemin-gifsicle in any install, and is now expected from the system.
*/
export const start = () => new Promise<void>((resolve, reject) => {
const child = spawn('gifsicle', ['--version'], { stdio: 'ignore' })
child.on('error', () => reject(new Error('the gifsicle binary is required for animated captures but was not found in PATH')))
child.on('close', code => {
if (code === 0) resolve()
else reject(new Error(`the gifsicle binary is not usable, "gifsicle --version" exited with code ${code}`))
})
})

export const optimizeGif = (input: Buffer, optimizationLevel: number) => {
// non-GIF input is returned untouched, as imagemin-gifsicle did
if (input.subarray(0, 3).toString('latin1') !== 'GIF') return Promise.resolve(input)

const args = ['--no-warnings', '--no-app-extensions', `--optimize=${optimizationLevel}`]
return new Promise<Buffer>((resolve, reject) => {
const child = spawn('gifsicle', args)
const stdout: Buffer[] = []
const stderr: Buffer[] = []
child.stdout.on('data', chunk => stdout.push(chunk))
child.stderr.on('data', chunk => stderr.push(chunk))
child.on('error', reject)
child.on('close', code => {
if (code === 0) resolve(Buffer.concat(stdout))
else reject(new Error(`gifsicle exited with code ${code}: ${Buffer.concat(stderr).toString()}`))
})
// gifsicle may exit before consuming all of stdin, do not crash on EPIPE
child.stdin.on('error', () => {})
child.stdin.end(input)
})
}
9 changes: 5 additions & 4 deletions api/utils/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,11 @@ export const withPage = async (

const cleanContext = async (page: Page) => {
// always empty cookies to prevent inheriting them in next use of the context
const cookies = await page.browserContext().cookies()
for (const cookie of cookies) {
await page.browserContext().deleteCookie(cookie)
}
const context = page.browserContext()
const cookies = await context.cookies()
// deleting them in a single call spares one CDP round trip per cookie, and this
// runs inside a 2s timeout that a session's worth of cookies could otherwise eat
if (cookies.length) await context.deleteCookie(...cookies)
await page.close()
}

Expand Down
2 changes: 1 addition & 1 deletion config/default.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ module.exports = {
executablePath: '/usr/bin/google-chrome-stable',
// args: ['--use-gl=egl', '--use-angle=swiftshader', '--in-process-gpu'],
args: [],
headless: 'new'
headless: true
},
observer: {
active: true,
Expand Down
1 change: 0 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
version: '3'
services:
mongo:
profiles:
Expand Down
Loading
Loading