Страница входа в сервис в web-приложении на Python и Starlette

programming

Опубликован:
2025-02-03T23:40:04.759940Z
Отредактирован:
2025-02-03T23:40:04.759940Z
Статус:
публичный
21
0
2

Вход пользователя в сервис web-приложения на Python и Starlette, процесс разработки которого мы внимательно и в пошаговом режиме исследуем в этом цикле статей, должен сопровождаться вводом логина и пароля. В очередном выпуске этого блога я покажу вёрстку страницы, на которой посетитель будущего web-сайта сможет ввести в специальную форму свои уникальные идентификационные данные (логин и пароль), нажать расположенную в этой форме "волшебную" кнопку и авторизоваться — действие элементарное, всех нас давно приучили к подобному действию многочисленные социальные сети и публичные сервисы современной сети Интернет. И сейчас я покажу вам, что прячется за старым холстом в каморке папы Карло. Будьте осторожны, в топике много кода, картинок и лирических отступлений автора.

Кэшируем капчу

Эта демонстрация является логическим продолжением предыдущего повествования, в ходе которого я показал процесс автоматизированного создания группы картинок с кодом для CAPTCHA-теста. На текущий момент эти картинки хранятся в SQL базе данных. Поскольку количество этих картинок выражается конечным целым числом, а доступ к каждой из них осуществляется по уникальному семизначному суффиксу, который в свою очередь является параметром url-адреса этой конкретной картинки, не исключена вероятность, что злоумышленник скачает все эти картинки, распознает их программно, а потом использует для преодоления поставленного барьера. Дабы затруднить реализацию такого коварного плана, замысел разработчика предусматривает механизм ротации картинок и изображенного на них кода при каждом использовании в тесте. В данном случае не важно, был тест успешным, или был провален. За каждым использованием конкретной картинки с кодом должен следовать принудительный процесс обновления в базе данных этой картинки и кода на ней.

Если посмотреть с другой стороны, не исключена ситуация, когда одна и та же картинка с кодом будет одновременно запрошена в двух различных сеансах двумя разными пользователями. Первый угадавший (или не угадавший) пользователь автоматически запустит механизм ротации, который изменит и картинку, и код на ней, при этом лишив второго пользователя возможности угадать код на показанной ему картинке. Отсюда следует необходимость сохранить данные перед показом картинки с кодом пользователю. Сохранить где-то ещё, вне основной SQL базы данных, чтобы механизм ротации не лишал пользователей возможности успешно пройти тест. И вот это самое где-то ещё я и должен сейчас описать адекватным и простым кодом.

Отметьте этот день в календаре — это день, когда в разрабатываемом в этом цикле статей web-приложении появится собственный, пусть и элементарный, API. Приступим...

Для размещения кода API мне необходим отдельный каталог. Создаю его в базовом каталоге приложения, даю ему соответствующее имя — api.

$ mkdir webapp/api

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

$ touch webapp/api/__init__.py

Кэшировать данные картинки для капчи я намерен в базу данных Redis, инструменты для подключения к которой разработал в доступной по этой короткой ссылке демонстрации. Для этого мне потребуется пара простых и удобных инструментов. Создаю с помощью текстового редактора Vim в каталоге api новый вложенный модуль, даю ему интуитивно понятное имя — redi.py.

$ vim webapp/api/redi.py

Пишу в этот файл следующий код...

Подключаю ранее разработанные вспомогательные функции.

from ..common.random import randomize
from ..common.redi import get_rc

Функция randomize генерирует случайную строку вида abcd1234, литеры в которой следуют в случайном порядке, а разрядность строки (количество символов) можно задать с помощью единственного аргумента функции. А при помощи функции get_rc можно создать активное подключение к текущей базе данных Redis.

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

