Initial commit
This commit is contained in:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
LicDataDecoder/
|
||||||
|
1c_enterprise_license_tools_0.15.0_2_linux_x86_64.tar.gz
|
||||||
|
.git/
|
||||||
|
*.md
|
||||||
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@@ -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/
|
||||||
162
API.md
Normal file
162
API.md
Normal file
@@ -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 <JWT>` — обходит 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
|
||||||
|
```
|
||||||
34
Dockerfile
Normal file
34
Dockerfile
Normal file
@@ -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"]
|
||||||
10
docker-compose.yml
Normal file
10
docker-compose.yml
Normal file
@@ -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
|
||||||
88
readme.md
Normal file
88
readme.md
Normal file
@@ -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 <token>` лимит снимается.
|
||||||
|
|
||||||
|
## Генерация 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 <your-token>" \
|
||||||
|
-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
|
||||||
|
```
|
||||||
3
web/.dockerignore
Normal file
3
web/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
tmp/
|
||||||
15
web/package.json
Normal file
15
web/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
453
web/public/index.html
Normal file
453
web/public/index.html
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Lic Decoder</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f5f5f5;
|
||||||
|
--surface: #fff;
|
||||||
|
--border: #ddd;
|
||||||
|
--fg: #222;
|
||||||
|
--fg2: #555;
|
||||||
|
--fg3: #888;
|
||||||
|
--accent: #2a6cb8;
|
||||||
|
--red: #c0392b;
|
||||||
|
--green: #27864a;
|
||||||
|
--radius: 6px;
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.layout {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 20px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 24px 0 8px;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
p, li {
|
||||||
|
color: var(--fg2);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
ul { margin: 6px 0 0 20px; }
|
||||||
|
li { margin-bottom: 4px; }
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.path-box {
|
||||||
|
font-family: "Cascadia Code", "Fira Code", Consolas, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: #f0f0f0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: var(--fg);
|
||||||
|
display: inline-block;
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
.status-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--fg3);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
width: 7px; height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--fg3);
|
||||||
|
}
|
||||||
|
.status-dot.ok { background: var(--green); }
|
||||||
|
.status-dot.fail { background: var(--red); }
|
||||||
|
|
||||||
|
.upload-zone {
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.upload-zone:hover, .upload-zone.dragover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.upload-zone p {
|
||||||
|
color: var(--fg3);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.upload-zone strong { color: var(--fg2); }
|
||||||
|
#file-input { display: none; }
|
||||||
|
|
||||||
|
.options {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.options label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--fg2);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.options input[type="checkbox"] {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
width: 15px; height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn:hover { opacity: 0.9; }
|
||||||
|
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.btn.loading { background: var(--fg3); }
|
||||||
|
.file-name {
|
||||||
|
margin-left: 10px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--fg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-box {
|
||||||
|
background: rgba(192,57,43,0.06);
|
||||||
|
border: 1px solid rgba(192,57,43,0.2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 12px;
|
||||||
|
color: var(--red);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 12px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.error-box.visible { display: block; }
|
||||||
|
|
||||||
|
.results {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.results.visible { display: block; }
|
||||||
|
.result-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.result-card-header {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg2);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.result-card-body {
|
||||||
|
padding: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-family: "Cascadia Code", "Fira Code", Consolas, monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-x: auto;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.result-card-body.secondary { color: var(--fg2); }
|
||||||
|
.key-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
.key-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--fg3);
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
.key-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 10px;
|
||||||
|
background: rgba(42,108,184,0.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
.keys-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
.hw-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.hw-grid .result-card {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
border: none;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.hw-grid .result-card:first-child { border-left: none; }
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.hw-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="layout">
|
||||||
|
<h1>Lic Decoder</h1>
|
||||||
|
|
||||||
|
<div class="status-bar" id="status-bar">
|
||||||
|
<div class="status-dot" id="status-dot"></div>
|
||||||
|
<span id="status-text">Проверка...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Описание</h2>
|
||||||
|
<p>Утилита для извлечения регистрационных данных из файлов лицензий 1С:Предприятие (.lic). Позволяет восстановить информацию о лицензии (компания, ФИО, почта, адрес, PIN-код), если файл LicData.txt был утерян.</p>
|
||||||
|
|
||||||
|
<h2>Источник файлов</h2>
|
||||||
|
<p>Файлы лицензий 1С хранятся в:</p>
|
||||||
|
<div class="path-box">C:\ProgramData\1C\licenses</div>
|
||||||
|
<p>Скопируйте нужный <strong>.lic</strong> файл и загрузите ниже.</p>
|
||||||
|
|
||||||
|
<h2>Что показывает</h2>
|
||||||
|
<ul>
|
||||||
|
<li>ORGANIZATION - Организация</li>
|
||||||
|
<li>FIRST NAME / MIDDLE NAME / LAST NAME - ФИО владельца</li>
|
||||||
|
<li>EMAIL - Электронная почта</li>
|
||||||
|
<li>COUNTRY / REGION / CITY / STREET - Адрес регистрации</li>
|
||||||
|
<li>PIN-CODE - PIN-код активации</li>
|
||||||
|
</ul>
|
||||||
|
<p style="margin-top:6px">При включении подробного режима также отображается аппаратная конфигурация лицензированной и текущей машины.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-zone" id="drop-zone">
|
||||||
|
<strong>Перетащите .lic файл</strong>
|
||||||
|
<p>или нажмите для выбора</p>
|
||||||
|
<input type="file" id="file-input" accept=".lic">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="options">
|
||||||
|
<input type="checkbox" id="detailed">
|
||||||
|
<label for="detailed">Подробный режим (аппаратная конфигурация)</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button class="btn" id="decode-btn" disabled>Декодировать</button>
|
||||||
|
<span class="file-name" id="file-name-display"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="error-box" class="error-box"></div>
|
||||||
|
|
||||||
|
<div id="results" class="results">
|
||||||
|
<div class="keys-card" id="keys-card" style="display:none">
|
||||||
|
<div class="key-row" id="pin-row">
|
||||||
|
<span class="key-label">PIN-код</span>
|
||||||
|
<span class="key-badge" id="pin-value"></span>
|
||||||
|
</div>
|
||||||
|
<div class="key-row" id="reg-row" style="display:none">
|
||||||
|
<span class="key-label">Рег. Номер Комплекта</span>
|
||||||
|
<span class="key-badge" id="reg-value"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="result-card">
|
||||||
|
<div class="result-card-header">Данные лицензии</div>
|
||||||
|
<div class="result-card-body" id="lic-data"></div>
|
||||||
|
</div>
|
||||||
|
<div class="result-card">
|
||||||
|
<div class="result-card-header">Валидация</div>
|
||||||
|
<div class="result-card-body" id="validate-data"></div>
|
||||||
|
</div>
|
||||||
|
<div class="result-card" id="hw-card" style="display:none">
|
||||||
|
<div class="result-card-header">Аппаратная конфигурация</div>
|
||||||
|
<div class="result-card-body" style="padding:0">
|
||||||
|
<div class="hw-grid">
|
||||||
|
<div class="result-card">
|
||||||
|
<div class="result-card-header">Лицензированная машина</div>
|
||||||
|
<div class="result-card-body secondary" id="lic-hw"></div>
|
||||||
|
</div>
|
||||||
|
<div class="result-card">
|
||||||
|
<div class="result-card-header">Текущая машина</div>
|
||||||
|
<div class="result-card-body secondary" id="cur-hw"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const dropZone = document.getElementById("drop-zone");
|
||||||
|
const fileInput = document.getElementById("file-input");
|
||||||
|
const decodeBtn = document.getElementById("decode-btn");
|
||||||
|
const detailedCb = document.getElementById("detailed");
|
||||||
|
const resultsDiv = document.getElementById("results");
|
||||||
|
const errorBox = document.getElementById("error-box");
|
||||||
|
const statusDot = document.getElementById("status-dot");
|
||||||
|
const statusText = document.getElementById("status-text");
|
||||||
|
const fileNameDisplay = document.getElementById("file-name-display");
|
||||||
|
|
||||||
|
let selectedFile = null;
|
||||||
|
|
||||||
|
async function checkStatus() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/status");
|
||||||
|
const data = await res.json();
|
||||||
|
let ready = true;
|
||||||
|
const parts = [];
|
||||||
|
if (data.jre.installed) {
|
||||||
|
if (!data.jre.valid) ready = false;
|
||||||
|
parts.push(data.jre.valid ? `JRE ${data.jre.version}` : `JRE ${data.jre.version} (устаревшая)`);
|
||||||
|
} else {
|
||||||
|
ready = false;
|
||||||
|
parts.push("JRE не найдена");
|
||||||
|
}
|
||||||
|
if (data.ring.installed) {
|
||||||
|
if (!data.ring.valid) ready = false;
|
||||||
|
parts.push(data.ring.valid ? `Ring ${data.ring.version}` : `Ring ${data.ring.version} (устаревший)`);
|
||||||
|
} else {
|
||||||
|
ready = false;
|
||||||
|
parts.push("Ring не найден");
|
||||||
|
}
|
||||||
|
statusText.textContent = parts.join(" \u00b7 ");
|
||||||
|
statusDot.className = "status-dot " + (ready ? "ok" : "fail");
|
||||||
|
} catch {
|
||||||
|
statusText.textContent = "Сервер недоступен";
|
||||||
|
statusDot.className = "status-dot fail";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dropZone.addEventListener("click", () => fileInput.click());
|
||||||
|
dropZone.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.classList.add("dragover"); });
|
||||||
|
dropZone.addEventListener("dragleave", () => dropZone.classList.remove("dragover"));
|
||||||
|
dropZone.addEventListener("drop", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.remove("dragover");
|
||||||
|
if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]);
|
||||||
|
});
|
||||||
|
fileInput.addEventListener("change", () => {
|
||||||
|
if (fileInput.files.length) handleFile(fileInput.files[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleFile(file) {
|
||||||
|
if (!file.name.toLowerCase().endsWith(".lic")) {
|
||||||
|
showError("Поддерживаются только файлы .lic");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedFile = file;
|
||||||
|
dropZone.querySelector("strong").textContent = file.name;
|
||||||
|
dropZone.querySelector("p").textContent = "Нажмите, чтобы выбрать другой";
|
||||||
|
fileNameDisplay.textContent = file.name;
|
||||||
|
decodeBtn.disabled = false;
|
||||||
|
resultsDiv.classList.remove("visible");
|
||||||
|
errorBox.classList.remove("visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
decodeBtn.addEventListener("click", async () => {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
decodeBtn.disabled = true;
|
||||||
|
decodeBtn.textContent = "Декодирование...";
|
||||||
|
decodeBtn.classList.add("loading");
|
||||||
|
resultsDiv.classList.remove("visible");
|
||||||
|
errorBox.classList.remove("visible");
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("license", selectedFile);
|
||||||
|
fd.append("detailed", detailedCb.checked);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/decode", { method: "POST", body: fd });
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.status === 429) {
|
||||||
|
const retry = data.retryAfter ? ` Попробуйте через ${data.retryAfter}с.` : "";
|
||||||
|
throw new Error("Лимит превышен: не более 3 декодирований в час." + retry);
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(data.error || data.details || "Декодирование не удалось");
|
||||||
|
|
||||||
|
let licText = data.licData;
|
||||||
|
let regNumber = null;
|
||||||
|
const regMatch = licText.match(/РегНомерКомплекта:\s*(.+)/);
|
||||||
|
if (regMatch) {
|
||||||
|
regNumber = regMatch[1].trim();
|
||||||
|
licText = licText.replace(/РегНомерКомплекта:\s*.+\n?/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("lic-data").textContent = licText.trim();
|
||||||
|
document.getElementById("pin-value").textContent = data.pinCode;
|
||||||
|
document.getElementById("keys-card").style.display = "";
|
||||||
|
if (regNumber) {
|
||||||
|
document.getElementById("reg-value").textContent = regNumber;
|
||||||
|
document.getElementById("reg-row").style.display = "";
|
||||||
|
} else {
|
||||||
|
document.getElementById("reg-row").style.display = "none";
|
||||||
|
}
|
||||||
|
document.getElementById("validate-data").textContent = data.validateData;
|
||||||
|
|
||||||
|
const hwCard = document.getElementById("hw-card");
|
||||||
|
if (data.licHWConfig || data.currentHWConfig) {
|
||||||
|
hwCard.style.display = "";
|
||||||
|
document.getElementById("lic-hw").textContent = data.licHWConfig || "N/A";
|
||||||
|
document.getElementById("cur-hw").textContent = data.currentHWConfig || "N/A";
|
||||||
|
} else {
|
||||||
|
hwCard.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsDiv.classList.add("visible");
|
||||||
|
} catch (e) {
|
||||||
|
showError(e.message);
|
||||||
|
} finally {
|
||||||
|
decodeBtn.disabled = false;
|
||||||
|
decodeBtn.textContent = "Декодировать";
|
||||||
|
decodeBtn.classList.remove("loading");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = text;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
errorBox.textContent = msg;
|
||||||
|
errorBox.classList.add("visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
checkStatus();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
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