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

Временные ссылки на контент системы

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

Токены доступа к контенту

Контроль доступа к ресурсу осуществляется с помощью специального токена доступа, который на клиент передается в составе следующей структуры:

  • Token - токен доступа, который используется клиентом при запросе контента по ссылке.
  • Scope - область действия (скоуп) токена. Используется при валидации токена при получении контента.
  • Expires - срок действия токена.

В БД информация о токенах хранится в таблице Tokens, которая имеет следующую структуру:

  • ID - уникальный идентификатор строки.
  • Scope - скоуп токена.
  • Created - дата создания токена.
  • Expires - срок действия токена.
  • CreatedByID - идентификатор сотрудника, который создал токен.
  • UserID - идентификатор сотрудника, для которого был создан токен (может быть не заполнено).
  • RefID - идентификатор ресурса, к которому относится этот токен (может быть не заполнено).
  • TokenHash - хеш токена (может быть не заполнено).
  • Signature - подпись токена (может быть не заполнено).

Токены могут храниться двумя способами:

  1. В открытом виде (plain). Актуально для токенов, которые используются для доступа к нечувствительным данным. Токен доступа совпадает с идентификатором строки ID, хеш и подпись не вычисляются.
  2. В защищённом виде (protected). Актуально для токенов, которые используются для доступа к чувствительному контенту системы (файлы и т.п.). Токен доступа хешируется и записывается в поле TokenHash, сам токен в БД не сохраняется. Ключевые поля токена (скоуп, идентификатор ресурса, идентификатор сотрудника, срок действия токена) подписываются и записываются в поле Signature.

Запись protected токенов осуществляется по следующему алгоритму:

  1. Токен хешируется и записывается в поле TokenHash.
  2. Ключевые поля токена подписываются, подпись записывается в поле Signature.

Important

Для protected токенов в поле ID записывается уникальный идентификатор строки в таблице, который не совпадает с самим токеном, в то время как для plain токенов значение в поле ID равно токену.

Поиск protected токенов при выполнении запроса на получение контента осуществляется по следующему алгоритму:

  1. Переданный в запросе токен хешируется.
  2. Поиск в таблице осуществляется по полю TokenHash.
  3. Если запись для токена была найдена, то для его полей вычисляется подпись.
  4. Вычисленная подпись сравнивается с первоначальной сохраненной подписью.
  5. Если подписи совпадают, запись токена возвращается, иначе считается, что токен не найден.

Таким образом, поля protected токена не могут быть изменены после его создания.

Important

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

Помимо этого, можно выделить несколько видов токенов:

  1. Токены, привязанные к пользователю (UserExclusive). Такие токены выделяются для пользователя в единственном экземпляре в рамках единственного скоупа, и при запросе токена будет возвращен уже существующий токен, если срок его действия еще не истек, в противном случае выделяется новый токен. Могут храниться только в открытом виде (plain), так как должна быть возможность возвращать токен после его создания в течение некоторого периода. Такие токены актуальны для множества однотипных ресурсов, для каждого из которых не требуется обеспечить отдельный доступ (например, аватары пользователей).
  2. Токены, привязанные к ссылке на контент (ContentLinkExclusive). Такие токены выделяются каждый раз, когда нужно сформировать ссылку на контент. Актуальны в случае, когда к каждому ресурсу в рамках одного типа нужно обеспечить доступ отдельно (например, ссылки на файлы).

Срок действия токена выбирается для каждого типа контента индивидуально, исходя из принципов безопасности и необходимости кэширования контента по ссылке (подробнее в разделе Кэширование возвращаемого контента браузером).

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

Ссылки на контент

Ссылки на контент могут быть сформированы двумя способами:

  1. https://server_name/content/{type}/{contentID}?token={token} - ссылка такого вида подходит для использования пользователем в браузере, если при получении контента возникли ошибки, то они отобразятся в читаемом виде в html.
  2. https://server_name/api/v1/content/{type}/{contentID}?token={token} - такая ссылка предназначена для использования сервисами, так как является REST методом. Если при получении контента возникли ошибки, то в ответе на запрос будет указан соответствующий код ответа HTTP, а сами ошибки будут указаны в теле формата JSON.