async def get_unique(conn, prefix, num):
    ## в бесконечном цикле
    while True:
        ## создаю имя ключа
        res = prefix + await randomize(num)
        ## заглядываю в текущую базу Redis
        if await conn.exists(res):
            ## если условие выполняется, ключ
            ## с именем в переменной res существует
            ## в базе данных
            ## возвращаюсь в начало цикла
            continue
        ## если ключ с запрошенным именем
        ## в текущей базе данных не существует,
        ## прерываю цикл и
        ## возвращаю только что созданное имя ключа
        return res

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

async def assign_cache(request, prefix, suffix, val, expiration):
    ## подключаюсь к базе данных Redis
    rc = await get_rc(request)
    ## создаю уникальное имя ключа
    cache = await get_unique(rc, prefix, 6)
    ## записываю в ключ с только что созданным
    ## именем данные картинки с кодом,
    ## меня интересуют суффикс и код на картинке
    await rc.hmset(cache, {'suffix': suffix, 'val': val})
    ## назначаю период выгорания для созданного ключа,
    ## по истечению которого ключ будет удалён
    await rc.expire(cache, expiration)
    ## закрываю соединение с текущей базой данных
    await rc.close()
    ## возвращаю имя только что созданного ключа
    return cache

Здесь следует обратить внимание на аргумент prefix в этой функции. Я планирую для данных капчи создавать ключи вида captcha:abc123, в этом случае prefix будет иметь вид captcha:. Сохраняю изменения в файл, но покидать текстовый редактор не тороплюсь, для него ещё есть много работы.

Кэшировать данные картинки с кодом для капчи я планирую самым простым http-запросом по методу GET, настало время создать обработчик для такого запроса. В командном режиме Vim создаю в каталоге api новый модуль, даю ему имя main.py — в данном случае имя модуля говорит нам, что модуль описывает вспомогательные http-запросы для главной подпрограммы разрабатываемого web-приложения, так мне будет проще ориентировать в недрах исходного кода программы.

:edit webapp/api/main.py

В этот модуль пишу следующий код...

Создать обработчик для запроса кэширования данных капчи я планирую на базе библиотечного класса HTTPEndpoint из арсенала Starlette, подключаю его.

from starlette.endpoints import HTTPEndpoint

Разрабатываемый обработчик запроса должен будет вернуть Content-Type JSON в своём ответе, подключаю соответствующий класс из арсенала Starlette.

from starlette.responses import JSONResponse

Для реализации задуманного мне потребуются разработанные ранее вспомогательные функции get_conn и assign_cache, подключаю.

from ..common.pg import get_conn
from .redi import assign_cache

И вот как в моих представлениях должен выглядеть обработчик запроса для кэширования данных картинки с кодом для капчи.

class Captcha(HTTPEndpoint):
    async def get(self, request):
        ## обрабатываю запрос по методу GET
        ## создаю подключение к PostgreSQL базе данных
        conn = await get_conn(request.app.config)
        ## запрашиваю данные случайной строки
        ## из этой базы данных, меня интересуют
        ## суффикс и код картинки из поля val
        captcha = await conn.fetchrow(
            'SELECT val, suffix FROM captchas ORDER BY random() LIMIT 1')
        ## создаю словарь с именем res, подключившись
        ## к текущей базе Redis с помощью функции assign_cache
        ## при этом задаю правильный prefix и время выгорания 3 минуты
        res = await assign_cache(
            request, 'captcha:',
            captcha.get('suffix'), captcha.get('val'), 180)
        ## создаю url-адрес для только что выбранной картинки
        url = request.url_for('captcha', suffix=captcha.get('suffix'))._url
        ## возвращаю полученные в итоге обработки данные,
        ## а именно, значения переменных res и url
        return JSONResponse({'captcha': res, 'url': url})

Сохраняю изменения в файл.

Только что созданному обработчику необходимо сопоставить соответствующий url-адрес. Карта url-адресов приложения хранится в "ините" базового каталога. Открываю этот файл в командном режиме Vim.

:edit webapp/__init__.py

В недрах этого модуля мне нужно подключить только что созданный класс Captcha.

from .api.main import Captcha

