add module code

This commit is contained in:
FullGreaM 2025-12-28 15:54:54 +03:00
parent 8c96ae1782
commit 014ed6f6a8
6 changed files with 326 additions and 0 deletions

1
index.js Normal file
View File

@ -0,0 +1 @@
module.exports = require("./module");

53
module/gm.js Normal file
View File

@ -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();

117
module/index.js Normal file
View File

@ -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 };

128
module/mimetype.js Normal file
View File

@ -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 };

22
package.json Normal file
View File

@ -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"
}

5
test.js Normal file
View File

@ -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);