# -*- coding: utf-8 -*- """Generate TTRPG-License-Key-Instructions.docx (run: python scripts/generate-license-key-docx.py).""" from __future__ import annotations from pathlib import Path from docx import Document from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.shared import Pt ROOT = Path(__file__).resolve().parents[1] OUT_PATHS = [ ROOT / "docs" / "TTRPG-License-Key-Instructions.docx", Path(r"D:\TTRPG-Release\TTRPG-License-Key-Instructions.docx"), ] def add_bullet(doc: Document, text: str, level: int = 0) -> None: style = "List Bullet" if level == 0 else "List Bullet 2" doc.add_paragraph(text, style=style) def add_step(doc: Document, n: int, title: str, body: str) -> None: p = doc.add_paragraph() p.add_run(f"Шаг {n}. {title}. ").bold = True p.add_run(body) def add_code(doc: Document, text: str) -> None: for line in text.strip().splitlines(): p = doc.add_paragraph(line) p.style = "No Spacing" for run in p.runs: run.font.name = "Consolas" run.font.size = Pt(9) def build_document() -> Document: doc = Document() normal = doc.styles["Normal"] normal.font.name = "Calibri" normal.font.size = Pt(11) title = doc.add_heading("TTRPG Player — выдача нового лицензионного ключа", level=0) title.alignment = WD_ALIGN_PARAGRAPH.CENTER doc.add_paragraph( "Инструкция для администратора: добавление нового продуктового ключа в базу " "сервера лицензий. Пользователь вводит этот ключ в приложении; сервер выдаёт " "подписанный лицензионный токен при активации." ) doc.add_heading("Термины", level=1) doc_terms = [ ( "Продуктовый ключ", "строка вида TTRPG-XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX — выдаётся покупателю, " "хранится в data.json на сервере.", ), ( "Лицензионный токен", "выдаётся автоматически при активации в приложении; вручную создавать не нужно.", ), ( "sub", "внутренний идентификатор лицензии на сервере (отзыв, учёт устройств).", ), ] for term, desc in doc_terms: p = doc.add_paragraph() p.add_run(f"{term} — ").bold = True p.add_run(desc) doc.add_heading("Где лежит сервер (продакшен)", level=1) srv = doc.add_table(rows=6, cols=2) srv.style = "Table Grid" srv.rows[0].cells[0].text = "Параметр" srv.rows[0].cells[1].text = "Значение" server_rows = [ ("Домен", "https://license.mailib.ru/"), ("VPS", "185.173.94.234"), ("Папка проекта", "/var/www/license_mailib_ru"), ("Файл ключей", "/var/www/license_mailib_ru/data.json"), ("Пользователь Linux", "dndlicense"), ] for i, (k, v) in enumerate(server_rows, start=1): srv.rows[i].cells[0].text = k srv.rows[i].cells[1].text = v doc.add_heading("Редактирование data.json (удобный редактор)", level=1) doc.add_paragraph( "Не используйте nano, если нужны выделение мышью и привычное копирование. " "Рекомендуется Cursor или VS Code с расширением Remote - SSH." ) add_step( doc, 1, "Настроить SSH", "В %USERPROFILE%\\.ssh\\config добавьте хост (ключ ttrpg_updates_root — тот же, " "что для публикации обновлений):", ) add_code( doc, """Host mailib-vps HostName 185.173.94.234 User root IdentityFile C:\\Users\\Administrator\\.ssh\\ttrpg_updates_root""", ) add_step( doc, 2, "Подключиться", "F1 → Remote-SSH: Connect to Host… → mailib-vps.", ) add_step( doc, 3, "Открыть папку", "File → Open Folder → /var/www/license_mailib_ru → открыть data.json.", ) add_bullet(doc, "Альтернатива: WinSCP (SFTP) → правый клик по data.json → Edit.") add_bullet( doc, "Альтернатива: scp скачать файл на ПК, править в Cursor, scp залить обратно.", ) doc.add_heading("Выдача нового ключа", level=1) add_step( doc, 1, "Резервная копия", "На сервере (SSH или терминал в Remote SSH):", ) add_code( doc, "cd /var/www/license_mailib_ru\n" "cp data.json \"data.json.bak.$(date +%F-%H%M%S)\"", ) add_step( doc, 2, "Сгенерировать запись (Node.js на сервере)", "Выполнить в каталоге /var/www/license_mailib_ru. Скрипт добавит запись в " "productKeys и выведет новый ключ в консоль. Срок и лимит устройств — в начале скрипта.", ) add_code( doc, r"""node -e " const fs = require('node:fs'); const crypto = require('node:crypto'); const dataPath = process.env.DND_LICENSE_DATA_PATH || './data.json'; const data = JSON.parse(fs.readFileSync(dataPath, 'utf8')); const maxDevices = 3; const expiresAtSec = Math.floor(new Date('2027-12-31T23:59:59Z').getTime() / 1000); const key = 'TTRPG-' + crypto.randomUUID().toUpperCase(); const sub = 'lic_' + crypto.randomUUID().replace(/-/g, ''); data.productKeys ??= []; data.productKeys.push({ key, sub, pid: 'dnd_player', maxDevices, expiresAtSec }); fs.writeFileSync(dataPath, JSON.stringify(data, null, 2) + '\n'); console.log('NEW PRODUCT KEY:', key); console.log('sub:', sub); console.log('expiresAtSec:', expiresAtSec); console.log('maxDevices:', maxDevices); " """, ) doc.add_paragraph( "Сохраните выведенный NEW PRODUCT KEY — его передаёте пользователю. " "Формат ключа должен соответствовать шаблону TTRPG-… (заглавные hex-символы)." ) add_step( doc, 3, "Или добавить вручную в data.json", "В массив productKeys добавьте объект (пример):", ) add_code( doc, """{ "key": "TTRPG-12345678-1234-1234-1234-123456789ABC", "sub": "lic_unique_id", "pid": "dnd_player", "maxDevices": 3, "expiresAtSec": 1830268799 }""", ) fields = doc.add_table(rows=6, cols=2) fields.style = "Table Grid" fields.rows[0].cells[0].text = "Поле" fields.rows[0].cells[1].text = "Описание" field_rows = [ ("key", "Продуктовый ключ для пользователя (уникальный)"), ("sub", "Уникальный ID лицензии на сервере"), ("pid", "Обычно dnd_player"), ("maxDevices", "Сколько разных deviceId можно активировать"), ("expiresAtSec", "Срок действия (Unix time, секунды)"), ] for i, (k, v) in enumerate(field_rows, start=1): fields.rows[i].cells[0].text = k fields.rows[i].cells[1].text = v add_step( doc, 4, "Права на файл (если правили от root)", "chown dndlicense:dndlicense /var/www/license_mailib_ru/data.json", ) add_step( doc, 5, "Перезапуск сервиса", "После изменения data.json:", ) add_code(doc, "sudo -u dndlicense pm2 restart dnd-license") doc.add_heading("Проверка", level=1) add_bullet(doc, "Сервер жив: curl https://license.mailib.ru/health → {\"ok\":true}") add_bullet( doc, "В приложении TTRPG Player: «Указать ключ» → вставить продуктовый ключ → активация.", ) add_bullet( doc, "При ошибке unknown_product_key — ключ не в data.json или опечатка; " "too_many_devices — превышен maxDevices.", ) doc.add_heading("Отзыв лицензии", level=1) doc.add_paragraph( "Чтобы отозвать лицензию по sub, добавьте sub в массив revokedSubs в data.json " "и перезапустите pm2. Либо POST /v1/admin/revoke с Bearer-токеном администратора " "(токен хранится только на сервере, в эту инструкцию не включён)." ) doc.add_heading("Локальная разработка", level=1) add_bullet( doc, "Исходники сервера на ПК: D:\\Work\\my_projects\\dnd_project\\DndGamePlayerLicenseServer", ) add_bullet(doc, "Локальный data.json: скопировать из data.example.json") add_bullet(doc, "Запуск: npm start (нужен LICENSE_PRIVATE_KEY_PEM)") add_bullet(doc, "Порт по умолчанию: 3847") doc.add_heading("Частые проблемы", level=1) problems = [ ( "Ключ не принимается в приложении", "Проверьте формат TTRPG-… (8-4-4-4-12 hex), что запись есть в productKeys, " "сервер перезапущен после правки data.json.", ), ( "JSON сломан после правки", "Восстановите из data.json.bak.*; проверьте запятые и кавычки.", ), ( "Нет прав на запись", "Правьте от root или chown на dndlicense; файл не должен быть только для чтения.", ), ] for prob, fix in problems: p = doc.add_paragraph() p.add_run(f"{prob}. ").bold = True p.add_run(fix) p = doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER p.add_run( "Репозиторий сервера: git.mailib.ru/ifontosh/DndGamePlayerLicenseServer" ).italic = True return doc def main() -> None: doc = build_document() for path in OUT_PATHS: path.parent.mkdir(parents=True, exist_ok=True) doc.save(str(path)) print(f"Wrote {path}") if __name__ == "__main__": main()