Поскольку создаваемый url-адрес будет относиться к API разрабатываемого web-приложения, создаю с помощью библиотечного класса Mount из арсенала Starlette новую группу адресов, вписываю в список параметра routes объекта app ещё один элемент.

        Mount('/api', name='api', routes=[
            Route('/captcha', Captcha, name='acaptcha'),
            ]),

И вот как этот код в конечном итоге выглядит в окне моего текстового редактора.

jmik37gREi.png

Сохраняю изменения в файл. Текстовый редактор не покидаю, для него ещё есть работа. А только что разработанный инструментарий следует протестировать...

Тестируем в браузере

В соседнем окне запускаю ещё один экземпляр терминала, вхожу в этом терминале в корневой каталог разрабатываемого web-приложения и активирую его виртуальное окружение. Штатный запуск отладочного сервера — это первое, что я ожидаю увидеть, внимание на следующий снимок экрана.

We1yQNOjfr.png

Как видно выше, отладочный сервер запустился без сбоев, ошибок и предупреждений в своём выхлопе. Отрадно..! Пойдём в браузер.

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

g7x0hayChP.png

Как видно на картинке выше, по заданному адресу сервер вернул JSON с именем ключа базы данных Redis, в котором хранятся кэшированные данные картинки с кодом, и url-адрес искомой картинки. Заглянем в Redis и проверим наличие указанного в ответе сервера ключа и данных искомой картинки.

onjcbrndlu.png

По предложенному в ответе сервера url-адресу тоже стоит проследовать, дабы убедиться, что код на картинке совпадает с кодом в ключе Redis, делать ещё один снимок экрана я не стану, просто поверьте мне на слово.

Следуем далее...

Редактируем главное меню

Итак, эта демонстрация посвящена вёрстке страницы авторизации, или, другими словами, страницы для входа пользователя в предлагаемый разрабатываемым web-приложением сервис. Этой странице необходим собственный url-адрес. Но создавать для этой страницы отдельный обработчик в карте адресов приложения я не буду. Я буду использовать параметры поиска на url-адресе стартовой страницы. В командном режиме текстового редактора Vim открываю файл базового шаблона приложения.

:edit webapp/templates/base.html

В коде этого файла ищу тег <ul> с классом navbar-right, в рамках этого тега нахожу ссылку с идентификатором login и дописываю этой ссылке атрибут href.

<a id="login" href="/?realm=login">Войти</a>

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

Gn7YuL8MIw.png

На стартовой странице я планирую обрабатывать параметр поиска с именем realm и значением login.

Ищу в этом же файле тег с идентификатором mc и дописываю этому тегу класс.

      <div id="mc"
           class="{% if listed %}listed{% else %}nonlisted{% endif %}">
        {% block page_body %}{% endblock page_body %}
      </div>

Здесь я с помощью инструментов Jinja2 задал этому тегу один из двух возможных классов: listed или nonlisted, в зависимости от значения в переменной listed, эта переменная чуть позже появится в соответствующих словарях обработчика стартовой страницы, об этом позже. Вот как внесённая правка выглядит в окне моего тестового редактора.

IF42OSbW47.png

Сохраняю изменения в файл, и остаюсь в окне текстового редактора Vim, для него есть ещё работа.

Обрабатываем параметры поиска

На стартовой странице появился параметр поиска с именем realm, его значение мне нужно обработать в соответствующей функции представления. Открываю в текстовом редакторе модуль views.py из каталога главной подпрограммы main.

:edit webapp/main/views.py

В этом файле нахожу функцию представления с именем show_idex. Эту функцию переписываю следующим образом.

