Кастомизация ссылок на файлы 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
и публикации клиентских расширений на сервере вы можете убедиться, что задача скачивания файла по ссылке решена.