Initial commit

This commit is contained in:
2026-05-25 14:16:59 +03:00
commit 77cc50f205
13 changed files with 1269 additions and 0 deletions

35
web/src/auth.js Normal file
View 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
View 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
View 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
View 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}`);
});