Ссылка содержит следующие параметры:

  • type - тип контента
  • contentID - идентификатор контента
  • token - токен доступа

Токен доступа может не содержаться в строке запроса, тогда он должен быть указан в стандартном заголовке Authorization (подробней о том, на что влияет указание токена в строке запроса, в разделе Кэширование возвращаемого контента браузером).

Получение контента по временной ссылке осуществляется в три этапа:

  1. Выделение уникального токена для доступа к ресурсу. Этот этап происходит в рамках сессии пользователя, и здесь может осуществляться проверка прав на создание временной ссылки на контент.
  2. Формирование ссылки на контент.
  3. Получение контента по сформированной ссылке. На этом этапе проверяется валидность переданного токена и возврат запрошенного контента. Для доступа к контенту по ссылке не требуется наличия сессии TESSA, авторизация осуществляется по токену, переданному в запросе.

Серверная реализация обработчика контента в проектном решении

Рассмотрим пример создания обработчика контента в рамках проектного решения:

  • Контентом в данном примере будут аватары контрагентов.
  • Ограничим время действия токена одним днем.
  • Как и в случае аватаров пользователей, токен будет привязан к пользователю (UserExclusive).
  • Будем полагать, что файлы аватаров прикрепляются к карточке контрагента с названием “avatar.jpg|jpeg”, и размер такого файла не превышает 20 КиБ.
  • Если файла в карточке нет, возвращаем null (пример можно дополнить, чтобы возвращать некое изображение-плейсхолдер в случае, если аватар контрагента не задан. Это может быть как растровое изображение, так и векторное изображение в формате svg).

Обработка ссылок на контент осуществляется на уровне платформы, в серверной части проектного решения необходимо:

  1. Определить тип для контента (не требует регистрации).
  2. Реализовать интерфейс обработчика IContentHandler, который содержит всю необходимую логику по созданию токена и возврату запрашиваемого контента.
  3. Зарегистрировать обработчик в контейнере Unity с именем, соответствующим типу контента.

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

