company_banner

Как я сделал систему приема платежей в Minecraft на чистом PowerShell


    В этой статье мы прикрутим богомерзкий донат к ванильному серверу Minecraft с помощью Powershell. Преимущество метода в том, что майнкрафт это лишь частный случай реализации автоматических платежей с помощью консольных команд. Мы лишь слушаем, что нам присылает платежная система и заворачиваем это в команду. И главное – никаких плагинов.
    А принимать платежи мы будем через PayPal. Самое главное, для того чтобы начать принимать платежи не нужно изменять код, PayPal отправит нам все что нужно. На сайте будем использовать через кнопки, так что на сайте можно обойтись чистым HTML. Абстрагируемся от тонкостей самой платежной системы и сконцентрируемся только на основных моментах в коде.

    Кстати, автор будет очень рад, если вы просмотрите все его модули и найдете в них детские ошибки, на которые укажете или исправите. Вот ссылка на гитхаб проекта.

    Пару слов о IPN


    IPN


    Принимать платежи будем через кнопки. Кнопки не требуют никакого бэкенда от вас, работают на чистом HTML, да еще и собственные поля имеют.

    Кнопки триггерят IPN – Instant Payment Notification, в котором данные поступают на наш WebListener. Структуру IPN рассмотрим чуть ниже.

    К тому же сделать свою кнопку может кто угодно, кто имеет учетную запись PayPal.
    IPN не обладает полнотой всей REST API PayPal, но базовый функционал можно реализовать и на ней. На самом деле, рассматриваемый нами IPN не REST API в полном смысле этого слова только потому, что сам PayPal не ждет от нас ничего кроме кода 200.

    Поднимаем WebListener


    PayPal, по соображениям безопасности, не отправляет запросы по HTTP, поэтому для начала работы нам нужно выпустить сертификат. 

    Автор использовал WinAcme. Выпустить сертификат можно на любой домен, а сертификат нужно положить в локальное хранилище сертификатов. Кстати, в образе WinAcme находится в корне диска.

    #Просматриваем хэш сертификата из персонального хранилище
    Get-ChildItem -Path Cert:\LocalMachine\My 
     
    #Всписываем сертификат с этим хешем на 443 порт.
    netsh http add sslcert ipport=0.0.0.0:443 certhash=D106F5676534794B6767D1FB75B58D5E33906710 "appid={00112233-4455-6677-8899-AABBCCDDEEFF}"

    Powershell умеет использовать классы из .net, что делает его почти равным .net. Сначала, используя класс HttpListener, поднимем Web сервер.

    #Используем класс из .net
    $http = [System.Net.HttpListener]::new() 
     
    #Добавляем префиксы к лисенеру
    $http.Prefixes.Add("http://donate.to/")
    $http.Prefixes.Add("https://donate.to/")
     
    #Стартуем сервер
    $http.Start()

    Чтобы проверить, что все сделано нормально, выполним netstat.



    Если в списке наш скрипт начал слушать 443 порт, значит, вы сделали все правильно, и мы можем перейти к приему обработке запросов. Только не забудьте про брандмауэр.

    Принимаем запрос


    С помощью IPN Simulator мы можем отправить себе тестовый POST запрос, чтобы посмотреть что это такое. Но в нем нельзя включить собственные поля, поэтому автор рекомендует сделать кнопку и сразу купить у себя что-нибудь. В IPN History появится нормальный запрос с кнопки которую вы будете использовать.  Автор сделал именно так, купив у себя один уголь за один рубль.

    Принимать будем с помощью цикла While. Пока веб-сервер работает, мы можем читать входящий поток данных.

    while ($http.IsListening) {
     
      $context = $http.GetContext()
     
      if ($context.Request.HttpMethod -eq 'POST' -and $context.Request.RawUrl -eq '/') {
     
        #Читаем содержимое POST запроса
        $Reader = [System.IO.StreamReader]::new($context.Request.InputStream).ReadToEnd()
        
        #Фиксим странные руны.
        $DecodedContent = [System.Web.HttpUtility]::UrlDecode($Reader)
     
        #Выводим платеж в терминал.
        $Payment | Format-Table
     
        #Отвечаем клиенту 200 OK и закрываем стрим.
        $context.Response.Headers.Add("Content-Type", "text/plain")
        $context.Response.StatusCode = 200
        $ResponseBuffer = [System.Text.Encoding]::UTF8.GetBytes("")
        $context.Response.ContentLength64 = $ResponseBuffer.Length
        $context.Response.OutputStream.Write($ResponseBuffer, 0, $ResponseBuffer.Length)
        $context.Response.Close()
      }
    }
    

    Если вы получаете вермишель подобную этой, то примените:

    $Payment = $DecodedContent -split "&" | ConvertFrom-StringData



    После этого вам наконец придет нормальный объект, где все Value это String.



    Можете бросить чтение прямо на этом месте, если не хотите погружаться все глубже в код, а просто хотите принимать от чьей-то API запросы.

    Вот код, который работает прямо из коробки, копируйте и используйте:

    #Запускаем лисенер
    $http = [System.Net.HttpListener]::new() 
     
    #Указываем домены, которые мы слушаем
    $http.Prefixes.Add("http://localhost/")
    $http.Prefixes.Add("https://localhost/")
     
    $http.Start()
     
    while ($http.IsListening) {
     
      $context = $http.GetContext()
     
      if ($context.Request.HttpMethod -eq 'POST' -and $context.Request.RawUrl -eq '/') {
     
        #Читаем содержимое POST запроса
        $Reader = [System.IO.StreamReader]::new($context.Request.InputStream).ReadToEnd()
        
        #Фиксим странные руны.
        $DecodedContent = [System.Web.HttpUtility]::UrlDecode($Reader)
              
        #Преобразуем вермишель IPN в массив строк
        $Payment = $DecodedContent -split "&" | ConvertFrom-StringData
     
        #Выводим платеж в терминал.
        $Payment | Format-Table
     
        #Отвечаем клиенту 200 OK и закрываем стрим.
        $context.Response.Headers.Add("Content-Type", "text/plain")
        $context.Response.StatusCode = 200
        $ResponseBuffer = [System.Text.Encoding]::UTF8.GetBytes("")
        $context.Response.ContentLength64 = $ResponseBuffer.Length
        $context.Response.OutputStream.Write($ResponseBuffer, 0, $ResponseBuffer.Length)
        $context.Response.Close()
      }
    }
    

    Нюансы Minecraft


    Вот мы разобрались как мы можем получать оповещения о платежах, можно теперь их зачислять. Но тут тоже не все так просто. Проблема в том, что игра не дает давать предметы или изменять статус игроков, которые не находятся на сервере. То есть нам нужно ждать пока человек зайдет на сервер, чтобы дать ему то, за что он заплатил.

    Поэтому вашему вниманию представлена общая принципиальная схема курильщика, по зачислению платежей.



    Прием платежей осуществляется через Listener выше, в него была добавлена всего одна строка для записи объекта в файл. Complete-Payment (Обработчик) смотрит на никнейм и сопоставляет с именем файла. Если нашел файл, составляет команду для rcon и выполняет ее.

    Start-Minecraft, о котором автор писал в предыдущей статье был немного изменен. Теперь он слушает вывод, смотрит на ники игроков и передает их в обработчик платежей.

    Делаем самые настоящие колбеки


    Не используя плагинов, мы сделаем истинные колбеки. Для этого был изменен Start-Minecraft. Теперь он не только умеет складывать StdOut в файл, но еще и проходиться по каждой строке регуляркой. Благо майнкрафт оставляет весьма специфичное сообщение, когда игрок входит на сервер.

    [04:20:00 INFO]: UUID of player XXPROHUNTERXX is 23e93d2e-r34d-7h15 -5h17-a9192cd70b48

    Из этой строки очень просто забрать никнейм. Вот весь код, который понадобится нам, чтобы забирать данные из строк Stdout.

    $Regex = [Regex]::new("of player ([^ ]+)")
     
    powershell.exe -file ".\Start-MinecraftHandler.ps1" -type $type -MinecraftPath $MinecraftPath | Tee-Object $LogFile -Append | ForEach-Object {
     
         Write-host $_
            
        $Player = $Regex.Matches($_).value -replace "of player "
            
        if ($true -eq $Regex.Matches($_).Success) {
            #обратный вызов стартует тут
        }
    }
    


    На конвейер $_ подается новая строка, её мы пишем в окно консоли и проходимся по ней регуляркой. Регулярка сама оповещает нас, когда срабатывает, что очень удобно.

    Отсюда мы можем вызывать любой код. К примеру, используя это же RCON, мы можем приветствовать игрока в ПМ, с помощью бота в дискорде оповещать о том, что кто-то зашел на сервер, банить за мат, ну и так далее.

    Делаем прием платежей


    Раз уж мы взялись за обработку платежей, то хотелось бы иметь как минимум достаточно полные данные об операции и историю проведенных операций, ведь речь идет о числах с двумя нулями, так сказать.

    Автор хочет оставить все предельно простым и не моделировать еще базу. Давайте рассмотрим NoSQL подход. Сделаем свой собственный класс, который будем импортировать все принятые платежи в папку /payments/ в файлы в формате Json.

        class Payment {
            #Дата прихода платежа.
            [datetime]$Date = [datetime]::ParseExact($i.payment_date, "HH:mm:ss MMM dd, yyyy PDT", [System.Globalization.CultureInfo]::InvariantCulture)
            #Приорбетенная вещь
            [string]$Item = $i.item_name
            #Количество вещей
            [UInt16]$Quantity = $i.Quantity
            #Какую сумму мы действительно получили
            [UInt16]$AmountPaid = $AmountPaid -as [UInt16]
            #В какой валюте был принят платеж
            [string]$Currency = $i.mc_currency
            #Никнейм игрока, который получит вещь
            [string]$Player = $i.option_selection1
        
            [bool]$Completed = $false
            [UInt16]$ItemId = $i.item_number
        }
    /source>
    
    Из предложенной модели будет понятно, кто, когда, что и в каком объеме купил и получил ли товар.
    
    Для кнопки, которую сгенерировал автор <b>option_selection1</b> – это никнейм игрока. Сюда можно подставить любой собственный input, все что угодно, но в данном случае никнейм.
    Свои собственные поля имеют нумерацию <b>option_selection1</b>,<b>option_selection2</b> и так далее.
    
    Как ранее было показано на схеме выше, ресивер не делает ничего иного, как складывает пришедшие платежи в файл.
    
    <source lang="powershell"> #Создаем новый объект по классу Payment, чтобы его легко можно было запихнуть в файл.
        $Payment = [Payment]::new()
        $Payment | Format-Table
        #Человекопонятно обзываем файл, в формате ЧЧ-ММ-ДД-ММ-ГГГГ
        $FileName = $Payment.Player + "-" + $Payment.date.Hour + "-" + $Payment.date.Minute + "-" + $Payment.date.Day + "-" + $Payment.date.Month + "-" + $Payment.date.Year + ".json"
     
    #Составляем путь, по которому наш объект будет экспортирован
        $JsonPath = Join-Path $MinecraftPath \payments\Pending $FileName
        
        #Экспортируем объект в джисонину
        $Payment | ConvertTo-Json | Out-File $JsonPath

    Вот и все, что требовалось от нашего листенера. Получить данные от PayPal и записать в файл.

    Делаем обработку платежей


    Вызывать обработчик будет та регулярка, о которой написано ранее. В модуль мы передаем никнейм игрока и все. Дальше запускается новый скрипт, который ищет файл, и если файл есть, дает игроку предмет, который записан в файле.

    powershell.exe -file "C:\mc.fern\Start-MinecraftHandler.ps1" -type $type -MinecraftPath $MinecraftPath | Tee-Object $LogFile -Append | ForEach-Object {
           
            #Так как строка оказалась в конвейере,нам придется её писать таким вот образом.
            Write-host $_   
     
            #Класс Regex сам оповестит нас о срабатывании
            if ($true -eq $Regex.Matches($_).Success) {
                
                #Удаляем все лишне и оставляем только ник игрока
                $Player = $Regex.Matches($_).value -replace "of player "
                
                #Вызываем самописную команду, которая найдет платеж и передаст игроку предмет
                Complete-Payment -Player $Player
            }
        }
    

    При срабатывании регулярки запускается модуль, который завершает платеж, то есть, отдает игроку предмет. Для этого в папке /Payments/Pending/ скрипт ищет файлы содержащие ник игрока зашедшего в игру и читает его содержание.

    Теперь нужно собрать команду для сервера и отправить её туда. Собираться она будет из файла. Ник игрока мы знаем, название предмета и его ID записали, сколько штук тоже записали, осталось только послать команду на игровой сервер. Для этого будем использовать mcrcon.

    #Находим файл содержащий ник игрока
        $JsonPath = Join-Path $MinecraftPath\payments\Pending -ChildPath $Player*
        $i = $JsonPath | Get-Item | Where-Object { !$_.PSIsContainer } | Get-Content | ConvertFrom-Json -ErrorVariable Errored
     
        #Если файл был найден выполняем процедуру зачисления
        if ($null -ne $i) {
     
            #Составляем команду 
            $Command = '"' + "give " + $i.Player + " " + $i.Item + " " + $i.Quantity + '"'
            Write-host $Command -ForegroundColor Green
        
            #Отправляем команду на сервер
            Start-Process -FilePath mcrcon.exe -ArgumentList "-H localhost -p 123 -w 5 $Command"
        
            #Составляем путь, по которому наш объект будет экспортирован
            $JsonPath = Join-Path $MinecraftPath\payments\Pending -ChildPath $FileName
            
            #Экспортируем объект в джисонину
            $i | ConvertTo-Json | Out-File $JsonPath
        
            #Перемещаем завершенный платеж в другую папку
            Move-Item  -Path $JsonPath -Destination $MinecraftPath\payments\Completed
        }
    

    Оформляем это все в удобный модуль


    Для процесса Java и процесса WebListener требуются разные потоки, но автора не устраивает нужда запускать отдельно WebListener и отдельно сервер. Автор хочет все и сразу одной командой.

    Поэтому используя Powershell 7, мы запустим и то и то. А поможет нам:

    ForEach-Object -Parallel {}

    Командлет работает с inputObject, поэтому мы подаем на него незамысловатый массив, а разделяем потоки с помощью свитча.

    "A", "B" | ForEach-Object -Parallel {
     
        Import-Module ".\Start-Minecraft.ps1"
     
        Import-Module ".\Start-WebListener.ps1"
     
        switch ($_) {
            "A" {
                Start-WebListener -Path "C:\mc\"
            }
            "B" {
                Start-Minecraft -Type Vanilla -LogFile ".\stdout.txt" -MinecraftPath "C:\mc\"
            }
            
        }
    }
    

    Таким вот костыльным образом мы запустили два разных процесса из одного терминала и даже не потеряли инпут. Но тут появилась еще одна проблема. WebListener лочит за собой консоль после штатной остановки сервера и никуда не хочет уходить.

    Чтобы не перезапускать терминал каждый раз, в Start-MinecraftHandler.ps1 и в Start-WebListener.ps1 был добавлен рандомный ключ, который будет останавливать сервер по POST на WebListener.

    Start-MinecraftHandler.ps1, когда фиксирует успешное завершение выполняет команду:

    Invoke-WebRequest -Method Post -Uri localhost -Body $StopToken | Out-Null

    $StopToken содержит случайное числовое значение, которое заранее передается скриптом запуска и в Listener и в Handler. Listener смотрит, что ему пришло в запросе и выключается, если тело запроса совпадает с $StopToken.

    if ($DecodedContent -eq $StopToken) {
            Write-Host "Stopping WebListener"
            $context.Response.Headers.Add("Content-Type", "text/plain")
            $context.Response.StatusCode = 200
            $ResponseBuffer = [System.Text.Encoding]::UTF8.GetBytes("")
            $context.Response.ContentLength64 = $ResponseBuffer.Length
            $context.Response.OutputStream.Write($ResponseBuffer, 0, $ResponseBuffer.Length)
            $context.Response.Close()
            $http.Close()
            break
          }
    

    Достаточно безопасно, о токене знает только оперативная память и более никто. Все модули запускаются из-под PowerShell 7, а путь к модулям для PowerShell 7 отличается от пути в Windows Powershell. Все было сложено сюда. Имейте в виду при написании своих собственных.

    C:\Program Files\PowerShell\7\Modules

    Делаем конфиг файл


    Чтобы всем этим безобразием можно было пользоваться без сильной головной боли, нужно сделать нормальный конфиг файл. Файл будет содержать в себе переменные и не более того. Цепляется конфиг с помощью стандартного:

    Import-Module $MinecraftPath\config.ps1 -Force

    Указывать нам нужно самое важное. Домен, который прослушивается, регулярку, которая ищет ник игрока, ибо от версии к версии вывод может быть разным, и пароль от rcon.

    Выглядит он вот так:

    #Домен, который мы будем слушать
    $DomainName = "localhost"
     
    #регулярное выражение, которое фиксирует вход игрока в игру
    #не изменяйте, если работает
    $RegExp = "of player ([^ ]+)"
    #После успешного нахождения по паттерну, нужно отрезать все, кроме ника.
    $RegExpCut = "of player "
     
    #Пароль от rcon, который был задан в server.properties
    $rconPassword = "123"

    Помещать конфиг желательно в папку с сервером, потому что скрипт ищет его в корне -MinecraftPath

    Как всем этим пользоваться?


    В первую очередь, эти скрипты установлены и готовы к использованию в маркетплейсе Ruvds, но если вы еще не клиент или не опробовали образ, вот ссылка на все файлы в репозитории, не стесняйтесь коммитить. 

    1. Скачайте и установите PowerShell 7
    2. Скачайте и распакуйте архив с модулями


    Теперь все нужные модули и команды у нас появились. Что же они делают?

    Start-Minecraft


    Параметры:

    -Type
    Forge или Vanilla. Запускает сервер либо с Server.Jar, либо Forge, выбирая самую последнюю версию, которая есть в папке.

    -MinecraftPath
    Указывает на папку, из которой будет запущен сервер.

    -LogFile
    Альтернативный способ сбора логов. Указывает на файл, в который будет записываться все, что появляются в консоли.

    -StartPaymentListener
    Вместе с сервером запускает и прием платежей. Сам прием платежей доступен как отдельный модуль. Заменяет командлет Start-Weblistener

    Start-Weblistener


    Запускает модуль приема платежей.

    -MinecraftPath
    Указывает на папку с конфиг файлом.

    -StopToken
    Указывает -Body HTTP POST запроса для остановки WebListener’a.

    Вывод:


    Ну и чудеса же случаются.

    RUVDS.com
    RUVDS – хостинг VDS/VPS серверов

    Комментарии 9

      +3

      Делаем однобуквенный ник и получаем чужие донаты, чей ник начинается с этой же буквы.




              #Приорбетенная вещь
              [string]$Item = $i.item_name
              #Количество вещей
              [UInt16]$Quantity = $i.Quantity

      Эта информация отправляется с html страницы? Тогда пользовтаель сможет её подменить и получить любую вещь в любом количестве.


      А если бы в майнкрафте можно было разделять команды, то пользователь смог бы любую команду запустить с правами админа/модератора (или чьи права дает RCON?)


      # $i.Quantity = '; gamemode creative @a'
      #Составляем команду 
      $Command = '"' + "give " + $i.Player + " " + $i.Item + " " + $i.Quantity + '"'
        0
        Спасибо за такой комментарий, плюсанул.
        Делаем однобуквенный ник и получаем чужие донаты, чей ник начинается с этой же буквы.
        Хорошее замечание, нужно фиксить.
        Эта информация отправляется с html страницы? Тогда пользовтаель сможет её подменить и получить любую вещь в любом количестве.
        Нет, вся информация хранится на стороне PayPal, спуфинг невозможен.
        А если бы в майнкрафте можно было разделять команды, то пользователь смог бы любую команду запустить с правами админа/модератора (или чьи права дает RCON?)
        RCON имеет права консоли. То есть админа. Хорошая догадка, но опять же, вся информация хранится платежной системой.
          0
          upd: Ник игрока мы получали от Paypal и хранили его в файле.
          Если ник начинался на ту же букву, человек мог затригерить отправку предмета. В таком случае ни покупатель, ни тот человек предмет бы не получили.
          Вот фикс.
            +1

            Почитал про IPN. У вас чуть ли не все возможные пункты безопасности нарушены.
            https://developer.paypal.com/docs/ipn/integration-guide/IPNIntro/#ipn-protocol-and-architecture


            • Сервер MC и IPN Listener расположены на одном ip. А зная ip адрес листенера пользователь может сам иницировать POST запрос с нужными данными. Нужно проверять от кого запрос, или делать ссылку с рандомным патчем и проверять его:
              example.com?secret=fdf7sajsaklfj8saur32nnf82jr5298

            • Paypal не даёт гарантии что не будет повторного запроса с тем же id транзакции. Нужно его сохранять и проверять.
            • Paypal может отправить запрос раньше времени, нужно проверять чтобы статус был "completed". Так же paypal отправляет запросы о других событиях, таких как chargeback. В текущей реализации скрипт воспримет возврат средств как новое поступление.
            • Нужно проверять сумму платежа, потому что если "PayPal payment button" не защищена, то пользователь может отправить сумму меньше запрашиваемой.
              0
              Сервер MC и IPN Listener расположены на одном ip. А зная ip адрес листенера пользователь может сам иницировать POST запрос с нужными данными. Нужно проверять от кого запрос, или делать ссылку с рандомным патчем и проверять его:
              Именно для этого в файл config.ps1 нужно ввести свой url. Listener не слушает запросы по своему ip адресу, если его не задать в качестве http префикса.
              Paypal не даёт гарантии что не будет повторного запроса с тем же id транзакции. Нужно его сохранять и проверять.
              Если дать ему статус 200 OK в ответ на его POST запрос, то IPN он посчитает как Delivered и больше слать его не будет. Остальные пункты автор добавил в todo, спасибо за замечания и плюс в карму.
          0

          Я надеюсь вы в курсе про EULA Minecraft'а и то, что далеко не на все в играх можно прикручивать донаты

            +1
            ну очень сильно покоробило от регулярок
            $Regex = [Regex]::new("of player ([^ ]+)")
                $Player = $Regex.Matches($_).value -replace "of player "
                  
                if ($true -eq $Regex.Matches($_).Success) {
                    #обратный вызов стартует тут
                }
            

            в PS аж с версии 1.0 есть
            
            if ($_ -match "of player ([^ ]+)") {
                $Player = $matches[1]
            }
            

            а иначе зачем вообще скобки?

            и почему везде Import-Module вместо dotsource?

            btw, помимо сказанного другими выше, складывать пути из имени игрока не очень хорошо. Мало ли, сделаю имя "../../ЧТО-НИБУДЬ_НЕХОРОШЕЕ"…
              0
              эта статья увеличила количество pay-to-win серверов.
                0
                Посмотрел бы видео «Как я сделал систему приема платежей в Minecraft на redstone» )

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое