299 lines
11 KiB
Python
299 lines
11 KiB
Python
# -*- 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()
|