Skip to content

Document Service

DocumentAppService is the read/streaming surface for documents attached to Hub entities (shipments and invoices). A Document row holds only metadata in SQL Server; the binary content lives in Azure Blob Storage. The service resolves a document's blob path, enforces organization-level access, streams the bytes back, and — for browser inline viewing — mints short-lived view tokens that allow an unauthenticated GET of a single document.

Overview

App service modules/hub/src/Hub.Application/Services/DocumentAppService.cs
Base class HubEntityBaseService<Document, DocumentDto, DocumentAppService>
Read permission HubPermissions.Document.View
DTO DocumentDto (generated partial; namespace Hub.Application.Contracts.Shipments)
Blob abstraction IStorageService, keyed BlobStorageContainer.Document ("documents")
Blob implementation modules/hub/src/Hub.Application/Services/DocumentStorageService.cs (Azure Blob SDK)
Token cache IDistributedCache (Redis), key doc-view:{token}, 5-min TTL
Entity abstract Document : CustomerOwnedGuidEntity (modules/hub/src/Hub.Domain/Entities/Document.cs)

This is read-only over HTTP; writes happen on the SPARK path

DocumentAppService derives from HubEntityBaseService, which implements ABP's IReadOnlyAppService<DocumentDto, Guid, …>. It exposes no create/update/delete endpoints. Blob uploads are performed elsewhere — see Writing blobs.

Not ABP BlobStoring

Although Volo.Abp.BlobStoring.* packages exist in Directory.Packages.props, the document path does not use ABP's BLOB storing abstraction (IBlobContainer, AbpBlobStoringOptions). It uses the Azure Blob SDK directly behind a custom IStorageService. Don't reach for IBlobContainer when working on documents.

Storage abstraction

IStorageService (keyed singleton)

The storage contract is intentionally tiny — upload and download a stream by blob name:

// modules/hub/src/Hub.Application.Contracts/Services/IStorageService.cs
public interface IStorageService : ISingletonDependency
{
    Task UploadAsync(string path, byte[] content, CancellationToken ct = default);
    Task UploadAsync(string path, Stream content, CancellationToken ct = default);
    Task<Stream> GetBlobStreamAsync(string blobName);
}

The implementation wraps an Azure BlobContainerClient and is registered as a keyed service so callers can ask for the storage bound to a specific container (today only "documents"):

// modules/hub/src/Hub.Application/Services/DocumentStorageService.cs
[ExposeKeyedService<IStorageService>(BlobStorageContainer.Document)]
public class DocumentStorageService(BlobServiceClient client) : IStorageService
{
    private readonly BlobContainerClient _containerClient =
        client.GetBlobContainerClient(BlobStorageContainer.Document)
        ?? throw new Exception("Unable to get blob container client");

    public async Task UploadAsync(string path, Stream content, CancellationToken ct = default)
        => await _containerClient.UploadBlobAsync(path, content, ct)!;

    public async Task<Stream> GetBlobStreamAsync(string blobName)
    {
        var blobClient = _containerClient.GetBlobClient(blobName)
            ?? throw new BusinessException("HUB:BlobNotFound", $"Blob client is null for: {blobName}");
        Response<BlobDownloadStreamingResult> download = await blobClient.DownloadStreamingAsync()!;
        return download.Value.Content!;
    }
}

BlobStorageContainer.Document is the constant "documents" (modules/hub/src/Hub.Domain.Shared/Consts/BlobStorageContainer.cs). The [ExposeKeyedService<T>] attribute is the ABP DI convention for keyed services; consumers inject with [FromKeyedServices(BlobStorageContainer.Document)] IStorageService ….

BlobServiceClient registration

The underlying BlobServiceClient is registered once in the Hub application module via Microsoft.Extensions.Azure, using the BlobStorage connection string:

// modules/hub/src/Hub.Application/HubApplicationModule.cs
context.Services.AddAzureClients(clientConfig =>
{
    clientConfig.AddBlobServiceClient(
        context.Configuration.GetRequiredConnectionString(ConnectionStringNames.BlobStorage));
});

ConnectionStringNames.BlobStorage resolves to the literal "BlobStorage" (src/Cargonerds.Domain.Shared/Configuration/ConnectionStringNames.cs). Locally this points at Azurite (devstoreaccount1); in the cloud it is a real *.blob.core.windows.net account. See Configuration and Appsettings.

Blob naming convention

There is no stored blob URL on the entity. The blob name is deterministically derived from the document and its parent, so the same string can be recomputed on both the read and write sides:

{CargonerdsCustomerId}/{ParentType}/{ParentId}/{Id}_{FileName}

ParentType is "shipments" for ShipmentBaseDocument and "invoices" for InvoiceDocument. The read side builds it from the cached DocumentData (BlobName computed property), and the SPARK write side builds the identical string by hand — see Gotchas.

