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
4 changes: 2 additions & 2 deletions app/models/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { nodeDirname } from '../support/node-dirname';
import { EventService } from '../support/EventService';
import { userCooldownStart, userDataDeletionStart, userPausedStart } from '../jobs/user-gone';
import { allExternalProviders } from '../support/ExtAuth';
import { spawnAsync } from '../support/spawn-async';
import { runImageMagick } from '../support/image-magick';

import { validate as validateUserPrefs } from './user-prefs';

Expand Down Expand Up @@ -1040,7 +1040,7 @@ export function addModel(dbAdapter) {
const retinaSize = size * 2;

const tmpPictureFile = join(tmpdir(), `resized-${uuid}-${size}`);
await spawnAsync('convert', [
await runImageMagick('convert', [
path,
'-auto-orient',
'-resize',
Expand Down
25 changes: 16 additions & 9 deletions app/setup/environment.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,22 @@ process.env.MONITOR_PREFIX = config.monitorPrefix;
const checkIfMediaDirectoriesExist = async () => {
let gotErrors = false;

const attConf = currentConfig().attachments;

const attachmentsDir = attConf.storage.rootDir + attConf.path;

try {
await access(attachmentsDir, constants.W_OK);
} catch {
gotErrors = true;
log(`Attachments dir does not exist: ${attachmentsDir}`);
const { attachments, profilePictures } = currentConfig();
const dirs = [
['Attachments', attachments],
['Profile pictures', profilePictures],
].filter(([, mediaConfig]) => mediaConfig.storage.type === 'fs');

for (const [name, mediaConfig] of dirs) {
const dir = mediaConfig.storage.rootDir + mediaConfig.path;

try {
// eslint-disable-next-line no-await-in-loop
await access(dir, constants.W_OK);
} catch {
gotErrors = true;
log(`${name} dir does not exist: ${dir}`);
}
}

if (gotErrors) {
Expand Down
66 changes: 66 additions & 0 deletions app/support/image-magick.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import config from 'config';
import createDebug from 'debug';

import { spawnAsync, type SpawnAsyncArgs, type SpawnAsyncOptions } from './spawn-async';

type ImageMagickCommand = 'convert' | 'identify';
type Runner = { command: string | null; useMagickCli: boolean };

const debug = createDebug('freefeed:image-magick');

let detectedRunner: Promise<Runner> | null = null;

export async function runImageMagick(
command: ImageMagickCommand,
args: SpawnAsyncArgs,
options: SpawnAsyncOptions & { binary: true },
): Promise<{ stdout: Buffer; stderr: string }>;
export async function runImageMagick(
command: ImageMagickCommand,
args: SpawnAsyncArgs,
options?: SpawnAsyncOptions,
): Promise<{ stdout: string; stderr: string }>;
export async function runImageMagick(
command: ImageMagickCommand,
args: SpawnAsyncArgs,
options: SpawnAsyncOptions = {},
): Promise<{ stdout: string | Buffer; stderr: string }> {
const runner = await getRunner();
const runnerArgs = runner.useMagickCli ? magickArgs(command, args) : args;

debug('run %s %o', runner.command ?? command, runnerArgs.flat());
return await spawnAsync(runner.command ?? command, runnerArgs, options);
}

function getRunner(): Promise<Runner> {
detectedRunner ??= detectRunner();
return detectedRunner;
}

async function detectRunner(): Promise<Runner> {
const { command } = config.imageMagick;

if (command === 'legacy') {
debug('using legacy ImageMagick commands');
return { command: null, useMagickCli: false };
}

if (command !== 'auto') {
debug('using configured ImageMagick command: %s', command);
return { command, useMagickCli: true };
}

try {
await spawnAsync('magick', ['-version']);
debug('using ImageMagick 7 magick CLI');
return { command: 'magick', useMagickCli: true };
} catch (e) {
const message = e instanceof Error ? e.message : e;
debug('magick CLI is not available, using legacy commands: %s', message);
return { command: null, useMagickCli: false };
}
}

function magickArgs(command: ImageMagickCommand, args: SpawnAsyncArgs): SpawnAsyncArgs {
return command === 'identify' ? ['identify', ...args] : args;
}
3 changes: 2 additions & 1 deletion app/support/media-files/detect.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { open } from 'fs/promises';

import { runImageMagick } from '../image-magick';
import { spawnAsync } from '../spawn-async';

import {
Expand All @@ -22,7 +23,7 @@ export async function detectMediaType(
if (probablyImage) {
// Identify using ImageMagick
try {
const out = await spawnAsync('identify', [
const out = await runImageMagick('identify', [
['-format', '%m %W %H %[orientation] %n|'],
`${localFilePath}[0,1]`, // Select only up to 2 first frames to reduce the memory usage
]);
Expand Down
3 changes: 2 additions & 1 deletion app/support/media-files/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { lookup as mimeLookup } from 'mime-types';
import { exiftool } from 'exiftool-vendored';
import createDebug from 'debug';

import { runImageMagick } from '../image-magick';
import { spawnAsync, type SpawnAsyncArgs } from '../spawn-async';
import { currentConfig } from '../app-async-context';
import { ContentTooLargeException } from '../exceptions';
Expand Down Expand Up @@ -247,7 +248,7 @@ async function processImage(
return;
}

await spawnAsync('convert', [
await runImageMagick('convert', [
`${localFilePath}[0]`, // Adding [0] for the case of animated or multi-page images
'-auto-orient',
['-resize', `${width}!x${height}!`],
Expand Down
3 changes: 3 additions & 0 deletions config/custom-environment-variables.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
"host": "FRFS_REDIS_HOST",
"port": { "__name": "FRFS_REDIS_PORT", "__format": "json" }
},
"imageMagick": {
"command": "FRFS_IMAGEMAGICK_COMMAND"
},
"postgres": {
"connection": {
"host": "FRFS_PG_HOST",
Expand Down
7 changes: 7 additions & 0 deletions config/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,13 @@ config.profilePictures = {
path: 'profilepics/', // must have trailing slash
};

config.imageMagick = {
// 'auto' prefers ImageMagick 7 'magick' CLI and falls back to ImageMagick 6
// legacy 'convert'/'identify' commands. Set to 'legacy' to force ImageMagick 6
// commands, or to 'magick' / a path to magick to force ImageMagick 7 CLI.
command: 'auto',
};

config.mailer = {
useSMTPTransport: false,
transport: defer((cfg) => (cfg.mailer.useSMTPTransport ? smtpTransport : stubTransport)),
Expand Down
6 changes: 4 additions & 2 deletions test/functional/attachments.js
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ describe('Attachments', () => {

// Test the v4 API response
const resp1 = await performJSONRequest('GET', `/v4/attachments/${id}`);
expect(resp1.attachments, 'to equal', {
expect(resp1.attachments, 'to satisfy', {
id,
mediaType: 'audio',
fileName: 'music.mp3',
Expand All @@ -259,7 +259,9 @@ describe('Attachments', () => {
'dc:creator': 'Piermic',
'dc:relation.isPartOf': 'Wikimedia',
},
duration: 24.032653,
// Different FFmpeg versions may report slightly different durations, so
// we allow a small delta
duration: expect.it('to be close to', 24.032653, 0.1),
createdAt: attObj.createdAt.toISOString(),
updatedAt: attObj.updatedAt.toISOString(),
createdBy: luna.user.id,
Expand Down
6 changes: 3 additions & 3 deletions test/functional/bookmarklet.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ describe('BookmarkletController', () => {
} else if (url === '/big-body.jpg') {
ctx.status = 200;
ctx.response.type = 'image/jpeg';
ctx.body = getStreamOfLength(imageSizeLimit * 2);
ctx.body = getStreamOfLength(imageSizeLimit + 1024);
} else if (url === '/im%C3%A5ge.png') {
ctx.status = 200;
ctx.response.type = 'image/png';
Expand Down Expand Up @@ -250,15 +250,15 @@ async function callBookmarklet(author, body) {
return respBody;
}

function getStreamOfLength(length, chunk = Buffer.alloc(1024)) {
function getStreamOfLength(length, chunk = Buffer.alloc(1024 * 1024)) {
let bytesLeft = length;
return new Readable({
read() {
if (bytesLeft >= chunk.length) {
this.push(chunk);
bytesLeft -= chunk.length;
} else if (bytesLeft > 0) {
this.push(chunk.slice(0, bytesLeft));
this.push(chunk.subarray(0, bytesLeft));
bytesLeft = 0;
} else {
this.push(null);
Expand Down
3 changes: 0 additions & 3 deletions test/functional/groups.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-expressions */
/* global $pg_database */
import request from 'superagent';
import { mkdirp } from 'mkdirp';
import config from 'config';

import cleanDB from '../dbCleaner';
import { getSingleton } from '../../app/app';
Expand Down Expand Up @@ -349,7 +347,6 @@ describe('GroupsController', () => {

beforeEach(async () => {
context = await funcTestHelper.createUserAsync('Luna', 'password');
await mkdirp(config.profilePictures.storage.rootDir + config.profilePictures.path);
await funcTestHelper.createGroupAsync(context, 'pepyatka-dev', 'Pepyatka Developers');
});

Expand Down
6 changes: 1 addition & 5 deletions test/functional/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
/* global $pg_database, $should */
import jwt from 'jsonwebtoken';
import * as _ from 'lodash-es';
import { mkdirp } from 'mkdirp';
import request from 'superagent';
import expect from 'unexpected';
import config from 'config';
Expand Down Expand Up @@ -1388,10 +1387,7 @@ describe('UsersController', () => {
let authToken;

beforeEach(async () => {
const [user] = await Promise.all([
funcTestHelper.createUserAsync('Luna', 'password'),
mkdirp(config.profilePictures.storage.rootDir + config.profilePictures.path),
]);
const user = await funcTestHelper.createUserAsync('Luna', 'password');

({ authToken } = user);
});
Expand Down
8 changes: 4 additions & 4 deletions test/integration/models/attachment-orientation.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import expect from 'unexpected';

import cleanDB from '../../dbCleaner';
import { User, Attachment } from '../../../app/models';
import { spawnAsync } from '../../../app/support/spawn-async';
import { runImageMagick } from '../../../app/support/image-magick';

const orientationNames = [
'Undefined', // No orientation tag
Expand Down Expand Up @@ -67,7 +67,7 @@ describe('Orientation', () => {
});

async function getOrientation(filename) {
const out = await spawnAsync('identify', ['-format', '%[orientation]', filename]);
const out = await runImageMagick('identify', ['-format', '%[orientation]', filename]);
return out.stdout;
}

Expand All @@ -76,7 +76,7 @@ async function getOrientation(filename) {
* orientation tag when it is not zero.
*/
async function createTestImage(filename, orientation) {
await spawnAsync('convert', [
await runImageMagick('convert', [
['-size', '200x300'],
'xc:#000000',
['-fill', '#ffffff'],
Expand Down Expand Up @@ -115,7 +115,7 @@ function patternForOrientation(orientation) {
}

async function expectOrientation(filePath, orientation) {
const { stdout: buffer } = await spawnAsync(
const { stdout: buffer } = await runImageMagick(
'convert',
[filePath, '-filter', 'Point', '-resize', '3x3', 'gray:-'],
{ binary: true },
Expand Down
14 changes: 4 additions & 10 deletions test/integration/models/attachment.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,14 @@ import { tmpdir } from 'os';
import { v4 as createUuid } from 'uuid';
import { before, beforeEach, describe, it } from 'mocha';
import expect from 'unexpected';
import { mkdirp } from 'mkdirp';
import { lookup as mimeLookup } from 'mime-types';

import cleanDB from '../../dbCleaner';
import { dbAdapter, Attachment } from '../../../app/models';
import { currentConfig } from '../../../app/support/app-async-context';
import { createUser } from '../helpers/users';
import { createPost } from '../helpers/posts-and-comments';
import { spawnAsync } from '../../../app/support/spawn-async';
import { runImageMagick } from '../../../app/support/image-magick';
import { withModifiedConfig } from '../../helpers/with-modified-config';
import { initJobProcessing } from '../../../app/jobs';
import { ATTACHMENT_PREPARE_VIDEO } from '../../../app/jobs/attachment-prepare-video';
Expand All @@ -35,11 +34,6 @@ describe('Attachments', () => {
// Create user
user = await createUser('luna');
jobManager = await initJobProcessing();

const attConf = currentConfig().attachments;

// Create directories for attachments
await mkdirp(attConf.storage.rootDir + attConf.path);
});

beforeEach(() => fakeS3Storage.clear());
Expand Down Expand Up @@ -112,7 +106,7 @@ describe('Attachments', () => {
currentConfig().attachments.storage.rootDir,
att.getRelFilePath('p2', 'webp'),
);
const out = await spawnAsync('identify', ['-format', '%w %h %[orientation]', originalFile]);
const out = await runImageMagick('identify', ['-format', '%w %h %[orientation]', originalFile]);
expect(out.stdout, 'to equal', '300 900 Undefined');
});

Expand All @@ -123,7 +117,7 @@ describe('Attachments', () => {
// original colors
{
const originalFile = join(rootDir, att.getRelFilePath('', att.fileExtension));
const { stdout: buffer } = await spawnAsync(
const { stdout: buffer } = await runImageMagick(
'convert',
[originalFile, '-resize', '1x1!', '-colorspace', 'sRGB', '-depth', '8', 'rgb:-'],
{ binary: true },
Expand All @@ -138,7 +132,7 @@ describe('Attachments', () => {
// preview colors
{
const previewFile = join(rootDir, att.getRelFilePath('p1', 'webp'));
const { stdout: buffer } = await spawnAsync(
const { stdout: buffer } = await runImageMagick(
'convert',
[previewFile, '-resize', '1x1!', '-colorspace', 'sRGB', '-depth', '8', 'rgb:-'],
{ binary: true },
Expand Down
10 changes: 9 additions & 1 deletion test/integration/models/media-files.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,15 @@ describe('Media files', () => {
const { file: fileName, info } = file;
it(`should detect media info for ${fileName}`, async () => {
const detected = await detectMediaType(join(samplesDir, fileName), fileName);
expect(info, 'to equal', detected);

if (info.duration) {
// Different FFmpeg versions may report slightly different durations, so
// we allow a small delta
const d = info.duration;
info.duration = expect.it('to be close to', d, 0.1);
}

expect(detected, 'to satisfy', info);
});
}
});
12 changes: 12 additions & 0 deletions test/testHelper.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { should } from 'chai';
import config from 'config';
import { mkdirp } from 'mkdirp';

import redisDb from '../app/setup/database';
import * as postgresDb from '../app/setup/postgres';
Expand All @@ -12,3 +14,13 @@ global.$pg_database = global.$postgres.connect();

// Show stack not only for errors, but also for warnings
process.on('warning', (e) => console.warn(e.stack));

export const mochaHooks = {
async beforeAll() {
const mediaDirs = [config.attachments, config.profilePictures]
.filter((mediaConfig) => mediaConfig.storage.type === 'fs')
.map((mediaConfig) => mkdirp(mediaConfig.storage.rootDir + mediaConfig.path));

await Promise.all(mediaDirs);
},
};
Loading
Loading