Initial commit
This commit is contained in:
35
web/src/auth.js
Normal file
35
web/src/auth.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const jwt = require("jsonwebtoken");
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
|
||||
function verifyToken(req, res, next) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
return res.status(401).json({ error: "Authorization header missing or invalid" });
|
||||
}
|
||||
|
||||
const token = authHeader.split(" ")[1];
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
req.jwt = decoded;
|
||||
next();
|
||||
} catch (e) {
|
||||
return res.status(401).json({ error: "Invalid or expired token" });
|
||||
}
|
||||
}
|
||||
|
||||
function hasValidToken(req) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) return false;
|
||||
|
||||
const token = authHeader.split(" ")[1];
|
||||
try {
|
||||
if (!JWT_SECRET) return false;
|
||||
jwt.verify(token, JWT_SECRET);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { verifyToken, hasValidToken };
|
||||
46
web/src/rateLimiter.js
Normal file
46
web/src/rateLimiter.js
Normal file
@@ -0,0 +1,46 @@
|
||||
const WINDOW_MS = 60 * 60 * 1000;
|
||||
const MAX_REQUESTS = 3;
|
||||
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
const store = new Map();
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, entry] of store) {
|
||||
if (now - entry.windowStart >= WINDOW_MS) {
|
||||
store.delete(ip);
|
||||
}
|
||||
}
|
||||
}, CLEANUP_INTERVAL_MS).unref();
|
||||
|
||||
function rateLimiter(req, res, next) {
|
||||
const ip = req.ip;
|
||||
const now = Date.now();
|
||||
let entry = store.get(ip);
|
||||
|
||||
if (!entry || now - entry.windowStart >= WINDOW_MS) {
|
||||
entry = { windowStart: now, count: 0 };
|
||||
store.set(ip, entry);
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
|
||||
const remaining = Math.max(0, MAX_REQUESTS - entry.count);
|
||||
const resetTime = Math.ceil((entry.windowStart + WINDOW_MS - now) / 1000);
|
||||
|
||||
res.set("X-RateLimit-Limit", String(MAX_REQUESTS));
|
||||
res.set("X-RateLimit-Remaining", String(remaining));
|
||||
res.set("X-RateLimit-Reset", String(resetTime));
|
||||
|
||||
if (entry.count > MAX_REQUESTS) {
|
||||
return res.status(429).json({
|
||||
error: "Rate limit exceeded",
|
||||
message: `Maximum ${MAX_REQUESTS} decode requests per hour. Retry after ${resetTime}s.`,
|
||||
retryAfter: resetTime,
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = rateLimiter;
|
||||
254
web/src/ring.js
Normal file
254
web/src/ring.js
Normal file
@@ -0,0 +1,254 @@
|
||||
const { execFile } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
const util = require("util");
|
||||
|
||||
const execFileAsync = util.promisify(execFile);
|
||||
|
||||
const RING_CMD = process.env.RING_CMD || "ring";
|
||||
const TMP_BASE = process.env.TMP_BASE || path.join(os.tmpdir(), "lic-decoder");
|
||||
|
||||
function makeTempDir() {
|
||||
fs.mkdirSync(TMP_BASE, { recursive: true });
|
||||
const dir = fs.mkdtempSync(path.join(TMP_BASE, "lic-"));
|
||||
return dir;
|
||||
}
|
||||
|
||||
function ringExec(args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(RING_CMD, args, { maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
||||
if (err && !stdout) {
|
||||
reject(new Error(stderr || err.message));
|
||||
return;
|
||||
}
|
||||
resolve(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function checkJRE() {
|
||||
try {
|
||||
const { stderr } = await execFileAsync("java", ["-version"], {
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const match = stderr.match(/"([^"]+)"/);
|
||||
if (!match) return { installed: false };
|
||||
const version = match[1];
|
||||
let valid = true;
|
||||
if (version.startsWith("1.8")) {
|
||||
const updateMatch = version.match(/1\.8\.0_(\d+)/);
|
||||
if (updateMatch && parseInt(updateMatch[1], 10) < 161) {
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
return { installed: true, version, valid };
|
||||
} catch {
|
||||
return { installed: false, valid: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function checkRing() {
|
||||
try {
|
||||
const stdout = await ringExec(["--version"]);
|
||||
const version = stdout.trim();
|
||||
if (!version) return { installed: false };
|
||||
const parts = version.split(/[.\-]/);
|
||||
let valid = true;
|
||||
const minor = parseInt(parts[1], 10);
|
||||
const patch = parseInt(parts[2], 10);
|
||||
if (minor < 11 || (minor === 11 && patch < 5)) {
|
||||
valid = false;
|
||||
}
|
||||
return { installed: true, version, valid };
|
||||
} catch {
|
||||
return { installed: false, valid: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function getLicName(tempDir, fileName) {
|
||||
const stdout = await ringExec([
|
||||
"license", "list",
|
||||
"--path", tempDir,
|
||||
"--send-statistics", "false",
|
||||
]);
|
||||
|
||||
const lines = stdout.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^(.+?)\s*\(file name:\s*"(.+)"\)/);
|
||||
if (match && match[2] === fileName) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
throw new Error("License name not found in ring output");
|
||||
}
|
||||
|
||||
async function getLicData(licName, tempDir) {
|
||||
const stdout = await ringExec([
|
||||
"license", "info",
|
||||
"--name", licName,
|
||||
"--path", tempDir,
|
||||
"--send-statistics", "false",
|
||||
]);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
async function getValidateData(licName, tempDir) {
|
||||
let stdout = await ringExec([
|
||||
"license", "validate",
|
||||
"--name", licName,
|
||||
"--path", tempDir,
|
||||
"--send-statistics", "false",
|
||||
]);
|
||||
|
||||
const errorPrefix =
|
||||
"Проверка лицензии завершилась с ошибкой.\nПо причине: ";
|
||||
const errorPrefix2 =
|
||||
"Проверка лицензии завершилась с ошибкой.\r\nПо причине: ";
|
||||
|
||||
let idx = stdout.indexOf(errorPrefix);
|
||||
if (idx === -1) idx = stdout.indexOf(errorPrefix2);
|
||||
|
||||
if (idx !== -1) {
|
||||
const prefixLen = stdout.indexOf(errorPrefix) !== -1 ? errorPrefix.length : errorPrefix2.length;
|
||||
stdout =
|
||||
"Ключевые параметры компьютера не соответствуют лицензии.\n" +
|
||||
"Для получения полного списка оборудования включите подробный режим.\n\n" +
|
||||
stdout.substring(0, idx) +
|
||||
stdout.substring(idx + prefixLen);
|
||||
}
|
||||
|
||||
return stdout;
|
||||
}
|
||||
|
||||
async function getDebugInfo(licName, tempDir) {
|
||||
const stdout = await ringExec([
|
||||
"-l", "debug",
|
||||
"license", "validate",
|
||||
"--name", licName,
|
||||
"--path", tempDir,
|
||||
"--send-statistics", "false",
|
||||
]);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
function buildPinCode(pinCode) {
|
||||
const dashPositions = new Set([4, 7, 10, 13]);
|
||||
let result = "";
|
||||
for (let i = 0; i < pinCode.length; i++) {
|
||||
if (dashPositions.has(i + 1)) {
|
||||
result += "-";
|
||||
}
|
||||
result += pinCode[i];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function decodeLicense(filePath, detailed = false) {
|
||||
const fileName = path.basename(filePath);
|
||||
const tempDir = makeTempDir();
|
||||
const tempFilePath = path.join(tempDir, fileName);
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath);
|
||||
let content = raw;
|
||||
if (raw[0] === 0xEF && raw[1] === 0xBB && raw[2] === 0xBF) {
|
||||
content = raw.slice(3);
|
||||
}
|
||||
fs.writeFileSync(tempFilePath, content);
|
||||
|
||||
const licName = await getLicName(tempDir, fileName);
|
||||
const pinCode = licName.substring(0, 15);
|
||||
|
||||
const licData = await getLicData(licName, tempDir);
|
||||
const validateData = await getValidateData(licName, tempDir);
|
||||
|
||||
const result = {
|
||||
licName,
|
||||
pinCode: buildPinCode(pinCode),
|
||||
licData: localize(licData.trim()),
|
||||
validateData: localize(validateData.trim()),
|
||||
licHWConfig: null,
|
||||
currentHWConfig: null,
|
||||
};
|
||||
|
||||
if (detailed) {
|
||||
const debugInfo = await getDebugInfo(licName, tempDir);
|
||||
|
||||
const licHWSplit = debugInfo.split(
|
||||
"[DEBUG ] com._1c.license.activator.crypt.Converter - getLicensePermitFromBase64 : Request : Computer info : \n"
|
||||
);
|
||||
for (let i = 1; i < licHWSplit.length; i++) {
|
||||
if (licHWSplit[i].includes("pin : " + pinCode)) {
|
||||
const endIdx = licHWSplit[i].indexOf("Customer info :");
|
||||
result.licHWConfig = licHWSplit[i].substring(0, endIdx).trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const currentHWSplit = debugInfo.split(
|
||||
"[DEBUG ] com._1c.license.activator.hard.HardInfo - computer info : "
|
||||
);
|
||||
if (currentHWSplit.length > 1) {
|
||||
const endIdx = currentHWSplit[1].indexOf("\n\n");
|
||||
result.currentHWConfig =
|
||||
endIdx !== -1
|
||||
? currentHWSplit[1].substring(0, endIdx).trim()
|
||||
: currentHWSplit[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const L10N = {
|
||||
"Owner details:": "Данные владельца:",
|
||||
"First name:": "Имя:",
|
||||
"Middle name:": "Отчество:",
|
||||
"Last name:": "Фамилия:",
|
||||
"Email:": "Эл. почта:",
|
||||
"Company:": "Организация:",
|
||||
"Country:": "Страна:",
|
||||
"ZIP code:": "Индекс:",
|
||||
"Region/area:": "Регион:",
|
||||
"Town:": "Город:",
|
||||
"Street:": "Улица:",
|
||||
"House:": "Дом:",
|
||||
"Building:": "Корпус:",
|
||||
"Apartment/office:": "Квартира/офис:",
|
||||
"Product details:": "Данные продукта:",
|
||||
"Description:": "Описание:",
|
||||
"License generation date:": "Дата генерации лицензии:",
|
||||
"Distribution kit registration number:": "Регистрационный номер комплекта:",
|
||||
"Product code:": "Код продукта:",
|
||||
"License type:": "Тип лицензии:",
|
||||
"License association type:": "Тип привязки лицензии:",
|
||||
"Client license": "Клиентская лицензия",
|
||||
"Computer": "Компьютер",
|
||||
"License file name:": "Файл лицензии:",
|
||||
"TechnicalInfo:": "Техническая информация:",
|
||||
"LicenseType:": "ТипЛицензии:",
|
||||
"LicenseAssociationType:": "ТипПривязкиЛицензии:",
|
||||
"LicenseGenerationDate:": "ДатаГенерацииЛицензии:",
|
||||
"ProductCode:": "КодПродукта:",
|
||||
"DistributionKitRegistrationNumber:": "РегНомерКомплекта:",
|
||||
"License check failed.": "Проверка лицензии завершилась с ошибкой.",
|
||||
"Reason: Hardware removed:": "Причина: Удалено оборудование:",
|
||||
};
|
||||
|
||||
function localize(text) {
|
||||
for (const [en, ru] of Object.entries(L10N)) {
|
||||
text = text.split(en).join(ru);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkJRE,
|
||||
checkRing,
|
||||
decodeLicense,
|
||||
buildPinCode,
|
||||
};
|
||||
152
web/src/server.js
Normal file
152
web/src/server.js
Normal file
@@ -0,0 +1,152 @@
|
||||
const express = require("express");
|
||||
const multer = require("multer");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const { checkJRE, checkRing, decodeLicense } = require("./ring");
|
||||
const rateLimiter = require("./rateLimiter");
|
||||
const { hasValidToken } = require("./auth");
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
app.set("trust proxy", true);
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || "/tmp/lic-decoder-uploads";
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, UPLOAD_DIR),
|
||||
filename: (_req, file, cb) => {
|
||||
const unique = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
cb(null, `${unique}-${file.originalname}`);
|
||||
},
|
||||
});
|
||||
const upload = multer({
|
||||
storage,
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (path.extname(file.originalname).toLowerCase() === ".lic") {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error("Only .lic files are allowed"));
|
||||
}
|
||||
},
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
app.use(express.static(path.join(__dirname, "..", "public")));
|
||||
app.use(express.json());
|
||||
|
||||
app.get("/api/status", async (_req, res) => {
|
||||
try {
|
||||
const [jre, ring] = await Promise.all([checkJRE(), checkRing()]);
|
||||
res.json({ jre, ring, ready: jre.valid && ring.valid });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/docs", (_req, res) => {
|
||||
res.json({
|
||||
endpoints: {
|
||||
"GET /api/status": {
|
||||
description: "Check system readiness (JRE and Ring availability)",
|
||||
auth: false,
|
||||
rateLimited: false,
|
||||
response: {
|
||||
jre: { installed: true, version: "11.0.21", valid: true },
|
||||
ring: { installed: true, version: "0.19.5+12", valid: true },
|
||||
ready: true,
|
||||
},
|
||||
},
|
||||
"POST /api/decode": {
|
||||
description: "Decode a 1C .lic license file",
|
||||
auth: "optional (Bearer JWT token in Authorization header bypasses rate limit)",
|
||||
rateLimited: true,
|
||||
rateLimit: { max: 3, window: "1 hour", bypass: "Valid JWT token" },
|
||||
contentType: "multipart/form-data",
|
||||
fields: {
|
||||
license: { type: "file", required: true, description: ".lic file (max 5MB)" },
|
||||
detailed: { type: "string", required: false, description: '"true" to include hardware config comparison' },
|
||||
},
|
||||
headers: {
|
||||
Authorization: { required: false, description: "Bearer <JWT_TOKEN> — bypasses rate limit" },
|
||||
},
|
||||
response: {
|
||||
licName: "string",
|
||||
pinCode: "string (formatted XXXX-XXX-XXX-XXX-X)",
|
||||
licData: "string (license data, localized)",
|
||||
validateData: "string (validation result, localized)",
|
||||
licHWConfig: "string|null (detailed mode only)",
|
||||
currentHWConfig: "string|null (detailed mode only)",
|
||||
},
|
||||
errors: {
|
||||
400: "No .lic file uploaded or invalid file type",
|
||||
401: "Invalid or expired JWT token",
|
||||
413: "File too large (max 5MB)",
|
||||
422: "Failed to decode license file",
|
||||
429: "Rate limit exceeded (3 per hour per IP without JWT)",
|
||||
},
|
||||
},
|
||||
},
|
||||
rateLimits: {
|
||||
window: "1 hour",
|
||||
maxRequests: 3,
|
||||
key: "IP address (from X-Forwarded-For / reverse proxy)",
|
||||
bypass: "Provide a valid JWT token in the Authorization: Bearer header",
|
||||
headers: {
|
||||
"X-RateLimit-Limit": "Maximum requests allowed in the window",
|
||||
"X-RateLimit-Remaining": "Remaining requests in the current window",
|
||||
"X-RateLimit-Reset": "Seconds until the window resets",
|
||||
},
|
||||
},
|
||||
authentication: {
|
||||
type: "Bearer JWT",
|
||||
header: "Authorization: Bearer <token>",
|
||||
note: "JWT is optional. A valid JWT token removes the rate limit. Token verification uses the JWT_SECRET environment variable set on the server.",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/api/decode", (req, res, next) => {
|
||||
if (hasValidToken(req)) {
|
||||
return next();
|
||||
}
|
||||
rateLimiter(req, res, next);
|
||||
}, upload.single("license"), async (req, res) => {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: "No .lic file uploaded" });
|
||||
}
|
||||
|
||||
const detailed = req.body.detailed === "true" || req.body.detailed === true;
|
||||
const filePath = req.file.path;
|
||||
|
||||
try {
|
||||
const result = await decodeLicense(filePath, detailed);
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
console.error("Decode error:", e.message);
|
||||
res.status(422).json({
|
||||
error: "Failed to decode license file",
|
||||
details: e.message,
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
app.use((err, _req, res, _next) => {
|
||||
if (err.code === "LIMIT_FILE_SIZE") {
|
||||
return res.status(413).json({ error: "File too large (max 5MB)" });
|
||||
}
|
||||
if (err.message === "Only .lic files are allowed") {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
console.error(err);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Lic Decoder Web running on http://localhost:${PORT}`);
|
||||
});
|
||||
Reference in New Issue
Block a user