From 77cc50f20594526966277f69652f345202b5a75b Mon Sep 17 00:00:00 2001 From: Vyacheslav K Date: Mon, 25 May 2026 14:16:59 +0300 Subject: [PATCH] Initial commit --- .dockerignore | 5 + .gitignore | 12 ++ API.md | 162 +++++++++++++++ Dockerfile | 34 ++++ docker-compose.yml | 10 + readme.md | 88 ++++++++ web/.dockerignore | 3 + web/package.json | 15 ++ web/public/index.html | 453 +++++++++++++++++++++++++++++++++++++++++ web/src/auth.js | 35 ++++ web/src/rateLimiter.js | 46 +++++ web/src/ring.js | 254 +++++++++++++++++++++++ web/src/server.js | 152 ++++++++++++++ 13 files changed, 1269 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 API.md create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 readme.md create mode 100644 web/.dockerignore create mode 100644 web/package.json create mode 100644 web/public/index.html create mode 100644 web/src/auth.js create mode 100644 web/src/rateLimiter.js create mode 100644 web/src/ring.js create mode 100644 web/src/server.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b68ce57 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules/ +LicDataDecoder/ +1c_enterprise_license_tools_0.15.0_2_linux_x86_64.tar.gz +.git/ +*.md \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f472d83 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +.env +.env.* +!.env.example +*.lic +1c_enterprise_license_tools_0.15.0_2_linux_x86_64.tar.gz +1c_enterprise_license_tools_0.15.0_2_linux_x86_64/ +.DS_Store +*.swp +*.swo +*~ +tmp/ \ No newline at end of file diff --git a/API.md b/API.md new file mode 100644 index 0000000..ba7aebe --- /dev/null +++ b/API.md @@ -0,0 +1,162 @@ +# Lic Decoder API + +## Endpoints + +### `GET /api/status` + +Проверка готовности системы (JRE и Ring). + +- **Аутентификация:** не требуется +- **Rate limit:** не применяется + +**Пример ответа:** + +```json +{ + "jre": { "installed": true, "version": "11.0.21", "valid": true }, + "ring": { "installed": true, "version": "0.19.5+12", "valid": true }, + "ready": true +} +``` + +--- + +### `POST /api/decode` + +Декодирование файла лицензии 1С (.lic). + +- **Content-Type:** `multipart/form-data` +- **Аутентификация:** опционально (Bearer JWT) — см. ниже +- **Rate limit:** 3 запроса в час на IP (без JWT) + +**Параметры:** + +| Поле | Тип | Обязательный | Описание | +|-----------|--------|--------------|--------------------------------------------------| +| `license` | file | да | .lic файл (макс. 5 МБ) | +| `detailed`| string | нет | `"true"` — добавить сравнение аппаратной конфигурации | + +**Заголовки:** + +| Заголовок | Обязательный | Описание | +|-----------------|--------------|---------------------------------------------| +| `Authorization` | нет | `Bearer ` — обходит rate limit | + +**Пример ответа:** + +```json +{ + "licName": "XXXXXXXXXXXXXXX", + "pinCode": "XXXX-XXX-XXX-XXX-X", + "licData": "Данные лицензии...", + "validateData": "Результат валидации...", + "licHWConfig": null, + "currentHWConfig": null +} +``` + +При `detailed=true` поля `licHWConfig` и `currentHWConfig` содержат текст аппаратных конфигураций. + +**Коды ошибок:** + +| Код | Описание | +|-----|-----------------------------------------------| +| 400 | Файл не загружен или неверный тип (.lic only) | +| 413 | Файл слишком большой (макс 5 МБ) | +| 422 | Не удалось декодировать файл лицензии | +| 429 | Превышен rate limit | + +--- + +### `GET /api/docs` + +Возвращает данную документацию в формате JSON. + +--- + +## Rate Limits + +| Параметр | Значение | +|--------------------|-------------------------------------| +| Окно | 1 час | +| Максимум запросов | 3 | +| Ключ | IP-адрес (из X-Forwarded-For) | +| Обход | Валидный JWT токен в Authorization | + +Сервер читает IP из заголовков реверс-прокси (`X-Forwarded-For`). Убедитесь что `trust proxy` включён (по умолчанию `true`). + +**Заголовки ответа:** + +| Заголовок | Описание | +|------------------------|------------------------------------| +| `X-RateLimit-Limit` | Максимум запросов в окне | +| `X-RateLimit-Remaining`| Оставшиеся запросы в текущем окне | +| `X-RateLimit-Reset` | Секунды до сброса окна | + +**Пример ответа 429:** + +```json +{ + "error": "Rate limit exceeded", + "message": "Maximum 3 decode requests per hour. Retry after 2453s.", + "retryAfter": 2453 +} +``` + +--- + +## Аутентификация (JWT) + +Аутентификация **опциональна**. Предоставление валидного JWT токена в заголовке `Authorization` снимает rate limit. + +### Использование + +``` +Authorization: Bearer <ваш_JWT_токен> +``` + +### Генерация токена + +Токен подписывается алгоритмом HS256 с секретом из переменной окружения `JWT_SECRET`: + +```bash +# Генерация токена (серверный JWT_SECRET должен совпадать) +JWT_SECRET="your-secret-key" node -e " + const jwt = require('jsonwebtoken'); + console.log(jwt.sign({}, process.env.JWT_SECRET, { expiresIn: '30d' })); +" +``` + +### Переменные окружения + +| Переменная | Описание | +|----------------|---------------------------------------------| +| `JWT_SECRET` | Секрет для проверки JWT | +| `PORT` | Порт сервера (по умолчанию 3000) | +| `UPLOAD_DIR` | Директория для загрузок | +| `TMP_BASE` | Базовая директория для временных файлов | +| `RING_CMD` | Путь к команде ring | + +--- + +## Примеры запросов + +### Без JWT (с rate limit) + +```bash +curl -F "license=@license.lic" http://localhost:3000/api/decode +``` + +### С JWT (без rate limit) + +```bash +curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..." \ + -F "license=@license.lic" \ + http://localhost:3000/api/decode +``` + +### Подробный режим + +```bash +curl -F "license=@license.lic" -F "detailed=true" http://localhost:3000/api/decode +``` \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..04ed086 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM eclipse-temurin:11-jre + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gawk \ + curl \ + && rm -rf /var/lib/apt/lists/* + +RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ + apt-get install -y --no-install-recommends nodejs && \ + rm -rf /var/lib/apt/lists/* + +COPY 1c_enterprise_license_tools_0.15.0_2_linux_x86_64/ /opt/1c-dist/ +RUN chmod +x /opt/1c-dist/1ce-installer-cli && \ + /opt/1c-dist/1ce-installer-cli install default --ignore-signature-warnings --products-home /opt/1C && \ + rm -rf /opt/1c-dist + +ENV PATH="/opt/1C/components/1c-enterprise-ring-0.19.5+12-x86_64:${PATH}" + +WORKDIR /app + +COPY web/package.json web/package-lock.json* ./ +RUN npm install --omit=dev && npm cache clean --force + +COPY web/src/ ./src/ +COPY web/public/ ./public/ + +ENV PORT=3000 +ENV TMP_BASE=/tmp/lic-decoder +ENV UPLOAD_DIR=/tmp/lic-decoder-uploads +ENV RING_CMD=ring + +EXPOSE 3000 + +CMD ["node", "src/server.js"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c374408 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +version: "3.8" + +services: + lic-decoder: + build: . + ports: + - "3080:3000" + environment: + - JWT_SECRET=${JWT_SECRET:-change-me-to-a-strong-secret} + restart: unless-stopped \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..51800e5 --- /dev/null +++ b/readme.md @@ -0,0 +1,88 @@ +# Lic Decoder + +Web-утилита для извлечения регистрационных данных из файлов лицензий 1С:Предприятие (.lic). Позволяет восстановить PIN-код, данные владельца и другую информацию, если файл LicData.txt был утерян. + +## Запуск через Docker + +1. Задайте секрет JWT в `.env` или напрямую в `docker-compose.yml`: + +```bash +echo "JWT_SECRET=your-strong-random-secret" > .env +``` + +2. Запустите: + +```bash +docker compose up -d +``` + +Приложение будет доступно на `http://localhost:3080`. + +## Переменные окружения + +| Переменная | По умолчанию | Описание | +|-----------------|-----------------------------|---------------------------------------| +| `JWT_SECRET` | `change-me-to-a-strong-secret` | Секрет для подписи/проверки JWT | +| `PORT` | `3000` | Порт внутри контейнера | +| `UPLOAD_DIR` | `/tmp/lic-decoder-uploads` | Директория для загрузок | +| `TMP_BASE` | `/tmp/lic-decoder` | Базовая директория временных файлов | +| `RING_CMD` | `ring` | Путь к команде ring | + +## Rate Limits + +Без JWT-токена: **3 декодирования в час** на один IP-адрес. IP берётся из заголовков реверс-прокси (`X-Forwarded-For`). + +С валидным JWT-токеном в заголовке `Authorization: Bearer ` лимит снимается. + +## Генерация JWT-токена + +```bash +JWT_SECRET="your-strong-random-secret" node -e " + const jwt = require('jsonwebtoken'); + console.log(jwt.sign({}, process.env.JWT_SECRET, { expiresIn: '30d' })); +" +``` + +## API + +Полная документация: [API.md](API.md) + +### Кратко + +| Метод | Путь | Описание | Auth | Rate Limit | +|--------|---------------|-----------------------------------|-------|------------| +| GET | `/api/status` | Проверка готовности системы | нет | нет | +| POST | `/api/decode` | Декодирование .lic файла | опц. | 3/час* | +| GET | `/api/docs` | API документация (JSON) | нет | нет | + +\* без JWT; с JWT — без лимита + +### Примеры + +```bash +# Проверка статуса +curl http://localhost:3080/api/status + +# Декодирование (без JWT — считается в rate limit) +curl -F "license=@license.lic" http://localhost:3080/api/decode + +# Декодирование (с JWT — без rate limit) +curl -H "Authorization: Bearer " \ + -F "license=@license.lic" \ + http://localhost:3080/api/decode + +# Подробный режим +curl -F "license=@license.lic" -F "detailed=true" http://localhost:3080/api/decode +``` + +## Локальная установка (без Docker) + +Необходимы: +- JRE 11+ +- 1C:Enterprise License Tools (ring) + +```bash +cd web +npm install +JWT_SECRET=your-secret npm run dev +``` \ No newline at end of file diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..c65114d --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,3 @@ +node_modules/ +*.log +tmp/ \ No newline at end of file diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..4f78726 --- /dev/null +++ b/web/package.json @@ -0,0 +1,15 @@ +{ + "name": "lic-decoder-web", + "version": "1.0.0", + "description": "1C License Decoder Web Application", + "main": "src/server.js", + "scripts": { + "start": "node src/server.js", + "dev": "node --watch src/server.js" + }, + "dependencies": { + "express": "^4.21.0", + "jsonwebtoken": "^9.0.2", + "multer": "^1.4.5-lts.1" + } +} \ No newline at end of file diff --git a/web/public/index.html b/web/public/index.html new file mode 100644 index 0000000..ab5f752 --- /dev/null +++ b/web/public/index.html @@ -0,0 +1,453 @@ + + + + + + Lic Decoder + + + +
+