Methods

Method Authorization HTTP Description
GetAsync(string id) Document.View (read permission) not exposed ([RemoteService(IsEnabled = false)]) Overrides the base metadata getter; remote-disabled
GetAsync(Guid id) [Authorize(Document.View)] GET /api/app/document/{id} Returns the raw document Stream from blob storage
CreateViewTokenByIdAsync(Guid documentId) [Authorize(Document.View)] POST /api/app/document/view-token-by-id/{documentId} Issues a 5-minute view token stored in Redis
GetStreamByTokenAsync(string token, bool download = false) [AllowAnonymous] GET /api/app/document/stream-by-token?token=…&download=… Streams the document by token (inline, or as attachment when download=true)
GetListAsync(...) (inherited) Document.View GET /api/app/document Paged metadata list, scoped via WithListDetailsAsync()

Auto-API routing

Routes follow ABP auto API controller conventions: Get*GET, Create*POST, and a leading Guid parameter becomes a path segment. The [RemoteService(IsEnabled = false)] on GetAsync(string id) (and on the base GetAsync(Guid)) suppresses those overloads from the generated client/controller, leaving the stream-returning GetAsync(Guid id) as the document download endpoint.

Reading and access control

GetAsync(Guid id) does not blindly read a blob — it resolves the document, checks the caller's organization access, and only then streams:

// modules/hub/src/Hub.Application/Services/DocumentAppService.cs
[Authorize(HubPermissions.Document.View)]
public async Task<Stream> GetAsync(Guid id)
{
    var data = await ResolveDocumentDataAsync(id);     // metadata + access check
    return await GetBlobStreamAsync(data.BlobName);    // -> IStorageService.GetBlobStreamAsync
}

ResolveDocumentDataAsync loads the Document (no-tracking), maps it to its parent, and — when the parent resolves to a shipment — calls CheckAccessAsync:

private static (Guid ParentId, Guid? ShipmentId, string ParentType) ResolveParentInfo(Document doc) =>
    doc switch
    {
        ShipmentBaseDocument sbd => (sbd.ShipmentBaseId, sbd.ShipmentBaseId, "shipments"),
        InvoiceDocument ind     => (ind.InvoiceId, ind?.Invoice?.ShipmentId, "invoices"),
        _ => throw new BusinessException("HUB:UnknownDocumentType",
                                         $"Unknown document type: {doc.GetType().Name}"),
    };

private async Task CheckAccessAsync(Guid shipmentId)
{
    bool hasAccess =
        await (await Get<IShipmentBaseRepository>().GetQueryableAsync())
            .Where(s => s.Id == shipmentId)
            .FilterAll(LazyServiceProvider)   // applies the org-unit visibility filter
            .CountAsync(CancellationToken) > 0;
    if (!hasAccess)
        throw new AbpAuthorizationException("No access to shipment").WithData("Id", shipmentId);
}

Two layers of protection apply here:

  • The Document.View permission is checked by ABP authorization ([Authorize] / ReadPermission). See Authorization.
  • Organization-unit visibility is enforced by .FilterAll(...) on the shipment query: if the current user cannot see the shipment that owns the document, the count is 0 and an AbpAuthorizationException is thrown. This is the Hub's manual (non-auto-active) org filter — the same mechanism HubEntityBaseService uses for list queries. See Authorization and Repositories.

Customer (tenant-like) isolation is additionally enforced automatically at the HubDbContext level via the always-on CustomerOwnedFilter global query filter, so a Document from a different CargonerdsCustomerId is never returned in the first place.

The inherited list path is narrowed to documents the user may see by overriding WithListDetailsAsync() so it walks from accessible shipments to their documents rather than querying Document directly:

protected override async Task<IQueryable<Document>> WithListDetailsAsync()
{
    return (await Get<IShipmentBaseRepository>().GetQueryableAsync())
        .FilterAll(LazyServiceProvider)
        .SelectMany(s => s.Documents);
}

View-token flow (anonymous inline viewing)

Browsers cannot attach a bearer token to a plain <img>/<iframe>/<embed> request, so the UI first asks for a single-purpose token, then points the element at the anonymous stream-by-token endpoint.

sequenceDiagram
    autonumber
    participant UI as Blazor UI
    participant API as DocumentAppService
    participant Redis as IDistributedCache (Redis)
    participant Blob as Azure Blob (documents)

    UI->>API: POST view-token-by-id/{documentId}  (Bearer token)
    API->>API: ResolveDocumentDataAsync (permission + org access check)
    API->>Redis: SET doc-view:{token} = DocumentData JSON (TTL 5 min)
    API-->>UI: { token }
    UI->>API: GET stream-by-token?token=…&download=false  (anonymous)
    API->>Redis: GET doc-view:{token}
    Redis-->>API: DocumentData JSON (or null)
    API->>Blob: DownloadStreamingAsync(BlobName)
    Blob-->>API: stream
    API-->>UI: IRemoteStreamContent (inline or attachment)

