diff --git a/index.js b/index.js new file mode 100644 index 0000000..d18bcc4 --- /dev/null +++ b/index.js @@ -0,0 +1 @@ +module.exports = require("./module"); diff --git a/module/gm.js b/module/gm.js new file mode 100644 index 0000000..623328f --- /dev/null +++ b/module/gm.js @@ -0,0 +1,53 @@ +const { spawn } = require("child_process"); + +// GM +async function callGM(params, stdin) { + const out = []; + const err = []; + + return await new Promise((resolve, reject) => { + gm = spawn("gm", params); + + gm.stdout.on("data", (c) => out.push(c)); + gm.stderr.on("data", (c) => err.push(c)); + + gm.on("close", (code) => { + if (code !== 0) return reject(Buffer.concat(err).toString()); + + resolve(Buffer.concat(out)); + }); + gm.stdin.end(stdin); + }); +} + +const d = (dbg) => console.debug(dbg) || dbg; +class GMController { + async cropImage(buffer, x, y, offsetX = 0, offsetY = 0) { + return await callGM( + ["convert", "-", "-crop", `${x}x${y}+${offsetX}+${offsetY}`, "png:-"], + buffer, + ); + } + + // For change hash-sum by insignificant changes in pixel colors + async repixelise(buffer) { + const noiseAmount = Math.random() * 1; // 0..1% + const brightness = (98 + Math.random() * 4).toFixed(2); // 98..102% + + return await callGM( + ["convert", "-", "-noise", ""+noiseAmount, "-modulate", brightness.toString(), "png:-"], + buffer, + ); + } + + async getSize (buffer) { + const [width, height] =(await callGM( + ['identify', '-format', '%w %h', '-'], + buffer, + )).toString().split(/\s{1,}/g).map(s => +s); + + return { width, height }; + } +} + +module.exports = new GMController(); diff --git a/module/index.js b/module/index.js new file mode 100644 index 0000000..5b4a0e1 --- /dev/null +++ b/module/index.js @@ -0,0 +1,117 @@ +const fs = require("fs"); +const path = require("path"); +const gm = require("./gm"); +const mime = require("./mimetype"); + +function shuffle(array) { + for (let i = array.length - 1; i > 0; i--) { + let j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } +} + +function readFixedBytes(pathToFile, size = 16) { + const fd = fs.openSync(pathToFile, "r"); + try { + const buffer = Buffer.alloc(size); + fs.readSync(fd, buffer, 0, size, 0); + return buffer; + } catch (err) { + console.error(err); + } finally { + fs.closeSync(fd); + } +} + +async function isImage (pathToFile, requiredSize=null) { + const contentType = mime.byPath(pathToFile).contentType; + const isImageMime = /^image\//m.test(contentType); + if ( + !isImageMime || !requiredSize || + !Array.isArray(requiredSize) + ) return isImageMime; + + const [width, height] = requiredSize; + const { width:ow, height:oh } = await gm.getSize( + fs.readFileSync(pathToFile) + ); + + return width === ow && height === oh; +} + +async function getImageFromFolderGeneral (imagesFolder) { + // Get list of images from folder + const entries = fs.readdirSync(imagesFolder, { withFileTypes: true }); + const images = []; + for (let entry of entries) { + if (!entry.isDirectory() && !!(await isImage(path.join(imagesFolder, entry.name), [150, 150]))) { + images.push(path.join(imagesFolder, entry.name)); + } + } + + if (images.length === 0) + throw new Error(imagesFolder, "has not required images (150x150 px)"); + + shuffle(images); + //console.debug(images); + return images[0]; +} + +function genHashGeneral(length = 3) { + return Buffer.from( + [...new Array(length)].map((x) => Math.round(Math.random() * 255)), + ).toString("hex"); +} + +async function generateTask (imagesFolder, options = {}) { + if (!path.isAbsolute(imagesFolder)) + imagesFolder = path.join(process.cwd(), imagesFolder); + + // Define tools + if (!options.genHash) options.genHash = genHashGeneral; + if (!options.getImageFromFolder) + options.getImageFromFolder = getImageFromFolderGeneral; + + const choosenImage = fs.readFileSync(await getImageFromFolderGeneral(imagesFolder)); + // Split the image by 9 shards + const fragments = new Object(); + let answer = ""; + const addOps = []; + for (let row = 0; row < 3; row++) + for (let col = 0; col < 3; col++) { + let key; + do { + key = options.genHash(); + } while (!!fragments[key]); + answer += key; + const shardBytes = await gm.repixelise( + await gm.cropImage(choosenImage, 50, 50, col * 50, row * 50), + ); + addOps.push( + () => + (fragments[key] = + `data:image/png;base64,${shardBytes.toString("base64")}`), + ); + } + shuffle(addOps); + addOps.forEach((f) => f()); + const successCode = options.genHash(8); + + return { + openPuzzleCaptchaAPI: { + getTask: { + isError: false, + mode: "3x3", + fragments + }, + taskSolved: { + isError: false, + isSuccess: true, + successCode + }, + }, + answer, + }; +} + +module.exports = { generateTask }; diff --git a/module/mimetype.js b/module/mimetype.js new file mode 100644 index 0000000..c03406d --- /dev/null +++ b/module/mimetype.js @@ -0,0 +1,128 @@ +const fs = require("fs"); + +function readByBuffer(buffer) { + if (!Buffer.isBuffer(buffer) || buffer.length < 4) { + return { contentType: "application/octet-stream", extension: "" }; + } + buffer = buffer.slice(0, 16); + + // ---------- IMAGES ---------- + + // PNG + if ( + buffer.length >= 8 && + buffer[0] === 0x89 && + buffer[1] === 0x50 && + buffer[2] === 0x4e && + buffer[3] === 0x47 + ) { + return { contentType: "image/png", extension: "png" }; + } + + // JPEG + if (buffer[0] === 0xff && buffer[1] === 0xd8) { + return { contentType: "image/jpeg", extension: "jpg" }; + } + + // GIF + if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) { + return { contentType: "image/gif", extension: "gif" }; + } + + // WEBP + if ( + buffer.length >= 12 && + buffer[0] === 0x52 && // R + buffer[1] === 0x49 && // I + buffer[2] === 0x46 && // F + buffer[3] === 0x46 && // F + buffer[8] === 0x57 && // W + buffer[9] === 0x45 && // E + buffer[10] === 0x42 && // B + buffer[11] === 0x50 // P + ) { + return { contentType: "image/webp", extension: "webp" }; + } + + // ---------- VIDEO / AUDIO ---------- + + // MP4 / M4V / MOV (ISO BMFF) + if (buffer.length >= 12 && buffer.slice(4, 8).toString() === "ftyp") { + const brand = buffer.slice(8, 12).toString(); + + if (["isom", "mp41", "mp42"].includes(brand)) { + return { contentType: "video/mp4", extension: "mp4" }; + } + + if (["M4V ", "M4VH"].includes(brand)) { + return { contentType: "video/x-m4v", extension: "m4v" }; + } + + if (brand === "qt ") { + return { contentType: "video/quicktime", extension: "mov" }; + } + } + + // MP3 + if ( + buffer.slice(0, 3).toString() === "ID3" || + (buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0) + ) { + return { contentType: "audio/mpeg", extension: "mp3" }; + } + + // WAV + if ( + buffer.slice(0, 4).toString() === "RIFF" && + buffer.slice(8, 12).toString() === "WAVE" + ) { + return { contentType: "audio/wav", extension: "wav" }; + } + + // OGG + if (buffer.slice(0, 4).toString() === "OggS") { + return { contentType: "audio/ogg", extension: "ogg" }; + } + + // ---------- DOCUMENTS ---------- + + // PDF + if (buffer.slice(0, 4).toString() === "%PDF") { + return { contentType: "application/pdf", extension: "pdf" }; + } + + // ZIP (docx, xlsx, apk, etc.) + if (buffer[0] === 0x50 && buffer[1] === 0x4b) { + return { contentType: "application/zip", extension: "zip" }; + } + + // ---------- TEXT ---------- + + // UTF-8 text (heuristic) + const text = buffer.toString("utf8"); + if (/^[\x09\x0A\x0D\x20-\x7E]*$/.test(text)) { + return { contentType: "text/plain", extension: "txt" }; + } + + // ---------- FALLBACK ---------- + return { + contentType: "application/octet-stream", + extension: "", + }; +} + +function readByPath(pathToFile) { + const size = 16; + const fd = fs.openSync(pathToFile, "r"); + try { + const buffer = Buffer.alloc(size); + fs.readSync(fd, buffer, 0, size, 0); + return readByBuffer(buffer); + } catch (err) { + console.error(err); + } finally { + fs.closeSync(fd); + } +} + +module.exports = { byPath: readByPath, byBuffer: readByBuffer }; diff --git a/package.json b/package.json new file mode 100644 index 0000000..97f18a8 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "openpuzzle-captcha", + "version": "25.12.0", + "description": "Generate of interactive captcha (Puzzles)", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "https://git.fullgream.tech/fullgream/openpuzzle-captcha.git" + }, + "keywords": [ + "captcha", + "puzzle", + "interactive", + "canvas", + "interactive-captcha" + ], + "author": "FullGreaM", + "license": "MIT" +} diff --git a/test.js b/test.js new file mode 100644 index 0000000..55b5e72 --- /dev/null +++ b/test.js @@ -0,0 +1,5 @@ +const path = require("path"); +const opc = require("./"); + +//opc.generateTask(path.join(__dirname, "./sandbox/simple-server/images")).then(console.log); +opc.generateTask("./sandbox/simple-server/images").then(console.log);