~mser/emote-server

07adda6564e58626d7a2c606a43113411bef9cf7 — Michael Serajnik 8 months ago f37d67b + 364bcf4 1.1.0
Merge branch 'release/1.1.0'
M .build.yml => .build.yml +15 -11
@@ 1,22 1,26 @@
image: alpine/edge
image: alpine/latest
packages:
  - docker
  - nodejs
sources:
  - https://git.sr.ht/~mser/emote-server
environment:
  project: emote-server
  docker_image: mserajnik/emote-server
  docker_username: mserajnik
secrets:
  - ceec741f-3de6-4940-980b-4ffda7fdf12d
  - 2be455df-9ff9-4803-bf5e-2c65d9d986a5
tasks:
  - skip_if_not_release: |
      cd $project
      git describe --exact-match HEAD || complete-build
  - setup: |
      sudo addgroup $(whoami) docker
      sudo service docker start
  - build: |
      cd emote-server
      if [ "$(git rev-parse origin/master)" != "$(git rev-parse HEAD)" ]; then
        echo "We are only building on master."
        exit 0
      fi
      VERSION=$(node -e 'console.log(require("./package.json").version)')
      docker build . -t mserajnik/emote-server:latest -t mserajnik/emote-server:$VERSION
      cat ~/.docker-hub-personal-access-token | docker login --username mserajnik --password-stdin
      docker push mserajnik/emote-server --all-tags
      cd $project
      version=$(node -e "console.log(require('./package.json').version)")
      docker build . -t $docker_image:latest -t $docker_image:$version
  - publish: |
      cat ~/.docker-hub-personal-access-token | docker login --username $docker_username --password-stdin
      docker push $docker_image --all-tags

M .env.example => .env.example +1 -1
@@ 5,6 5,6 @@ EMOTE_SERVER_ACCESS_KEY=
EMOTE_SERVER_NUMBER_OF_WORKERS=

# Files
EMOTE_SERVER_SUPPORTED_FILE_EXTENSIONS=png,gif
EMOTE_SERVER_SUPPORTED_FILE_EXTENSIONS=png,gif,apng
EMOTE_SERVER_EMOTES_PATH=./emotes
EMOTE_SERVER_FROZEN_EMOTES_PATH=./frozen-emotes

M CHANGELOG.md => CHANGELOG.md +7 -0
@@ 8,6 8,12 @@ and this project adheres to

## [Unreleased]

## [1.1.0] - 2021-04-02

### Added

+ Frozen emote generation support for APNGs

## [1.0.0] - 2021-03-29

### Added


@@ 15,4 21,5 @@ and this project adheres to
+ Initial release

[Unreleased]: https://git.sr.ht/~mser/emote-server/tree/develop
[1.1.0]: https://git.sr.ht/~mser/emote-server/tree/1.1.0
[1.0.0]: https://git.sr.ht/~mser/emote-server/tree/1.0.0