async def show_index(request):
    conn = await get_conn(request.app.config)
    ## новый код
    ## создаю текущего пользователя и присваиваю
    ## этой переменной значение None, текущего пользователя
    ## в арсенале web-приложения пока нет
    cu = None
    ## получаю из параметров поиска
    ## обрабатываемого url-адреса
    ## значение параметра realm
    realm = request.query_params.get('realm')
    amount = await conn.fetchval('SELECT count(*) FROM users')
    ## проверяю первое условие
    if cu is None:
        ## если условие выполняется, и текущий пользователь
        ## не существует, проверяю второе условие
        if realm == 'login':
            ## если условие выполняется, в параметре
            ## поиска realm хранится строка login?
            ## закрываю созданное ранее соединение
            ## с базой данных PostgreSQL
            await conn.close()
            ## рендерю шаблон с именем login.html
            ## и возвращаю его содержимое в http-ответе сервера
            return request.app.jinja.TemplateResponse(
                'main/login.html',
                {'request': request,
                 'listed': False})
    ## если первое условие не выполняется
    ## и текущий пользователь существует
    ## исполняю разработанный ранее код
    await conn.close()
    await set_flashed(request, f'Известно пользователей: {amount}.')
    return request.app.jinja.TemplateResponse(
        'main/index.html',
        {'request': request,
         'message': 'Читайте меня, читайте..!',
         'listed': True,
         'flashed': await get_flashed(request)})

Вот как выглядит эта функция представления в окне моего редактора после внесённых в этот файл только что правок.

uodYeAmaKG.png

Обращаю ваше внимание, что в шаблон index.html я добавил переменную listed, обозначающую соответствующий класс тега с идентификатором mc в коде страницы, соответствующие правки в базовый шаблон я уже внёс чуть ранее.

Сохраняю изменения в файл. Остаюсь в окне текстового редактора.

Создаём шаблон страницы входа

Раз уж в обработчике стартовой страницы я использовал ещё один шаблон, этот шаблон нужно создать. В командном режиме в окне текстового редактора Vim исполняю следующую команду.

:edit webapp/templates/main/login.html

Шаблон login.html будет наследником шаблона base.html, и в этот шаблон пишу следующий код.

{% extends "base.html" %}

{% block title_part %}Login{% endblock title_part %}

{% block styles %}
  {{ super() }}
  {% assets filters='cssmin', output='generic/css/main/login.css',
            'css/base.css' %}
    <link rel="stylesheet" href="{{ ASSET_URL }}">
  {% endassets %}
{% endblock styles %}

{% block scripts %}
  {{ super() }}
  {% assets filters='rjsmin', output='generic/js/main/login.js',
            'js/main/login.js' %}
    <script src="{{ ASSET_URL }}"></script>
  {% endassets %}
{% endblock scripts %}

Между блоками styles и scripts дописываю ещё один блок, даю ему имя templates. В рамках этого блока создаю тег <script> с идентификатором logint. Вот как выглядит код этого блока.