namespace Tessa.Extensions.Shared { public static class AbContentTypes { /// <summary> /// Тип контента для аватаров контрагентов. /// </summary> public const string PartnerAvatar = "partner-avatar"; } }

В проекте Tessa.Extensions.Server создайте подпапку Content, в которую добавьте интерфейс IPartnerAvatarContentTokenProvider:

using Tessa.Content;

namespace Tessa.Extensions.Server.Content { /// <summary> /// Предоставляет токен для получения контента аватаров контрагентов. /// </summary> public interface IPartnerAvatarContentTokenProvider : IUserExclusiveContentTokenProvider { } }

В данном интерфейсе можно описать дополнительную логику получения токена, которая не описана в IUserExclusiveContentTokenProvider, который является провайдером токенов типа UserExclusive.

В этой же подпапке добавьте класс-реализацию этого интерфейса PartnerAvatarContentTokenProvider:

using System; using Tessa.Content; using Tessa.Platform.Runtime;

namespace Tessa.Extensions.Server.Content { public sealed class PartnerAvatarContentTokenProvider : UserExclusiveContentTokenProvider, IPartnerAvatarContentTokenProvider { #region Constants

/// <summary> /// Срок действия токена. /// </summary> private const int ExpirationDaysCount = 3;

/// <summary> /// Название скоупа. /// </summary> private const string ScopeName = "partner-avatar";

#endregion

#region Constructor

public PartnerAvatarContentTokenProvider( IUserExclusiveContentTokenRepository tokenRepository, IContentAccessTokenGenerator tokenGenerator, ISession session) : base(new(ScopeName, TimeSpan.FromDays(ExpirationDaysCount)), tokenRepository, tokenGenerator, session) { }

#endregion } }

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

Далее в текущую подпапку добавьте класс-обработчик для контента AbContentTypes.PartnerAvatar PartnerAvatarContentHandler:

using LinqToDB; using System; using System.Linq; using System.Net.Mime; using System.Threading; using System.Threading.Tasks; using Tessa.Cards; using Tessa.Content; using Tessa.Platform; using Tessa.Platform.Data; using Tessa.Platform.Runtime; using Tessa.Platform.Storage; using Tessa.Platform.Validation;

namespace Tessa.Extensions.Server.Content { /// <summary> /// Обработчик контента с типом <see cref="AbContentScopes.PartnerAvatar"/>. /// </summary> public sealed class PartnerAvatarContentHandler : IContentHandler { #region Constants

private const int MaxImageSizeInBytes = 20 * 1024;

#endregion

#region Fields

private readonly IDbScope dbScope; private readonly ISession session; private readonly IPartnerAvatarContentTokenProvider tokenProvider; private readonly ICardStreamServerRepository cardStreamServerRepository;

private static readonly string[] avatarPatterns = new[] { "avatar.jpg", "avatar.jpeg" };

#endregion

#region Constructor

public PartnerAvatarContentHandler( IDbScope dbScope, ISession session, IPartnerAvatarContentTokenProvider tokenProvider, ICardStreamServerRepository cardStreamServerRepository) { this.dbScope = NotNullOrThrow(dbScope); this.session = NotNullOrThrow(session); this.tokenProvider = NotNullOrThrow(tokenProvider); this.cardStreamServerRepository = NotNullOrThrow(cardStreamServerRepository); }

#endregion

#region IContentHandler

/// <inheritdoc/> public bool AllowTokenRequestFromClient => true;

/// <inheritdoc/> public async Task<ContentResponse> GetContentAsync(Guid contentID, string tokenID, CancellationToken cancellationToken = default) { ThrowIfNullOrEmpty(tokenID);

var validationResultBuilder = new ValidationResultBuilder();

// Пытаемся получить валидный токен по его идентификатору var tokenInfo = await this.tokenProvider.TryGetTokenInfoAsync(tokenID, validationResultBuilder, cancellationToken); if (tokenInfo is null) { return new(validationResultBuilder); }

// Проверяем, что запрашивается аватар существующего контрагента if (!await this.CheckIfPartnerExistsAsync(contentID, cancellationToken)) { validationResultBuilder.AddError(this, $"Контрагент с идентификатором {contentID} не существует.", cancellationToken); return new(validationResultBuilder); }

// Получаем контент аватара и возвращаем его var (content, contentValdationResult) = await this.GetContentAsync(contentID, cancellationToken); validationResultBuilder.Add(contentValdationResult);

return new(validationResultBuilder, content, GetCacheOptions(tokenInfo)); }

/// <inheritdoc/> public async Task<ContentTokenResponse> GetTokenAsync(ContentTokenRequest request, CancellationToken cancellationToken = default) { ThrowIfNull(request);

var forceReissue = request.Info.TryGet<bool?>("ForceReissue") ?? false;

var validationResultBuilder = new ValidationStorageResultBuilder();

var tokenRequest = new UserExclusiveContentTokenRequest { UserID = this.session.User.ID, ForceReissue = forceReissue };

// Запрашиваем токен для текущего пользователя var tokenInfo = await this.tokenProvider.GetOrCreateTokenInfoAsync(tokenRequest, validationResultBuilder, cancellationToken);

return new(validationResultBuilder, tokenInfo?.ToAccessToken()); }

#endregion

#region Private methods

private async Task<bool> CheckIfPartnerExistsAsync(Guid partnerID, CancellationToken cancellationToken = default) { await using var _ = this.dbScope.Create();

var query = this.dbScope.BuilderFactory .SelectExists(b => b .Select().V(null) .From("Partners").NoLock() .Where().C("ID").Equals().P("PartnerID") ).Build();

return await this.dbScope.Db .SetCommand(query, this.dbScope.Db.Parameter("PartnerID", partnerID, DataType.Guid)) .LogCommand() .ExecuteAsync<bool>(cancellationToken); }

private async Task<(ContentResult? Result, ValidationResult ValidationResult)> GetContentAsync( Guid partnerID, CancellationToken cancellationToken = default) { var fileRequest = await this.GetSourceFileContentRequestAsync(partnerID, cancellationToken); if (fileRequest is null) { return (null, ValidationResult.Empty); }

var fileContentResult = await this.cardStreamServerRepository.GetFileContentAsync(fileRequest, cancellationToken); var validationResult = fileContentResult.Response.ValidationResult.Build();

if (!validationResult.IsSuccessful || !fileContentResult.HasContent) { return (null, validationResult); }

var stream = await fileContentResult.GetContentOrThrowAsync(cancellationToken); var contentResult = new ContentResult(stream, fileRequest.FileName!, MediaTypeNames.Image.Jpeg);

return (contentResult, ValidationResult.Empty); }

private async Task<CardGetFileContentRequest?> GetSourceFileContentRequestAsync( Guid partnerID, CancellationToken cancellationToken = default) { await using var _ = this.dbScope.Create();

var query = this.dbScope.BuilderFactory .Select().Top(1) .C("f", "ID").As(nameof(CardGetFileContentRequest.CardID)) .C("f", "RowID").As(nameof(CardGetFileContentRequest.FileID)) .C("f", "VersionRowID").As(nameof(CardGetFileContentRequest.VersionRowID)) .C("f", "Name").As(nameof(CardGetFileContentRequest.FileName)) .C("f", "TypeID").As(nameof(CardGetFileContentRequest.FileTypeID)) .C("f", "TypeCaption").As(nameof(CardGetFileContentRequest.FileTypeName)) .From("Files", "f").NoLock() .InnerJoin("FileVersions", "fv").NoLock() .On().C("fv", "RowID").Equals().C("f", "VersionRowID") .Where().C("f", "ID").Equals().P("PartnerID") .And().LowerC("f", "Name").In(avatarPatterns.AsEnumerable()) .And().C("fv", "Size").Less().P("MaxSize") .Limit(1) .Build();

return await this.dbScope.Db .SetCommand(query, this.dbScope.Db.Parameter("PartnerID", partnerID, DataType.Guid), this.dbScope.Db.Parameter("MaxSize", MaxImageSizeInBytes, DataType.Int32)) .LogCommand() .ExecuteAsync<CardGetFileContentRequest>(cancellationToken); }

private static ContentCacheOptions GetCacheOptions(IContentTokenInfo token) { var maxAge = (int) ((token.Expires - DateTime.UtcNow).TotalSeconds); // кешируем на время остатка жизни токена

return new() { MaxAge = maxAge > 0 ? maxAge : 0 }; }

#endregion } }

Класс регистратора Registrator добавьте в ту же подпапку:

using Tessa.Content; using Tessa.Extensions.Shared; using Unity;

namespace Tessa.Extensions.Server.Content { [Registrator] public sealed class Registrator : RegistratorBase { public override void RegisterUnity() => this.UnityContainer .RegisterSingleton<IPartnerAvatarContentTokenProvider, PartnerAvatarContentTokenProvider>() .RegisterSingleton<IContentHandler, PartnerAvatarContentHandler>(AbContentTypes.PartnerAvatar); } }

Important

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