M README.md => README.md +8 -8
@@ 27,7 27,7 @@ API.
        + [Deleting emotes](#deleting-emotes)
        + [Listing emotes](#listing-emotes)
        + [Getting emotes](#getting-emotes)
        + [Getting frozen GIF emotes](#getting-frozen-gif-emotes)
        + [Getting frozen emotes](#getting-frozen-emotes)
+ [Maintainer](#maintainer)
+ [Contribute](#contribute)
+ [License](#license)


@@ 144,9 144,9 @@ attention to the instructions to prevent issues.
  note that increasing the number of workers beyond the number of logical CPUs
  might be detrimental to performance or cause even more serious issues (e.g.,
  crashes).
+ `EMOTE_SERVER_SUPPORTED_FILE_EXTENSIONS=png,gif`: sets the file extensions
  for the files the server should serve. The extensions need to be separated
  with `,`.
+ `EMOTE_SERVER_SUPPORTED_FILE_EXTENSIONS=png,gif,apng`: sets the file
  extensions for the files the server should serve. The extensions need to be
  separated with `,`.
+ `EMOTE_SERVER_EMOTES_PATH=./emotes`: the path emotes are served from. Can be
  relative or absolute.



@@ 286,14 286,14 @@ __Possible errors:__
+ `AccessKeyError`
+ `GetError`

###### Getting frozen GIF emotes
###### Getting frozen emotes

Responds with a "frozen" PNG version (containing the first frame) of the
requested GIF emote.
requested GIF or APNG emote.

__Route:__ `GET /frozen-emotes/<GIF emote filename>`
__Route:__ `GET /frozen-emotes/<GIF/APNG emote filename>`

__Output on success:__ The requested frozen GIF emote
__Output on success:__ The requested frozen emote

__Possible errors:__


M docker-compose.yml.example => docker-compose.yml.example +1 -1
@@ 16,6 16,6 @@ services:
      - EMOTE_SERVER_PORT=8000
      - EMOTE_SERVER_ACCESS_KEY=
      - EMOTE_SERVER_NUMBER_OF_WORKERS=
      - EMOTE_SERVER_SUPPORTED_FILE_EXTENSIONS=png,gif
      - EMOTE_SERVER_SUPPORTED_FILE_EXTENSIONS=png,gif,apng
      - EMOTE_SERVER_EMOTES_PATH=/data/emotes
      - EMOTE_SERVER_FROZEN_EMOTES_PATH=/data/frozen-emotes

M index.js => index.js +1 -1
@@ 195,7 195,7 @@ service.get('/frozen-emotes/:emote', async (req, res) => {
    }
  }

  if (!await emotes.isGifEmote(req.params.emote)) {
  if (!await emotes.isAnimatedEmote(req.params.emote)) {
    return res.send({
      success: false,
      error: 'GetError'

M package.json => package.json +5 -2
@@ 1,6 1,6 @@
{
  "name": "emote-server",
  "version": "1.0.0",
  "version": "1.1.0",
  "description": "A simple application to list and serve emotes",
  "author": "Michael Serajnik <m@mser.at>",
  "license": "AGPL-3.0-or-later",


@@ 13,14 13,17 @@
    "url": "https://todo.sr.ht/~mser/emote-server"
  },
  "scripts": {
    "start": "node ./bin/www"
    "start": "node ./bin/www",
    "test": "eslint ."
  },
  "engines": {
    "node": ">=14.0.0 <16.0.0"
  },
  "dependencies": {
    "apng-js": "^1.1.1",
    "body-parser": "^1.19.0",
    "connect-query": "^1.0.0",
    "cross-blob": "^2.0.0",
    "dotenv": "^8.2.0",
    "express-fileupload": "^1.2.1",
    "fast-glob": "^3.2.5",

M src/config/index.js => src/config/index.js +1 -1
@@ 13,7 13,7 @@ if (frozenEmotesPath.startsWith('.')) {
}

module.exports = {
  version: '1.0.0',
  version: '1.1.0',
  apiVersion: 1,
  publicUrl: process.env.EMOTE_SERVER_PUBLIC_URL || 'http://localhost',
  port: process.env.EMOTE_SERVER_PORT || 8000,

M src/util/emotes.js => src/util/emotes.js +49 -15
@@ 4,6 4,9 @@ const fsp = fs.promises
const fg = require('fast-glob')
const FileType = require('file-type')
const gifFrames = require('gif-frames')
// apng-js requires a Blob polyfill; this is sadly not mentioned in the docs
globalThis.Blob = require('cross-blob')
const parseApng = require('apng-js').default

const config = require('../config')



@@ 80,7 83,7 @@ module.exports = {
      }
    }

    const isGifEmote = await this.isGifEmote(fileName)
    const isAnimatedEmote = await this.isAnimatedEmote(fileName)

    try {
      await fsp.unlink(filePath)


@@ 92,7 95,7 @@ module.exports = {
      }
    }

    if (isGifEmote) {
    if (isAnimatedEmote) {
      const frozenFilePath = `${config.frozenEmotesPath}/${fileName}.png`

      if (await this.frozenEmoteExists(fileName)) {


@@ 120,14 123,19 @@ module.exports = {
      code: 200
    }
  },
  async isGifEmote (fileName) {
  async getMimeType (filePath) {
    await fsp.access(filePath, fs.constants.F_OK)
    const fileType = await FileType.fromFile(filePath)

    return fileType.mime
  },
  async isAnimatedEmote (fileName) {
    const filePath = `${config.emotesPath}/${fileName}`

    try {
      await fsp.access(filePath, fs.constants.F_OK)
      const fileType = await FileType.fromFile(filePath)
      const mimeType = await this.getMimeType(filePath)

      if (!fileType || fileType.mime !== 'image/gif') {
      if (!mimeType || !['image/gif', 'image/apng'].includes(mimeType)) {
        return false
      }
    } catch {


@@ 155,14 163,34 @@ module.exports = {
      return true
    }

    let frameData
    let mimeType

    try {
      frameData = await gifFrames({
        url: filePath,
        frames: 0,
        outputType: 'png'
      })
      mimeType = await this.getMimeType(filePath)

      if (!mimeType || !['image/gif', 'image/apng'].includes(mimeType)) {
        return false
      }
    } catch {
      return false
    }

    let frameDataStream

    try {
      switch (mimeType) {
        case 'image/gif':
          frameDataStream = (await gifFrames({
            url: filePath,
            frames: 0,
            outputType: 'png'
          }))[0].getImage()
          break
        case 'image/apng':
          frameDataStream = (parseApng(
            await fsp.readFile(filePath)
          )).frames[0].imageData.stream()
      }
    } catch {
      return false
    }


@@ 174,9 202,15 @@ module.exports = {

          ws
            .on('finish', () => resolve(true))
            .on('error', () => reject(false))

          frameData[0].getImage().pipe(ws)
            .on('error', () => reject(new Error('Failed to write file.')))

          switch (mimeType) {
            case 'image/gif':
              frameDataStream.pipe(ws)
              break
            case 'image/apng':
              frameDataStream.pipe(ws)
          }
        })
      })()


M yarn.lock => yarn.lock +29 -6
@@ 145,9 145,9 @@ ajv@^6.10.0, ajv@^6.12.3, ajv@^6.12.4:
    uri-js "^4.2.2"

ajv@^8.0.1:
  version "8.0.1"
  resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.0.1.tgz#dac101898a87f8ebb57fea69617e8096523c628c"
  integrity sha512-46ZA4TalFcLLqX1dEU3dhdY38wAtDydJ4e7QQTVekLUTzXkb1LfqU6VOBXC/a9wiv4T094WURqJH6ZitF92Kqw==
  version "8.0.3"
  resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.0.3.tgz#81f1b07003b329f000b7912e59a24f52392867b6"
  integrity sha512-Df6NAivu9KpZw+q8ySijAgLvr1mUA5ihkRvCLCxpdYR21ann5yIuN+PpFxmweSj7i3yjJ0x5LN5KVs0RRzskAQ==
  dependencies:
    fast-deep-equal "^3.1.1"
    json-schema-traverse "^1.0.0"


@@ 178,6 178,11 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0:
  dependencies:
    color-convert "^2.0.1"

apng-js@^1.1.1:
  version "1.1.1"
  resolved "https://registry.yarnpkg.com/apng-js/-/apng-js-1.1.1.tgz#d356544c44361af7e10fe105b7adee4bb60bd55d"
  integrity sha512-UWaloDssWCE8Bj0wipyNxEXPnMadYS0VAjghCLas5nKGqfiBMNdQJhg8Fawq2+jZ50IOM1feKwjiqPAC/bvKgg==

argparse@^1.0.7:
  version "1.0.10"
  resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911"


@@ 249,6 254,11 @@ bcrypt-pbkdf@^1.0.0:
  dependencies:
    tweetnacl "^0.14.3"

blob-polyfill@^4.0.20200601:
  version "4.0.20200601"
  resolved "https://registry.yarnpkg.com/blob-polyfill/-/blob-polyfill-4.0.20200601.tgz#16430e9e50d63e122c6aac18b31f5f0bc8c0bf92"
  integrity sha512-1jB6WOIp6IDxNyng5+9A8g/f0uiphib2ELCN+XAnlssinsW8s1k4VYG9b1TxIUd3pdm9RJSLQuBh6iohYmD4hA==

body-parser@^1.19.0:
  version "1.19.0"
  resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"


@@ 392,6 402,14 @@ core-util-is@1.0.2, core-util-is@~1.0.0:
  resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
  integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=

cross-blob@^2.0.0:
  version "2.0.0"
  resolved "https://registry.yarnpkg.com/cross-blob/-/cross-blob-2.0.0.tgz#a7cf1901c746b606ec31e0d04838d18888ea35a4"
  integrity sha512-NWzFuyG4GTZnM9MtQvjPYVlO12lZCSBdoHIHCZl9WKShsK3CqO+bEH7nuKwlVomlByb37XvT6nVY5uQxDBmk5Q==
  dependencies:
    blob-polyfill "^4.0.20200601"
    fetch-blob "^2.0.1"

cross-spawn@^7.0.2:
  version "7.0.3"
  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"


@@ 803,6 821,11 @@ fastq@^1.6.0:
  dependencies:
    reusify "^1.0.4"

fetch-blob@^2.0.1:
  version "2.1.1"
  resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-2.1.1.tgz#a54ab0d5ed7ccdb0691db77b6674308b23fb2237"
  integrity sha512-Uf+gxPCe1hTOFXwkxYyckn8iUSk6CFXGy5VENZKifovUTZC9eUODWSBhOBS7zICGrAetKzdwLMr85KhIcePMAQ==

file-entry-cache@^6.0.1:
  version "6.0.1"
  resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027"


@@ 2035,9 2058,9 @@ supports-color@^7.1.0:
    has-flag "^4.0.0"

table@^6.0.4:
  version "6.0.8"
  resolved "https://registry.yarnpkg.com/table/-/table-6.0.8.tgz#b63f35af8d90601de282a3292226007a9429644f"
  integrity sha512-OBAdezyozae8IvjHGXBDHByVkLCcsmffXUSj8LXkNb0SluRd4ug3GFCjk6JynZONIPhOkyr0Nnvbq1rlIspXyQ==
  version "6.0.9"
  resolved "https://registry.yarnpkg.com/table/-/table-6.0.9.tgz#790a12bf1e09b87b30e60419bafd6a1fd85536fb"
  integrity sha512-F3cLs9a3hL1Z7N4+EkSscsel3z55XT950AvB05bwayrNg5T1/gykXtigioTAjbltvbMSJvvhFCbnf6mX+ntnJQ==
  dependencies:
    ajv "^8.0.1"
    is-boolean-object "^1.1.0"