{%- block templates -%}
  {{- super() -}}
  <script id="logint" type="text/template">
    <div id="loginf" class="content-block">
      <h1>Вход в сервис</h1>
      <div class="today-field">some</div>
      <div class="form-help">
        <p>
          Для входа в сервис введите свой логин, пароль и код с картинки,
          нажмите кнопку "Войти в сервис". Логином может быть
          зарегестрированный псевдоним или адрес электронной почты. Форма
          действительна в течение 3-х минут после загрузки страницы или
          обновления картинки с кодом. Если код на картинке плохо
          читается, можно обновить картинку кнопкой со стрелками и найти
          картинку с хорошо читаемым кодом. <b>Для справки:</b> код
          состоит из строчных латинских букв и цифр без повторов, будьте
          внимательны, очень легко спутать букву "o" и цифру "0", "l" и "1",
          "i" и "j". Если в коде допущена ошибка, тест считается проваленным,
          но есть возможность исправить ошибку и отправить форму вновь.
        </p>
      </div>
      <div class="form-form">
        <form>
          <div class="form-group">
            <div class="form-label text-right">
              <label for="logininput">Логин:</label>
            </div>
            <div class="form-input">
              <input class="form-control"
                     id="logininput"
                     name="logininput"
                     autocomplete="username"
                     placeholder="введите свой логин"
                     required
                     type="text">
            </div>
            <div class="footer-bottom"></div>
          </div>
          <div class="form-group">
            <div class="form-label text-right">
              <label for="password">Пароль:</label>
            </div>
            <div class="form-input">
              <input class="form-control"
                     id="password"
                     name="password"
                     autocomplete="current-password"
                     placeholder="введите свой пароль"
                     required
                     type="password">
            </div>
          </div>
          <div class="form-group">
            <div class="form-input checkbox">
              <label>
                <input id="remember_me"
                       name="remember_me"
                       type="checkbox">
                <label for="remember_me">
                  Хранить сессию 30 дней
                </label>
              </label>
            </div>
          </div>
          <div class="form-group">
            <div class="form-label captcha-options text-right">
              <button type="button"
                      title="обновить картинку"
                      id="lcaptcha-reload"
                      class="btn btn-default">
                <span class="glyphicon glyphicon-refresh"
                      aria-hidden="true"></span>
              </button>
            </div>
            <div id="lcaptcha-field" class="form-input captcha-field"
                 style="background:url(<% url %>)"></div>
            <div class="footer-bottom"></div>
          </div>
          <div class="form-group">
            <div class="form-label text-right">
              <label for="lcaptcha">Код с картинки:</label>
            </div>
            <div class="form-input">
              <input class="form-control"
                     id="lcaptcha"
                     placeholder="введите код с картинки"
                     required
                     type="text">
            </div>
            <div class="footer-bottom"></div>
          </div>
          <div class="form-group hidden">
            <div class="form-input">
              <input class="form-control"
                     id="lsuffix"
                     required
                     type="text"
                     value="<% captcha %>">
            </div>
          </div>
          <div class="form-group">
            <div class="form-input">
              <button type="button"
                      class="btn btn-primary"
                      id="login-submit"
                      name="login-submit">Войти в сервис</button>
              <button type="button"
                      class="btn btn-info"
                      id="login-reg">Получить пароль</button>
            </div>
          </div>
        </form>
      </div>
    </div>
  </script>
{%- endblock templates -%}

Этот код определяет шаблон для обработки инструментами библиотеки Mustache.js, эта библиотека была подключена в приложение в одной из предыдущих демонстраций этого цикла статей. В этом коде следует обратить внимание на теги с идентификаторами lcaptcha-field и lsuffix, в этих тегах я задал значения с помощью переменных Mustache.js, внимание на следующий снимок экрана.

k0KCDaF7rh.png

Значения обозначенных переменных я намерен получить из кэширующего данные капчи GET-запроса, который уже оформлен кодом в начале этой демонстрации. Сохраняю изменения в файл.

Рендерим страницу инструментами JavaScript

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

:edit webapp/static/js/main/login.js

В этот файл пишу следующий код.

$(function() {
  "use strict";
  let dt = luxon.DateTime.now();
  $.ajax({
    method: 'GET',
    url: '/api/captcha',
    success: function(data) {
      let form = Mustache.render($('#logint').html(), data);
      $('#mc').append(form);
      if ($('.today-field').length) renderTF('.today-field', dt);
      checkPC(860);
    },
    dataType: 'json'
  });
});

Этот код использует инструменты jQuery, эта библиотека имеется в арсенале разрабатываемого web-приложения, она подключена ранее. В этом коде следует обратить внимание на следующую функцию.

  $.ajax({
    method: 'GET',
    url: '/api/captcha',
    success: function(data) {
      let form = Mustache.render($('#logint').html(), data);
      $('#mc').append(form);
      if ($('.today-field').length) renderTF('.today-field', dt);
      checkPC(860);
    },
    dataType: 'json'
  });

Эта функция реализует Ajax-запрос по методу GET и по указанном в ней url-адресу, кэширующему данные картинки с кодом для капчи. В ключе success я рендерю шаблон с идентификатором logint — его код представлен выше, передаю этому шаблону полученные из ответа сервера, и итоговый html код вставляю в тег с идентификатором mc. Поскольку в теле этой функции я использовал вызовы разработанных ранее функций renderTF и checkPC, эти файлы необходимо дописать в соответствующий asset шаблона login.html — открываю его в окне текстового редактора.

