Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ The English translation covers the latest versions only; the full history (in Ru
- [phone] A subscription page has edit, pause and delete in the header, a poster, quality chips, notifications, a link to the card and a check count
- [phone] The subscription sheet keeps Delete and Save above the system bar. A swipe down with unsaved edits asks whether to save them. Releases already found do not notify
- [phone] An episode is no longer marked watched because the file was only preloaded or probed. The mark needs a real watch or a manual mark
- [tech] Billing: a separate Crypto Pay testnet token (`CRYPTO_PAY_TESTNET_TOKEN`). The testnet switch picks the token and the host together. Invoice creation and the `/v1/crypto-pay` signature use only the active mode. With testnet on and no testnet token, crypto stays disabled and the live token is not sent there. A webhook signed by the other mode returns 200, is logged as ignored, and does not extend a subscription. The billing image was not deployed
- [phone] Catalog: one, two or three cards a row, list and compact. The poster keeps quality on the left and the season line at the bottom, plus watching and a progress bar. The list has a status line and no size
- [phone] Search: plus opens magnet paste and a .torrent file, and a pasted magnet on any tab opens add. An http(s) .torrent link from another app is added the same way. A file error is visible at once. The feed line drops the tracker phrase, and 0-1 seeds are marked. The sources hint is one line with a close button
- [phone] Calendar lists days, Earlier stays folded, and the aired/today plates are gone. Month view marks today with a yellow circle, the selected day in gray, up to three dots plus a count, and a yellow dot for a subscription. A subscription that is any quality and best-only reads "best"
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
- [phone] Карточка подписки: правки, пауза и удаление в шапке, постер, чипы качества, уведомления, ссылка на карточку и счетчик проверок
- [phone] Лист подписки: «Удалить» и «Сохранить» остаются над системной панелью. Свайп вниз с несохраненными правками спрашивает, сохранить ли их. Уже найденные раздачи не присылают уведомление
- [phone] Серия больше не помечается «Просмотрено» от предзагрузки или проверки файла. Отметка только после реального просмотра или ручной пометки
- [tech] Биллинг: отдельный токен Crypto Pay для testnet (`CRYPTO_PAY_TESTNET_TOKEN`). Переключатель testnet выбирает токен и хост вместе. Счёт и подпись вебхука `/v1/crypto-pay` идут только в активный режим. Без testnet-токена крипта в testnet выключена, боевой токен туда не уходит. Вебхук другого режима отвечает 200 и пишется как игнорированный, подписку не продлевает. Образ биллинга не выкатывался
- [phone] Каталог: одна, две или три карточки в ряд, список и компактный вид. На постере качество слева и строка сезонов снизу, «смотрите» с полоской. В списке строка статуса, без размера
- [phone] Поиск: «+» для magnet и файла .torrent, вставленная magnet-ссылка на любой вкладке открывает добавление. Ссылка http(s) на .torrent из другого приложения добавляется так же. Ошибка файла видна сразу. В ленте короткая строка размера и сидов без «по данным трекера», 0-1 сид выделен. Подсказка источников - одна строка с крестиком
- [phone] Календарь: список по дням, «Ранее» свернуто, без плашек «сегодня» и «вышла». В месяце сегодня - желтый круг, выбранный день серый, до трех точек и счетчик, желтая точка у подписки. У подписки «любое качество» и «только лучшее» пишется «лучшее»
Expand Down
10 changes: 6 additions & 4 deletions ops/billing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ HSTS (`max-age=15552000; includeSubDomains`, без preload), `X-Content-Type-Op
| Токен бота | `BILLING_BOT_TOKEN` |
| Секрет ЮMoney | `BILLING_YOOMONEY_SECRET` |
| Токен Crypto Pay | `CRYPTO_PAY_TOKEN` |
| Токен Crypto Pay (testnet) | `CRYPTO_PAY_TESTNET_TOKEN` |
| SMTP-пароль | `BILLING_SMTP_PASS` |
| Ключ API Jackett | `BILLING_JACKETT_API_KEY` |
| Ключ API JacRed | `BILLING_JACRED_API_KEY` |
Expand Down Expand Up @@ -126,14 +127,15 @@ sudo $DC up -d --no-deps billing #
| --- | --- |
| Включить звёзды, цена подписки и доп. устройства в звёздах | **Настройки**, группа **Звёзды** |
| Включить крипту, цены USDT/TON/BTC, курс ₽ за 1, testnet, только не из РФ | **Настройки**, группа **Крипта** |
| Токен Crypto Pay | `CRYPTO_PAY_TOKEN` в `.env`. В админке только «задан» и маска. В лог токен не пишется |
| Вебхук Crypto Pay | `POST /v1/crypto-pay`. Подпись `crypto-pay-api-signature` - HMAC-SHA256 сырого тела, ключ SHA256 токена |
| Testnet | Переключатель в админке. Хост `testnet-pay.crypt.bot`, токен тот же `CRYPTO_PAY_TOKEN` (тестовый токен кладётся в эту переменную) |
| Токен Crypto Pay | `CRYPTO_PAY_TOKEN` в `.env`, хост `pay.crypt.bot`. В админке только «задан» и маска. В лог токен не пишется |
| Токен Crypto Pay (testnet) | `CRYPTO_PAY_TESTNET_TOKEN` в `.env`. В админке только «задан» и маска. В лог токен не пишется |
| Вебхук Crypto Pay | `POST /v1/crypto-pay`. Подпись `crypto-pay-api-signature` - HMAC-SHA256 сырого тела, ключ SHA256 токена активного режима |
| Testnet | Переключатель в админке. Включен: хост `testnet-pay.crypt.bot` и `CRYPTO_PAY_TESTNET_TOKEN`. Выключен: `pay.crypt.bot` и `CRYPTO_PAY_TOKEN`. Если testnet включен, а `CRYPTO_PAY_TESTNET_TOKEN` пуст, счёт не создаётся (`disabled`), боевой токен на testnet не отправляется. Уже созданная ссылка другого режима не выдаётся повторно: у каждого режима своя ссылка, ссылка того же режима переиспользуется. Вебхук принимает подпись только активного режима. Подпись другого режима отвечает `200`, пишется в журнал как игнорированная и подписку не продлевает |
| Ссылка крипты | В боте (кнопка на сайт Crypto Pay и кнопка Crypto Bot) и в приложении, кнопка «Криптой». Сайт omp-player.online на это указывает |
| Возврат звёзд | Карточка пользователя, «Вернуть звёзды» (`refundStarPayment`). Дни этой кнопкой не снимаются |
| Выручка | Вкладка **Финансы**, блок «по способам». В таблице заказов есть колонка «Способ» |

`pre_checkout_query` только проверяет счёт и не включает подписку. `successful_payment` с валютой XTR включает её. Недоплата (сумма меньше заказа, другая валюта, чужой invoice id) записывается как ошибка и подписку не включает. Неверная подпись Crypto Pay - `400`. Повтор уже принятого вебхука - `200` и без второго продления.
`pre_checkout_query` только проверяет счёт и не включает подписку. `successful_payment` с валютой XTR включает её. Недоплата (сумма меньше заказа, другая валюта, чужой invoice id) записывается как ошибка и подписку не включает. Неверная подпись Crypto Pay - `400`. Подпись неактивного режима - `200` без продления. Повтор уже принятого вебхука - `200` и без второго продления.

Флаг «только не из РФ» смотрит код страны Cloudflare (`CF-IPCountry`). Пустой код и `RU` ссылку не выдают. Бот узнаёт страну из последнего захода приложения.

Expand Down
1 change: 1 addition & 0 deletions ops/billing/admin/src/lib/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ const PROBLEM_KIND: Record<string, string> = {
duplicate: 'повтор уведомления',
amount: 'неверная сумма',
bad_signature: 'неверная подпись',
ignored: 'игнорирован',
webhook: 'уведомление',
no_account: 'нет аккаунта',
no_subscription: 'нет подписки',
Expand Down
6 changes: 4 additions & 2 deletions ops/billing/admin/src/panels/sections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,7 @@ const SETTING_GROUPS: { title: string; fields: [string, string][] }[] = [
['yoomoneyWallet', 'Кошелёк ЮMoney'],
['yoomoneySecret', 'Секрет ЮMoney'],
['cryptoPayToken', 'Токен Crypto Pay'],
['cryptoPayTestnetToken', 'Токен Crypto Pay (testnet)'],
],
},
{
Expand Down Expand Up @@ -641,7 +642,7 @@ const SETTING_GROUPS: { title: string; fields: [string, string][] }[] = [
];

/** Secrets are read-only in the panel: they live in the server's .env. */
const SECRET_KEYS = new Set(['botToken', 'yoomoneySecret', 'cryptoPayToken', 'smtpPass', 'jackettApiKey', 'jacredApiKey', 'indexerPass']);
const SECRET_KEYS = new Set(['botToken', 'yoomoneySecret', 'cryptoPayToken', 'cryptoPayTestnetToken', 'smtpPass', 'jackettApiKey', 'jacredApiKey', 'indexerPass']);

/** One read-only secret: «задан ✅, ****abcd» or «не задан», and where it changes. */
function SecretField({ name, label, view, fallback }: { name: string; label: string; view?: SecretView; fallback?: string }) {
Expand Down Expand Up @@ -827,7 +828,8 @@ export function SettingsCard({
setCryptoTest(e.target.checked);
}}
/>
Тестовая сеть Crypto Pay (testnet-pay.crypt.bot). Токен всё равно берётся из CRYPTO_PAY_TOKEN.
Тестовая сеть Crypto Pay (testnet-pay.crypt.bot). Токен этого режима:{' '}
{cryptoTest ? String(settings.cryptoPayTestnetToken || 'не задан') : String(settings.cryptoPayToken || 'не задан')}.
</label>
<label className="flex items-center gap-2 text-sm">
<input
Expand Down

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion ops/billing/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="icon" href="/admin/logo.svg" type="image/svg+xml" />
<script type="module" crossorigin src="/admin/assets/index-BBBXsVw3.js"></script>
<script type="module" crossorigin src="/admin/assets/index-Cif9tUAp.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-BA9iWkwX.css">
</head>
<body>
Expand Down
36 changes: 35 additions & 1 deletion ops/billing/src/cryptoPay.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { sameHex } from './crypto.mjs';
* Crypto Pay API (@CryptoBot).
* https://help.crypt.bot/crypto-pay-api
*
* The token is CRYPTO_PAY_TOKEN. It is never written here and never logged.
* The live token is CRYPTO_PAY_TOKEN. The testnet token is CRYPTO_PAY_TESTNET_TOKEN.
* cryptoPayCredentials picks the one for settings.cryptoTestnet. Neither token is logged.
* The webhook signature is HMAC-SHA256 of the raw body. The key is SHA256(token), the raw digest.
*/

Expand All @@ -21,6 +22,39 @@ export function cryptoPayHost(testnet) {
return testnet ? TESTNET : MAINNET;
}

/**
* Token and host for the Crypto Pay mode that is on.
* testnet uses CRYPTO_PAY_TESTNET_TOKEN and testnet-pay.crypt.bot.
* The live mode uses CRYPTO_PAY_TOKEN and pay.crypt.bot.
* ready is false when that mode's token is empty. The other token is not returned.
*/
export function cryptoPayCredentials(settings) {
const s = settings || {};
const testnet = s.cryptoTestnet === true;
const token = String((testnet ? s.cryptoPayTestnetToken : s.cryptoPayToken) || '');
return {
testnet,
host: cryptoPayHost(testnet),
token,
ready: token !== '',
};
}

/**
* Which configured token signed this body.
* 'active' is the mode that is on (apply the payment).
* 'inactive' is the other mode (ack, do not apply).
* 'none' matches neither token.
*/
export function cryptoPaySignatureMode(rawBody, header, settings) {
const creds = cryptoPayCredentials(settings);
if (creds.token && cryptoPayAuthentic(rawBody, header, creds.token)) return 'active';
const s = settings || {};
const other = String((creds.testnet ? s.cryptoPayToken : s.cryptoPayTestnetToken) || '');
if (other && other !== creds.token && cryptoPayAuthentic(rawBody, header, other)) return 'inactive';
return 'none';
}

/** SHA256(token) as the HMAC key (32 raw bytes, not the hex text). */
export function cryptoPayKey(token) {
return createHash('sha256').update(String(token == null ? '' : token), 'utf8').digest();
Expand Down
1 change: 1 addition & 0 deletions ops/billing/src/envSecrets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const ENV_SECRETS = [
{ key: 'botToken', env: 'BILLING_BOT_TOKEN', label: 'Токен бота' },
{ key: 'yoomoneySecret', env: 'BILLING_YOOMONEY_SECRET', label: 'Секрет ЮMoney' },
{ key: 'cryptoPayToken', env: 'CRYPTO_PAY_TOKEN', label: 'Токен Crypto Pay' },
{ key: 'cryptoPayTestnetToken', env: 'CRYPTO_PAY_TESTNET_TOKEN', label: 'Токен Crypto Pay (testnet)' },
{ key: 'smtpPass', env: 'BILLING_SMTP_PASS', label: 'SMTP-пароль' },
{ key: 'jackettApiKey', env: 'BILLING_JACKETT_API_KEY', label: 'Ключ API Jackett' },
{ key: 'jacredApiKey', env: 'BILLING_JACRED_API_KEY', label: 'Ключ API JacRed' },
Expand Down
7 changes: 5 additions & 2 deletions ops/billing/src/logic.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1361,7 +1361,7 @@ export function settleReferrals(state, now) {
return { state: { ...state, subscriptions, referralJournal: journal }, granted };
}

function applyPaymentUnsettled(input, { operationId, label, amount, currency, unaccepted, now, withdrawAmount, notificationType, requireWithdraw }) {
function applyPaymentUnsettled(input, { operationId, label, amount, currency, unaccepted, now, withdrawAmount, notificationType, requireWithdraw, cryptoTestnet }) {
if (!operationId || seenPayment(input, operationId)) {
return {
state: logWebhook(input, { kind: 'duplicate', operationId: operationId || '', label: label || '', amount: amount || '', at: now }),
Expand Down Expand Up @@ -1441,7 +1441,10 @@ function applyPaymentUnsettled(input, { operationId, label, amount, currency, un
result: { duplicate: true },
};
}
if (!cryptoInvoiceMatches(quote, operationId)) {
// The webhook passes the mode that signed the body. A settings save in between must not
// turn that payment into a permanent bad_label.
const signedTestnet = cryptoTestnet === undefined ? state.settings.cryptoTestnet === true : cryptoTestnet === true;
if (!cryptoInvoiceMatches(quote, operationId, signedTestnet)) {
const payments = state.payments.concat([stampPayment(state, { ...row, error: 'bad_label' })]);
return { state: { ...state, payments }, result: { error: 'bad_label' } };
}
Expand Down
23 changes: 19 additions & 4 deletions ops/billing/src/payMethods.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,25 @@ export function altCovers(amount, currency, quote) {
return false;
}

/** A stored Crypto Pay invoice id must be the one that was paid. An order with no id yet still matches. */
export function cryptoInvoiceMatches(quote, operationId) {
if (!quote || quote.method !== 'crypto' || !quote.invoiceId) return true;
return String(operationId || '') === 'crypto:' + String(quote.invoiceId);
/**
* A Crypto Pay notice must name an invoice of the mode that is on.
* When that mode has its own stored link, only that id matches: the last requested
* link can belong to the other mode. A quote that never recorded a mode still matches
* its single invoice id. An order with no id yet still matches.
* `testnet` is settings.cryptoTestnet. Omitted means the live mode.
*/
export function cryptoInvoiceMatches(quote, operationId, testnet) {
if (!quote || quote.method !== 'crypto') return true;
const mode = testnet === true;
const bag = quote.cryptoLinks;
const saved = bag && bag[mode ? 'testnet' : 'live'];
const op = String(operationId || '');
if (saved && saved.invoiceId != null && String(saved.invoiceId) !== '') {
return op === 'crypto:' + String(saved.invoiceId);
}
if (quote.cryptoTestnet != null && quote.cryptoTestnet !== mode) return false;
if (!quote.invoiceId) return true;
return op === 'crypto:' + String(quote.invoiceId);
}

/**
Expand Down
Loading
Loading