Lic Decoder

+ +
+
+ Проверка... +
+ +
+

Описание

+

Утилита для извлечения регистрационных данных из файлов лицензий 1С:Предприятие (.lic). Позволяет восстановить информацию о лицензии (компания, ФИО, почта, адрес, PIN-код), если файл LicData.txt был утерян.

+ +

Источник файлов

+

Файлы лицензий 1С хранятся в:

+
C:\ProgramData\1C\licenses
+

Скопируйте нужный .lic файл и загрузите ниже.

+ +

Что показывает

+
    +
  • ORGANIZATION - Организация
  • +
  • FIRST NAME / MIDDLE NAME / LAST NAME - ФИО владельца
  • +
  • EMAIL - Электронная почта
  • +
  • COUNTRY / REGION / CITY / STREET - Адрес регистрации
  • +
  • PIN-CODE - PIN-код активации
  • +
+

При включении подробного режима также отображается аппаратная конфигурация лицензированной и текущей машины.

+
+ +
+ Перетащите .lic файл +

или нажмите для выбора

+ +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+
Данные лицензии
+
+
+
+
Валидация
+
+
+ +
+
+ + + + \ No newline at end of file diff --git a/web/src/auth.js b/web/src/auth.js new file mode 100644 index 0000000..1382dd2 --- /dev/null +++ b/web/src/auth.js @@ -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 }; \ No newline at end of file diff --git a/web/src/rateLimiter.js b/web/src/rateLimiter.js new file mode 100644 index 0000000..f763db9 --- /dev/null +++ b/web/src/rateLimiter.js @@ -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; \ No newline at end of file diff --git a/web/src/ring.js b/web/src/ring.js new file mode 100644 index 0000000..a1e90cf --- /dev/null +++ b/web/src/ring.js @@ -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, +}; \ No newline at end of file diff --git a/web/src/server.js b/web/src/server.js new file mode 100644 index 0000000..b8f70a6 --- /dev/null +++ b/web/src/server.js @@ -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 — 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 ", + 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}`); +}); \ No newline at end of file