Merge pull request #3 from rishighan/mimetype-check

 MIMEtype check for comic book archives
This commit was merged in pull request #3.
This commit is contained in:
2023-03-23 23:59:30 -04:00
committed by GitHub
10 changed files with 4201 additions and 16348 deletions

View File

@@ -7,8 +7,10 @@ module.exports = {
ignorePatterns: [ "test/*"], ignorePatterns: [ "test/*"],
parser: "@typescript-eslint/parser", parser: "@typescript-eslint/parser",
parserOptions: { parserOptions: {
ecmaVersion: "esnext",
project: "tsconfig.json", project: "tsconfig.json",
sourceType: "module" sourceType: "module",
ecmaVersion: "latest",
}, },
plugins: ["prefer-arrow", "import", "@typescript-eslint"], plugins: ["prefer-arrow", "import", "@typescript-eslint"],
rules: { rules: {

View File

@@ -1,13 +1,14 @@
const path = require("path"); const path = require("path");
const mkdir = require("mkdirp").sync; const mkdir = require("mkdirp").sync;
const DbService = require("moleculer-db"); const DbService = require("moleculer-db");
const MongoAdapter = require("moleculer-db-adapter-mongoose");
export const DbMixin = (collection, model) => { export const DbMixin = (collection, model) => {
if(process.env.MONGO_URI) { if (process.env.MONGO_URI) {
const MongooseAdapter = require("moleculer-db-adapter-mongoose");
return { return {
mixins: [DbService], mixins: [DbService],
adapter: new MongoAdapter(process.env.MONGO_URI, { adapter: new MongooseAdapter(process.env.MONGO_URI, {
user: process.env.MONGO_INITDB_ROOT_USERNAME, user: process.env.MONGO_INITDB_ROOT_USERNAME,
pass: process.env.MONGO_INITDB_ROOT_PASSWORD, pass: process.env.MONGO_INITDB_ROOT_PASSWORD,
keepAlive: true, keepAlive: true,
@@ -15,7 +16,6 @@ export const DbMixin = (collection, model) => {
family: 4, family: 4,
}), }),
model, model,
collection,
}; };
} }
mkdir(path.resolve("./data")); mkdir(path.resolve("./data"));

View File

@@ -25,15 +25,13 @@ const RawFileDetailsSchema = mongoose.Schema({
filePath: String, filePath: String,
fileSize: Number, fileSize: Number,
extension: String, extension: String,
mimeType: String,
containedIn: String, containedIn: String,
pageCount: Number, pageCount: Number,
cover: { cover: {
filePath: String, filePath: String,
stats: Object, stats: Object,
}, },
calibreMetadata: {
coverWriteResult: String,
},
}); });
const LOCGSchema = mongoose.Schema({ const LOCGSchema = mongoose.Schema({

20376
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{ {
"name": "threetwo-core-service", "name": "threetwo-core-service",
"version": "0.0.1", "version": "0.0.1",
"description": "", "description": "Endpoints for common operations in ThreeTwo",
"scripts": { "scripts": {
"build": "tsc --build tsconfig.json", "build": "tsc --build tsconfig.json",
"dev": "ts-node ./node_modules/moleculer/bin/moleculer-runner.js --hot --repl --config moleculer.config.ts services/**/*.service.ts", "dev": "ts-node ./node_modules/moleculer/bin/moleculer-runner.js --hot --repl --config moleculer.config.ts services/**/*.service.ts",
@@ -18,26 +18,30 @@
"microservices", "microservices",
"moleculer" "moleculer"
], ],
"author": "", "author": "Rishi Ghan",
"devDependencies": { "devDependencies": {
"@elastic/elasticsearch": "^8.6.0",
"@types/lodash": "^4.14.168", "@types/lodash": "^4.14.168",
"@typescript-eslint/eslint-plugin": "^4.33.0", "@typescript-eslint/eslint-plugin": "^5.56.0",
"@typescript-eslint/parser": "^4.33.0", "@typescript-eslint/parser": "^5.56.0",
"eslint": "^7.32.0", "eslint": "^8.36.0",
"eslint-plugin-import": "^2.20.2", "eslint-plugin-import": "^2.20.2",
"eslint-plugin-prefer-arrow": "^1.2.2", "eslint-plugin-prefer-arrow": "^1.2.2",
"install": "^0.13.0", "install": "^0.13.0",
"jest": "^27.5.1", "jest": "^29.5.0",
"jest-cli": "^27.5.1", "jest-cli": "^29.5.0",
"moleculer-repl": "^0.7.0", "moleculer-repl": "^0.7.0",
"node-calibre": "^2.1.1",
"npm": "^8.4.1", "npm": "^8.4.1",
"ts-jest": "^27.1.4", "ts-jest": "^29.0.5",
"ts-node": "^8.8.1", "ts-node": "^10.9.1",
"typescript": "^4.6.4" "typescript": "^5.0.2"
}, },
"dependencies": { "dependencies": {
"@npcz/magic": "^1.3.14",
"redis": "^4.6.5",
"@socket.io/redis-adapter": "^8.1.0",
"@bluelovers/fast-glob": "https://github.com/rishighan/fast-glob-v2-api.git", "@bluelovers/fast-glob": "https://github.com/rishighan/fast-glob-v2-api.git",
"@elastic/elasticsearch": "^8.6.0",
"@jorgeferrero/stream-to-buffer": "^2.0.6", "@jorgeferrero/stream-to-buffer": "^2.0.6",
"@root/walk": "^1.1.0", "@root/walk": "^1.1.0",
"@types/jest": "^27.4.1", "@types/jest": "^27.4.1",
@@ -61,22 +65,20 @@
"leven": "^3.1.0", "leven": "^3.1.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mkdirp": "^0.5.5", "mkdirp": "^0.5.5",
"moleculer": "^0.14.28", "moleculer": "^0.14.29",
"moleculer-bull": "github:rishighan/moleculer-bull#1.0.0", "moleculer-bull": "github:rishighan/moleculer-bull#1.0.0",
"moleculer-db": "^0.8.23", "moleculer-db": "^0.8.23",
"moleculer-db-adapter-mongo": "^0.4.17",
"moleculer-db-adapter-mongoose": "^0.9.2", "moleculer-db-adapter-mongoose": "^0.9.2",
"moleculer-io": "^2.2.0", "moleculer-io": "^2.2.0",
"moleculer-web": "^0.10.5", "moleculer-web": "^0.10.5",
"mongoosastic-ts": "^6.0.3", "mongoosastic-ts": "^6.0.3",
"mongoose": "^6.10.4",
"mongoose-paginate-v2": "^1.3.18", "mongoose-paginate-v2": "^1.3.18",
"nats": "^1.3.2", "nats": "^1.3.2",
"node-calibre": "^2.1.1",
"opds-extra": "^3.0.9", "opds-extra": "^3.0.9",
"p7zip-threetwo": "^1.0.4", "p7zip-threetwo": "^1.0.4",
"sanitize-filename-ts": "^1.0.2", "sanitize-filename-ts": "^1.0.2",
"sharp": "^0.30.4", "sharp": "^0.30.4",
"socket.io-redis": "^6.1.1",
"threetwo-ui-typings": "^1.0.14", "threetwo-ui-typings": "^1.0.14",
"through2": "^4.0.2", "through2": "^4.0.2",
"unrar": "^0.2.0", "unrar": "^0.2.0",

View File

@@ -80,6 +80,7 @@ export default class QueueService extends Service {
filePath, filePath,
fileSize, fileSize,
extension, extension,
mimeType,
cover, cover,
containedIn, containedIn,
comicInfoJSON, comicInfoJSON,
@@ -113,6 +114,7 @@ export default class QueueService extends Service {
filePath, filePath,
fileSize, fileSize,
extension, extension,
mimeType,
containedIn, containedIn,
cover, cover,
}, },

View File

@@ -75,6 +75,7 @@ export default class ImportService extends Service {
return await walkFolder(ctx.params.basePathToWalk, [ return await walkFolder(ctx.params.basePathToWalk, [
".cbz", ".cbz",
".cbr", ".cbr",
".cb7",
]); ]);
}, },
}, },

View File

@@ -1,10 +1,16 @@
"use strict"; "use strict";
import { Service, ServiceBroker, ServiceSchema } from "moleculer"; import { Service, ServiceBroker, ServiceSchema } from "moleculer";
import { createClient } from "redis";
import { createAdapter } from "@socket.io/redis-adapter";
const SocketIOService = require("moleculer-io"); const SocketIOService = require("moleculer-io");
const redisAdapter = require("socket.io-redis");
const redisURL = new URL(process.env.REDIS_URI); const redisURL = new URL(process.env.REDIS_URI);
console.log(redisURL.hostname); // console.log(redisURL.hostname);
const pubClient = createClient({ url: `redis://${redisURL.hostname}:6379` });
(async () => {
await pubClient.connect();
})();
const subClient = pubClient.duplicate();
export default class SocketService extends Service { export default class SocketService extends Service {
// @ts-ignore // @ts-ignore
public constructor( public constructor(
@@ -66,10 +72,7 @@ export default class SocketService extends Service {
}, },
}, },
options: { options: {
adapter: redisAdapter({ adapter: createAdapter(pubClient, subClient),
host: redisURL.hostname,
port: 6379,
}),
}, },
}, },
}, },

View File

@@ -3,6 +3,7 @@ const fse = require("fs-extra");
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
import { FileMagic, MagicFlags } from "@npcz/magic";
const { readdir, stat } = require("fs/promises"); const { readdir, stat } = require("fs/promises");
import { import {
IExplodedPathResponse, IExplodedPathResponse,
@@ -17,6 +18,17 @@ import { sanitize } from "sanitize-filename-ts";
const ALLOWED_IMAGE_FILE_FORMATS = [".jpg", ".jpeg", ".png"]; const ALLOWED_IMAGE_FILE_FORMATS = [".jpg", ".jpeg", ".png"];
// Tell FileMagic where to find the magic.mgc file
FileMagic.magicFile = require.resolve("@npcz/magic/dist/magic.mgc");
// We can onlu use MAGIC_PRESERVE_ATIME on operating suystems that support
// it and that includes OS X for example. It's a good practice as we don't
// want to change the last access time because we are just checking the file
// contents type
if (process.platform === "darwin" || process.platform === "linux") {
FileMagic.defaulFlags = MagicFlags.MAGIC_PRESERVE_ATIME;
}
export const walkFolder = async ( export const walkFolder = async (
folder: string, folder: string,
formats: string[] formats: string[]
@@ -73,11 +85,11 @@ export const explodePath = (filePath: string): IExplodedPathResponse => {
// returns a promise which resolves true if file exists: // returns a promise which resolves true if file exists:
export const checkFileExists = (filepath) => { export const checkFileExists = (filepath) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
fs.access(filepath, fs.constants.F_OK, error => { fs.access(filepath, fs.constants.F_OK, (error) => {
resolve(!error); resolve(!error);
}); });
}); });
} };
export const getSizeOfDirectory = async ( export const getSizeOfDirectory = async (
directoryPath: string, directoryPath: string,
@@ -109,6 +121,12 @@ export const constructPaths = (
walkedFolder.extension, walkedFolder.extension,
}); });
/**
* Method that gets file metadata from a filepath.
* Extracts the file extension, file name with and without the extension
* @param {string} filePath
* @returns {Object} object
*/
export const getFileConstituents = (filePath: string) => { export const getFileConstituents = (filePath: string) => {
const extension = path.extname(filePath); const extension = path.extname(filePath);
const fileNameWithExtension = path.basename(filePath); const fileNameWithExtension = path.basename(filePath);
@@ -123,14 +141,33 @@ export const getFileConstituents = (filePath: string) => {
}; };
}; };
/**
* Method that infers MIME type from a filepath
* @param {string} filePath
* @returns {Promise} string
*/
export const getMimeType = async (filePath: string) => {
return await FileMagic.getInstance().then((magic: FileMagic) => {
return magic.detect(
path.resolve(filePath),
magic.flags | MagicFlags.MAGIC_MIME
);
});
};
export const createDirectory = async (options: any, directoryPath: string) => { export const createDirectory = async (options: any, directoryPath: string) => {
try { try {
await fse.ensureDir(directoryPath, options); await fse.ensureDir(directoryPath, options);
console.info(`Directory [ %s ] was created.`, directoryPath); console.info(`Directory [ %s ] was created.`, directoryPath);
} catch (error) { } catch (error) {
throw new Errors.MoleculerError("Failed to create directory", 500, "FileOpsError", error); throw new Errors.MoleculerError(
"Failed to create directory",
500,
"FileOpsError",
error
);
} }
} };
const filterOutDotFiles = (entities) => const filterOutDotFiles = (entities) =>
entities.filter((ent) => !ent.name.startsWith(".")); entities.filter((ent) => !ent.name.startsWith("."));

View File

@@ -44,6 +44,7 @@ import {
getFileConstituents, getFileConstituents,
createDirectory, createDirectory,
walkFolder, walkFolder,
getMimeType,
} from "../utils/file.utils"; } from "../utils/file.utils";
import { convertXMLToJSON } from "./xml.utils"; import { convertXMLToJSON } from "./xml.utils";
const fse = require("fs-extra"); const fse = require("fs-extra");
@@ -62,7 +63,8 @@ interface RarFile {
} }
const UNRAR_BIN_PATH = process.env.UNRAR_BIN_PATH || "/usr/local/bin/unrar"; const UNRAR_BIN_PATH = process.env.UNRAR_BIN_PATH || "/usr/local/bin/unrar";
// errors array
const errors = [];
/** /**
* Method that extracts comicInfo.xml file from a .rar archive, if one exists. * Method that extracts comicInfo.xml file from a .rar archive, if one exists.
* Also extracts the first image in the listing, which is assumed to be the cover. * Also extracts the first image in the listing, which is assumed to be the cover.
@@ -70,7 +72,8 @@ const UNRAR_BIN_PATH = process.env.UNRAR_BIN_PATH || "/usr/local/bin/unrar";
* @returns {any} * @returns {any}
*/ */
export const extractComicInfoXMLFromRar = async ( export const extractComicInfoXMLFromRar = async (
filePath: string filePath: string,
mimeType: string,
): Promise<any> => { ): Promise<any> => {
try { try {
// Create the target directory // Create the target directory
@@ -87,13 +90,13 @@ export const extractComicInfoXMLFromRar = async (
const archive = new Unrar({ const archive = new Unrar({
path: path.resolve(filePath), path: path.resolve(filePath),
bin: `${UNRAR_BIN_PATH}`, // this will change depending on Docker base OS bin: `${UNRAR_BIN_PATH}`, // this will change depending on Docker base OS
arguments: ["-v"] arguments: ["-v"],
}); });
const filesInArchive: [RarFile] = await new Promise( const filesInArchive: [RarFile] = await new Promise(
(resolve, reject) => { (resolve, reject) => {
return archive.list((err, entries) => { return archive.list((err, entries) => {
if (err) { if (err) {
console.log(`DEBUG: ${JSON.stringify(err, null, 2)}` ) console.log(`DEBUG: ${JSON.stringify(err, null, 2)}`);
reject(err); reject(err);
} }
resolve(entries); resolve(entries);
@@ -151,7 +154,9 @@ export const extractComicInfoXMLFromRar = async (
const comicInfoJSON = await convertXMLToJSON( const comicInfoJSON = await convertXMLToJSON(
comicinfostring.toString() comicinfostring.toString()
); );
console.log(`comicInfo.xml successfully written: ${comicInfoJSON.comicinfo}`) console.log(
`comicInfo.xml successfully written: ${comicInfoJSON.comicinfo}`
);
resolve({ comicInfoJSON: comicInfoJSON.comicinfo }); resolve({ comicInfoJSON: comicInfoJSON.comicinfo });
} }
}); });
@@ -182,6 +187,7 @@ export const extractComicInfoXMLFromRar = async (
extension, extension,
containedIn: targetDirectory, containedIn: targetDirectory,
fileSize: fse.statSync(filePath).size, fileSize: fse.statSync(filePath).size,
mimeType,
cover: { cover: {
filePath: path.relative( filePath: path.relative(
process.cwd(), process.cwd(),
@@ -202,7 +208,8 @@ export const extractComicInfoXMLFromRar = async (
}; };
export const extractComicInfoXMLFromZip = async ( export const extractComicInfoXMLFromZip = async (
filePath: string filePath: string,
mimeType: string,
): Promise<any> => { ): Promise<any> => {
try { try {
// Create the target directory // Create the target directory
@@ -247,7 +254,7 @@ export const extractComicInfoXMLFromZip = async (
// Push the first file (cover) to our extraction target // Push the first file (cover) to our extraction target
extractionTargets.push(files[0].name); extractionTargets.push(files[0].name);
filesToWriteToDisk.coverFile = path.basename(files[0].name); filesToWriteToDisk.coverFile = path.basename(files[0].name);
console.log(`sanitized or not, here I am: ${filesToWriteToDisk.coverFile}`);
if (!isEmpty(comicInfoXMLFileObject)) { if (!isEmpty(comicInfoXMLFileObject)) {
filesToWriteToDisk.comicInfoXML = comicInfoXMLFileObject[0].name; filesToWriteToDisk.comicInfoXML = comicInfoXMLFileObject[0].name;
extractionTargets.push(filesToWriteToDisk.comicInfoXML); extractionTargets.push(filesToWriteToDisk.comicInfoXML);
@@ -318,6 +325,7 @@ export const extractComicInfoXMLFromZip = async (
filePath, filePath,
name: fileNameWithoutExtension, name: fileNameWithoutExtension,
extension, extension,
mimeType,
containedIn: targetDirectory, containedIn: targetDirectory,
fileSize: fse.statSync(filePath).size, fileSize: fse.statSync(filePath).size,
cover: { cover: {
@@ -342,18 +350,17 @@ export const extractComicInfoXMLFromZip = async (
export const extractFromArchive = async (filePath: string) => { export const extractFromArchive = async (filePath: string) => {
console.info(`Unrar is located at: ${UNRAR_BIN_PATH}`); console.info(`Unrar is located at: ${UNRAR_BIN_PATH}`);
console.info(`p7zip is located at: ${process.env.SEVENZ_BINARY_PATH}`); console.info(`p7zip is located at: ${process.env.SEVENZ_BINARY_PATH}`);
const { extension } = getFileConstituents(filePath);
console.log( const mimeType = await getMimeType(filePath);
`Detected file type is ${extension}, looking for comicinfo.xml...` console.log(`File has the following mime-type: ${mimeType}`);
); switch (mimeType) {
switch (extension) { case "application/x-7z-compressed; charset=binary":
case ".cbz": case "application/zip; charset=binary":
case ".cb7": const cbzResult = await extractComicInfoXMLFromZip(filePath, mimeType);
const cbzResult = await extractComicInfoXMLFromZip(filePath);
return Object.assign({}, ...cbzResult); return Object.assign({}, ...cbzResult);
case ".cbr": case "application/x-rar; charset=binary":
const cbrResult = await extractComicInfoXMLFromRar(filePath); const cbrResult = await extractComicInfoXMLFromRar(filePath, mimeType);
return Object.assign({}, ...cbrResult); return Object.assign({}, ...cbrResult);
default: default:
@@ -374,13 +381,20 @@ export const uncompressEntireArchive = async (
filePath: string, filePath: string,
options: any options: any
) => { ) => {
const { extension } = getFileConstituents(filePath); const mimeType = await getMimeType(filePath);
switch (extension) { console.log(`File has the following mime-type: ${mimeType}`);
case ".cbz": switch (mimeType) {
case ".cb7": case "application/x-7z-compressed; charset=binary":
return await uncompressZipArchive(filePath, options); case "application/zip; charset=binary":
case ".cbr": return await uncompressZipArchive(filePath, {
return await uncompressRarArchive(filePath, options); ...options,
mimeType,
});
case "application/x-rar; charset=binary":
return await uncompressRarArchive(filePath, {
...options,
mimeType,
});
} }
}; };