Перейти к содержанию

Кастомизация ссылок на файлы web-клиента

В примере описан кейс, когда в проектном решении в отдельном контроллере определяется GET-метод для загрузки файлов по идентификатору вида /files/0e0a6ad9-15ea-482d-9d10-0a0bb3a054f1. Здесь это физические файлы карточки, хотя аналогичным образом возможно возвращать виртуальные файлы, файлы из серверной папки или произвольное html-содержимое.

Чтобы пользователь смог корректно переходить по этим ссылкам, требуется обеспечить:

  • авторизацию по токену сессии, передаваемому через cookies;
  • автоматический редирект на экран логина, если токен отсутствовал;
  • возврат по обратной ссылке из окна логина после того, как пользователь выполнит вход;
  • вывод ошибок понятным пользователю образом (вместо JSON-объекта).

Серверный контроллер для обработки метода

В проекте Tessa.Extensions.Server.Web добавьте файл AbFilesController.cs, который будет определять логику обработки ссылки. Здесь Ab - это короткий префикс проектного решения, который также будет использован в именах типов далее в этом примере. Для вашего решения укажите любой свой префикс.

using System; using System.Net; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using NLog; using Tessa.Cards; using Tessa.Platform; using Tessa.Platform.Data; using Tessa.Platform.Validation; using Tessa.Web; using Tessa.Web.Helpers;

namespace Tessa.Extensions.Server.Web.Services { [Route("files"), AllowAnonymous, ApiController] [ProducesErrorResponseType(typeof(PlainValidationResult))] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] public sealed class AbFilesController : Controller { public AbFilesController( ICardStreamServerRepository cardStreamServerRepository, IDbScope dbScope) { this.cardStreamServerRepository = NotNullOrThrow(cardStreamServerRepository); this.dbScope = NotNullOrThrow(dbScope); }

private readonly ICardStreamServerRepository cardStreamServerRepository;

private readonly IDbScope dbScope;

private static readonly ILogger logger = LogManager.GetCurrentClassLogger();

private async Task<(Guid? CardID, Guid? VersionRowID, string? FileName)> TryGetFileInfoAsync( Guid id, CancellationToken cancellationToken) { await using var _ = this.dbScope.Create(); this.dbScope.Db .SetCommand( this.dbScope.BuilderFactory .Select().C(null, "ID", "VersionRowID", "Name") .From("Files").NoLock() .Where().C("RowID").Equals().P("RowID") .Build(), this.dbScope.Db.Parameter("RowID", id)) .LogCommand();

await using var reader = await this.dbScope.Db.ExecuteReaderAsync(cancellationToken); if (!await reader.ReadAsync(cancellationToken)) { return (null, null, null); }

return (reader.GetGuid(0), reader.GetGuid(1), reader.GetString(2)); }

// запрос вида files/0e0a6ad9-15ea-482d-9d10-0a0bb3a054f1 [HttpGet("{id:guid}"), SessionMethod] [ProducesResponseType(StatusCodes.Status200OK)] public async Task<IActionResult> GetFile(Guid id, CancellationToken cancellationToken = default) { try { // получаем идентификатор карточки и последней версии файла, а также его имя из БД var (cardID, versionRowID, fileName) = await this.TryGetFileInfoAsync(id, cancellationToken); if (!cardID.HasValue) { throw new HttpStatusCodeException(HttpStatusCode.NotFound, "Файл не найден"); }

var request = new CardGetFileContentRequest { CardID = cardID, FileID = id, VersionRowID = versionRowID, FileName = fileName, ServiceType = CardServiceType.Client };

return await FileContentHelper.GetFileContentAsync( this.cardStreamServerRepository, this.Response, request, cancellationToken); } catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogException(ex, LogLevel.Warn);