Token creation serializes the whole DocumentData (not just a path) into Redis:

[Authorize(HubPermissions.Document.View)]
public async Task<DocumentViewTokenResultDto> CreateViewTokenByIdAsync(Guid documentId)
{
    var tokenData = await ResolveDocumentDataAsync(documentId);   // re-runs the access check

    var token = Guid.NewGuid().ToString("N");
    await cache.SetStringAsync(
        $"doc-view:{token}",
        JsonSerializer.Serialize(tokenData, TokenJsonOptions),
        new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) });

    return new DocumentViewTokenResultDto { Token = token };
}

Token consumption is [AllowAnonymous]; the only credential is the opaque token, and the cached DocumentData already encodes which blob to fetch:

[AllowAnonymous]
public async Task<IRemoteStreamContent> GetStreamByTokenAsync(string token, bool download = false)
{
    var data = await cache.GetStringAsync($"doc-view:{token}");
    if (data is null)
        throw new BusinessException("HUB:TokenExpired", "Token expired or invalid.");

    var tokenInfo = JsonSerializer.Deserialize<DocumentData>(data, TokenJsonOptions);
    if (tokenInfo is null)
        throw new BusinessException("HUB:InvalidToken", "Invalid token data.");

    var stream = await GetBlobStreamAsync(tokenInfo.BlobName);
    var contentType = tokenInfo.ContentType ?? "application/octet-stream";

    return download
        ? new RemoteStreamContent(stream, tokenInfo.FileName, contentType)   // attachment
        : new RemoteStreamContent(stream, contentType: contentType);          // inline
}

DocumentData is a private nested type whose BlobName is the computed naming convention:

private sealed class DocumentData
{
    public Guid Id { get; set; }
    public string FileName { get; set; } = "";
    public Guid ParentId { get; set; }
    public string ParentType { get; set; } = "shipments";
    public Guid CargonerdsCustomerId { get; set; }
    public string? ContentType { get; set; }

    public string BlobName => $"{CargonerdsCustomerId}/{ParentType}/{ParentId}/{Id}_{FileName}";
}

DocumentViewTokenResultDto (modules/hub/src/Hub.Application.Contracts/Dtos/) is just a { "token": "…" } wrapper.

Why tokens, and why a 5-minute TTL

The token decouples the authenticated permission/access check (done once at mint time) from the credential-less fetch the browser performs for an embedded preview. The 5-minute absolute expiry keeps the window small. The same single-use/short-lived token pattern is used for Excel downloads (ExcelExportDownloadCache, also 5 min) — see Caching.

The token is a bearer credential for one document

Anyone holding a live doc-view:{token} value can fetch that document anonymously until it expires. Because the cached DocumentData carries the resolved CargonerdsCustomerId and blob path, the anonymous read does not re-check the user's permission or org access — it trusts the mint-time check. Treat the token as sensitive and short-lived.

Writing blobs (the SPARK booking path)

Document blobs are uploaded outside DocumentAppService. The verified writer is the CW1 booking export, which streams each shipment document to blob storage during a SPARK booking, building the same blob path the reader expects:

// modules/hub/src/Hub.Application/Services/Cw1BookingService.cs (~line 417)
string path =
    $"{shipmentBaseDocument.Parent!.CargonerdsCustomerId}/shipments/{shipmentBaseDocument.Parent.Id}/{shipmentBaseDocument.Id}_{shipmentBaseDocument.FileName}";

await documentStorageService.UploadAsync(path, shipmentBaseDocument.Content, ct);

On upload failure the document is removed from the shipment's collection and the error is logged (LogDocumentBlobUploadFailed) rather than aborting the whole booking. The documentStorageService here is the same keyed IStorageService injected via [FromKeyedServices(BlobStorageContainer.Document)].

Domain model

Document is an abstract CustomerOwnedGuidEntity that also implements ISearchable<Document> (modules/hub/src/Hub.Domain/Entities/Document.cs). It stores file metadata and content hashing, not the bytes:

Field Notes
FileName, Description SearchProperties => [s => s.FileName, s => s.Description]
FileType (FileType?) navigation; faceted ([Facet])
FileSize, FileDate size set automatically when Content is assigned
MimeType faceted; flows into the view-token ContentType
Sha256Sum hex SHA-256, computed in the Content setter
IsDeleted, DeletedAt soft-delete flags (faceted)
VersionNumber document version
Content / Base64Content [NotMapped] helpers for external data exchange — not persisted
ExternalIdentifiers ICollection<ExternalDocumentIdentifier> (SPARK linkage)
Parent (IDocumentParent) abstract; the owning aggregate