:edit webapp/templates/main/login.html

И редактирую блок scripts этого шаблона следующим образом, внимание на следующий снимок экрана.

cY4hFJimIn.png

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

Сохраняю изменения всех файлов.

Стилизуем страницу авторизации

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

Создаю файл для стилей картинки CAPTCHA.

:edit webapp/static/css/captcha.css

Пишу в этот файл следующий код.

.captcha-field {
  border: 1px solid gainsboro;
  box-shadow: 0 0 4px tan;
  border-radius: 3px;
  width: 120px;
  height: 60px;
}

.captcha-options {
  padding-top: 12px;
}

Блок формы авторизации я обработаю в файле с именем content-block.css, создаю его.

:edit webapp/static/css/content-block.css

В этом блоке стилизую только заголовок блока, пишу в этот файл следующий код.

.content-block h1 {
  margin: 0;
  font-size: 1.4em;
}

Справочный текст формы авторизации я обработаю стилями файла с именем form-help.css, создаю его.

:edit webapp/static/css/form-help.css

В этот файл добавлю следующий код.

.form-help {
  font-style: italic;
  text-align: justify;
  font-size: 0.9em;
}

.form-help p {
  margin: 0;
}

Поля формы авторизации имеют соответствующие лэйблы, их я обработаю в файле form-labeled.css, создаю его.

:edit webapp/static/css/form-labeled.css

Пишу в этот файл следующий код.

.form-form {
  margin: 0;
  font-size: 0.9em;
}

.form-group {
  margin: 6px 0 0;
}

.form-label {
  width: 25%;
  float: left;
  padding-top: 7px;
}

.checkbox {
  margin: 0;
}

.checkbox label label {
  padding-left: 3px;
}

.form-input {
  margin-left: 26%;
}

В теге с идентификатором mc появился новый класс, его я обработаю в файле mc.css, создаю его.

:edit webapp/static/css/mc.css

Пишу в этот файл следующий код.

.nonlisted {
  padding: 8px;
  border-radius: 4px;
  border: 1px solid gainsboro;
  background-image: linear-gradient(to top, ivory, ivory);
  box-shadow: 0 0 4px gainsboro;
}

И для полного счастья мне не хватает пары вариантов для обработки тега с идентификатором page-content, файл с именем page-content.css уже существует, открываю его в окне текстового редактора.

:edit webapp/static/css/page-content.css

Привожу код этого файла к следующему виду.

#page-content {
  padding: 4px 0 6px;
  margin-left: auto;
  margin-right: auto;
  width: 840px;
}

@media (max-width: 768px) {
  #page-content {
    width: 748px;
  }
}

Всё это великолепие новых файлов с таблицами стилей необходимо добавить в шаблон страницы авторизации. Вновь открываю его в окне текстового редактора.

:edit webapp/templates/main/login.html

В блоке styles привожу его assets к следующему виду, внимание на снимок экрана.

yaY6By3Sz4.png

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

Тестируем в браузере

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

6KcZwuyEEF.png

Следую по этой ссылке, и, как говорят французы, вуаля...

v5iU9n9U4s.png

Констатирую, что у меня получилась вполне симпатичная форма для авторизации пользователей. Как видно на снимке экрана, в поле CAPTCHA теста отображается картинка с кодом, как и ожидалось.

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

Подводим промежуточный итог

Работе конец, наконец-то, всё чисто и гладко... Честно признаться, я немного запурхался, отлаживая представленный в этой демонстрации код. А что вы думали..? Web-разработка — путь тернистый, кормить в пути не обещали. Но зато, в итоге всех потраченных усилий и злоключений в арсенале разрабатываемого web-приложения появилась станица авторизации пользователей. Текущую версию кода приложения, как всегда, можно найти в моём профиле на github.com по этой короткой ссылке. А я получаю возможность продолжить разработку, и в следующем выпуске этого блога я планирую показать инструменты ротации картинок CAPTCHA-теста. Продолжения цикла не будет, не востребовано.