  • Для часто запрашиваемого контента можно добавить кэширование в памяти процесса (для кэширования можно использовать ICardCache.Settings).

  • В текущем примере используется наследник класса UserExclusiveContentTokenProvider, который обеспечивает управление токенами типа UserExclusive и их кэширование в памяти. Если UserExclusiveContentTokenProvider не используется, но нужно дополнительно обеспечить кэширование токенов в памяти, используйте ContentTokenInMemoryCache. (подробнее в разделе Интерфейсы для доступа к общей функциональности)

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

В проекте Tessa.Extensions.Server создайте подпапку Initialization, в которую добавьте расширение PartnerAvatarContentTokenServerInitializationExtension:

using NLog; using System.Threading.Tasks; using Tessa.Content; using Tessa.Extensions.Server.Content; using Tessa.Platform; using Tessa.Platform.Initialization; using Tessa.Platform.Runtime; using Tessa.Platform.Storage; using Tessa.Platform.Validation;

namespace Tessa.Extensions.Server.Initialization { public sealed class PartnerAvatarContentTokenServerInitializationExtension : ServerInitializationExtension { #region Fields

private readonly IPartnerAvatarContentTokenProvider contentTokenProvider; private readonly ISession session;

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

#endregion

#region Constructor

public PartnerAvatarContentTokenServerInitializationExtension( IPartnerAvatarContentTokenProvider contentTokenProvider, ISession session) { this.contentTokenProvider = NotNullOrThrow(contentTokenProvider); this.session = NotNullOrThrow(session); }

#endregion

#region Overrides

public override async Task AfterRequest(IServerInitializationExtensionContext context) { if (!context.RequestIsSuccessful) { return; }

var tokenRequest = new UserExclusiveContentTokenRequest { UserID = this.session.User.ID };

var validationResult = new ValidationResultBuilder(); var token = await this.contentTokenProvider.GetOrCreateTokenInfoAsync(tokenRequest, validationResult, context.CancellationToken);

logger.LogResult(validationResult);

context.Response!.Info["PartnerAvatarContentToken"] = token?.ToAccessToken().ToSerializedDictionary(); }

#endregion } }

Класс регистратора Registrator добавьте в ту же подпапку:

using Tessa.Platform.Initialization; using Tessa.Platform.Runtime; using Unity;

namespace Tessa.Extensions.Server.Initialization { [Registrator] public sealed class Registrator : RegistratorBase { public override void RegisterUnity() { this.UnityContainer .RegisterSingleton<PartnerAvatarContentTokenServerInitializationExtension>(); }

public override void RegisterExtensions(IExtensionContainer extensionContainer) { extensionContainer .RegisterExtension<IServerInitializationExtension, PartnerAvatarContentTokenServerInitializationExtension>(x => x .WithOrder(ExtensionStage.AfterPlatform, 1) .WithUnity(this.UnityContainer) .WhenApplications(ApplicationIdentifiers.WebClient)); } } }

Расширения web-клиента для использования ссылок на контент

Реализуем клиентскую логику для отображения аватаров контрагентов.

В папке проектных расширений web-клиента solution (в сборке она расположена в WebClient SDK/src/solution) создайте подпапку content. Добавьте в неё файл abContentTypes.ts с константой PartnerAvatar, аналогичной реализации в C# AbContentTypes.cs:

/** * @helper */ export namespace AbContentTypes { export const partnerAvatar = 'partner-avatar'; }

В эту же подпапку добавьте файл contentTypes.ts, который будет содержать описание типов:

import { ValidationResult } from '@tessa/core'; import { IUserExclusiveContentTokenManager } from '@tessa/platform';

/** Manages partner avatar tokens operations. */ export interface IPartnerAvatarTokenManager extends IUserExclusiveContentTokenManager {}

/** Manages common avatar operations. */ export interface IPartnerAvatarManager { /** * Returns partner avatar link for partner {@link partnerId} * @param partnerId Partner identifier. */ getAvatarLink(partnerId: string): Promise<[string | null, ValidationResult]>; }

Менеджер токенов IPartnerAvatarTokenManager обеспечивает хранение пользовательского токена, кроме этого он запрашивает новый токен, когда срок действия текущего подходит к концу. Менеджер аватаров IPartnerAvatarManager формирует ссылку на аватар контрагента, используя токен из IPartnerAvatarTokenManager. Функциональность обоих менеджеров можно расширить при необходимости.

Сначала реализуем менеджер токенов, для этого добавьте файл partnerAvatarTokenManager.ts:

import { inject, injectable } from '@tessa/application'; import { IContentService, IContentService$, UserExclusiveContentTokenManager } from '@tessa/platform'; import { AbContentTypes } from './abContentTypes'; import { IPartnerAvatarTokenManager } from './contentTypes';

@injectable() export class PartnerAvatarTokenManager extends UserExclusiveContentTokenManager implements IPartnerAvatarTokenManager { //#region constructor

constructor(@inject(IContentService$) contentService: IContentService) { super(contentService, AbContentTypes.partnerAvatar); }

//#endregion }

После этого добавьте файл самого менеджера partnerAvatarManager.ts:

import { IApiClient, IApiClient$, inject, injectable } from '@tessa/application'; import { ValidationResult } from '@tessa/core'; import { AbContentTypes } from './abContentTypes'; import { IPartnerAvatarTokenManager$ } from './contentInjects'; import { IPartnerAvatarManager, IPartnerAvatarTokenManager } from './contentTypes';

@injectable() export class PartnerAvatarManager implements IPartnerAvatarManager { //#region ctor

constructor( @inject(IPartnerAvatarTokenManager$) private readonly _tokenManager: IPartnerAvatarTokenManager, @inject(IApiClient$) private readonly _apiClient: IApiClient ) {}

//#endregion

//#region IPartnerAvatarManager

async getAvatarLink(partnerId: string): Promise<[string | null, ValidationResult]> { const [tokenInfo, validationResult] = await this._tokenManager.getTokenInfo(); if (!tokenInfo) { return [null, validationResult]; }

const url = await this._apiClient.getUrl( `/content/${AbContentTypes.partnerAvatar}/${partnerId}`, { searchParams: { token: tokenInfo.token } } );

return [url, ValidationResult.empty]; }

//#endregion }

Оба менеджера должны быть зарегистрированы в DI-контейнере, для этого сначала добавьте файл contentInjects.ts:

import { createInjectToken } from '@tessa/application'; import { IPartnerAvatarManager, IPartnerAvatarTokenManager } from './contentTypes';

/** @category injects */ export const IPartnerAvatarTokenManager$ = createInjectToken<IPartnerAvatarTokenManager>( 'IPartnerAvatarTokenManager' );

/** @category injects */ export const IPartnerAvatarManager$ = createInjectToken<IPartnerAvatarManager>('IPartnerAvatarManager');

А после регистратор abContentRegistrator.ts:

import { ExtensionRegistrator } from '@tessa/application'; import { IPartnerAvatarManager$, IPartnerAvatarTokenManager$ } from './contentInjects'; import { PartnerAvatarManager } from './partnerAvatarManager'; import { PartnerAvatarTokenManager } from './partnerAvatarTokenManager';

export const AbContentRegistrator: ExtensionRegistrator = { async registerTypes(container) { container.bind(IPartnerAvatarManager$).to(PartnerAvatarManager).inSingletonScope(); container.bind(IPartnerAvatarTokenManager$).to(PartnerAvatarTokenManager).inSingletonScope(); } };

Important

Все регистраторы нужно добавлять в bundleRegistrator.ts.

Для удобства получения ссылки на аватар добавим хелперный метод. Создайте файл abContentLinks.ts в текущей подпапке content:

import { ValidationResult } from '@tessa/core'; import { IPartnerAvatarManager$ } from './contentInjects';

/** * @helper */ export namespace AbContentLinks { /** * Returns partner avatar link for partner with identifier {@link partnerId}. * @param userId User identifier. */ export async function getPartnerAvatarLink( partnerId: string ): Promise<[string | null, ValidationResult]> { const manager = window.tessa.diContainer.get(IPartnerAvatarManager$); return await manager.getAvatarLink(partnerId); } }

Так как токен передается при инициализации с сервера, реализуем расширение для его получения и установки на клиенте. В папке проектных расширений web-клиента solution создайте подпапку initialization и добавьте файл partnerAvatarContentTokenInitializationExtension.ts:

import { ContentAccessTokenInfo, IInitializationExtensionContext, InitializationExtension } from '@tessa/platform'; import { extension, inject } from '@tessa/application'; import { StorageAccessor } from '@tessa/core'; import { IPartnerAvatarTokenManager$ } from '../content/contentInjects'; import { IPartnerAvatarTokenManager } from '../content/contentTypes';

@extension({ name: 'PartnerAvatarContentTokenInitializationExtension' }) export class PartnerAvatarContentTokenInitializationExtension extends InitializationExtension { //#region constructors

constructor( @inject(IPartnerAvatarTokenManager$) private readonly _tokenManager: IPartnerAvatarTokenManager ) { super(); }

//#endregion

//#region base overrides

public override async afterRequest(context: IInitializationExtensionContext): Promise<void> { const info = context.response?.tryGetInfo(); if (!info) { return; }

const storageAccessor = new StorageAccessor(info); const tokenInfo = storageAccessor.tryGetObject('PartnerAvatarContentToken', storage => new ContentAccessTokenInfo().deserializeFromStorage(storage) );

if (tokenInfo) { await this._tokenManager.setTokenInfo(tokenInfo); } }

//#endregion }

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

Добавьте файл регистратора abInitializationRegistrator.ts в эту же подпапку:

import { ExtensionRegistrator, ExtensionStage } from '@tessa/application'; import { PartnerAvatarContentTokenInitializationExtension } from './partnerAvatarContentTokenInitializationExtension';

export const AbInitializationRegistrator: ExtensionRegistrator = { async registerTypes() {}, async registerExtensions(container) { container.registerExtension({ extension: PartnerAvatarContentTokenInitializationExtension, stage: ExtensionStage.AfterPlatform, singleton: true }); } };

Добавьте регистратор в bundleRegistrator.ts.

Для демонстрации применения ссылок на аватары контрагентов создадим расширение на узел рабочего места, которое будет добавлять в ячейку представления “Контрагенты” аватар контрагента, указанного в строке представления.

Для этого в папке проектных расширений web-клиента solution создайте подпапку views и добавьте файл partnerAvatarViewExtension.ts:

import { WorkplaceViewComponentExtension } from 'tessa/ui/views/extensions'; import { IWorkplaceViewComponent, StandardViewComponentContentItemFactory } from 'tessa/ui/views'; import { TableGridViewModelBase } from 'tessa/ui/views/content'; import { extension } from '@tessa/application'; import React, { FC, useEffect, useState } from 'react'; import { AbContentLinks } from '../content/abContentLinks';

@extension({ name: 'PartnerAvatarViewExtension' }) export class PartnerAvatarViewExtension extends WorkplaceViewComponentExtension { public getExtensionName(): string { return 'PartnerAvatarViewExtension'; }

// расширение выполняется только для представления "Контрагенты" public shouldExecute(model: IWorkplaceViewComponent): boolean { return model.getViewMetadata(model)?.alias === 'Partners'; }

public initialize(model: IWorkplaceViewComponent): void { const tableFactory = model.contentFactories.get(StandardViewComponentContentItemFactory.Table); if (!tableFactory) { return; }

model.contentFactories.set(StandardViewComponentContentItemFactory.Table, c => { const vm = tableFactory(c); if (vm instanceof TableGridViewModelBase) { // переопределим метод создания ячейки представления const initAction = vm.createCellAction; vm.createCellAction = options => { const cell = initAction(options); // добавляем аватар в ячейку "Краткое наименование" if ( options.column.columnName === 'PartnerName' && (!options.value || typeof options.value === 'string') ) { // получаем идентификатор контрагента из соответствующей колонки представления const partnerId = options.row.data.get('PartnerID'); if (partnerId && typeof partnerId === 'string') { // переопределяем метод получения контента ячейки на кастомный компонент cell.getContent = () => ( <PartnerAvatar partnerId={partnerId} partnerName={options.value} /> ); } }

return cell; }; }

return vm; }); } }

// Компонент для отображения аватара и наименования контрагента const PartnerAvatar: FC<{ partnerId: string; partnerName: string }> = ({ partnerId, partnerName }) => { const [partnerLink, setPartnerLink] = useState<string | null>(null);

// асинхронно получаем ссылку на аватар useEffect(() => { AbContentLinks.getPartnerAvatarLink(partnerId).then(([link]) => { setPartnerLink(link); }); }, [partnerId]);

if (!partnerLink) { return <span>{partnerName}</span>; }

return ( <div style={{ display: 'flex', gap: '5px', alignItems: 'center' }}> <img src={partnerLink} width="44" height="44" style={{ borderRadius: '100%' }} /> <span>{partnerName}</span> </div> ); };

После этого добавьте в эту же подпапку регистратор abViewsRegistrator.ts:

import { ExtensionRegistrator, ExtensionStage } from '@tessa/application'; import { PartnerAvatarViewExtension } from './partnerAvatarViewExtension';

export const AbViewsRegistrator: ExtensionRegistrator = { async registerTypes() {}, async registerExtensions(container) { container.registerExtension({ extension: PartnerAvatarViewExtension, stage: ExtensionStage.AfterPlatform, singleton: true }); } };

Добавьте регистратор в bundleRegistrator.ts.

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

Интерфейсы для доступа к общей функциональности

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