// отображаем ошибку на странице в браузере return this.ErrorView(ex, "Ошибка доступа к файлу", caption: await LocalizeNameAsync("UI_Common_Dialog_Error", cancellationToken)); } } } }

  • Конструкторы в контроллерах могут получать зависимости как из DI-контейнера ASP.NET Core, так и из контейнера Unity для текущего запроса. В этом коде используются зависимости из Unity.

  • Метод TryGetFileInfoAsync по идентификатору файла, полученному из ссылки, загружает из базы данных информацию по: идентификатору карточки, идентификатору последней версии файла, актуальному имени файла. Эта информация требуется для выполнения запроса к API карточек, где одного идентификатора файла недостаточно.

  • Метод GetFile определяет маршрут для GET-метода, который к маршруту контроллера files добавляет идентификатор файла. Атрибут SessionMethod гарантирует, что выполнение доступно только для пользователя с валидной сессией.

  • Запрос на загрузку контента файлов CardGetFileContentRequest выполняется через стандартное API карточек, используя вспомогательный метод FileContentHelper.GetFileContentAsync - он формирует полученный файл в виде объекта FileStreamResult, понятного браузеру как скачиваемый файл с определённым именем.

  • Указание в запросе CardServiceType.Client обязательно для проверок доступа пользователя к файлу. Ошибки доступа и любые ошибки response.ValidationResult выбрасываются в виде исключения ValidationException, которое перехватывается ниже и выводится пользователю на экране ошибки.

  • Метод обработки ошибок this.ErrorView возвращает представление ASP.NET Core (т.е. страницу) с экраном ошибки для указанного исключения (пользователю показывается текст без стек-трейса). Вторым параметром передаётся заголовок окна.

  • Выбрасывание ошибки HttpStatusCodeException определяет, что страница с ошибкой возвращается с HTTP-кодом NotFound (для обычных исключений будет BadRequest). Пользователи не заметят разницу, но в браузере на вкладке “Сеть” будет отображён код для этого запроса, что может быть полезно для отладки и автоматизированных тестов.

Серверный редирект на экран логина

Если у пользователя отсутствует валидный токен сессии в cookies (в т.ч. если сессия истекла или была закрыта администратором), то web-клиент для обычных ссылок на представления и карточки выполняет редирект на экран логина с указанием в адресе параметра back_url, в котором закодирован путь, на который приложение должно перейти после успешного логина.

Рассмотрим, как достичь такого поведения для описываемых ссылок на файлы files/{id}. Для этого необходимо переопределить стандартную зависимость IClientPathParser в DI-контейнере ASP.NET Core.

Для определения зависимостей в DI-контейнере ASP.NET Core используется специальный тип регистраторов: классы с атрибутом [WebRegistrator], наследуемые от WebRegistratorBase.

Tip

В параметрах атрибута WebRegistrator можно указать порядок относительно других выполняемых регистраторов.

В методе RegisterServices доступен DI-контейнер Services, в котором выполняется регистрация после того, как все платформенные зависимости уже зарегистрированы. Регистрация по объявленным в платформе интерфейсам будет переопределять платформенную реализацию на вашу.

В проекте Tessa.Extensions.Server.Web добавьте файл AbClientPathParser.cs, в котором будет добавлен префикс маршрута /files/ в коллекцию RedirectableToLoginPrefixes. Это определяет, что маршруты, начинаемые с такого префикса, должны учитываться при редиректе на экран логина при отсутствии валидной сессии. Все добавляемые значения проверяются без учёта регистра.

Important

Важно все добавляемые префиксы снабжать начальным и конечным слэшами /.

using Microsoft.Extensions.DependencyInjection; using Tessa.Web.Client.Services; using Tessa.Web.Registrations; using Tessa.Web.Services;

namespace Tessa.Extensions.Server.Web.Services { public class AbClientPathParser : ClientPathParser { [WebRegistrator] public sealed class WebRegistrator : WebRegistratorBase { public override void RegisterServices() => this.Services.AddSingleton<IClientPathParser, AbClientPathParser>(); }

public AbClientPathParser(IWebPathParser webPathParser) : base(webPathParser) { this.RedirectableToLoginPrefixes.Add("/files/"); } } }

  • Для простоты класс регистратора WebRegistrator добавлен внутрь класса AbClientPathParser, т.к. он регистрирует единственную зависимость. Его возможно указать и как обычный класс в пространстве имён (по аналогии с классами Registrator).

  • Вы можете переопределить другие методы класса, например, добавив более сложную логику в метод IsRedirectableToLoginPathAsync. Следующее определение метода аналогично добавлению префикса в RedirectableToLoginPrefixes:

public override async ValueTask<bool> IsRedirectableToLoginPathAsync( string? requestPath, HttpContext context, ITessaWebScope scope, CancellationToken cancellationToken = default) => await base.IsRedirectableToLoginPathAsync(requestPath, context, scope, cancellationToken) || requestPath?.StartsWith("/files/", StringComparison.OrdinalIgnoreCase) == true;

Скомпилируйте библиотеку Tessa.Extensions.Server.Web и замените .dll файл на сервере.

Теперь при наличии валидной сессии в cookies файл должен корректно скачиваться браузером в “Загрузки”, а при отсутствии - выполняется редирект на экран логина по адресу вида https://server_name/login?back_url=files%2Ffe01eba3-a985-4a40-9e2b-92ed2f721778

Клиентский редирект по обратной ссылке

Если валидная сессия отсутствовала на момент выполнения ссылки files/{id}, после чего пользователь выполнил логин, то открывается приложение web-клиента, но не выполняется переход по обратной ссылке back_url для скачивания файла.

Клиентское приложение выполняет проверку ссылок, переданных в back_url, и разрешает переход только по стандартным ссылкам на карточки, представления и скопированным в карточках ссылкам на файлы. Для перехода по ссылке files/{id} напишем клиентский обработчик.

В папке проектных расширений web-клиента solution (в сборке она расположена в WebClient SDK/src/solution) создайте подпапку redirect. Добавьте в неё файл registrator.ts, который будет содержать регистрацию обработчика ссылок.

import { ExtensionRegistrator } from '@tessa/application'; import { PageLifecycleSingleton } from 'common'; import { ApiService } from 'tessa'; import { Application } from 'tessa/application';

export const AbRedirectRegistrator: ExtensionRegistrator = { async registerTypes() { Application.canRedirectTo = path => { // переход по путям "files/..." и "/files/..." выполняет запрос к серверу по этому пути (скачивает файл), // после чего открывает стандартное приложение const matches = path.match(/(^|\/)files\/([^\/]+)/); const result = !!(matches && matches.length > 2);

if (result) { PageLifecycleSingleton.instance.showConfirmBeforeUnload = false;

// выполняем запрос к серверу const redirectPath = path.startsWith('/') ? path.slice(1) : path; location.replace(ApiService.instance.getURL(redirectPath)); }

// false - открываем стандартное приложение (по корневому пути); // true - открывается страница по кастомному пути в web-клиенте (актуально, если путь - алиас для карточки или представления) return false; }; }, async registerExtensions(_container) {} };

  • Метод Application.canRedirectTo определяет логику проверки ссылки back_url после того, как она была проверена платформой и определено, что она не является стандартной ссылкой.

  • Код обработчика выше определяет, что если путь начинается с /files/ или files/, то требуется выполнить переход браузером на страницу из back_url относительно адреса web-клиента (который обычно указан в параметре GutFawkesAuth файла app.json на сервере). Например, если web-клиент по адресу https://server_name/tessa/web, то переход будет по адресу https://server_name/tessa/web/files/....

  • Если переход неудачен, то пользователю отобразится экран ошибки, иначе - будет скачан файл.

  • Далее метод возвращает false, чтобы определить, что далее необходимо штатно загрузить приложение web-клиента. Т.о. если не выполнен переход на экран ошибки, то помимо скаченного файла отобразится и рабочее место пользователя так, как если бы мы не переопределили обработчик.

Note

Если в методе вернуть true, то в web-клиенте должна также определяться логика отображения компонента по этой ссылке. Поскольку файл - не страница, то здесь возвращается false.

Теперь пропишите класс регистратора AbRedirectRegistrator в файле bundleRegistrator.ts в папке solution:

import { Application } from 'tessa/application'; import { AbRedirectRegistrator } from './redirect/registrator';

Application.instance.registerBundle({ name: 'Tessa.Extensions.Solution.js', buildTime: process.env.BUILD_TIME!, registry: [ // add registrators here AbRedirectRegistrator ] });

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

Back to top