The Content setter is where size and hash are derived:

public byte[]? Content
{
    get => _content;
    set
    {
        if (_content?.Equals(value) == true) return;
        _content = value;
        if (_content is null) return;
        FileSize = _content.Length;
        Sha256Sum = Convert.ToHexString(SHA256.HashData(_content));
    }
}

Concrete subtypes share one table via EF table-per-hierarchy and bind a concrete parent:

public class ShipmentBaseDocument : Document   // ParentType "shipments"
{
    public Guid ShipmentBaseId { get; set; }
    public virtual ShipmentBase? ShipmentBase { get; set; }
    public override IDocumentParent Parent => ShipmentBase;
}

public class InvoiceDocument : Document        // ParentType "invoices"
{
    public Guid InvoiceId { get; set; }
    public virtual Invoice Invoice { get; set; }
    public override IDocumentParent Parent => Invoice;
}

IDocumentParent : IGuidEntity, ICustomerOwned exposes IEnumerable<Document> Documents, which is how the list query (WithListDetailsAsync) and the upload path reach a parent's documents. See Entities and Aggregate roots.

Permissions

Defined in HubPermissions.Document (modules/hub/src/Hub.Application.Contracts/Permissions/HubPermissions.cs):

public static class Document
{
    [GrantInRoles(RoleConsts.DefaultCustomer)]
    public const string DocumentGroup = GroupName + ".Document";

    [GrantInRoles(RoleConsts.DefaultCustomer)]
    public const string View   = DocumentGroup + ".View";
    public const string Create = DocumentGroup + ".Create";
    public const string Delete = DocumentGroup + ".Delete";
}

View is granted to the DefaultCustomer role by default; Create/Delete are defined but are not used by DocumentAppService (it is read-only). See the full list under Permissions reference and the Authorization page, backed by ABP authorization & permission management.

DTO and mapping

DocumentAppService.ToDto maps with Riok.Mapperly (not AutoMapper for the entity DTO) and passes a reference handler so the polymorphic graph serializes cleanly:

[return: NotNullIfNotNull("entity")]
protected override DocumentDto? ToDto(Document? entity)
    => entity?.ToDto(new SuperTypeResolutionReferenceHandler());

DocumentDto is a generated partial (Nextended.CodeGen, namespace Hub.Application.Contracts.Shipments). See DTOs and ABP object-to-object mapping & Mapperly.

Configuration reference

Setting Where Notes
ConnectionStrings:BlobStorage appsettings*.json Azurite locally, real storage account in cloud
Redis:Configuration appsettings*.json backs the doc-view:{token} cache
Container name BlobStorageContainer.Document = "documents" single hard-coded container
Token TTL DocumentAppService (const) TimeSpan.FromMinutes(5)

See Configuration and Caching.

Gotchas

Read and write must agree on the blob path

The blob name is computed in two placesDocumentData.BlobName (read) and an inline interpolated string in Cw1BookingService (write). They are not derived from a shared helper, so any change to the convention {CargonerdsCustomerId}/{ParentType}/{ParentId}/{Id}_{FileName} must be applied to both, or downloads will 404 against blobs that were uploaded under a different name.

  • Content/Base64Content are [NotMapped]. They exist only for exchanging bytes with external systems/converters. A Document loaded from SQL has Content == null; the bytes always come from blob storage via IStorageService. The upload path in Cw1BookingService explicitly throws if Content is null.
  • Anonymous token reads skip the access check. GetStreamByTokenAsync trusts the mint-time permission/org check baked into the cached DocumentData; it does not re-authorize. Mint tokens only for users who already passed CreateViewTokenByIdAsync.
  • Invoice documents may have a null shipment. ResolveParentInfo uses ind?.Invoice?.ShipmentId; when an InvoiceDocument has no associated shipment, CheckAccessAsync is skipped (the org-unit shipment check only runs when ShipmentId != null). Customer isolation still applies via the global query filter.
  • Unknown subtypes throw. ResolveParentInfo only handles ShipmentBaseDocument and InvoiceDocument; any other Document subtype raises HUB:UnknownDocumentType. A new document type must be added to that switch.
  • GetAsync(string id) is remote-disabled. Do not expect a JSON-metadata GET /api/app/document/{id} for a string id — that overload carries [RemoteService(IsEnabled = false)]. The Guid overload is the binary stream endpoint; metadata is obtained through the list endpoint or DocumentDto graphs.
  • Token cache lives in Redis, not the EF second-level cache. doc-view:{token} uses IDistributedCache (Redis), which is a different subsystem from the Hub's 1-minute in-memory EF query cache. See Caching.
  • It is not ABP BlobStoring. Re-stated because it is the most common wrong assumption: there is no IBlobContainer involved in the document flow.

See also