  • IContentAccessTokenGenerator - генерирует уникальные токены доступа.
  • IContentTokenSignatureProvider - предоставляет методы для подписания и проверки подписи для данных токена.
  • IContentTokenRepository - репозиторий для управления токенами доступа. Предоставляет методы для получения, сохранения и удаления токенов. В платформе для него зарегистрированы две именованные реализации для управления plain и protected токенами - nameof(ContentTokenType.Plain) и nameof(ContentTokenType.Protected) соответственно.
  • IContentTokenProvider - провайдер токенов доступа, надстройка над IContentTokenRepository. Предоставляет методы для создания, получения и удаления токенов. В платформе для него зарегистрированы две именованные реализации для управления plain и protected токенами - nameof(ContentTokenType.Plain) и nameof(ContentTokenType.Protected) соответственно.
  • IUserExclusiveContentTokenRepository - репозиторий для управления plain токенами доступа типа UserExclusive. Обеспечивает эксклюзивность токена для пользователя в рамках определенного скоупа.
  • IUserExclusiveContentTokenProvider - провайдер токенов доступа типа UserExclusive, его базовая реализация UserExclusiveContentTokenProvider обеспечивает кэширование токенов в памяти. Зависит от IUserExclusiveContentTokenRepository и является его надстройкой.
  • ContentTokenInMemoryCache - потокобезопасный кэш токенов доступа в памяти процесса.

Контент, который используется на клиенте и часто им запрашивается (например, аватары пользователей), целесообразно кэшировать на стороне браузера, чтобы снизить нагрузку на сервер и обеспечить максимально быстрое отображение для пользователя.

Для этого при возврате контента в обработчике IContentHandler в параметре CacheOptions можно указать количество секунд, в течение которых контент может быть получен из кэша браузера. Если этот параметр указан и отличен от 0, в заголовки ответа будет добавлен заголовок Cache-Control с указанным значением. При этом стоит учитывать следующее:

  • Кэширование происходит по ссылке. Пока ссылка на контент остается неизменной, контент может быть получен из кэша.
  • Единственным изменяемым параметром в ссылке на контент, который уникально определяется скоупом и идентификатором контента, является токен.
  • На период действия, указанный в CacheOptions, контент будет браться из кэша, даже несмотря на то, что сам контент на сервере может быть изменен, так как ссылка на контент изменяется только с изменением токена.
  • Если необходимо, чтобы клиент получил обновленный контент сразу, то нужно принудительно сбросить текущий токен и создать новый (в примере выше при запросе токена для этого использовался параметр ForceReissue), после чего переформировать ссылку с новым токеном.
  • Стоит учитывать, что если токен является UserExclusive, то он указывается в ссылках на все ресурсы в рамках скоупа, соответственно, при изменении токена обновятся все ссылки для этого скоупа, и браузер будет вынужден запросить контент по всем ссылкам заново.
  • Выбор срока действия токена для кэшируемого контента является компромиссом между производительностью и сроком получения обновленного контента.
  • Ссылка на контент формируется вручную. Если ссылка формируется на клиенте, то необходимо отслеживать срок действия токена и получать новый токен по истечении срока действия текущего.
  • Если контент закэширован браузером, то он может быть получен пользователем, даже если указанный в ссылке токен был сброшен.

Мониторинг запросов получения контента по ссылке

Для запросов получения контента по ссылке в платформе реализованы счетчики производительности и источник данных для трассировки (подробнее в разделе Веб-сервис Monitor для диагностики и трассировки).

Список счетчиков производительности:

  • total-content-by-link-requests - количество запросов на получение контента по ссылке.
  • total-content-by-link-requests-duration - суммарное время выполнения запросов на получение контента по ссылке в миллисекундах.
  • current-content-by-link-requests - количество запросов на получение контента по ссылке в данный момент.

Все счетчики имеют параметр ContentType, в котором указан тип запрашиваемого контента.

Чтобы отследить среднее время выполнения запросов за некоторый период (например, 5 минут), можно использовать следующее выражение:
rate(total-content-by-link-requests-duration[5m]) / rate(total-content-by-link-requests[5m]).

Для трассировки запросов доступен источник трассировки GetContentByLink, который предоставляет специфичные для контента тэги:

  • ContentType - тип запрашиваемого контента.
  • Token - замаскированный токе доступа, который был использован для получения контента.

Пример результата трассировки получения контента аватара по ссылке:

Back to top