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:
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.Viewpermission 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 is0and anAbpAuthorizationExceptionis thrown. This is the Hub's manual (non-auto-active) org filter — the same mechanismHubEntityBaseServiceuses 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 places — DocumentData.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/Base64Contentare[NotMapped]. They exist only for exchanging bytes with external systems/converters. ADocumentloaded from SQL hasContent == null; the bytes always come from blob storage viaIStorageService. The upload path inCw1BookingServiceexplicitly throws ifContentis null.- Anonymous token reads skip the access check.
GetStreamByTokenAsynctrusts the mint-time permission/org check baked into the cachedDocumentData; it does not re-authorize. Mint tokens only for users who already passedCreateViewTokenByIdAsync. - Invoice documents may have a null shipment.
ResolveParentInfousesind?.Invoice?.ShipmentId; when anInvoiceDocumenthas no associated shipment,CheckAccessAsyncis skipped (the org-unit shipment check only runs whenShipmentId != null). Customer isolation still applies via the global query filter. - Unknown subtypes throw.
ResolveParentInfoonly handlesShipmentBaseDocumentandInvoiceDocument; any otherDocumentsubtype raisesHUB:UnknownDocumentType. A new document type must be added to thatswitch. GetAsync(string id)is remote-disabled. Do not expect a JSON-metadataGET /api/app/document/{id}for a string id — that overload carries[RemoteService(IsEnabled = false)]. TheGuidoverload is the binary stream endpoint; metadata is obtained through the list endpoint orDocumentDtographs.- Token cache lives in Redis, not the EF second-level cache.
doc-view:{token}usesIDistributedCache(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
IBlobContainerinvolved in the document flow.
See also¶
- Shipment Service — the primary parent of documents
- Services overview
- Entities and Aggregate roots
- Authorization and Permissions reference
- Caching — Redis token caches and the EF second-level cache
- Configuration and Appsettings
- Architecture overview