diff --git a/common.props b/common.props index facbf7b479..25d59f8ecb 100644 --- a/common.props +++ b/common.props @@ -1,8 +1,8 @@ latest - 10.6.0 - 5.6.0 + 10.7.0-preview + 5.7.0-preview $(NoWarn);CS1591;CS0436 https://abp.io/assets/abp_nupkg.png https://abp.io/ diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/gifs/designer-hybrid-flow.gif b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/gifs/designer-hybrid-flow.gif new file mode 100644 index 0000000000..e57cf107dc Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/gifs/designer-hybrid-flow.gif differ diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/gifs/runtime-workflow.gif b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/gifs/runtime-workflow.gif new file mode 100644 index 0000000000..4bc6a19584 Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/gifs/runtime-workflow.gif differ diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/code-backlog-summary.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/code-backlog-summary.png new file mode 100644 index 0000000000..5df821842e Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/code-backlog-summary.png differ diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/custom-endpoint-summary.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/custom-endpoint-summary.png new file mode 100644 index 0000000000..1622631d45 Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/custom-endpoint-summary.png differ diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-code-layer.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-code-layer.png new file mode 100644 index 0000000000..40d12507a2 Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-code-layer.png differ diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-endpoint.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-endpoint.png new file mode 100644 index 0000000000..eda5c6b88c Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-endpoint.png differ diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-entity.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-entity.png new file mode 100644 index 0000000000..a683e6cc21 Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-entity.png differ diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-form.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-form.png new file mode 100644 index 0000000000..8df3e079d9 Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-form.png differ diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-page.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-page.png new file mode 100644 index 0000000000..6ca404b734 Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-devjson-page.png differ diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-enum-status-modal.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-enum-status-modal.png new file mode 100644 index 0000000000..5caf7a8c34 Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-enum-status-modal.png differ diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-enum-status-saved.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-enum-status-saved.png new file mode 100644 index 0000000000..727953c4b4 Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-enum-status-saved.png differ diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-form-create-modal.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-form-create-modal.png new file mode 100644 index 0000000000..f1e3f0fa52 Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-form-create-modal.png differ diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-page-create-before-save.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-page-create-before-save.png new file mode 100644 index 0000000000..2a68c8c060 Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-page-create-before-save.png differ diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-review-template-properties.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-review-template-properties.png new file mode 100644 index 0000000000..b81b1e76e7 Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-review-template-properties.png differ diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-runtime-entity.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-runtime-entity.png new file mode 100644 index 0000000000..e1eeccec7e Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-runtime-entity.png differ diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-vendor-application-properties.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-vendor-application-properties.png new file mode 100644 index 0000000000..a87a00c529 Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-vendor-application-properties.png differ diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-vendor-escalation-save-modal.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-vendor-escalation-save-modal.png new file mode 100644 index 0000000000..b56f365486 Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/designer-vendor-escalation-save-modal.png differ diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-form-documents.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-form-documents.png new file mode 100644 index 0000000000..d73c53ca18 Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-form-documents.png differ diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-grid-filtered.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-grid-filtered.png new file mode 100644 index 0000000000..eeb88d0be5 Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-grid-filtered.png differ diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-review-rejected.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-review-rejected.png new file mode 100644 index 0000000000..6bb837bd4d Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-review-rejected.png differ diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-vendor-escalations.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-vendor-escalations.png new file mode 100644 index 0000000000..ddcc333f9b Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/assets/screenshots/runtime-vendor-escalations.png differ diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/cover.png b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/cover.png new file mode 100644 index 0000000000..a6d312b64d Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/cover.png differ diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/post.md b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/post.md new file mode 100644 index 0000000000..e76a154a25 --- /dev/null +++ b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/post.md @@ -0,0 +1,394 @@ +# Building a Vendor Onboarding Workflow with ABP Low-Code + +Vendor onboarding usually starts with a few familiar steps. + +A company sends its details, someone checks the documents, another person reviews the score, and the team either approves the vendor or asks for more information. After a while, the process turns into a mix of spreadsheets, uploaded files, status notes, and "who is waiting on this one?" messages. + +In this article, we'll build that workflow with the [Low-Code System](https://abp.io/docs/latest/low-code/index). We'll model the data in the [Low-Code Designer](https://abp.io/docs/latest/low-code/designer), let the [React runtime](https://abp.io/docs/latest/low-code/react-runtime) render the page, and then add one [custom endpoint](https://abp.io/docs/latest/low-code/custom-endpoints) for a summary that does not belong to normal CRUD. + +The example is an internal operations page where a team receives vendor applications, reviews compliance documents, tracks deadlines, and follows rejected or priority vendors from one place. + +That is a good place to try ABP Low-Code, because the first version of the workflow is mostly data, screens, validation rules, and a few process-specific actions. You do not need to hand-write a React page only to list vendor applications, upload a compliance document, or show a rejection reason when the status is rejected. + +We will start from an already running ABP React + EF Core application with Low-Code enabled, so the article can stay focused on the Admin Console, the Designer, and the runtime flow. + +## What We Are Building + +The workflow has one main record: `VendorApplication`. + +A reviewer should be able to: + +- Create a vendor application with company and contact details. +- Track whether the vendor is `Submitted`, `InReview`, `Approved`, or `Rejected`. +- Set the requested date and approval deadline. +- Mark priority vendors. +- Assign a category such as `Software`, `Services`, or `Hardware`. +- Upload a logo and a compliance document. +- Fill in a rejection reason only when the application is rejected. +- Filter the generated grid by status, requested date, priority, and category. +- Call a summary endpoint that returns counts for dashboard-like use. + +We'll also touch two extra pieces around that main record. `VendorReviewTemplate` comes from C# so you can see how code-defined metadata appears in the Designer. Later, a `VendorEscalation` model is added while the Designer is switched to `Runtime JSON`. You could build the whole workflow with one entry point, but using these three entry points makes the hybrid model visible without turning the article into three separate implementations. + +## A Quick Note on How Low-Code Fits Together + +The Low-Code Designer is where you describe the model and the UI metadata. In this article we use four areas: + +- `Data` for enums and entities. +- `Pages` for the generated grid route. +- `Forms` for the create/edit form layout. +- `Actions` for the custom HTTP endpoint. + +The Designer stores metadata. The React runtime reads that metadata and renders the page at runtime. That is the important mental model: when we add a field to the entity, the field can become a grid column, a filter, a validation rule, or a form input depending on how we configure the metadata around it. + +There is also one database detail to keep in mind. Metadata that comes from C# code or from `Dev JSON` is source-controlled application metadata. When it introduces or changes a persisted entity, run the normal EF Core migration and database update flow before using the generated runtime page. In the validated demo for this article I used SQLite, so the migration updated the local SQLite database. `Runtime JSON` is different: it is authored at runtime, so I do not run a C# migration in that section. + +## Add a Code-Defined Review Template + +Let's start with one model that does not come from the Designer. + +In this workflow, vendor reviewers can use review templates. The template itself is not the center of the workflow, so I kept it focused on the review rules: + +```csharp +[DynamicEnum] +public enum VendorReviewTemplateType +{ + Standard = 0, + Security = 1, + Finance = 2 +} + +[DynamicEntity(DefaultDisplayPropertyName = nameof(Name))] +[DynamicEntityUI(DisplayName = "Vendor Review Templates")] +public class VendorReviewTemplate : DynamicEntityBase +{ + [Required] + [StringLength(128)] + [DynamicPropertyUnique] + public string Name { get; set; } + + public VendorReviewTemplateType TemplateType { get; set; } + public int MinimumComplianceScore { get; set; } + public bool RequiresDocumentReview { get; set; } + public string? Notes { get; set; } +} +``` + +Then include the entity in your EF Core DbContext. This is the part that makes the migration create a real backing table for the code-defined model: + +```csharp +public DbSet VendorReviewTemplates { get; set; } + +builder.Entity(b => +{ + b.ToTable( + VendorOnboardingLowCodeConsts.DbTablePrefix + "VendorReviewTemplates", + VendorOnboardingLowCodeConsts.DbSchema + ); + b.ConfigureByConvention(); + b.Property(x => x.Name).IsRequired().HasMaxLength(128); + b.Property(x => x.Notes).HasMaxLength(512); + b.HasIndex(x => x.Name).IsUnique(); +}); +``` + +Because this model is defined in C#, treat it like the rest of your application schema changes: add the entity, add the DbSet/mapping, create/apply the EF Core migration, and then start the application. + +After the app starts, open **Admin Console > Low-Code Designer > Data**. The model is visible there, but it is read-only because it was defined in code. + +![Code-defined VendorReviewTemplate shown as read-only in the Low-Code Designer](assets/screenshots/designer-code-layer.png) + +Open the **Properties** tab and you can see the fields that came from the C# class. They are available to the Low-Code System, but the Designer marks them as code-owned. + +![The Properties tab for the code-defined VendorReviewTemplate entity](assets/screenshots/designer-review-template-properties.png) + +That is useful in real projects. Some metadata can be shipped with the application, while the rest of the workflow can still be designed through the Admin Console. + +## Create the Vendor Enums + +Now move to the part we actually build in the Designer. + +The animation below shows the Designer path in one pass. The next sections slow it down and explain the enum, entity, page, and form steps. + +![Creating the Designer metadata for the vendor onboarding workflow](assets/gifs/designer-hybrid-flow.gif) + +Open `Data > Enums` and create the status enum: + +```text +VendorApplicationStatus +Submitted +InReview +Approved +Rejected +``` + +Before saving, the enum modal should contain the name and the four values: + +![The VendorApplicationStatus enum creation modal before saving](assets/screenshots/designer-enum-status-modal.png) + +Then create the category enum: + +```text +VendorCategory +Software +Services +Hardware +``` + +The order of the status values matters for the custom endpoint later, because the script checks the enum values by their numeric indexes. In this example `Submitted` is `0`, `Approved` is `2`, and `Rejected` is `3`. + +After saving, the enum detail page shows the numeric values that the runtime and scripts will use: + +![The saved VendorApplicationStatus enum values in the Designer](assets/screenshots/designer-enum-status-saved.png) + +## Create the VendorApplication Entity + +Go to `Data > Entities` and create `VendorApplication`. + +This is the model that drives the rest of the article. Add these fields: + +| Field | Type | Configuration | +| --- | --- | --- | +| `CompanyName` | `String` | Required and unique | +| `ContactEmail` | `String` | Required, email validation | +| `Status` | `Enum` | `VendorApplicationStatus` | +| `RequestedOn` | `Date` | Application date | +| `ApprovalDeadline` | `Date` | Review deadline | +| `IsPriority` | `Boolean` | Priority flag | +| `Category` | `Enum` | `VendorCategory` | +| `ComplianceScore` | `Int` | Review score | +| `Logo` | `Image` | Logo upload | +| `ComplianceDocument` | `File` | Document upload | +| `RejectionReason` | `String` | Optional | + +![VendorApplication entity definition in the Designer](assets/screenshots/designer-devjson-entity.png) + +The **Properties** tab is where the entity becomes more than a name. The table shows the field types, enum bindings, and source layer. Scroll down and the upload-related fields are visible with their `Image` and `File` types: + +![The VendorApplication properties table in the Designer](assets/screenshots/designer-vendor-application-properties.png) + +There is no React code yet, but we already have a lot of behavior described: required fields, uniqueness, email validation, enum fields, upload fields, and the data shape that the runtime will use. + +The `Image` and `File` types are worth calling out. They are not plain strings with a path. In the generated form they become upload controls, which is exactly what we need for vendor logos and compliance documents. + +Since `VendorApplication` is authored in the `Dev JSON` layer, it also belongs to the source-controlled model. After saving the entity metadata, create/apply the EF Core migration before you open the generated page in the runtime. This is the step that creates the backing table for the low-code entity in the database. + +## Generate a Grid Page + +The reviewers need a page where they can work with applications, so go to `Pages` and create a `dataGrid` page named `vendor-onboarding`. + +Bind it to `VendorApplication`. + +Before saving the page, the modal connects the route name, title, icon, and entity: + +![The vendor-onboarding data grid page modal before saving](assets/screenshots/designer-page-create-before-save.png) + +After the page is created, set `RequestedOn` as the default sort field, keep it descending, adjust the icon if you want, and assign `vendor-application-form` as the create/edit form: + +![The vendor-onboarding data grid page bound to VendorApplication](assets/screenshots/designer-devjson-page.png) + +For the review workflow, keep the configured columns focused on the fields reviewers use most: + +- Company name +- Status +- Requested date +- Priority +- Category + +Then configure the filters you want reviewers to use most often. In this workflow, the important filters are company, status, requested date, priority, and category. Depending on the runtime defaults, the generated grid may still expose additional fields such as contact email; the workflow is still driven by the focused page metadata above. + +Once the page is saved, the React runtime can resolve the route from the page metadata. The grid is generated from the entity and page configuration rather than from a hand-written React component. + +## Build the Create/Edit Form + +A grid is not enough. We also need a form that feels like the workflow. + +Go to `Forms` and create `vendor-application-form` for `VendorApplication`. Split the fields into three tabs: + +![The vendor-application-form creation modal before saving](assets/screenshots/designer-form-create-modal.png) + +- **Company**: `CompanyName`, `ContactEmail`, `Category`, `IsPriority` +- **Review**: `Status`, `RequestedOn`, `ApprovalDeadline`, `ComplianceScore`, `RejectionReason` +- **Documents**: `Logo`, `ComplianceDocument` + +Now add the conditional behavior for `RejectionReason`. In this demo I used two complementary rules: one rule shows the field when `Status = Rejected`, and the other hides it for non-rejected statuses. + +![The vendor-application-form with tabs and a conditional rule](assets/screenshots/designer-devjson-form.png) + +This is one of the places where Low-Code becomes more than "generate a CRUD page". The runtime does more than render a static form; it evaluates the rule while the user edits the record. + +## Apply the Migration Before Opening the Runtime + +Before opening the generated page, apply the database migration for the `Dev JSON` changes. We used `Dev JSON` for `VendorApplication`, so the Designer wrote source-controlled descriptor files under `_Dynamic`. The entity shape is now part of the application model, and the database needs the matching backing table before the React runtime can save records. + +That is why `Dev JSON` is a good fit during development: the metadata files and the EF Core migration can be reviewed, committed, and reproduced in another environment. If the same entity had been created in the `Runtime JSON` layer, you would not create a C# migration for that runtime edit; the metadata change would be stored in the database instead. In practice, use `Dev JSON` for development-time, source-controlled changes, and use `Runtime JSON` when you want production-time changes to be managed from the Admin Console and persisted in the database. + +## Try It in the React Runtime + +Open the generated `vendor-onboarding` page in the React runtime and create a vendor application. + +On the `Documents` tab, the `Logo` and `ComplianceDocument` fields are rendered as upload fields: + +![The generated Documents tab rendering image and file upload fields](assets/screenshots/runtime-form-documents.png) + +Now edit a record and change the status to `Rejected`. The `RejectionReason` field becomes available on the `Review` tab: + +![The Review tab showing the conditional RejectionReason field](assets/screenshots/runtime-review-rejected.png) + +After saving a few records, use the generated filters to narrow the list to rejected vendors. Depending on the runtime configuration, the filter panel can expose more fields than the small set you configured for the workflow; here we only use the `Status = Rejected` filter: + +![The generated grid filtered by Status = Rejected](assets/screenshots/runtime-grid-filtered.png) + +The short animation below gives a quick pass through the same runtime states: upload fields, the conditional rejection reason, and the filtered grid. + +![Generated upload fields, conditional review field, and grid filters in the React runtime](assets/gifs/runtime-workflow.gif) + +At this point we have a working page, form, validation, uploads, and filters. The important part is that all of it came from the metadata we configured in the Designer. + +## Add a Custom Summary Endpoint + +Generated CRUD is enough for day-to-day record editing, but teams often need one operation that is specific to their process. + +For vendor onboarding, a summary endpoint is a good example: + +```text +GET /api/custom/vendor-onboarding/summary +``` + +In the Designer, open `Actions` and create a custom HTTP action with that route. The script can use the [Scripting API](https://abp.io/docs/latest/low-code/scripting-api) to query the same `VendorApplication` data that the generated grid uses. + +![The custom HTTP action configured under Actions in the Designer](assets/screenshots/designer-devjson-endpoint.png) + +Here is the script used in the demo: + +```js +var entityName = 'Acme.VendorOnboardingLowCode.Procurement.VendorApplication'; +var vendorQuery = await db.query(entityName); +var totalVendors = await db.count(entityName); +var submittedVendors = await vendorQuery.where(x => x.Status === 0).count(); +var approvedVendors = await vendorQuery.where(x => x.Status === 2).count(); +var today = query.today || new Date().toISOString().slice(0, 10); +var overdueReviews = await vendorQuery + .where(x => x.ApprovalDeadline != null && x.ApprovalDeadline < today && x.Status !== 2) + .count(); + +return ok({ + totalVendors: totalVendors, + submittedVendors: submittedVendors, + approvedVendors: approvedVendors, + overdueReviews: overdueReviews, + evaluatedOn: today +}); +``` + +Use the entity name shown in your Designer. In the screenshots, it is `Acme.VendorOnboardingLowCode.Procurement.VendorApplication`. + +When the endpoint runs, it returns the current counts from the low-code records: + +![The JSON response of the custom summary endpoint](assets/screenshots/custom-endpoint-summary.png) + +That is the bridge I like here. The page and form stay metadata-driven, but the process-specific summary is a short script exposed as a custom endpoint. + +## Add One Runtime Model + +Now switch the Designer layer to `Runtime JSON` and add one more entity: `VendorEscalation`. + +This model represents the items that need extra attention. It could have been created in the same place as `VendorApplication`; I am adding it here only to show that runtime-authored metadata participates in the same Low-Code System. + +Unlike the code and `Dev JSON` examples above, this runtime-authored model is not part of the source-controlled migration flow in this walkthrough. + +The create modal is the same Designer experience, but the selected layer is now `Runtime JSON`: + +![The VendorEscalation entity creation modal in the Runtime JSON layer](assets/screenshots/designer-vendor-escalation-save-modal.png) + +![The VendorEscalation entity authored in the Runtime JSON layer](assets/screenshots/designer-runtime-entity.png) + +Create a data grid page for it and open it in the React runtime: + +![The runtime-generated Vendor Escalations page](assets/screenshots/runtime-vendor-escalations.png) + +From the user's point of view, it behaves like the first generated page. From the metadata point of view, we have now seen code-defined metadata, Designer-authored metadata, and runtime-authored metadata in the same application. + +## Read the Same Data from ABP Code + +The last bridge is application code. + +Sometimes the generated page is not the only consumer. You may want a typed application service, a scheduled job, or another API to read the same low-code records. The code below shows the idea by returning a backlog summary: + +```csharp +private readonly IRepository _vendorApplicationRepository; +private readonly IAsyncQueryableExecuter _queryableExecuter; + +public async Task GetBacklogAsync() +{ + var entityDescriptor = DynamicModelManager.Instance.Find( + "Acme.VendorOnboardingLowCode.Procurement.VendorApplication" + ); + + if (entityDescriptor == null) + { + throw new UserFriendlyException("VendorApplication model was not found."); + } + + var query = await _vendorApplicationRepository + .SetEntityName(entityDescriptor.Name) + .GetQueryableAsync(); + var today = DateOnly.FromDateTime(Clock.Now); + var priorityQuery = query.Where(vendor => + vendor.Data["IsPriority"] != null && + (bool?)vendor.Data["IsPriority"] == true); + + var nextPriorityVendor = await _queryableExecuter.FirstOrDefaultAsync( + priorityQuery.OrderByDescending(vendor => + (DateOnly?)vendor.Data["RequestedOn"])); + + return new VendorBacklogDto + { + TotalVendors = checked((int)await _queryableExecuter.LongCountAsync(query)), + PriorityVendors = checked((int)await _queryableExecuter.LongCountAsync(priorityQuery)), + RejectedVendors = checked((int)await _queryableExecuter.LongCountAsync( + query.Where(vendor => + vendor.Data["Status"] != null && + (int?)vendor.Data["Status"] == 3))), + OverdueReviews = checked((int)await _queryableExecuter.LongCountAsync( + query.Where(vendor => + vendor.Data["ApprovalDeadline"] != null && + (DateOnly?)vendor.Data["ApprovalDeadline"] < today && + vendor.Data["Status"] != null && + (int?)vendor.Data["Status"] != 2))), + NextPriorityVendor = nextPriorityVendor?.GetData("CompanyName") + }; +} +``` + +![The typed backlog summary returned by the application service](assets/screenshots/code-backlog-summary.png) + +The important detail is that the aggregate operations stay on `IQueryable`; the code does not load every vendor into memory just to count them. This is not a replacement for the generated page. It is the other direction: use the generated page for the admin experience, then read the same records from normal ABP code when another part of the application needs them. + +## Going Further + +The workflow we built is intentionally focused, but the same shape can grow in a few directions: + +- Add permissions around the generated pages and custom endpoint. +- Add more form rules for review-specific fields. +- Add an approval notification after a vendor is accepted. +- Add a scheduled job that checks overdue applications. +- Build a dashboard widget on top of the summary endpoint. + +The main pattern stays the same: model the data in the Low-Code Designer, let the React runtime render the operational page, and add code or scripting only for the parts that are specific to your business process. + +## Conclusion + +ABP Low-Code is useful when the first version of a business workflow is mostly metadata: entities, fields, filters, forms, validation, uploads, and a few custom actions. + +In this vendor onboarding example, the `VendorApplication` model gave us a generated grid and form, the runtime handled upload fields and conditional UI, and a custom endpoint added the summary that CRUD would not provide by itself. We also saw that low-code metadata can come from the Designer, from runtime JSON, or from C# code when you need that bridge. + +That is the part worth remembering: you can start with a working admin experience quickly, then extend the workflow where the generated behavior stops being enough. + +### Further Reading + +- [Low-Code System Overview](https://abp.io/docs/latest/low-code/index) +- [Low-Code Designer](https://abp.io/docs/latest/low-code/designer) +- [React Runtime](https://abp.io/docs/latest/low-code/react-runtime) +- [Custom Endpoints](https://abp.io/docs/latest/low-code/custom-endpoints) +- [Scripting API](https://abp.io/docs/latest/low-code/scripting-api) diff --git a/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/summary.md b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/summary.md new file mode 100644 index 0000000000..cc1f78fded --- /dev/null +++ b/docs/en/Community-Articles/2026-07-07-building-a-vendor-onboarding-workflow-with-abp-low-code/summary.md @@ -0,0 +1 @@ +Build a vendor onboarding workflow with ABP Low-Code: model vendor applications in the Designer, let the React runtime render the grid and form, then add a custom endpoint and a typed ABP code bridge for process-level counts. diff --git a/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-calendar-kanban-flow.gif b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-calendar-kanban-flow.gif new file mode 100644 index 0000000000..031a41a302 Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-calendar-kanban-flow.gif differ diff --git a/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-custom-endpoint-flow.gif b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-custom-endpoint-flow.gif new file mode 100644 index 0000000000..7dc75e3980 Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-custom-endpoint-flow.gif differ diff --git a/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-grid-form-flow.gif b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-grid-form-flow.gif new file mode 100644 index 0000000000..e9adf0e551 Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-grid-form-flow.gif differ diff --git a/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-hero-loop.gif b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-hero-loop.gif new file mode 100644 index 0000000000..551ab661a6 Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-hero-loop.gif differ diff --git a/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-page-builder.gif b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-page-builder.gif new file mode 100644 index 0000000000..99f02f0a9c Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/gifs/eventflow-page-builder.gif differ diff --git a/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/abp-studio-lowcode-system.png b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/abp-studio-lowcode-system.png new file mode 100644 index 0000000000..fac98fbf17 Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/abp-studio-lowcode-system.png differ diff --git a/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/admin-console-lowcode.png b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/admin-console-lowcode.png new file mode 100644 index 0000000000..f6d2603eb7 Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/admin-console-lowcode.png differ diff --git a/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/overview-dashboard.png b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/overview-dashboard.png new file mode 100644 index 0000000000..6cc9175a3c Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/overview-dashboard.png differ diff --git a/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/sponsor-activation-form.png b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/sponsor-activation-form.png new file mode 100644 index 0000000000..e5a22863aa Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/assets/screenshots/sponsor-activation-form.png differ diff --git a/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/cover.png b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/cover.png new file mode 100644 index 0000000000..78819c4b8b Binary files /dev/null and b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/cover.png differ diff --git a/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/post.md b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/post.md new file mode 100644 index 0000000000..07491fb74f --- /dev/null +++ b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/post.md @@ -0,0 +1,243 @@ +# Introducing ABP Low-Code: Build Real ABP Apps in Minutes + +**Create runtime-managed pages, generated React screens, code-first C# entities, and Script API extensions without leaving the ABP application model.** + +The opening loop below is the outcome this article is proving: one ABP application moving from runtime editing to generated operational screens and then into code-backed extension points. + +![ABP Low-Code runtime loop — grids, forms, calendars, and pipelines inside one ABP app](assets/gifs/eventflow-hero-loop.gif) + +> **Want to try the same path?** Start from ABP Studio, enable the Low-Code runtime and designer, define pages in the Admin Console, and see them resolve inside the running ABP app. + +--- + +## ABP Low-Code at a glance + +| Runtime authoring | Generated screens | ABP-native extensibility | One application model | +| :---: | :---: | :---: | :---: | +| Define and update pages in the Admin Console | Grid, Form, Calendar, Kanban, Gallery, Dashboard | Code-first entities, Script API actions, and C# query paths | Runtime metadata, generated UI, and application code stay together | + +--- + +## Built into the ABP Platform + +Low-code is most useful when speed does not create a separate stack to maintain later. + +That is where many low-code products start to strain. They move quickly at the beginning, then force a second implementation track when the app needs permissions, auditability, custom logic, or tighter integration with existing application code. + +ABP Low-Code takes a different path. It runs **inside the ABP Platform**, so runtime-managed pages are part of an application foundation that already includes identity, permissions, audit logging, APIs, and code-level extensibility. + +--- + +## Edit at runtime. See it in the app. + +In the Low-Code Designer inside the Admin Console, you update a runtime-managed page. A few seconds later, the same application surface is visible in the live app. No rebuild loop. No parallel front-end implementation. No "we will wire it later" gap between authoring and runtime. + +ABP Low-Code shortens the cycle from model change to running screen while keeping the output grounded in the same ABP application. + +The screenshot below shows that authoring step directly: a runtime page is being configured in the Admin Console's Low-Code Designer, where low-code defines the grid, form, actions, and view composition that the live application will resolve. + +![ABP Low-Code designer workspace for a runtime page](assets/screenshots/admin-console-lowcode.png) + +> **What this shows:** authoring and runtime are connected. Pages are defined in the designer and resolved in the running application. + +--- + +## CRUD is table stakes + +If low-code only saves you from drawing a table and a form, it is not enough. Business applications need richer operational surfaces. + +In the generated app, the `Events` screen ships with search, actions, filters, and form-driven editing. The form structure already understands tabs, relations, validation, and business-shaped input instead of leaving you with a blank shell to finish by hand. + +The next GIF shows the actual runtime page produced from that model: first the generated `Events` grid with operational actions, then the generated form with structured inputs instead of a blank CRUD shell. + +![Generated event grid and generated event form](assets/gifs/eventflow-grid-form-flow.gif) + +The point is not just generated CRUD. It is generated CRUD that already looks like the operational screens teams maintain in real applications. + +--- + +## One model, multiple operational screens + +Business users do not think in one view. Operators want a calendar for scheduling, a kanban board for workflow, a grid for bulk operations, a gallery when media matters, and a dashboard when they need the state of the business at a glance. + +ABP Low-Code keeps those surfaces attached to the same underlying model. The same `Session` model can appear as a **calendar** for planning and a **kanban pipeline** for operational flow. The same generated app can also include a **speaker gallery** and an **overview dashboard** for metrics. + +The next GIF keeps the same `Session` model but changes how the team works with it: calendar for planning, then kanban for operational flow, without rebuilding a second screen by hand. + +![The same runtime model shown as calendar and kanban views](assets/gifs/eventflow-calendar-kanban-flow.gif) + +The dashboard screenshot below continues that same application story. It is another surface generated around the same underlying data, this time optimized for KPIs, counts, and current operational status. + +![Overview dashboard in the live runtime app](assets/screenshots/overview-dashboard.png) + +This is where ABP Low-Code starts to feel less like a form generator and more like a runtime application layer: one model, many working screens, no second implementation track for each view type. + +--- + +## When low-code needs code + +The real differentiator is not that ABP Low-Code can go fast. It is that **speed does not require isolation from the application foundation**. + +When generated CRUD is not enough, you extend the same app instead of throwing the low-code layer away. + +ABP Low-Code exposes a server-side **Script API** inside the same application model. That scripting surface can back: + +- **Custom endpoints** when the UI needs an API-shaped response. +- **Interceptors** when create or update commands need validation or mutation. +- **Event handlers** when logic should react to runtime events. +- **Background jobs** when work should continue asynchronously. +- **Background workers** when operational logic should run on a schedule. + +In this article, the visible proof happens to be `GET /api/custom/eventflow/highlights`. The next GIF focuses on an endpoint because it is the easiest proof surface to read. But the broader point is that endpoints are only one consumer of the same low-code scripting layer. + +That hybrid model matters in both directions: + +- **Code-first ABP entities can be surfaced in low-code flows and runtime pages.** +- **Low-code-managed data and screens stay reachable from Script API actions, application services, repository queries, and custom endpoints.** +- **Teams do not lose architectural control just because they gained a faster authoring layer.** + +The next GIF steps into that Script API surface. In the same Admin Console, a script-backed low-code endpoint is opened, executed from the built-in test area, and its returned payload is shown immediately below so you can see runtime data flowing through an API-shaped contract. + +![Script API endpoint definition and executed dry-run result inside the Admin Console](assets/gifs/eventflow-custom-endpoint-flow.gif) + +The actual capability is the shared ABP application model behind it: script when runtime logic is enough, C# when typed application services and repository queries are the better fit. + +This is the difference between "low-code as a shortcut" and "low-code as part of your application platform." + +--- + +## From code-first entity to generated page + +The first direction is code-first to low-code. A **code-first** `SponsorActivation` entity checked into the ASP.NET Core project can still become a working runtime page without forking into a separate low-code-only model. + +The code-first entity carries the same metadata that ABP Low-Code uses to generate the page: + +```csharp +[DynamicEntity(DefaultDisplayPropertyName = nameof(CompanyName))] +[DynamicEntityUI("Sponsor Activations")] +[DynamicEntityAttachments("application/pdf", "image/*", MaxFileCount = 4)] +public class SponsorActivation : DynamicEntityBase +{ + [Required] + [DynamicPropertyUI(DisplayName = "Sponsor")] + public string CompanyName { get; private set; } + + [Required] + [EmailAddress] + [DynamicPropertyUI(DisplayName = "Contact Email")] + public string ContactEmail { get; private set; } + + public SponsorActivationStatus Status { get; set; } + + [DynamicForeignKey("EventFlow.Events.Event", "Title")] + public Guid? EventId { get; set; } + + [DynamicForeignKey("Volo.Abp.Identity.IdentityUser", nameof(IdentityUser.UserName), ForeignAccess.View)] + public Guid? OwnerUserId { get; set; } + + [DynamicPropertyType(EntityPropertyType.Money)] + public decimal ActivationBudget { get; set; } + + [DynamicPropertyImageOptions("image/png", "image/jpeg")] + public string? BrandLogo { get; set; } + + [DynamicPropertyFileOptions("application/pdf", ".pptx", ".docx")] + public string? ActivationBrief { get; set; } +} +``` + +That class lives as normal C# source, gets migrated like the rest of the application, and is seeded with real records so the runtime page does not open as an empty shell. + +Inside the designer, selecting the `SponsorActivation` entity auto-generates the page identity, binds the grid to the entity, and lands on a real runtime route at `/dynamic/sponsor-activation`. The generated surface includes sponsor, email, event lookup, owner lookup, budget, image, and file fields directly from the C# model. + +The next GIF shows that bridge in action: a new code-first `SponsorActivation` entity is selected inside low-code, a page is generated from its metadata, and the resulting runtime route opens with the modeled fields already wired in. + +![ABP Low-Code selecting the SponsorActivation C# entity, generating the page, and opening the resulting runtime surface](assets/gifs/eventflow-page-builder.gif) + +The screenshot after that is the resulting page, not a placeholder. You are looking at the generated form that came from the C# entity definition, including lookups, budget handling, image upload, and file upload fields. + +![Generated SponsorActivation form showing lookups, budget, image, and file fields coming directly from the C# entity](assets/screenshots/sponsor-activation-form.png) + +That is the distinction that matters: code-first ABP entities can move through low-code without becoming throwaway artifacts, and low-code-generated surfaces remain part of the same application story. + +--- + +## Low-code data stays reachable from C# + +The bridge also works in the other direction. A normal ABP application service can query a low-code model through `IRepository`, apply real filters, and combine that result with code-first aggregates. + +The service behind the endpoint in the previous section looks like this: + +```csharp +public async Task GetHybridSummaryAsync() +{ + var liveSessionQuery = (await _dynamicEntityRepository + .SetEntityName("EventFlow.Events.Session") + .GetQueryableAsync()) + .Where("int(it[\"Status\"]) == @0", 2); + + var publicSessionQuery = liveSessionQuery + .Where("bool(it[\"IsPublic\"]) == @0", true); + + var sponsorQuery = (await _sponsorActivationRepository.GetQueryableAsync()) + .Where(activation => + activation.Status == SponsorActivationStatus.Approved || + activation.Status == SponsorActivationStatus.Live); + + var liveSessionCount = await AsyncExecuter.CountAsync(liveSessionQuery); + var publicSessionCount = await AsyncExecuter.CountAsync(publicSessionQuery); + var activeSponsorActivationCount = await AsyncExecuter.CountAsync(sponsorQuery); + + return new EventFlowLowCodeProofDto + { + LiveSessionCount = liveSessionCount, + PublicSessionCount = publicSessionCount, + ActiveSponsorActivationCount = activeSponsorActivationCount + }; +} +``` + +Here, low-code-managed `Session` rows are filtered from C# with real `Where(...)` clauses, then combined with the typed `SponsorActivation` repository. The endpoint and dashboard are just one presentation surface for that shared ABP query path. + +That is the ABP difference: low-code data stays reachable from code, and code-first entities stay reachable from low-code. + +--- + +## Why ABP Low-Code matters + +The value is not novelty. It is a faster way to build real business applications without separating speed from the application foundation. + +- **Speed without replatforming.** Runtime-managed screens reduce delivery time without moving the team onto a separate application stack. +- **Governance without friction.** Permissions, identity, auditability, and ABP platform foundations stay part of the story from day one. +- **Extensibility without rewrite pressure.** When custom behavior shows up, the same application can be extended instead of replacing the low-code output. + +That is the core ABP Low-Code promise: faster delivery, still inside the application model you can extend. + +--- + +## Try it yourself + +The public starting point for ABP Low-Code is **ABP Studio**. + +The screenshot below is the exact toggle in the ABP Studio solution wizard where low-code runtime and designer support are enabled for a new ABP solution. + +![ABP Studio new solution wizard with the Low-Code runtime and designer option enabled](assets/screenshots/abp-studio-lowcode-system.png) + +1. Open **ABP Studio** and create a new solution. +2. In the solution wizard, enable **Include Low-Code runtime and designer**. +3. Complete the wizard, then run the generated backend and React UI from the solution. +4. Sign in with the administrator account created for that solution. +5. Open **Admin Console** to define runtime-managed entities, forms, pages, permissions, endpoints, and script actions. +6. Switch to the application side to see those changes resolve live in the running app. + +--- + +## Further reading + +- [ABP Low-Code Designer Documentation](https://abp.io/docs/latest/low-code/designer) +- [ABP Low-Code Configuration & Fluent API](https://abp.io/docs/latest/low-code/fluent-api) +- [ABP Low-Code Scripting API](https://abp.io/docs/latest/low-code/scripting-api) +- [ABP Low-Code Script Actions](https://abp.io/docs/latest/low-code/script-actions) +- [ABP Low-Code Interceptors](https://abp.io/docs/latest/low-code/interceptors) +- [ABP Studio Documentation](https://abp.io/docs/latest/studio) +- [Get Started with ABP: Creating a Layered Web Application](https://abp.io/docs/latest/get-started/layered-web-application) diff --git a/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/summary.md b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/summary.md new file mode 100644 index 0000000000..b782a568a7 --- /dev/null +++ b/docs/en/Community-Articles/2026-07-07-introducing-abp-low-code/summary.md @@ -0,0 +1 @@ +Discover how ABP Low-Code blends runtime page building with code-first entities, C# queries, and extensible application logic. diff --git a/docs/en/Community-Articles/2026-07-17-Empathy-At-Work/Post.md b/docs/en/Community-Articles/2026-07-17-Empathy-At-Work/Post.md new file mode 100644 index 0000000000..ae93fa86f1 --- /dev/null +++ b/docs/en/Community-Articles/2026-07-17-Empathy-At-Work/Post.md @@ -0,0 +1,186 @@ +# Empathy in the Workplace for Software Companies + +My articles are mostly technical but this time I want to mention about a very important soft-skill in workplaces. +That's empathy! This is an emotional skill (EQ) which is important like IQ but without this skill you cannot have charisma at your workspace. +For those who don't know what's charisma at workspace check out my previous article section 👉 [whats-charisma-at-work](https://abp.io/community/articles/my-speakers-view-of-convex-summit-2026-3uk6ln1l#so-lets-think-whats-charisma-at-work). +Even though empathy comes out of the box with your character, if you realize you lack of it you can improve this level. + +![hero](images/hero.png) + +## What's Empathy? + +**It's a discipline which puts behaviors to human-centered practices.** +**Why we do it?** For understanding users’ goals, restrictions, emotions, mentality and tradeoffs. Then using that understanding to improve what teams build, sell, market and support. +**Why do we need it?** Simple! If you don't know other's mentality, you most probably go by chance. + +**Empathy means making an effort to understand how another person sees a feature, message, workflow or pricing decision.** + +> Great software is not created only with clean code, attractive designs, marketing campaigns or polished sales demos. + +It is created when teams understand the people behind the requirements: + +- What are users trying to achieve? +- What do they already know? +- What confuses or slows them down? +- What makes them trust the product? +- What do they see as valuable? + +For a software company, empathy should not be treated only as a soft skill or company value. It should be a practical way to replace internal assumptions with real evidence about users, buyers, administrators, developers and other people affected by the product. + +Empathy needs to be a cross-functional responsibility. Developers, designers, product managers, sales, marketing, support and leaders all have visibility into different aspects of the customer experience. + + +## What Empathy Means in a Software Company + +There are two components of empathy: + +* **Affective empathy**: is experiencing the same emotions as another person. + +* **Cognitive empathy**: is the ability to understand another person's perspective, intentions, needs, desires, concerns and constraints. + +Both are important. However, cognitive empathy is usually more useful when teams review a feature, workflow, message, onboarding process or pricing decision. + +It encourages the team to ask what a specific person would understand and experience. + +> **The useful question is not:** +> “Would I like this?” +> +> **It is:** +> “Would this specific user, in this situation, with this knowledge and these limitations, understand the value and complete the task?” + +This difference is important because employees know much more about the product than customers do. + +> A workflow that is easy to the developer who implemented it, **may be confusing to a first time user**. +> A msg that sounds clear to a software engineer **can be a technical jargon to a buyer**. +> A feature that seems simple in a sales demo **can still be hard to use in a real company.** + +Affective empathy also matters because it encourages people to care about customers and take community-minded actions. But emotion is not always a good guide for assessment. +A great customer story can recieve too much attention, even when it does not represent most users.Emotional pressure may cause stress or wrong decisions. + + + +--- + + + +A better approach is to combine emotional concern with structured questions: + +- What kind of disappointment or confusion might this situation cause? +- What is the user trying to achieve? +- What information can the user see? +- What would the user reasonably understand? +- What could stop the user from continuing? +- What would make the user trust the product? + +In simple terms: + +> Empathy means testing our assumptions and learning how real users actually think, feel and use the product or feature. + +ALWAYS ASK YOURSELF: + +> **If I were using this feature / app, what would I criticize?** + +I know *we can easily criticize other people's work* but when it comes to criticize our own work we just can't do it. Because you know the difficulties of your work and you don't know about other people's difficulties. That's why you cannot truly criticize yourself. But the real success comes after you improve your own critizing skills. + +**Sit on the other side of the desk for a minute please** + +--- + +![Why empathy matters in software development?](images/why-empathy.png) + +--- + +## Empathy Is a Cross-Functional Responsibility + +![Empathy questions for developers, designers, sales, marketing and the wider team](images/roles.png) + +Each team member should see a different part of the customer reality. + +| Team | Ask your self this question | Inspect these things... | +| ---------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | +| Developers | Where might a new user get lost, stuck or confused by the system? If users wait on this screen so much, will they close the app? | Defaults, errors, performance, learnability, edge cases and technical friction | +| Designers | Does the interface match the user’s language, expectations, abilities and situation? Is it understandable? | Navigation, accessibility, cognitive load, interaction flow and error recovery | +| Product managers | Are we solving a real and important user problem? | User goals, priorities, evidence, value and expected outcomes | +| Sales | What would make a buyer question the value, risk, effort or credibility? | Demo flow, objections, trust signals, implementation concerns and time to value | +| Marketing | Would the intended customer recognize the problem and believe the promise? | Positioning, jargon, calls to action, expectation-setting and message-market fit | +| Support | Where does the product repeatedly cause confusion or extra work? | Ticket themes, escalations, documentation gaps and common workarounds | +| Leaders | What in our process makes customer understanding difficult or optional? | Incentives, priorities, team structure, review habits, tech trends and psychological safety | + +--- + +## ISO Standard of Empathy Loop + +And yes! There is even a standard for what I'm talking about. That is 9241-210, the ISO standard. +Its full name is ***Ergonomics of human-system interaction***. It covers all the works which has interactivity. +So our application screens, APIs are all included in this standard. +The main idea is simple: teams should design software around real users, their goals and their working environment. +Not only around technical requirements. +These 6 steps about how to design a better system, puts customers in the center. + + ![iso-9241-210](images/iso-9241-210.png) + +Let me adjust these to a software developing team: + +1. Decide how user experience work will be managed, who is responsible and what risks or limitations exist. + The below are the different areas to understand the feature/app/requirements: + - User interviews + - Customer calls + - Support quetions + - Sales notes + - Product analytics + - Surveys + - Session recordings + - Contextual observation + - Customer feedback + - Win-loss analysis +2. Learn your users, what they want to do, where they use the product and what problems they face. + In this section you really do empathy. Understand your user’s: + - Goals + - Concerns + - Knowledge level + - Mental model + - Limitations + - Expectations + - Work environment + - Emotional state +3. Turn user needs into clear and testable requirements. + - For example imagine there's a problem like Users don't use the reporting module. + We need to open an issue for this as "*New users can't easily find the information they need to prepare a weekly performance report.*" +4. Build ideas, wireframes, prototypes or simulations. + It is better to test simple versions early before spending too much time on development. + You can do the followings: + - Prototypes + - New workflows + - Updated copy + - Better defaults + - Simplified onboarding + - Improved documentation + - Pricing changes + - Sales and marketing materials +5. Test the product with users or UX experts. Check whether it is easy to use and whether it meets user requirements. And be open to the discussions. + You can test via the following methods: + - Usability testing + - Customer interviews + - Prototype testing + - Cognitive walkthroughs + - Heuristic reviews + - A/B tests + - Product analytics + - Write feedback forms +6. If there're still problems, improve the design and test again. + The process is complete, once the critical user requirements are fulfilled. + Empathy isn’t a one-day workshop. + +--- + + + +## Better Empathy, Better Software + +![quote](images/quote.png) + +It is used by developers to predict failure, designers to decrease cognitive dissonance, sales teams to quantify buyer risk, marketers to speak in the language of the customer and leaders to desgn systems that encourage learning rather than assumptions. + +**When teams regularly inquire about how their work will be understood, used, trusted and valued by the users on the other side of the screen, they create products they take pride in using, recommending and standing behind.** + +Thanks for reading ... \ No newline at end of file diff --git a/docs/en/Community-Articles/2026-07-17-Empathy-At-Work/images/cover.jpg b/docs/en/Community-Articles/2026-07-17-Empathy-At-Work/images/cover.jpg new file mode 100644 index 0000000000..2e14e88a47 Binary files /dev/null and b/docs/en/Community-Articles/2026-07-17-Empathy-At-Work/images/cover.jpg differ diff --git a/docs/en/Community-Articles/2026-07-17-Empathy-At-Work/images/hero.png b/docs/en/Community-Articles/2026-07-17-Empathy-At-Work/images/hero.png new file mode 100644 index 0000000000..e453a91e6a Binary files /dev/null and b/docs/en/Community-Articles/2026-07-17-Empathy-At-Work/images/hero.png differ diff --git a/docs/en/Community-Articles/2026-07-17-Empathy-At-Work/images/iso-9241-210.png b/docs/en/Community-Articles/2026-07-17-Empathy-At-Work/images/iso-9241-210.png new file mode 100644 index 0000000000..9d78ec41c2 Binary files /dev/null and b/docs/en/Community-Articles/2026-07-17-Empathy-At-Work/images/iso-9241-210.png differ diff --git a/docs/en/Community-Articles/2026-07-17-Empathy-At-Work/images/quote.png b/docs/en/Community-Articles/2026-07-17-Empathy-At-Work/images/quote.png new file mode 100644 index 0000000000..cfd14b6767 Binary files /dev/null and b/docs/en/Community-Articles/2026-07-17-Empathy-At-Work/images/quote.png differ diff --git a/docs/en/Community-Articles/2026-07-17-Empathy-At-Work/images/roles.png b/docs/en/Community-Articles/2026-07-17-Empathy-At-Work/images/roles.png new file mode 100644 index 0000000000..fbae10dcaa Binary files /dev/null and b/docs/en/Community-Articles/2026-07-17-Empathy-At-Work/images/roles.png differ diff --git a/docs/en/Community-Articles/2026-07-17-Empathy-At-Work/images/why-empathy.png b/docs/en/Community-Articles/2026-07-17-Empathy-At-Work/images/why-empathy.png new file mode 100644 index 0000000000..da14b663f3 Binary files /dev/null and b/docs/en/Community-Articles/2026-07-17-Empathy-At-Work/images/why-empathy.png differ diff --git a/docs/en/Community-Articles/2026-07-17-WAD-RECAP/post.md b/docs/en/Community-Articles/2026-07-17-WAD-RECAP/post.md new file mode 100644 index 0000000000..69e5fe24db --- /dev/null +++ b/docs/en/Community-Articles/2026-07-17-WAD-RECAP/post.md @@ -0,0 +1,64 @@ +WeAreDevelopers World Congress 2026 has come to an end, and we'd like to thank everyone who stopped by the ABP booth in Berlin! + +We had the opportunity to meet developers, architects, engineering leaders, and technology enthusiasts from around the world. It was a pleasure connecting with so many members of the developer community, hearing about the projects you're building, and discussing the challenges and opportunities shaping modern software development. + +![ABP team at WeAreDevelopers World Congress 2026.1](https://abp.io/api/file-management/file-descriptor/share?shareToken=CfDJ8BFJWl%2BAUE9Cj%2FpQ216BBS4zHy%2BR4dF3%2BYf%2FtQCpm6USG%2BPfkEFUE0giWmqZzA%2FqHbedsADTkW5jxPZVxyJjD1ZxYkCFVpgySB8KSYBTBAdDql%2FEFmCA8GZ7%2F1p0W2Y5V2ob%2F5I77rotPvY3K2lPaKkH4WPQnNbou02%2BJVW6wPxN) + +![ABP team at WeAreDevelopers World Congress 2026.2](https://abp.io/api/file-management/file-descriptor/share?shareToken=CfDJ8BFJWl%2BAUE9Cj%2FpQ216BBS69OYPeQbcVnawHZSL4ohAgZW3zQ%2BLnKM2ZJcj3mVMXlqXqyI7JQNCfbjJODmfRzdEaEyjXI2Afgx4q9gnOTM%2F8jfISltQ%2FhJIlLt8cyKzG4t%2FLWhjP4K0olwpw2AxU1FHx30pKKKb03NCGPF%2BXoq67) + +## **Great Conversations and Product Demos** + +Throughout the event, our team showcased the latest developments across the ABP ecosystem, including ABP Framework, ABP Studio, and our AI-powered development capabilities. + +We had countless conversations about modular application development, clean architecture, microservices, AI-assisted development, and how teams can build enterprise applications faster while maintaining long-term quality and maintainability. + +Thank you to everyone who shared feedback, asked questions, and explored how ABP can support your development journey. + +![ABP team at WeAreDevelopers World Congress 2026.3](https://abp.io/api/file-management/file-descriptor/share?shareToken=CfDJ8BFJWl%2BAUE9Cj%2FpQ216BBS5Lh7%2Fs%2BJm9dBfa89UIM8DtdlERFkfiUAx2DzqxE0v8z5hf%2BDWpFiiYVOjWq5NTdFxaqqzM079kwIYLEGcL7LljvWtjm5EZtbFYBlqXt8P4stnEmMSclaZocHZp4OgS%2BfN0caba4RQpLQgjIESBICFU) + +![ABP team at WeAreDevelopers World Congress 2026.4](https://abp.io/api/file-management/file-descriptor/share?shareToken=CfDJ8BFJWl%2BAUE9Cj%2FpQ216BBS4MxSzQqrl5ZNL%2BYgzHUxeTjm0uv%2B68PDOkP8P%2BwVJeSiVYFwsyumyB2tI85Ik5nJMbWt5zzaOHUUhYKKSHFZMRg4uAcAVSyT9hIdF3G3DeN6lfzCraFeA7SOvbgPTSiho6VxgzIndricFCd6bfMleD) + +## **Sharing Our Experience on Stage** + +In addition to connecting with attendees at our booth, we were proud to see our Co-founder, **Halil İbrahim Kalkan**, speak at WeAreDevelopers World Congress 2026. + +His session, **"Dynamic Entities in .NET: Building Low-Code Systems on Top of Entity Framework Core"** explored how developers can build flexible, dynamic applications while leveraging the power of Entity Framework Core and the .NET ecosystem. + +It was a great opportunity to share the engineering practices and ideas behind ABP with the wider developer community. Thank you to everyone who attended the session and joined the discussion. + +![ABP team at WeAreDevelopers World Congress 2026.5](https://abp.io/api/file-management/file-descriptor/share?shareToken=CfDJ8BFJWl%2BAUE9Cj%2FpQ216BBS6o2J%2FNrkCu8kKnfIGPt4h9zpeS61T2EHZ76ICCnBJlBMiQbXHWjHfPa7ZrWWmSA8om%2F5%2FPGUtcVR9yeGXj7jckumTHqSk1hTDQLDrs8pyYs4K1hz3FOpDmsNo8DBxaf8BDBtYY8RnfSMjUuhTiTMVn) + +## **More Than Just a Conference** + +WeAreDevelopers World Congress wasn't only about technical sessions. The event also featured interactive experiences, including a lively arcade gaming area, creating plenty of opportunities for attendees to relax, connect, and enjoy the conference between talks. + +This is the approach I'd recommend. It keeps the ABP story focused while giving you a natural place to include photos or videos of the arcade area. + +[![Watch the Gaming Area video on YouTube](https://abp.io/api/file-management/file-descriptor/share?shareToken=CfDJ8BFJWl%2BAUE9Cj%2FpQ216BBS7Zcq8bydK7%2BRRT0moIRkCgYSX6gkXffggpSCpS7%2B%2BZQuC42apGawV4nYr%2FKDuY7UHtlw7AbIFV5cIIevx2UqQ1IPG%2Bp4IWeYKH0isSwi0Jk36jBkH21UQiNlUFnif38Cd8copX22EX1eGV0DGpwjId)](https://youtu.be/K2WzoMfO76k) + +![ABP team at WeAreDevelopers World Congress 2026.7](https://abp.io/api/file-management/file-descriptor/share?shareToken=CfDJ8BFJWl%2BAUE9Cj%2FpQ216BBS5E2azupUQeKZCFpz8uOxJfDT%2BoD%2B6sKWEDVrQZLKPgwWauQman6CjAA35QPNHDPaR89CJNnOaqAr%2BDVZNUF5LLpqtkkbhDxL8cs19hRvOozR%2B%2FEMDMLQv05ZicCEswkaH68pIi8Htau91x2j%2B%2FZUNY) + +## **Meeting The Developer Community** + +One of the best parts of WeAreDevelopers World Congress is bringing together developers, architects, engineering leaders, and technology experts from around the world. The conference featured inspiring keynotes and technical sessions covering AI, software architecture, cloud, developer productivity, and many other topics that are shaping the future of software development. + +![ABP team at WeAreDevelopers World Congress 2026.8](https://abp.io/api/file-management/file-descriptor/share?shareToken=CfDJ8BFJWl%2BAUE9Cj%2FpQ216BBS4sS8dtDS3uz8BQ08GHrJhfYezTNm7iIPkGxmwSzAdqu3I5Xm83qEuWbyZyrkz%2FexjK%2BqWZ%2BwC2eUpOcjNJPk7a4RM97Es6Yy1SVC1k08fcpqmbF22enrV9%2FCRwLRaA0693i9TAlo1NBOcQAGDDLgfx) + +![ABP team at WeAreDevelopers World Congress 2026.10](https://abp.io/api/file-management/file-descriptor/share?shareToken=CfDJ8BFJWl%2BAUE9Cj%2FpQ216BBS6R8ZtcNmxyjBlV%2Fqiov6VfF0%2Fz9bl4VCdmSDfkDK%2Bmx8dmbrxOiiw8bIfymVWjEbIw9wzujp0R90K%2FgC7s3n7UaPSROiKcLwOBms3JB8To9G3wywNHRC3uKOLiFdszXLwNyZmqRemsUm5%2FTXTfAzy5) + +![ABP team at WeAreDevelopers World Congress 2026.11](https://abp.io/api/file-management/file-descriptor/share?shareToken=CfDJ8BFJWl%2BAUE9Cj%2FpQ216BBS7FG5qcTtOkfnSg7C9sq9zTrhQ%2FlW5Qe3YPb1WUnp6Yg%2BSXIPdg8B7L%2BiAtW7wqVK6%2FLU3EFpxIlEA1zYa23xwlggpo6V8%2BWFppT88NGxQ5Kn6vBV8S2vX1rytjS7RUjBHVXxJR8X5Vk49WYv5kCER9) + +## **Until Next Time** + +A big thank you to the WeAreDevelopers team for organizing another fantastic event and to everyone who visited us at Hall A, Booth A-41. + +If we didn't get the chance to meet in Berlin, you can always explore ABP online, join our community, or reach out to us with your questions and feedback. + +We appreciate everyone who made WeAreDevelopers World Congress 2026 such a memorable experience, and we look forward to seeing you again at future events! + +​ +![ABP team at WeAreDevelopers World Congress 2026.12](https://abp.io/api/file-management/file-descriptor/share?shareToken=CfDJ8BFJWl%2BAUE9Cj%2FpQ216BBS60ifxXi0WKAOMXk3uWgymwuMfSRR441sbaecJaHrzvlKokwVAQHsFtcr%2ByT9WJlUME5VTvE3iny0Rx9tVmSqlKRfKrAgvsEsyl1ACFjqjUvzqlXvswIpLWXNxoqIm%2BGylB6JFSA1cASNXNIs21tq9P) + +![ABP team at WeAreDevelopers World Congress 2026.13](https://abp.io/api/file-management/file-descriptor/share?shareToken=CfDJ8BFJWl%2BAUE9Cj%2FpQ216BBS734IO%2BudVyD2%2FGzg7qP1XkRTg7ZT5zZ2pLv5%2FoDqvo8sB%2FGEfkshjTmD0YBjDCeWKrpTHI6pK1TEIg%2FQoMX6SGoudWzymr2pQMFpzF2ATCE%2FdB3XRNSITLSaS6L40V8UfryzP8DBjDwlNizxPI5tGw) + +​ diff --git a/docs/en/cli/index.md b/docs/en/cli/index.md index 8196ba993e..1174c12f4b 100644 --- a/docs/en/cli/index.md +++ b/docs/en/cli/index.md @@ -809,14 +809,15 @@ abp generate-proxy -t csharp -url https://localhost:44302/ - `csharp`: C#, work in the `*.HttpApi.Client` project directory. There are some additional options for this client: - `--without-contracts`: Avoid generating the application service interface, class, enum and dto types. - `--folder`: Folder name to place generated CSharp code in. Default value: `ClientProxies`. - - `ng`: Angular. There are some additional options for this client: - - `--api-name` or `-a`: The name of the API endpoint defined in the `/src/environments/environment.ts`. Default value: `default`. - - `--source` or `-s`: Specifies the Angular project name to resolve the root namespace & API definition URL from. Default value: `defaultProject`. - - `--target`: Specifies the Angular project name to place generated code in. Default value: `defaultProject`. - - `--module`: Backend module name. Default value: `app`. - - `--entry-point`: Targets the Angular project to place the generated code. - - `--url`: Specifies api definition url. Default value is API Name's url in environment file. - - `--prompt` or `-p`: Asks the options from the command line prompt (for the unspecified options). + - `ng`: Angular. There are some additional options for this client: + - `--api-name` or `-a`: The name of the API endpoint defined in the `/src/environments/environment.ts`. Default value: `default`. + - `--source` or `-s`: Specifies the Angular project name to resolve the root namespace & API definition URL from. Default value: `defaultProject`. + - `--target`: Specifies the Angular project name to place generated code in. Default value: `defaultProject`. + - `--module`: Backend module name. Default value: `app`. + - `--entry-point`: Targets the Angular project to place the generated code. + - `--url`: Specifies api definition url. Default value is API Name's url in environment file. + - `--resource-api`: Adds optional Resource API helpers for `GET` endpoints while keeping the generated Observable services. This parameter requires Angular v22 or later. + - `--prompt` or `-p`: Asks the options from the command line prompt (for the unspecified options). - `js`: JavaScript. work in the `*.Web` project directory. There are some additional options for this client: - `--output` or `-o`: JavaScript file path or folder to place generated code in. - `--module` or `-m`: Specifies the name of the backend module you wish to generate proxies for. Default value: `app`. @@ -1314,4 +1315,3 @@ var tokenResponse = await httpClient.RequestClientCredentialsTokenAsync( - [Examples for the new command](./new-command-samples.md) - [Video tutorial](https://abp.io/video-courses/essentials/abp-cli) - diff --git a/docs/en/docs-nav.json b/docs/en/docs-nav.json index 118e4bb264..5a50f84bfa 100644 --- a/docs/en/docs-nav.json +++ b/docs/en/docs-nav.json @@ -716,6 +716,14 @@ "path": "framework/infrastructure/blob-storing/custom-provider.md" } ] + }, + { + "text": "Content Pipeline", + "path": "framework/infrastructure/blob-storing/pipeline.md" + }, + { + "text": "Encryption", + "path": "framework/infrastructure/blob-storing/encryption.md" } ] }, diff --git a/docs/en/framework/api-development/auto-controllers.md b/docs/en/framework/api-development/auto-controllers.md index ee6d790e22..7b08e0d2b4 100644 --- a/docs/en/framework/api-development/auto-controllers.md +++ b/docs/en/framework/api-development/auto-controllers.md @@ -62,6 +62,8 @@ ABP uses a naming convention while determining the HTTP method for a service met If you need to customize HTTP method for a particular method, then you can use one of the standard ASP.NET Core attributes ([HttpPost], [HttpGet], [HttpPut]... etc.). This requires to add [Microsoft.AspNetCore.Mvc.Core](https://www.nuget.org/packages/Microsoft.AspNetCore.Mvc.Core) nuget package to your project that contains the service. +The naming convention doesn't map the HTTP QUERY method (a safe method that carries its parameters in the request body, useful when a GET request would have too many query string parameters). If you want to expose an action as a QUERY endpoint, use the `[AcceptVerbs("QUERY")]` attribute explicitly. Such an action is treated as a safe method, so it is not audited and doesn't start a transactional unit of work by default, just like a GET request. However, unlike a GET request, a QUERY request still requires the anti-forgery token because it carries a request body. This is consistent with ASP.NET Core, which doesn't treat QUERY as an anti-forgery exempt method. + ### Route Route is calculated based on some conventions: diff --git a/docs/en/framework/architecture/domain-driven-design/unit-of-work.md b/docs/en/framework/architecture/domain-driven-design/unit-of-work.md index 63ee51149a..613620ab13 100644 --- a/docs/en/framework/architecture/domain-driven-design/unit-of-work.md +++ b/docs/en/framework/architecture/domain-driven-design/unit-of-work.md @@ -38,10 +38,10 @@ All of these are automatically handled by the ABP. While the section above explains the UOW as it is database transaction, actually a UOW doesn't have to be transactional. By default; -* **HTTP GET** requests don't start a transactional UOW. They still starts a UOW, but **doesn't create a database transaction**. +* **HTTP GET** and **HTTP QUERY** requests don't start a transactional UOW. They still start a UOW, but **don't create a database transaction**. * All other HTTP request types start a UOW with a database transaction, if database level transactions are supported by the underlying database provider. -This is because an HTTP GET request doesn't (and shouldn't) make any change in the database. You can change this behavior using the options explained below. +This is because they are safe HTTP methods that don't (and shouldn't) make any change in the database. You can change this behavior using the options explained below. ## Default Options diff --git a/docs/en/framework/infrastructure/audit-logging.md b/docs/en/framework/infrastructure/audit-logging.md index c5885f0d01..ab2c59748e 100644 --- a/docs/en/framework/infrastructure/audit-logging.md +++ b/docs/en/framework/infrastructure/audit-logging.md @@ -48,7 +48,7 @@ Here, a list of the options you can configure: * `IsEnabledForAnonymousUsers` (default: `true`): If you want to write audit logs only for the authenticated users, set this to `false`. If you save audit logs for anonymous users, you will see `null` for `UserId` values for these users. * `AlwaysLogOnException` (default: `true`): If you set to true, it always saves the audit log on an exception/error case without checking other options (except `IsEnabled`, which completely disables the audit logging). * `IsEnabledForIntegrationServices` (default: `false`): Audit Logging is disabled for [integration services](../api-development/integration-services.md) by default. Set this property as `true` to enable it. -* `IsEnabledForGetRequests` (default: `false`): HTTP GET requests should not make any change in the database normally and audit log system doesn't save audit log objects for GET request. Set this to `true` to enable it also for the GET requests. +* `IsEnabledForGetRequests` (default: `false`): Safe HTTP methods (GET, HEAD and QUERY) should not make any change in the database normally and the audit log system doesn't save audit log objects for these requests. Set this to `true` to enable it also for the safe requests. * `DisableLogActionInfo` (default: `false`):If you set to true, Will no longer log `AuditLogActionInfo`. * `ApplicationName`: If multiple applications are saving audit logs into a single database, set this property to your application name, so you can distinguish the logs of different applications. If you don't set, it will set from the `IApplicationInfoAccessor.ApplicationName` value, which is the entry assembly name by default. * `IgnoredTypes`: A list of `Type`s to be ignored for audit logging. If this is an entity type, changes for this type of entities will not be saved. This list is also used while serializing the action parameters. diff --git a/docs/en/framework/infrastructure/blob-storing/aws.md b/docs/en/framework/infrastructure/blob-storing/aws.md index 1ee55bbb74..5e90e34e78 100644 --- a/docs/en/framework/infrastructure/blob-storing/aws.md +++ b/docs/en/framework/infrastructure/blob-storing/aws.md @@ -67,7 +67,7 @@ Configure(options => * **ProfilesLocation** (string): The path to the aws credentials file to look at. * **Region** (string): The system name of the AWS region (e.g., `us-east-1`). **Required** for real AWS S3. Optional when `ServiceURL` is configured for an S3-compatible service; some services accept any value (or `auto` for Cloudflare R2). * **ServiceURL** (string): Custom service URL for S3-compatible APIs (e.g., MinIO, DigitalOcean Spaces, Cloudflare R2). If not specified, the default AWS S3 service URL will be used based on the region. When using S3-compatible services, this should point to your service endpoint (e.g., `https://minio.example.com:9000`). The AWS SDK automatically appends a trailing slash to the configured value. -* **DisablePayloadSigning** (bool): Default `false`. When set to `true`, the provider sends `x-amz-content-sha256: UNSIGNED-PAYLOAD` on `PutObject` requests instead of the streaming chunked signature (`STREAMING-AWS4-HMAC-SHA256-PAYLOAD`) that the AWS SDK v4 uses by default. Required for Cloudflare R2 and other S3-compatible services that do not implement streaming signing. The endpoint must be HTTPS when this option is enabled. Leave as `false` for real AWS S3. +* **DisablePayloadSigning** (bool): Default `false`. When set to `true`, the provider sends `x-amz-content-sha256: UNSIGNED-PAYLOAD` on `PutObject` and multipart `UploadPart` requests instead of the streaming chunked signature (`STREAMING-AWS4-HMAC-SHA256-PAYLOAD`) that the AWS SDK v4 uses by default. Required for Cloudflare R2 and other S3-compatible services that do not implement streaming signing. The endpoint must be HTTPS when this option is enabled. Leave as `false` for real AWS S3. * **Policy** (string): An IAM policy in JSON format that you want to use as an inline session policy. * **DurationSeconds** (int): Validity period(s) of a temporary access certificate,minimum is 900 and the maximum is 3600. **note**: Using sub-accounts operated OSS,if the value is 0. * **ContainerName** (string): You can specify the container name in Aws. If this is not specified, it uses the name of the BLOB container defined with the `BlobContainerName` attribute (see the [BLOB storing document](../blob-storing)). Please note that Aws has some **rules for naming containers**. A container name must be a valid DNS name, conforming to the [following naming rules](https://docs.aws.amazon.com/AmazonS3/latest/dev/BucketRestrictions.html): @@ -147,7 +147,21 @@ Configure(options => > **Note**: When using S3-compatible services, the provider automatically enables path-style requests which are required by most S3-compatible implementations. -> **Note on `DisablePayloadSigning`**: AWS SDK v4 sends `PutObject` requests with `x-amz-content-sha256: STREAMING-AWS4-HMAC-SHA256-PAYLOAD`. Cloudflare R2 (and some other S3-compatible services) return `501 NotImplemented` for this signing mode. Setting `DisablePayloadSigning = true` switches to `UNSIGNED-PAYLOAD`, which these services accept. The endpoint must be HTTPS. Leave it `false` for real AWS S3. +> **Note on `DisablePayloadSigning`**: AWS SDK v4 sends `PutObject` and multipart `UploadPart` requests with `x-amz-content-sha256: STREAMING-AWS4-HMAC-SHA256-PAYLOAD`. Cloudflare R2 (and some other S3-compatible services) return `501 NotImplemented` for this signing mode. Setting `DisablePayloadSigning = true` switches to `UNSIGNED-PAYLOAD` (for the multipart parts too), which these services accept. The endpoint must be HTTPS. Leave it `false` for real AWS S3. + +## Non-Seekable Uploads + +The AWS SDK can not rewind a non-seekable stream to retry a failed upload. For the containers using the [encryption](./encryption.md) or the [content pipeline](./pipeline.md) (which produce non-seekable streams), the provider compensates for that; containers without these features keep the plain `PutObject` upload they always had, also for non-seekable streams: + +* A source with a known length of up to 16 MB is buffered in memory and uploaded as a regular, retryable `PutObject` request. +* A larger (or unknown-length) source is uploaded as a **multipart upload** (`TransferUtility`), which buffers and retries the upload part by part with constant memory usage. + +Notes on the multipart path: + +* The `ETag` of a multipart object is not the MD5 of the content. +* The SDK aborts a failed multipart upload, but an abort can also fail (network cut, process exit); when it does, the abort error is what surfaces (the original upload error is replaced). Configure an [AbortIncompleteMultipartUpload lifecycle rule](https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpu-abort-incomplete-mpu-lifecycle-config.html) on the bucket, so incomplete parts do not accumulate storage costs. +* A non-seekable multipart upload uses 5 MB parts, which limits a single BLOB to about 48.8 GB (the 10,000 parts limit of S3). +* Some S3-compatible services do not implement multipart uploads completely; validate your service before enabling encryption or pipeline contributors on large BLOBs. (With a custom `ServiceURL`, the client requests checksums only when required, so no default CRC part checksums are sent.) ## Aws Blob Name Calculator diff --git a/docs/en/framework/infrastructure/blob-storing/encryption.md b/docs/en/framework/infrastructure/blob-storing/encryption.md new file mode 100644 index 0000000000..5797a4a0ee --- /dev/null +++ b/docs/en/framework/infrastructure/blob-storing/encryption.md @@ -0,0 +1,249 @@ +```json +//[doc-seo] +{ + "Description": "Learn how to encrypt BLOBs at rest in ABP Framework, using container-specific, tenant-specific or global passphrases." +} +``` + +# BLOB Encryption + +The BLOB Storing system can **encrypt BLOBs at rest**, transparently, on top of the configured [storage provider](../blob-storing): the BLOB stream is encrypted (AES-256-GCM, authenticated) before it reaches the provider and decrypted while it is read back. The `IBlobProvider` interface stays unchanged, but a provider must handle non-seekable, non-replayable input streams correctly (the built-in providers were adjusted where needed; the MinIO provider still requires a known content length — see the behavioral notes below). The combination is covered by automated tests for the File System provider; other providers consume the same standard stream contract, but validate your provider setup before relying on it in production. + +> Read the [BLOB Storing document](../blob-storing) to understand how to use the BLOB storing system. The encryption is part of the [Volo.Abp.BlobStoring](https://www.nuget.org/packages/Volo.Abp.BlobStoring) package; no additional package is needed. It requires a platform with AES-GCM support; it is not available on .NET Standard 2.0 targets (like .NET Framework). + +## Enabling Encryption + +Encryption is enabled **per container**, with the `UseEncryption` extension method: + +**Example: Encrypt the BLOBs of a specific container** + +````csharp +Configure(options => +{ + options.Containers.Configure(container => + { + container.UseEncryption(); + }); +}); + +// A passphrase must be configured (here globally); see "Resolving the Passphrase" below +Configure(options => +{ + options.DefaultPassPhrase = context.Configuration["MyApp:BlobPassPhrase"]; +}); +```` + +**Example: Encrypt all containers by default** + +````csharp +Configure(options => +{ + options.Containers.ConfigureDefault(container => + { + container.UseEncryption(); + }); + + // A single container can still opt out: + options.Containers.Configure(container => + { + container.DisableEncryption(); + }); +}); +```` + +Containers that don't enable encryption are not affected at all. + +> `DisableEncryption()` turns the transparent decryption off and also clears this container's **own** passphrase and legacy option. Re-enabling it later with a parameterless `UseEncryption()` restores any values still inherited from the default container configuration; a container-specific passphrase that was cleared has to be passed again. BLOBs that were already stored encrypted are then returned **as stored** (raw `ABPE` ciphertext) while reading, without an error (when the container also uses [pipeline contributors](./pipeline.md), they still run and typically fail on the ciphertext). Re-saving under the old configuration does not help, since the save encrypts again: read the BLOBs **while encryption is still enabled**, export the plain content to a temporary location, apply the configuration change and write the content back. + +## Resolving the Passphrase + +When encryption is enabled, the passphrase for a **new** BLOB is resolved in the following order: + +1. **Container-specific passphrase**: If a passphrase is passed to the `UseEncryption` method, it is always used for that container. Calling `UseEncryption()` again without parameters keeps the configured values, so multiple modules can safely compose the configuration; use `ClearEncryptionPassPhrase()` to remove a configured or inherited container passphrase: + +````csharp +options.Containers.Configure(container => +{ + container.UseEncryption("my-container-passphrase"); +}); +```` + +2. **Global passphrase**: The `AbpBlobStoringEncryptionOptions.DefaultPassPhrase` is used as the fallback: + +````csharp +Configure(options => +{ + options.DefaultPassPhrase = "my-global-passphrase"; +}); +```` + +If encryption is enabled but no passphrase can be resolved, saving and reading encrypted BLOBs fails with an `AbpException` (on .NET Standard 2.0 targets a `PlatformNotSupportedException` is thrown before that, see above). + +> Treat passphrases as production secrets: read them from your configuration/secret store instead of hard-coding them, and prefer long, machine-generated values. + +The **source** of the passphrase is recorded in the encrypted BLOB, and only that source is used while decrypting it. So, for example, a BLOB written with the global passphrase stays readable after a container-specific passphrase is configured later. + +> Keep your passphrases safe. If the passphrase a BLOB was encrypted with is lost or changed, that BLOB can not be decrypted anymore. + +### Customizing the Passphrase Resolution + +The passphrase resolution is implemented by the `IBlobEncryptionKeyProvider` service. The default implementation (`DefaultBlobEncryptionKeyProvider`) applies the rules above. You can [replace](../../fundamentals/dependency-injection.md) it with your own implementation to read the passphrases from another source, like a vault or another secret store (the provider must be able to return the passphrase itself; hardware-backed non-exportable keys are not supported). + +A custom provider can also supply **tenant-specific** passphrases: return `BlobEncryptionKeySource.Tenant` while encrypting and resolve the same tenant's passphrase when it is requested for decryption. The key source recorded in the BLOB header routes each BLOB back to the provider that can decrypt it. The following implementation gives every tenant its own passphrase and keeps the standard rules for the host side: + +````csharp +[Dependency(ReplaceServices = true)] +public class MyTenantBlobEncryptionKeyProvider : DefaultBlobEncryptionKeyProvider +{ + public MyTenantBlobEncryptionKeyProvider( + IOptions options) + : base(options) + { + } + + public override async Task ResolveForEncryptionAsync( + BlobEncryptionKeyContext context, + CancellationToken cancellationToken = default) + { + // Keep a container-specific passphrase as the highest-priority source + var containerPassPhrase = GetContainerPassPhraseOrNull(context.Configuration); + if (string.IsNullOrWhiteSpace(containerPassPhrase) && context.TenantId.HasValue) + { + return new BlobEncryptionKey( + BlobEncryptionKeySource.Tenant, + await GetTenantPassPhraseAsync(context.TenantId.Value, cancellationToken) + ); + } + + return await base.ResolveForEncryptionAsync(context, cancellationToken); + } + + public override async Task ResolveForDecryptionAsync( + BlobEncryptionKeySource keySource, + BlobEncryptionKeyContext context, + CancellationToken cancellationToken = default) + { + if (keySource == BlobEncryptionKeySource.Tenant) + { + if (!context.TenantId.HasValue) + { + throw new AbpException( + "The BLOB was encrypted with a tenant-specific passphrase, " + + "but there is no current tenant!"); + } + + return await GetTenantPassPhraseAsync(context.TenantId.Value, cancellationToken); + } + + return await base.ResolveForDecryptionAsync(keySource, context, cancellationToken); + } + + private Task GetTenantPassPhraseAsync( + Guid tenantId, CancellationToken cancellationToken) + { + // Read the tenant's passphrase from your secret store. It must return + // the same value for the lifetime of the tenant's BLOBs. + throw new NotImplementedException(); + } +} +```` + +Notes on this pattern: + +* The multi-tenant BLOB containers already isolate tenants physically (see the [BLOB Storing document](../blob-storing)); tenant-specific passphrases add **cryptographic** isolation on top: one tenant's BLOBs can not be decrypted with another tenant's (or the host's) passphrase, and the tenant identity is part of the authenticated data. +* The tenant is taken from `context.TenantId` (the tenant the BLOB belongs to), which is correct for both saving and reading — no ambient `ICurrentTenant` lookup is needed. +* Tenant passphrases only apply to containers with `IsMultiTenant = true` (the default). A shared (`IsMultiTenant = false`) container runs its BLOB operations in the host context (`context.TenantId` is null there), so the sample never selects the tenant source on such a container and falls back to the container/global passphrase. + +## BLOBs Stored Before Enabling Encryption + +By default, reading a BLOB that does not have the encrypted format fails, so a tampered or corrupted BLOB can not silently bypass the authenticity check. If a container already has plaintext BLOBs from before encryption was enabled, allow reading them explicitly: + +````csharp +options.Containers.Configure(container => +{ + container.UseEncryption(allowLegacyPlainText: true); +}); +```` + +With this option, content that does not start with the recognized encrypted format magic is returned as-is, **without any authenticity check** — including an encrypted BLOB whose leading magic bytes were corrupted or stripped. (A BLOB that still starts with the format magic but has a corrupted header is *not* returned as plaintext; it fails as an invalid encrypted format.) Treat it as a short-term migration switch: new BLOBs are always encrypted, and the option should be disabled once the existing BLOBs are migrated (re-saved). + +A typical migration of an existing container: + +1. Enable encryption with `UseEncryption(allowLegacyPlainText: true)` and deploy. New and updated BLOBs are written encrypted; the existing plaintext BLOBs stay readable. +2. Re-save the existing BLOBs (the BLOB storing system has no list operation, so iterate the BLOB names from your own application data): + +````csharp +var bytes = await container.GetAllBytesAsync(blobName); +await container.SaveAsync(blobName, bytes, overrideExisting: true); +```` + +3. Remove the `allowLegacyPlainText` option, so reading fails closed again for any content that does not have the encrypted format. + +> Legacy plaintext content that itself starts with the `ABPE` format magic can not be distinguished from an encrypted BLOB and fails to be read through the encrypted container. Read it with encryption disabled (or from the raw storage) and re-save it once through the encrypted container to encrypt it. Also note that legacy BLOBs are returned over a non-seekable wrapper stream while this option is enabled (the `Length` stays available when the provider stream knows it). + +## Changing a Passphrase + +The format does not support key rotation: a BLOB is only readable with the exact passphrase it was written with, and there is no way to keep an old and a new passphrase of the **same source** active at the same time. So changing a passphrase in place makes the BLOBs written with the old one permanently unreadable — migrate the content **before** the change: + +* **From the global to a container-specific passphrase**: this direction works without downtime, because the two are different key sources. Configure the new container passphrase; BLOBs recorded with the `Global` source keep decrypting with `DefaultPassPhrase`, while new saves use the container passphrase. Re-save the existing BLOBs (as in the migration steps above) to move them to the new passphrase; the global one can be retired once no BLOB uses it anymore. +* **Any other change**: while the old passphrase is still configured, read the BLOBs and re-save them into a container using a different key source (or export them to a safe location), then apply the change and save them back. Verify the migrated BLOBs are readable before deleting anything. + +## Behavioral Changes for Encrypted Containers + +* The stream returned for an encrypted BLOB (from `GetAsync`) is read-only and non-seekable, and its `Length` is not available; read it sequentially (for example with `CopyToAsync`). (The **encrypting** stream that is uploaded does expose its length when the source exposes both its length and position — that is a save-side detail for providers that need the object size; see the format section.) +* Opening a BLOB throws an `AbpException` when the content does not have a valid encrypted format. **While reading**, a `CryptographicException` is thrown when the content fails authentication (tampered data or a wrong passphrase), and an `AbpException` when a structural corruption is detected (like a missing end-of-stream record on a truncated BLOB). +* Each returned chunk is individually authenticated as it is read; the completeness of the whole BLOB (the authenticated terminal record, and that nothing was truncated or appended at the end) is verified only when the decryption stream is read to its end. When [content-pipeline contributors](./pipeline.md) are enabled, the framework runs this end verification when the composed stream returned by `GetAsync` reaches EOF, so a contributor that stops at its own length or end marker can not hide a truncated terminal record. (This relies on the decrypting stream implementing `IBlobAuthenticatedEndStream`, which the built-in one does; a custom `CreateDecryptingStreamAsync` override that wraps the stream must forward that interface, or the check is skipped.) A caller that intentionally reads only a prefix (and disposes) gets authentication for the chunks it consumed, not a completeness guarantee for the whole BLOB. +* The file system provider retries a failed save only while it is replayable: before the target file was opened, or for a seekable overwrite (where it rewinds the source and truncates the target again). A non-replayable encrypting stream that fails after the target was opened throws, and any partially written content fails closed while reading instead of being returned as damaged data (except with `allowLegacyPlainText`, where a fragment shorter than the format magic is returned as legacy plaintext — see above). +* Some storage providers consume the stream **synchronously** (like the Aliyun provider); they require a source stream that also supports synchronous reads, exactly like they do without encryption. +* The MinIO provider needs the object size before uploading. It works with encrypted content when the source stream exposes its length (and position); a source whose length can not be determined must be materialized (for example, saved as a byte array) first. + +## Performance and Cost + +Deriving the encryption key from the passphrase is intentionally expensive (PBKDF2-SHA256), so leaked storage can not be brute-forced cheaply. Understand the cost profile before enabling encryption on hot containers: + +* One key derivation runs on **every BLOB save** and on **every encrypted BLOB open** (before the stream is returned). The cost does not depend on the BLOB size — it scales with the number of operations, so many small, frequently read BLOBs amplify it the most. +* Every BLOB uses its own random salt, so derivation results can not be cached or reused; reading the same BLOB again derives the key again. +* The default iteration count is 100,000 (tens of milliseconds of CPU per operation, hardware dependent). Measure on your target hardware and concurrency before enabling encryption on high-frequency containers — it is not a microsecond-level transparent overhead. +* Use a long, machine-generated (at least 128 bits of entropy) value from your secret store as the passphrase in production. For low-entropy, human-chosen passphrases you can raise the iteration count — this increases the offline guessing cost and the per-operation CPU cost by the same factor: + +````csharp +Configure(options => +{ + options.KdfIterations = 600_000; // allowed range: 100,000 - 600,000 +}); +```` + +Changing the iteration count only affects newly written BLOBs; existing BLOBs are decrypted with the count recorded in their own header. + +## The Encryption Format + +* Encryption is authenticated (AES-256-GCM): modified, re-ordered, corrupted or truncated content of a BLOB is detected while reading. +* Every encrypted BLOB is bound to its storage identity (the *normalized* container name, BLOB name and tenant). Copying or renaming an encrypted BLOB at the storage level makes it unreadable at the new location, which also makes substituting one (validly encrypted) BLOB for another detectable. Re-writing an older version of the same BLOB back to its own location is not detectable at this layer. +* Because of the identity binding, the following otherwise-legal operations make the affected encrypted BLOBs permanently unreadable: changing the `IsMultiTenant` value of the container (this affects the BLOBs that were saved under a tenant; BLOBs saved in the host context keep the same null tenant identity and stay readable), moving BLOBs between tenants or containers, and switching to a storage provider that normalizes container/BLOB names differently (for example, providers that lowercase container names). The binding is the **logical** identity (the normalized names and the tenant), not the physical location of the provider. Before such a change, read the affected BLOBs under the old configuration and export the plain content — re-saving in place does not help, since the save encrypts again with the old identity — then apply the change and write the content back. +* The container and BLOB names are part of the authenticated identity, so on an encrypted container they must be valid UTF-16 (a name with unpaired surrogates is rejected with an `AbpException`). Normal names are unaffected. +* The data is processed in chunks with **constant memory usage**, independent from the BLOB size. +* Every BLOB is encrypted with its own key, derived (PBKDF2-SHA256) from the passphrase and a random per-BLOB salt. +* When the source stream exposes both its length and position, the encrypted stream exposes its exact resulting length for providers that require the object size before uploading. + +### What Is (Not) Protected + +* Only the BLOB **content** is encrypted. Container names, BLOB names and any provider-level metadata stay in plaintext, so the existence of a BLOB is visible in the storage. The size overhead is deterministic (see below), so the exact plaintext length can be recovered from the stored object size. +* The size overhead is small and deterministic: a 39-byte prefix, plus 20 bytes per 64 KB chunk, plus a 20-byte end-of-stream record (about 0.03% for large BLOBs). +* Server-side encryption offered by the storage provider (like S3 or Azure Storage encryption) is complementary, not redundant: it uses provider-managed keys at the storage layer, while this feature encrypts with application-managed passphrases before the content leaves your application. They can be combined for defense in depth. + +## Troubleshooting + +| Error | Cause and solution | +|---|---| +| `AbpException`: *The BLOB does not have the encrypted BLOB format...* | The BLOB was saved before encryption was enabled (or by an application without encryption). Use `allowLegacyPlainText: true` during the migration. | +| `AbpException`: *...no passphrase could be resolved* | Encryption is enabled, but neither a container passphrase nor `DefaultPassPhrase` is configured. | +| `AbpException`: *...the default key provider does not supply tenant keys* | The BLOB was encrypted by a custom key provider with a tenant-specific passphrase; the same provider must be registered to read it back. | +| `AbpException`: *...that passphrase is not available anymore* | The passphrase of the key source recorded in the BLOB was removed or cleared from the configuration. Restore it. | +| `CryptographicException` while reading | Wrong passphrase, tampered/corrupted content, or the BLOB was copied, renamed or moved across containers/tenants at the storage level (see the identity binding above). | +| `PlatformNotSupportedException` | The application runs on .NET Standard 2.0 (like .NET Framework) or on a platform without AES-GCM support. | + +## See Also + +* [BLOB Storing](../blob-storing) +* [BLOB Content Pipeline](./pipeline.md) +* [Creating a custom BLOB storage provider](./custom-provider.md) diff --git a/docs/en/framework/infrastructure/blob-storing/index.md b/docs/en/framework/infrastructure/blob-storing/index.md index a00ad62ec8..c82410395d 100644 --- a/docs/en/framework/infrastructure/blob-storing/index.md +++ b/docs/en/framework/infrastructure/blob-storing/index.md @@ -313,6 +313,44 @@ Configure(options => > If your application is not multi-tenant, no worry, it works as expected. You don't need to configure the `IsMultiTenant` option. +## Encrypting BLOBs + +The BLOB Storing system can **encrypt BLOBs at rest**, transparently, on top of the configured storage provider: + +````csharp +Configure(options => +{ + options.Containers.Configure(container => + { + container.UseEncryption(); + }); +}); + +// A passphrase must be configured; see the encryption document +Configure(options => +{ + options.DefaultPassPhrase = context.Configuration["MyApp:BlobPassPhrase"]; +}); +```` + +The encryption passphrase can be container-specific or **global**, and per-tenant passphrases can be plugged in over the key provider; every BLOB derives its own encryption key from the passphrase. See the [BLOB Encryption document](./encryption.md) for details. + +## Transforming BLOB Content + +The BLOB content can be passed through a **pipeline of contributors** (compression, watermarking, content validation...) while it is saved and read, without changing the storage provider: + +````csharp +Configure(options => +{ + options.Containers.Configure(container => + { + container.PipelineContributors.Add(); + }); +}); +```` + +See the [BLOB Content Pipeline document](./pipeline.md) for details. + ## Extending the BLOB Storing System Most of the times, you won't need to customize the BLOB storage system except [creating a custom BLOB storage provider](./custom-provider.md). However, you can replace any service (injected via [dependency injection](../../fundamentals/dependency-injection.md)), if you need. Here, some other services not mentioned above, but you may want to know: @@ -328,4 +366,6 @@ If you want to create folders and move files between folders, assign permissions ## See Also +* [BLOB Content Pipeline](./pipeline.md) +* [BLOB Encryption](./encryption.md) * [Creating a custom BLOB storage provider](./custom-provider.md) diff --git a/docs/en/framework/infrastructure/blob-storing/pipeline.md b/docs/en/framework/infrastructure/blob-storing/pipeline.md new file mode 100644 index 0000000000..ecd48e3639 --- /dev/null +++ b/docs/en/framework/infrastructure/blob-storing/pipeline.md @@ -0,0 +1,122 @@ +```json +//[doc-seo] +{ + "Description": "Learn how to transform BLOB content transparently (compression, validation, watermarking...) with pipeline contributors in ABP Framework." +} +``` + +# BLOB Content Pipeline + +The BLOB Storing system can pass the BLOB content through a **pipeline of contributors** while it is saved and read. A contributor transforms the content stream transparently, on top of the configured [storage provider](../blob-storing): compression, watermarking, content validation or any other stream transformation can be implemented without changing the storage provider or the application code that works with `IBlobContainer`. + +> Read the [BLOB Storing document](../blob-storing) to understand how to use the BLOB storing system. The pipeline is part of the [Volo.Abp.BlobStoring](https://www.nuget.org/packages/Volo.Abp.BlobStoring) package; no additional package is needed. + +## Creating a Pipeline Contributor + +A pipeline contributor implements the `IBlobPipelineContributor` interface. The following example compresses the BLOBs with GZip: + +````csharp +public class GZipBlobPipelineContributor : IBlobPipelineContributor, ITransientDependency +{ + public async Task OnSavingAsync(BlobPipelineContext context) + { + var compressedStream = new MemoryStream(); + try + { + using (var gzipStream = new GZipStream(compressedStream, CompressionLevel.Fastest, leaveOpen: true)) + { + await context.BlobStream.CopyToAsync(gzipStream, context.CancellationToken); + } + } + catch + { + // A stream is only tracked for disposal by the pipeline once it is assigned + // to context.BlobStream, so dispose it here if the eager work fails first + compressedStream.Dispose(); + throw; + } + + compressedStream.Position = 0; + context.BlobStream = compressedStream; + } + + public Task OnGettingAsync(BlobPipelineContext context) + { + context.BlobStream = new GZipStream(context.BlobStream, CompressionMode.Decompress); + return Task.CompletedTask; + } +} +```` + +* `OnSavingAsync` is called before the BLOB reaches the storage provider. Replace `context.BlobStream` with the transformed content; it is also allowed to materialize the content eagerly, like the example does (a lazily transforming, read-only wrapper keeps the memory usage constant instead, which is preferable for large BLOBs). +* `OnGettingAsync` is called after the BLOB was read from the storage provider, in the reverse direction of `OnSavingAsync`. +* The `BlobPipelineContext` also provides the normalized container/BLOB names, the container configuration, the tenant id and a scoped `ServiceProvider`. Contributors are resolved from the [dependency injection](../../fundamentals/dependency-injection.md) system (register them like any other service, for example with `ITransientDependency`). While saving, the scope stays alive until the save operation completes; while getting, until the stream returned to the caller is disposed. + +### The Stream Ownership Contract + +* If a stream (or the DI scope) fails to dispose **after** the storage provider already saved the BLOB, `SaveAsync` still throws that cleanup error even though the data is committed — a retry with the default `overrideExisting: false` would then get a `BlobAlreadyExistsException`. +* **While saving**, do not dispose the stream you received (notice the `leaveOpen: true` in the example): every stream you assign to `context.BlobStream` is disposed after the save, while the original stream stays owned by the caller. A stream is only tracked from the moment it is assigned, so if you create a stream and then do work that may fail (like the eager copy above) before assigning it, dispose it yourself on the failure path. +* **While getting**, the stream you set must dispose the stream you received when it is disposed (a `GZipStream` already does that by default), because the composed stream is returned to the caller as a whole. + +## Configuring Containers + +Contributors are configured **per container**, like the other container options: + +````csharp +Configure(options => +{ + options.Containers.Configure(container => + { + container.PipelineContributors.Add(); + }); +}); +```` + +Configuring the default container applies the contributor to all containers; a named container can add its own contributors on top of them: + +````csharp +Configure(options => +{ + options.Containers.ConfigureDefault(container => + { + container.PipelineContributors.Add(); + }); + + options.Containers.Configure(container => + { + // Runs after the GZip contributor while saving + container.PipelineContributors.Add(); + }); +}); +```` + +Think of the composition as **global stages plus container stages**: the contributors of the default container run first while saving, then the own ones of the container (each contributor type runs once). The inherited contributors are kept even when a container overrides its storage provider; set `InheritPipelineContributors` to `false` on a container to opt out of the global stages completely: + +````csharp +options.Containers.Configure(container => +{ + container.InheritPipelineContributors = false; +}); +```` + +## Execution Order and Encryption + +* While **saving**, the contributors run in the configuration order, and the built-in [encryption](./encryption.md) always runs **after** them (immediately before the storage provider). +* While **getting**, the decryption runs first and the contributors run in the **reverse** order. + +So, contributors always work on the plain content, a compressing contributor always compresses before the encryption (encrypted data can not be compressed), and the stored form is always ciphertext when the encryption is enabled. + +## Behavioral Notes + +* The stream returned for a container with contributors is generally read-only and non-seekable, and its `Length` is only available when the transformation can provide it. See the behavioral notes of the [BLOB Encryption document](./encryption.md) — the same stream semantics apply to the pipeline. +* When a contributor changes the content size lazily, the final length is unknown to the storage provider; providers that require the object size before uploading need an eagerly materialized (or length-aware) stream. +* Some storage providers consume the stream **synchronously** (like the Aliyun provider); they require contributor streams that also support synchronous reads, exactly like they do without the pipeline. +* Containers without contributors are not affected at all. + +> **A contributor that transforms the content is part of the persisted data format.** A BLOB is only readable with the same transforming contributors, in the same order, it was saved with: adding, removing or re-ordering **transforming** contributors on a container that already has BLOBs makes the existing content fail to be read (or, for transformations without an own format check, silently return wrong content). A **metadata-only** contributor that neither consumes nor replaces `context.BlobStream` does not change the stored format, so it can be added to a container with existing BLOBs. A contributor that reads the content to validate it must return a pass-through wrapper (it still counts as consuming the stream); not replacing the stream after reading it would leave an empty/truncated stream for the provider. To change transforming contributors, migrate by reading the BLOBs **with the old configuration** and exporting the plain content, applying the change, and then writing the content back; re-saving in place under the old configuration does not change the stored form. + +## See Also + +* [BLOB Storing](../blob-storing) +* [BLOB Encryption](./encryption.md) +* [Creating a custom BLOB storage provider](./custom-provider.md) diff --git a/docs/en/framework/ui/angular/service-proxies.md b/docs/en/framework/ui/angular/service-proxies.md index c816158f77..4f1c4abc5d 100644 --- a/docs/en/framework/ui/angular/service-proxies.md +++ b/docs/en/framework/ui/angular/service-proxies.md @@ -88,12 +88,15 @@ export const environment: Config.Environment = { - **target:** Target for the Angular project to place the generated code. For example, if it's `permission-management`, it'll look like this (npm/ng-packs/packages/*permission-management*). - **entryPoint:** To create the generated proxy folder in the target. The directory is `permission-management/proxy/src/lib/proxy` and the `permission-management` is the value of target. If you want to create a folder for the generated proxy, there are two options, you should either set the value `proxy` as the entryPoint or go to project.json and change the `sourceRoot` from `packages/permission-management/src` to `packages/permission-management/proxy/src`. No need to change the sourceRoot of project with the property. if you keep it empty, the proxy will be generated into the folder defined in the sourceRoot property. - **serviceType:** The service type of the generated proxy. The options are `application`, `integration` and `all`. The default value is `application`. A developer can mark a service "integration service". If you want to skip proxy generation for the service, then this is the correct setting. More info about [Integration Services](../../api-development/integration-services.md) +- **resourceApi:** Generates optional `rxResource` helpers for `GET` endpoints. This is off by default and keeps the current Observable-based services unchanged. This parameter requires Angular v22 or later ### Services The `generate-proxy` command generates one service per back-end controller and a method (property with a function value actually) for each action in the controller. These methods call backend APIs via [RestService](./http-requests#restservice). +If you pass `--resource-api`, the generator keeps those methods and adds matching `rxResource` helpers for read operations. + A variable named `apiName` (available as of v2.4) is defined in each service. `apiName` matches the module's `RemoteServiceName`. This variable passes to the `RestService` as a parameter at each request. If there is no microservice API defined in the environment, `RestService` uses the default. See [getting a specific API endpoint from application config](./http-requests#how-to-get-a-specific-api-endpoint-from-application-config) The `providedIn` property of the services is defined as `'root'`. Therefore there is no need to provide them in a module. You can use them directly by injecting as shown below: @@ -188,4 +191,4 @@ When you run a project on Visual Studio using IIS Express as the web server, the ## See Also -* [Video tutorial](https://abp.io/video-courses/essentials/generating-client-proxies) \ No newline at end of file +* [Video tutorial](https://abp.io/video-courses/essentials/generating-client-proxies) diff --git a/docs/en/suite/create-solution.md b/docs/en/suite/create-solution.md index 1ec5a799cd..e6fe1afc78 100644 --- a/docs/en/suite/create-solution.md +++ b/docs/en/suite/create-solution.md @@ -23,8 +23,8 @@ ## Create a new solution -From ABP Suite v8.3, **create a new solution** option has been removed. Instead, it's suggessted to use [ABP Studio](../studio/index.md) or [ABP CLI](../cli/index.md) to create a new solution. After, creating an ABP Solution, then you can generate CRUD pages via ABP Suite as always. +From ABP Suite v8.3, **create a new solution** option has been removed. Instead, it's suggested to use [ABP Studio](../studio/index.md) or [ABP CLI](../cli/index.md) to create a new solution. After creating an ABP solution, you can generate CRUD pages via ABP Suite as usual. -> **Note:** ABP Suite does not generate React UI pages. If you create a modern React solution, use Suite where applicable for backend and application-layer generation, and develop the React UI in the source-owned [React UI](../framework/ui/react/index.md) application. ABP license holders can use [ABP Studio AI Agent](https://abp.io/studio/ai-agent) with predefined AI credits to generate React pages more easily. +> **Note:** For modern React UI solutions, ABP Suite generates the backend, the application layer and the React CRUD UI. Develop any React UI that generation does not cover in the source-owned [React UI](../framework/ui/react/index.md) application. ABP license holders can also use the [ABP Studio AI Agent](https://abp.io/studio/ai-agent) with predefined AI credits for React pages. > ABP Studio has a shortcut for running [ABP Suite](./index.md) to allow using it without starting it externally and using it on a browser, which means you can create, manage, deploy your applications in a single desktop application and also generate CRUD pages via Suite as a pre-integrated application to ABP Studio. \ No newline at end of file diff --git a/docs/en/suite/creating-many-to-many-relationship.md b/docs/en/suite/creating-many-to-many-relationship.md index 005cab9896..8310f24c31 100644 --- a/docs/en/suite/creating-many-to-many-relationship.md +++ b/docs/en/suite/creating-many-to-many-relationship.md @@ -75,6 +75,6 @@ Click the **Navigations** tab. Then click the **Add navigation collection** butt The below image is the final page created by ABP Suite. The **new book** dialog has a **Categories** tab which lists all categories of the book and allows add/remove categories. -> **Note:** ABP Suite does not generate React UI for this page. The generated UI applies to Suite-supported UI stacks such as MVC, Blazor and Angular. ABP license holders can use [ABP Studio AI Agent](https://abp.io/studio/ai-agent) with predefined AI credits to generate React pages more easily. +> **Note:** For modern React UI solutions, ABP Suite generates the navigation collection as an inline multi-select on the entity's create/edit dialog — add or remove connected records, pre-selected when editing an existing record, and available as a list filter — alongside the MVC, Blazor and Angular UI stacks. ![navigation-collection-books-page](../images/navigation-collection-books-page.8.1.png) diff --git a/docs/en/suite/creating-master-detail-relationship.md b/docs/en/suite/creating-master-detail-relationship.md index 94bda1b7fa..905b1b7e24 100644 --- a/docs/en/suite/creating-master-detail-relationship.md +++ b/docs/en/suite/creating-master-detail-relationship.md @@ -39,7 +39,7 @@ You need to specify the **Entity type** as _Master_ (default). Then, provide the Create a child entity, specify the **Entity type** as _Child_, and associate it with a master entity. In the figure above, you can see an example of, a child entity **OrderLine** with the associated master entity **Order**. When you specify the **Entity type** as _Child_, then the UI and tests will not be generated for the child entity and instead, all of the orchestration of the entity will be delegated to its master entity. -> **Note:** ABP Suite does not generate React UI for master-detail pages. The generated UI applies to Suite-supported UI stacks such as MVC, Blazor and Angular. ABP license holders can use [ABP Studio AI Agent](https://abp.io/studio/ai-agent) with predefined AI credits to generate React pages more easily. +> **Note:** For modern React UI solutions, ABP Suite generates the master-detail UI: once the master record exists, each child entity renders as a sub-grid on its own tab inside the master edit dialog, with its own add/edit/delete dialog. This is generated in addition to the MVC, Blazor and Angular UI stacks. Application services will be generated for the child entity, so it's possible for you to consume its endpoints and create-update-list or delete the child entity specifically. Suite also respects your multi-tenancy selection and sets the child entity as a multi-tenant entity, if the master entity has multi-tenancy enabled. diff --git a/docs/en/suite/customizing-the-generated-code.md b/docs/en/suite/customizing-the-generated-code.md index 18f63d642c..3bb64647de 100644 --- a/docs/en/suite/customizing-the-generated-code.md +++ b/docs/en/suite/customizing-the-generated-code.md @@ -58,7 +58,7 @@ You can see the example below, which demonstrates defining a new method in the ` For the UI side, ABP Suite provides convenient comment placeholders within pages for MVC, Blazor, and Angular UIs. These comment sections serve as hook points where you can add your custom code. By placing your custom logic with these hook points, you can enhance the UI to your needs without the fear of losing your changes during the next CRUD page generation. -> **Note:** ABP Suite does not generate React UI pages or React customization hook points. React UI is part of ABP's modern template system and is customized directly in the source-owned [React UI](../framework/ui/react/index.md) application. ABP license holders can use [ABP Studio AI Agent](https://abp.io/studio/ai-agent) with predefined AI credits to generate React pages and customize them more easily. +> **Note:** These comment-based hook points are available for the MVC, Blazor and Angular UIs. ABP Suite also generates React CRUD pages for modern React UI solutions, but the React pages do not provide the same in-page custom-code hook points yet, so re-generating an entity overwrites its React page. Keep customizations to the React UI in the source-owned [React UI](../framework/ui/react/index.md) application, outside the generated pages. ABP license holders can also use the [ABP Studio AI Agent](https://abp.io/studio/ai-agent) with predefined AI credits to evolve the React UI. ### Customizations on MVC UI diff --git a/docs/en/suite/editing-templates.md b/docs/en/suite/editing-templates.md index 3a6160a215..1c730356a0 100644 --- a/docs/en/suite/editing-templates.md +++ b/docs/en/suite/editing-templates.md @@ -11,12 +11,12 @@ ABP Suite uses templates to generate the code files. You can see the template fi ![Suite template list](../images/suite-templates-8.1.png) -The are 2 template filters: +There are 2 template filters: -1. **UI**: You can switch between `Angular`, `MVC` and `Blazor` templates. +1. **UI**: You can switch between `Angular`, `MVC`, `Blazor` and `React` templates. 2. **Database provider:** You can switch between `EF Core` and `MongoDb` templates. -> **Note:** ABP Suite does not include React UI templates or generate React client-side code. React UI belongs to the modern template system and should be developed in the source-owned [React UI](../framework/ui/react/index.md) application. ABP license holders can use [ABP Studio AI Agent](https://abp.io/studio/ai-agent) with predefined AI credits to generate React pages more easily. +> **Note:** For modern React UI solutions, the React templates are listed here as well. Customizing one makes ABP Suite generate the React pages from your version instead of the built-in template, the same way as for the Angular, MVC and Blazor UI stacks. These filters are automatically selected based on your ABP solution. The selected solution is shown on the top right of the page. In the current screenshot it is "Acme.BookStore". @@ -33,6 +33,7 @@ There's a naming convention for the template files. * If the template name has `Frontend.Mvc` prefix, it's used for razor pages, menus, JavaScript, CSS files. * If the template name has `Frontend.Blazor` prefix (no `.MudBlazor` segment), it's used for Blazorise-based razor components. * If the template name has `Frontend.Blazor.MudBlazor` prefix, it's used for [MudBlazor](https://mudblazor.com)-based razor components. +* If the template name has `Frontend.React` prefix, it's used for the modern React UI code generation. > The template list only shows the variant that matches your solution's [Blazor UI library](index.md#blazor-ui-library) (Blazorise or MudBlazor), so you never see both trees side-by-side and the "outdated" check only flags the active set. diff --git a/docs/en/suite/generating-crud-page.md b/docs/en/suite/generating-crud-page.md index 07f1e6ca91..3cc36f62ac 100644 --- a/docs/en/suite/generating-crud-page.md +++ b/docs/en/suite/generating-crud-page.md @@ -110,7 +110,7 @@ To create a new entity, make sure the *-New entity-* is selected in the **Entity * **Create user interface:** Creates pages, modals, components, `JavaScript`, `CSS` files and adds the new page to the main menu. If you don't have a requirement to manage the entity via user interface, you can uncheck this option. - > **Note:** This option does not generate React UI pages. ABP Suite's UI generation targets Suite-supported UI stacks such as MVC, Blazor and Angular. For React-based solutions, build the UI in the source-owned [React UI](../framework/ui/react/index.md) application. ABP license holders can use [ABP Studio AI Agent](https://abp.io/studio/ai-agent) with predefined AI credits to generate React pages more easily. + > **Note:** For modern React UI solutions, this option also generates and registers React CRUD pages, alongside the MVC, Blazor and Angular UI stacks. React generation is template-based and does not use AI. > **Blazor UI library:** For Blazor solutions, the generated UI matches the underlying Blazor component library. Solutions that reference `Volo.Abp.MudBlazorUI` produce pages built around `MudDataGrid`, `MudDialog`, `MudForm` and the rest of the [MudBlazor](https://mudblazor.com) controls; Blazorise-based solutions keep emitting the original `DataGrid` / `Modal` / `Validations` markup. ABP Suite picks the variant automatically by scanning the Blazor project `.csproj` and shows the resolved value in the solution info tooltip — no extra switch is required. See the [Blazor UI library](index.md#blazor-ui-library) section for details. diff --git a/docs/en/suite/index.md b/docs/en/suite/index.md index dfeaca8cb4..6ab4232fa3 100644 --- a/docs/en/suite/index.md +++ b/docs/en/suite/index.md @@ -21,7 +21,7 @@ ABP Suite is a complementary tool to the ABP Platform. ABP Suite allows you to build web pages in a matter of minutes. -> **Important:** ABP Suite does not generate React UI pages or client-side React code. Its CRUD UI generation is designed for the established Suite-supported UI stacks, such as MVC, Blazor and Angular. The new [React UI](../framework/ui/react/index.md) belongs to ABP's modern template system and is designed for an AI-first, AI-oriented development flow with a modern frontend stack such as React, TypeScript, Vite, TanStack Router, TanStack Query, shadcn/ui, Zod and React Hook Form. In React UI solutions, ABP Suite can still be useful for the backend and application layers where applicable, but the React UI side is expected to be developed in the source-owned React application. ABP license holders can use [ABP Studio AI Agent](https://abp.io/studio/ai-agent) with predefined AI credits to generate React pages and evolve the UI more easily. +> **Note:** ABP Suite also generates React CRUD pages for modern React UI solutions, in addition to the MVC, Blazor and Angular UI stacks. The generator is template-based and does not use AI. It covers the standard CRUD scenarios — navigation-property lookups, many-to-many navigation collections, master-detail child grids, file upload, enums, filtering, Excel export and bulk delete — and registers each page in the React app's route configuration and menu. It's a .NET Core Global tool that can be installed from the command line. If you are using [ABP Studio](../studio/index.md), you don't even need to install it because it should already be installed, when you first installed the [ABP Studio](../studio/index.md). diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Uow/AbpUowActionFilter.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Uow/AbpUowActionFilter.cs index 5cd9bf8089..f1997fb8d4 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Uow/AbpUowActionFilter.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Uow/AbpUowActionFilter.cs @@ -1,5 +1,4 @@ using System; -using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.Abstractions; @@ -7,6 +6,7 @@ using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Options; using Volo.Abp.AspNetCore.Filters; using Volo.Abp.DependencyInjection; +using Volo.Abp.Http; using Volo.Abp.Threading; using Volo.Abp.Uow; @@ -81,7 +81,8 @@ public class AbpUowActionFilter : IAsyncActionFilter, IAbpFilter, ITransientDepe { var abpUnitOfWorkDefaultOptions = context.GetRequiredService>().Value; options.IsTransactional = abpUnitOfWorkDefaultOptions.CalculateIsTransactional( - autoValue: !string.Equals(context.HttpContext.Request.Method, HttpMethod.Get.Method, StringComparison.OrdinalIgnoreCase) + autoValue: !(HttpMethodHelper.IsGet(context.HttpContext.Request.Method) + || HttpMethodHelper.IsQuery(context.HttpContext.Request.Method)) ); } diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Uow/AbpUowPageFilter.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Uow/AbpUowPageFilter.cs index b086df1a42..d8f86bf261 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Uow/AbpUowPageFilter.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Uow/AbpUowPageFilter.cs @@ -1,5 +1,4 @@ using System; -using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.Abstractions; @@ -7,6 +6,7 @@ using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Options; using Volo.Abp.AspNetCore.Filters; using Volo.Abp.DependencyInjection; +using Volo.Abp.Http; using Volo.Abp.Threading; using Volo.Abp.Uow; @@ -86,7 +86,8 @@ public class AbpUowPageFilter : IAsyncPageFilter, IAbpFilter, ITransientDependen { var abpUnitOfWorkDefaultOptions = context.GetRequiredService>().Value; options.IsTransactional = abpUnitOfWorkDefaultOptions.CalculateIsTransactional( - autoValue: !string.Equals(context.HttpContext.Request.Method, HttpMethod.Get.Method, StringComparison.OrdinalIgnoreCase) + autoValue: !(HttpMethodHelper.IsGet(context.HttpContext.Request.Method) + || HttpMethodHelper.IsQuery(context.HttpContext.Request.Method)) ); } diff --git a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Auditing/AbpAuditingMiddleware.cs b/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Auditing/AbpAuditingMiddleware.cs index 86cc997ae7..112b3be61a 100644 --- a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Auditing/AbpAuditingMiddleware.cs +++ b/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Auditing/AbpAuditingMiddleware.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Options; using Volo.Abp.AspNetCore.Middleware; using Volo.Abp.Auditing; using Volo.Abp.DependencyInjection; +using Volo.Abp.Http; using Volo.Abp.Uow; using Volo.Abp.Users; @@ -135,8 +136,9 @@ public class AbpAuditingMiddleware : AbpMiddlewareBase, ITransientDependency } if (!AuditingOptions.IsEnabledForGetRequests && - (string.Equals(httpContext.Request.Method, HttpMethods.Get, StringComparison.OrdinalIgnoreCase) || - string.Equals(httpContext.Request.Method, HttpMethods.Head, StringComparison.OrdinalIgnoreCase))) + (HttpMethodHelper.IsGet(httpContext.Request.Method) || + HttpMethodHelper.IsHead(httpContext.Request.Method) || + HttpMethodHelper.IsQuery(httpContext.Request.Method))) { return false; } diff --git a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AspNetCoreUnitOfWorkTransactionBehaviourProvider.cs b/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AspNetCoreUnitOfWorkTransactionBehaviourProvider.cs index a1758d3a90..e323b323d1 100644 --- a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AspNetCoreUnitOfWorkTransactionBehaviourProvider.cs +++ b/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AspNetCoreUnitOfWorkTransactionBehaviourProvider.cs @@ -1,8 +1,8 @@ using System; -using System.Net.Http; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; using Volo.Abp.DependencyInjection; +using Volo.Abp.Http; using Volo.Abp.Uow; namespace Volo.Abp.AspNetCore.Uow; @@ -37,10 +37,8 @@ public class AspNetCoreUnitOfWorkTransactionBehaviourProvider : IUnitOfWorkTrans } } - return !string.Equals( - httpContext.Request.Method, - HttpMethod.Get.Method, StringComparison.OrdinalIgnoreCase - ); + var method = httpContext.Request.Method; + return !(HttpMethodHelper.IsGet(method) || HttpMethodHelper.IsQuery(method)); } } diff --git a/framework/src/Volo.Abp.Auditing/Volo/Abp/Auditing/AbpAuditingOptions.cs b/framework/src/Volo.Abp.Auditing/Volo/Abp/Auditing/AbpAuditingOptions.cs index f9f5284b29..81d87023f7 100644 --- a/framework/src/Volo.Abp.Auditing/Volo/Abp/Auditing/AbpAuditingOptions.cs +++ b/framework/src/Volo.Abp.Auditing/Volo/Abp/Auditing/AbpAuditingOptions.cs @@ -62,6 +62,7 @@ public class AbpAuditingOptions //TODO: Move this to asp.net core layer or convert it to a more dynamic strategy? /// /// Default: false. + /// When false, safe methods (GET, HEAD and QUERY) are excluded from audit logging. /// public bool IsEnabledForGetRequests { get; set; } diff --git a/framework/src/Volo.Abp.Auditing/Volo/Abp/Auditing/AuditingInterceptor.cs b/framework/src/Volo.Abp.Auditing/Volo/Abp/Auditing/AuditingInterceptor.cs index 52c6302da2..0dd70d68e7 100644 --- a/framework/src/Volo.Abp.Auditing/Volo/Abp/Auditing/AuditingInterceptor.cs +++ b/framework/src/Volo.Abp.Auditing/Volo/Abp/Auditing/AuditingInterceptor.cs @@ -193,6 +193,7 @@ public class AuditingInterceptor : AbpInterceptor, ITransientDependency if (!options.IsEnabledForGetRequests && (string.Equals(auditLogInfo.HttpMethod, "Get", StringComparison.OrdinalIgnoreCase) || string.Equals(auditLogInfo.HttpMethod, "Head", StringComparison.OrdinalIgnoreCase) || + string.Equals(auditLogInfo.HttpMethod, "Query", StringComparison.OrdinalIgnoreCase) || invocation.Method.Name.StartsWith("Get", StringComparison.OrdinalIgnoreCase))) { return false; diff --git a/framework/src/Volo.Abp.BlobStoring.Aws/Properties/AssemblyInfo.cs b/framework/src/Volo.Abp.BlobStoring.Aws/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..83b0e5ff18 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring.Aws/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Volo.Abp.BlobStoring.Aws.Tests")] diff --git a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProvider.cs b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProvider.cs index b81d722bae..505fdb4377 100644 --- a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProvider.cs +++ b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProvider.cs @@ -1,8 +1,11 @@ using System; using System.IO; +using System.Linq; +using System.Threading; using System.Threading.Tasks; using Amazon.S3; using Amazon.S3.Model; +using Amazon.S3.Transfer; using Amazon.S3.Util; using Volo.Abp.DependencyInjection; @@ -10,6 +13,10 @@ namespace Volo.Abp.BlobStoring.Aws; public class AwsBlobProvider : BlobProviderBase, ITransientDependency { + // Aligned with the TransferUtility threshold under which a non-seekable upload + // would be sent as a single (non-retryable) request instead of multipart + protected const long MaxBufferedUploadLength = 16 * 1024 * 1024; + protected IAwsBlobNameCalculator AwsBlobNameCalculator { get; } protected IAmazonS3ClientFactory AmazonS3ClientFactory { get; } protected IBlobNormalizeNamingService BlobNormalizeNamingService { get; } @@ -43,13 +50,30 @@ public class AwsBlobProvider : BlobProviderBase, ITransientDependency await CreateContainerIfNotExists(amazonS3Client, containerName); } - await amazonS3Client.PutObjectAsync(new PutObjectRequest + if (!RequiresRetrySafeUpload(args)) { - BucketName = containerName, - Key = blobName, - InputStream = args.BlobStream, - DisablePayloadSigning = configuration.DisablePayloadSigning - }); + await PutObjectAsync(amazonS3Client, containerName, blobName, args.BlobStream, configuration, args.CancellationToken); + return; + } + + // The SDK can not retry the upload of a non-seekable stream (like an encrypting + // stream). A small source with a known length is buffered in memory and uploaded + // as a retryable PutObject; anything larger (or with an unknown length) goes + // through a TransferUtility multipart upload, which buffers and retries part by part + var remainingLength = GetRemainingLengthOrNull(args.BlobStream); + if (remainingLength != null && remainingLength <= MaxBufferedUploadLength) + { + using (var bufferedStream = new MemoryStream((int)remainingLength.Value)) + { + await args.BlobStream.CopyToAsync(bufferedStream, 81920, args.CancellationToken); + bufferedStream.Position = 0; + await PutObjectAsync(amazonS3Client, containerName, blobName, bufferedStream, configuration, args.CancellationToken); + } + + return; + } + + await UploadMultipartAsync(amazonS3Client, containerName, blobName, args.BlobStream, configuration, args.CancellationToken); } } @@ -108,6 +132,95 @@ public class AwsBlobProvider : BlobProviderBase, ITransientDependency } } + /// + /// The retry-safe (buffered/multipart) upload only applies to non-seekable streams + /// of containers using the encryption or the content pipeline; other containers + /// keep the plain PutObject behavior they always had. + /// + protected virtual bool RequiresRetrySafeUpload(BlobProviderSaveArgs args) + { + if (args.BlobStream.CanSeek) + { + return false; + } + + return args.Configuration.IsEncryptionEnabled() || + args.Configuration.GetEffectivePipelineContributors().Any(); + } + + protected virtual long? GetRemainingLengthOrNull(Stream stream) + { + try + { + var remainingLength = stream.Length - stream.Position; + return remainingLength >= 0 ? remainingLength : null; + } + catch (Exception ex) when (ex is NotSupportedException || ex is IOException) + { + // The length is optional; a probe failure must not fail the save + return null; + } + } + + protected virtual async Task PutObjectAsync( + AmazonS3Client amazonS3Client, + string containerName, + string blobName, + Stream blobStream, + AwsBlobProviderConfiguration configuration, + CancellationToken cancellationToken) + { + await amazonS3Client.PutObjectAsync(CreatePutObjectRequest(containerName, blobName, blobStream, configuration), cancellationToken); + } + + protected virtual PutObjectRequest CreatePutObjectRequest( + string containerName, + string blobName, + Stream blobStream, + AwsBlobProviderConfiguration configuration) + { + return new PutObjectRequest + { + BucketName = containerName, + Key = blobName, + InputStream = blobStream, + AutoCloseStream = false, + DisablePayloadSigning = configuration.DisablePayloadSigning + }; + } + + protected virtual async Task UploadMultipartAsync( + AmazonS3Client amazonS3Client, + string containerName, + string blobName, + Stream blobStream, + AwsBlobProviderConfiguration configuration, + CancellationToken cancellationToken) + { + using (var transferUtility = new TransferUtility(amazonS3Client)) + { + await transferUtility.UploadAsync(CreateMultipartUploadRequest(containerName, blobName, blobStream, configuration), cancellationToken); + } + } + + protected virtual TransferUtilityUploadRequest CreateMultipartUploadRequest( + string containerName, + string blobName, + Stream blobStream, + AwsBlobProviderConfiguration configuration) + { + return new TransferUtilityUploadRequest + { + BucketName = containerName, + Key = blobName, + // The unseekable multipart path of the SDK ignores AutoCloseStream + // and disposes the input, so the ownership is protected by a wrapper + InputStream = new LeaveOpenStreamWrapper(blobStream), + AutoCloseStream = false, + DisablePayloadSigning = configuration.DisablePayloadSigning + }; + } + protected virtual async Task GetAmazonS3Client(BlobProviderArgs args) { var configuration = args.Configuration.GetAwsConfiguration(); diff --git a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs index fa318bdcb8..17fa070f2e 100644 --- a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs +++ b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs @@ -94,7 +94,7 @@ public class AwsBlobProviderConfiguration } /// - /// When true, payload signing is disabled on PutObject upload requests so the SDK sends + /// When true, payload signing is disabled on PutObject and UploadPart upload requests so the SDK sends /// x-amz-content-sha256: UNSIGNED-PAYLOAD instead of the streaming chunked signature /// (STREAMING-AWS4-HMAC-SHA256-PAYLOAD) that AWS SDK v4 uses by default. Required for /// Cloudflare R2 and other S3-compatible services that do not implement streaming signing. diff --git a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/LeaveOpenStreamWrapper.cs b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/LeaveOpenStreamWrapper.cs new file mode 100644 index 0000000000..c2385a5af7 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/LeaveOpenStreamWrapper.cs @@ -0,0 +1,90 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Volo.Abp.BlobStoring.Aws; + +/// +/// The unseekable multipart path of the AWS SDK ignores AutoCloseStream and +/// disposes its input; this wrapper protects the ownership of the wrapped stream. +/// +internal sealed class LeaveOpenStreamWrapper : Stream +{ + private readonly Stream _inner; + + public LeaveOpenStreamWrapper(Stream inner) + { + _inner = inner; + } + + public override bool CanRead => _inner.CanRead; + public override bool CanSeek => _inner.CanSeek; + public override bool CanWrite => false; + + // The SDK computes the optional content length from Length/Position, but only + // handles NotSupportedException; translate an IOException of a probe, so an + // unknown length stays "unknown" instead of failing the upload + public override long Length + { + get + { + try + { + return _inner.Length; + } + catch (IOException ex) + { + throw new NotSupportedException("The length of the stream is not available!", ex); + } + } + } + + public override long Position + { + get + { + try + { + return _inner.Position; + } + catch (IOException ex) + { + throw new NotSupportedException("The position of the stream is not available!", ex); + } + } + set => _inner.Position = value; + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { +#if NETSTANDARD2_0 + return _inner.ReadAsync(buffer, offset, count, cancellationToken); +#else + // The SDK reads over this (old) overload; dispatch it over the modern one, + // so a source that only implements ReadAsync(Memory) keeps working + return _inner.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); +#endif + } + +#if !NETSTANDARD2_0 + public override int Read(Span buffer) => _inner.Read(buffer); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + return _inner.ReadAsync(buffer, cancellationToken); + } +#endif + + public override long Seek(long offset, SeekOrigin origin) => _inner.Seek(offset, origin); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + // Disposing the wrapper must not dispose the wrapped stream +} diff --git a/framework/src/Volo.Abp.BlobStoring.FileSystem/Volo/Abp/BlobStoring/FileSystem/FileSystemBlobProvider.cs b/framework/src/Volo.Abp.BlobStoring.FileSystem/Volo/Abp/BlobStoring/FileSystem/FileSystemBlobProvider.cs index 2a410005db..75edbc2ac9 100644 --- a/framework/src/Volo.Abp.BlobStoring.FileSystem/Volo/Abp/BlobStoring/FileSystem/FileSystemBlobProvider.cs +++ b/framework/src/Volo.Abp.BlobStoring.FileSystem/Volo/Abp/BlobStoring/FileSystem/FileSystemBlobProvider.cs @@ -31,12 +31,36 @@ public class FileSystemBlobProvider : BlobProviderBase, ITransientDependency ? FileMode.Create : FileMode.CreateNew; - await Policy.Handle() + // A failure is only retried while it is replayable: before OpenFileStream returns + // (the source is untouched), or for a seekable overwrite (the source can seek back + // and FileMode.Create truncates the partial content). Otherwise a retry would + // replay a half-consumed source or hit the file a failed CreateNew attempt left behind. + long sourcePosition; + try + { + sourcePosition = args.BlobStream.CanSeek && fileMode == FileMode.Create ? args.BlobStream.Position : -1; + } + catch (Exception ex) when (ex is NotSupportedException || ex is IOException) + { + // A failing position probe degrades to a single, non-replayable attempt + sourcePosition = -1; + } + + var targetOpened = false; + + await Policy.Handle(_ => sourcePosition >= 0 || !targetOpened) .WaitAndRetryAsync(2, retryCount => TimeSpan.FromSeconds(retryCount)) .ExecuteAsync(async () => { - using (var fileStream = File.Open(filePath, fileMode, FileAccess.Write)) + if (sourcePosition >= 0) { + args.BlobStream.Seek(sourcePosition, SeekOrigin.Begin); + } + + using (var fileStream = OpenFileStream(filePath, fileMode)) + { + targetOpened = true; + await args.BlobStream.CopyToAsync( fileStream, args.CancellationToken @@ -73,6 +97,11 @@ public class FileSystemBlobProvider : BlobProviderBase, ITransientDependency .ExecuteAsync(() => Task.FromResult(File.OpenRead(filePath))); } + protected virtual Stream OpenFileStream(string filePath, FileMode fileMode) + { + return File.Open(filePath, fileMode, FileAccess.Write); + } + protected virtual Task ExistsAsync(string filePath) { return Task.FromResult(File.Exists(filePath)); diff --git a/framework/src/Volo.Abp.BlobStoring/Properties/AssemblyInfo.cs b/framework/src/Volo.Abp.BlobStoring/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..a4659c3620 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Volo.Abp.BlobStoring.Tests")] diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/AbpBlobStoringEncryptionOptions.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/AbpBlobStoringEncryptionOptions.cs new file mode 100644 index 0000000000..d19f6a1203 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/AbpBlobStoringEncryptionOptions.cs @@ -0,0 +1,21 @@ +namespace Volo.Abp.BlobStoring; + +/// +/// Global options of the BLOB encryption; enable it per container with the +/// extension method. +/// +public class AbpBlobStoringEncryptionOptions +{ + /// + /// The global passphrase, used when no container-specific passphrase is available. + /// Default: null (encryption must be explicitly keyed). + /// + public string? DefaultPassPhrase { get; set; } + + /// + /// PBKDF2 iteration count for newly encrypted BLOBs (existing BLOBs use the count + /// in their own header). Higher values raise both the offline guessing cost and + /// the CPU cost of every save/read. Allowed: 100,000 - 600,000. Default: 100,000. + /// + public int KdfIterations { get; set; } = 100_000; +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainer.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainer.cs index be1846f8c0..e2fa40f587 100644 --- a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainer.cs +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainer.cs @@ -1,7 +1,11 @@ -using System; +using System; +using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Runtime.ExceptionServices; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; using Volo.Abp.MultiTenancy; using Volo.Abp.Threading; @@ -116,16 +120,87 @@ public class BlobContainer : IBlobContainer { var blobNormalizeNaming = BlobNormalizeNamingService.NormalizeNaming(Configuration, ContainerName, name); - await Provider.SaveAsync( - new BlobProviderSaveArgs( - blobNormalizeNaming.ContainerName!, - Configuration, - blobNormalizeNaming.BlobName!, - stream, - overrideExisting, - CancellationTokenProvider.FallbackToProvider(cancellationToken) - ) - ); + var fallbackCancellationToken = CancellationTokenProvider.FallbackToProvider(cancellationToken); + var contributorTypes = Configuration.GetEffectivePipelineContributors().ToList(); + + // Every stream the pipeline creates is disposed after the save (disposing the + // encrypting wrapper also zeroes the derived key); the caller keeps the original + var pipelineStreams = new List(); + AsyncServiceScope? contributorScope = null; + var completed = false; + try + { + if (contributorTypes.Count > 0) + { + contributorScope = ServiceProvider.CreateAsyncScope(); + var context = new BlobPipelineContext( + contributorScope.Value.ServiceProvider, + blobNormalizeNaming.ContainerName!, + blobNormalizeNaming.BlobName!, + Configuration, + GetTenantIdOrNull(), + stream, + fallbackCancellationToken + ); + + context.CreatedStreams = pipelineStreams; + await RunPipelineAsync(context, contributorTypes, saving: true); + stream = context.BlobStream; + } + + if (BlobEncryptionConfiguration.IsEnabled(Configuration)) + { + stream = await CreateEncryptingStreamAsync(blobNormalizeNaming, stream, fallbackCancellationToken); + pipelineStreams.Add(stream); + } + + await Provider.SaveAsync( + new BlobProviderSaveArgs( + blobNormalizeNaming.ContainerName!, + Configuration, + blobNormalizeNaming.BlobName!, + stream, + overrideExisting, + fallbackCancellationToken + ) + ); + + completed = true; + } + finally + { + // Best-effort cleanup: a failing Dispose must not prevent releasing the + // remaining streams/scope or hide the exception that is already propagating + Exception? disposeException = null; + for (var i = pipelineStreams.Count - 1; i >= 0; i--) + { + try + { + await DisposeStreamAsync(pipelineStreams[i]); + } + catch (Exception ex) + { + disposeException ??= ex; + } + } + + if (contributorScope != null) + { + try + { + await contributorScope.Value.DisposeAsync(); + } + catch (Exception ex) + { + disposeException ??= ex; + } + } + + if (completed && disposeException != null) + { + ExceptionDispatchInfo.Capture(disposeException).Throw(); + } + } } } @@ -194,14 +269,296 @@ public class BlobContainer : IBlobContainer var blobNormalizeNaming = BlobNormalizeNamingService.NormalizeNaming(Configuration, ContainerName, name); - return await Provider.GetOrNullAsync( + var fallbackCancellationToken = CancellationTokenProvider.FallbackToProvider(cancellationToken); + + var stream = await Provider.GetOrNullAsync( new BlobProviderGetArgs( blobNormalizeNaming.ContainerName!, Configuration, blobNormalizeNaming.BlobName!, - CancellationTokenProvider.FallbackToProvider(cancellationToken) + fallbackCancellationToken ) ); + + if (stream == null) + { + return null; + } + + // The provider stream is now owned by the container: reading the configuration + // (a mis-typed value can throw) must not leak it + List contributorTypes; + bool encryptionEnabled; + try + { + contributorTypes = Configuration.GetEffectivePipelineContributors().ToList(); + encryptionEnabled = BlobEncryptionConfiguration.IsEnabled(Configuration); + } + catch + { + await TryDisposeStreamAsync(stream); + throw; + } + + // Captured so the pipeline can verify the decryption reached its authenticated + // end (the terminal record) when the composed stream reaches EOF, even if a + // contributor stops reading the decrypted content early. A custom + // CreateDecryptingStreamAsync override that wraps the stream should implement + // IBlobAuthenticatedEndStream (and forward), or this verification is skipped. + IBlobAuthenticatedEndStream? authenticatedEndSource = null; + if (encryptionEnabled) + { + // The method owns the provider stream: it is disposed there on any failure + stream = await CreateDecryptingStreamAsync(blobNormalizeNaming, stream, fallbackCancellationToken); + authenticatedEndSource = stream as IBlobAuthenticatedEndStream; + } + + if (contributorTypes.Count == 0) + { + // Without contributors the caller reads the decrypting stream directly, so + // reading it to its end verifies the terminal record on its own + return stream; + } + + // Contributors run in the reverse order after the decryption; the scope stays + // alive until the returned (lazily transforming) stream is disposed. Resolve the + // tenant id once, before the scope is created, so nothing between scope creation + // and the successful return can throw and leak the scope or the provider stream + Guid? tenantId; + try + { + tenantId = GetTenantIdOrNull(); + } + catch + { + await TryDisposeStreamAsync(stream); + throw; + } + + contributorTypes.Reverse(); + AsyncServiceScope contributorScope; + try + { + contributorScope = ServiceProvider.CreateAsyncScope(); + } + catch + { + await TryDisposeStreamAsync(stream); + throw; + } + + var context = new BlobPipelineContext( + contributorScope.ServiceProvider, + blobNormalizeNaming.ContainerName!, + blobNormalizeNaming.BlobName!, + Configuration, + tenantId, + stream, + fallbackCancellationToken + ); + + try + { + await RunPipelineAsync(context, contributorTypes, saving: false); + + return new BlobPipelineScopeStream(context.BlobStream, contributorScope, CurrentTenant, tenantId, authenticatedEndSource); + } + catch + { + // Best-effort cleanup that keeps the original exception: the current + // context stream owns the whole chain down to the provider stream + await TryDisposeStreamAsync(context.BlobStream); + + try + { + await contributorScope.DisposeAsync(); + } + catch + { + // ignored + } + + throw; + } + } + } + + /// + /// Runs the pipeline contributors on the context stream. While saving, the context + /// collects every stream a contributor creates (at assignment, so intermediate + /// replacements within one contributor call are not lost) to be disposed by the + /// caller; while reading, the composed stream is returned to the caller as a whole, + /// so each wrapper owns the stream it received. + /// + protected virtual async Task RunPipelineAsync( + BlobPipelineContext context, + IReadOnlyList contributorTypes, + bool saving) + { + foreach (var contributorType in contributorTypes) + { + var contributor = (IBlobPipelineContributor)context.ServiceProvider.GetRequiredService(contributorType); + + if (saving) + { + await contributor.OnSavingAsync(context); + } + else + { + await contributor.OnGettingAsync(context); + } + } + } + + /// + /// Wraps the stream for encryption. The caller keeps the ownership of + /// ; the wrapper is disposed after the provider call. + /// + protected virtual async Task CreateEncryptingStreamAsync(BlobNormalizeNaming blobNormalizeNaming, Stream stream, CancellationToken cancellationToken) + { + // The key is fully resolved before returning, so the scope can be released here + var scope = ServiceProvider.CreateAsyncScope(); + Stream? encryptingStream = null; + var completed = false; + try + { + encryptingStream = await scope.ServiceProvider + .GetRequiredService() + .CreateEncryptingStreamAsync( + Configuration, + blobNormalizeNaming.ContainerName!, + blobNormalizeNaming.BlobName!, + GetTenantIdOrNull(), + stream, + cancellationToken + ); + completed = true; + } + finally + { + try + { + await scope.DisposeAsync(); + } + catch + { + // A failing scope dispose must not leak the created stream (it zeroes + // the key) and must not replace an exception already propagating + if (encryptingStream != null) + { + await TryDisposeStreamAsync(encryptingStream); + } + + if (completed) + { + throw; + } + } + } + + return encryptingStream!; + } + + /// + /// Wraps the provider stream for decryption. The method owns : + /// the returned stream disposes it, and it is also disposed when this method fails. + /// Opening throws for format violations; reading throws + /// on failed authentication. + /// The returned stream implements , which the + /// content pipeline uses to verify the authenticated end on EOF. An override that wraps + /// the returned stream should implement that interface too (and forward), otherwise the + /// pipeline can not run the end verification for this container. + /// + protected virtual async Task CreateDecryptingStreamAsync(BlobNormalizeNaming blobNormalizeNaming, Stream stream, CancellationToken cancellationToken) + { + AsyncServiceScope scope; + try + { + scope = ServiceProvider.CreateAsyncScope(); + } + catch + { + await TryDisposeStreamAsync(stream); + throw; + } + + Stream? decryptingStream = null; + var completed = false; + try + { + decryptingStream = await scope.ServiceProvider + .GetRequiredService() + .CreateDecryptingStreamAsync( + Configuration, + blobNormalizeNaming.ContainerName!, + blobNormalizeNaming.BlobName!, + GetTenantIdOrNull(), + stream, + cancellationToken + ); + completed = true; + } + catch + { + // Best-effort cleanup that keeps the original exception + await TryDisposeStreamAsync(stream); + throw; + } + finally + { + try + { + await scope.DisposeAsync(); + } + catch + { + // A failing scope dispose must not leak the created stream (it zeroes + // the key and disposes the provider stream — exactly once, since the + // caller does not dispose after this method) and must not replace an + // exception already propagating + if (decryptingStream != null) + { + await TryDisposeStreamAsync(decryptingStream); + } + + if (completed) + { + throw; + } + } + } + + return decryptingStream!; + } + + protected virtual async Task DisposeStreamAsync(Stream stream) + { +#if NETSTANDARD2_0 + // Stream has no DisposeAsync on netstandard2.0, but a stream may still implement + // IAsyncDisposable (via Microsoft.Bcl.AsyncInterfaces) for its async-only cleanup + if (stream is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync(); + } + else + { + stream.Dispose(); + } +#else + // Also covers wrappers that only implement DisposeAsync + await stream.DisposeAsync(); +#endif + } + + protected virtual async Task TryDisposeStreamAsync(Stream stream) + { + try + { + await DisposeStreamAsync(stream); + } + catch + { + // ignored: best-effort cleanup during exception handling } } diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainerConfiguration.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainerConfiguration.cs index 3d6cd86941..fa9ef808da 100644 --- a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainerConfiguration.cs +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainerConfiguration.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using JetBrains.Annotations; using Volo.Abp.Collections; @@ -32,6 +33,19 @@ public class BlobContainerConfiguration public ITypeList NamingNormalizers { get; } + /// + /// The implementations transforming + /// the content of this container while it is saved and read. + /// + public ITypeList PipelineContributors { get; } + + /// + /// Set false to stop inheriting the pipeline contributors of the default + /// container configuration, so only the own + /// of this container run. Default: true. + /// + public bool InheritPipelineContributors { get; set; } = true; + [NotNull] private readonly Dictionary _properties; private readonly BlobContainerConfiguration? _fallbackConfiguration; @@ -39,6 +53,7 @@ public class BlobContainerConfiguration public BlobContainerConfiguration(BlobContainerConfiguration? fallbackConfiguration = null) { NamingNormalizers = new TypeList(); + PipelineContributors = new TypeList(); _fallbackConfiguration = fallbackConfiguration; _properties = new Dictionary(); } @@ -57,6 +72,26 @@ public class BlobContainerConfiguration return NamingNormalizers; } + /// + /// Returns the pipeline contributors in effect for this container: the contributors + /// of the fallback (default) configuration first, then the own ones (each contributor + /// type runs once). Contributors are provider-independent content transformations, + /// so overriding does not reset the inherited ones; use + /// to opt out of the inherited ones. + /// + public IEnumerable GetEffectivePipelineContributors() + { + if (_fallbackConfiguration == null || !InheritPipelineContributors) + { + return PipelineContributors.Distinct(); + } + + return _fallbackConfiguration + .GetEffectivePipelineContributors() + .Concat(PipelineContributors) + .Distinct(); + } + public T? GetConfigurationOrDefault(string name, T? defaultValue = default) { return (T?)GetConfigurationOrNull(name, defaultValue); diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainerConfigurationEncryptionExtensions.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainerConfigurationEncryptionExtensions.cs new file mode 100644 index 0000000000..382405e04d --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainerConfigurationEncryptionExtensions.cs @@ -0,0 +1,99 @@ +using JetBrains.Annotations; + +namespace Volo.Abp.BlobStoring; + +public static class BlobContainerConfigurationEncryptionExtensions +{ + /// + /// Enables encryption for the BLOBs of this container. Calling it again is safe: + /// omitted parameters keep the already configured (or inherited) values, + /// so multiple modules can compose the configuration. + /// + /// The container configuration. + /// + /// Optional container-specific passphrase. Without one, the passphrase is resolved + /// by the . Use + /// to remove a configured passphrase. + /// + /// + /// Allows reading BLOBs stored as plaintext before encryption was enabled: + /// content without the encrypted format header is then returned as-is, + /// without any authenticity check. Keep it disabled (default) unless + /// the container really has such BLOBs. + /// + public static BlobContainerConfiguration UseEncryption( + [NotNull] this BlobContainerConfiguration containerConfiguration, + string? passPhrase = null, + bool? allowLegacyPlainText = null) + { + Check.NotNull(containerConfiguration, nameof(containerConfiguration)); + + // Validate all arguments before touching the configuration, so a failed + // call does not leave it partially modified + if (passPhrase != null) + { + Check.NotNullOrWhiteSpace(passPhrase, nameof(passPhrase)); + } + + containerConfiguration.SetConfiguration(BlobEncryptionConfigurationNames.Enabled, true); + + if (allowLegacyPlainText.HasValue) + { + containerConfiguration.SetConfiguration(BlobEncryptionConfigurationNames.AllowLegacyPlainText, allowLegacyPlainText.Value); + } + + if (passPhrase != null) + { + containerConfiguration.SetConfiguration(BlobEncryptionConfigurationNames.PassPhrase, passPhrase); + } + + return containerConfiguration; + } + + /// + /// Indicates whether encryption is enabled for this container (own or inherited + /// configuration). Storage providers can use it to detect a transformed content stream. + /// + public static bool IsEncryptionEnabled([NotNull] this BlobContainerConfiguration containerConfiguration) + { + Check.NotNull(containerConfiguration, nameof(containerConfiguration)); + + return BlobEncryptionConfiguration.IsEnabled(containerConfiguration); + } + + /// + /// Removes the container passphrase (including an inherited one), so the + /// resolves the passphrase again. + /// BLOBs encrypted with the removed passphrase can not be read anymore. + /// + public static BlobContainerConfiguration ClearEncryptionPassPhrase( + [NotNull] this BlobContainerConfiguration containerConfiguration) + { + Check.NotNull(containerConfiguration, nameof(containerConfiguration)); + + // An explicit empty value shadows a passphrase inherited from the + // default (fallback) container configuration. + containerConfiguration.SetConfiguration(BlobEncryptionConfigurationNames.PassPhrase, string.Empty); + + return containerConfiguration; + } + + /// + /// Disables encryption for this container (even when inherited from the default + /// configuration) and removes its own passphrase/legacy options. Existing encrypted + /// BLOBs are then returned as stored (still encrypted bytes) while reading — unless + /// the container also has pipeline contributors, which still run and typically fail + /// on the ciphertext. + /// + public static BlobContainerConfiguration DisableEncryption( + [NotNull] this BlobContainerConfiguration containerConfiguration) + { + Check.NotNull(containerConfiguration, nameof(containerConfiguration)); + + containerConfiguration.SetConfiguration(BlobEncryptionConfigurationNames.Enabled, false); + containerConfiguration.ClearConfiguration(BlobEncryptionConfigurationNames.PassPhrase); + containerConfiguration.ClearConfiguration(BlobEncryptionConfigurationNames.AllowLegacyPlainText); + + return containerConfiguration; + } +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionCodec.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionCodec.cs new file mode 100644 index 0000000000..88ba4b6f52 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionCodec.cs @@ -0,0 +1,693 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Microsoft.Extensions.Options; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BlobStoring; + +/// +/// Implements the encrypted BLOB format (version 1) using AES-256-GCM. +/// Not available on .NET Standard 2.0 (no AES-GCM). +/// +/// Format: "ABPE" magic (4) + format version (1) + header (34: algorithm 1, +/// key source 1, KDF iterations 4, random per-BLOB KDF salt 16, chunk size 4, +/// base nonce 8), followed by authenticated chunk records (4-byte big-endian +/// cipher length, cipher chunk, 16-byte tag) and an authenticated zero-length +/// terminal record. The whole prefix, the storage identity (container, BLOB +/// name, tenant) and the chunk index are bound to every chunk as associated +/// data; the per-BLOB salt gives every BLOB its own derived key. +/// +/// +public class BlobEncryptionCodec : IBlobEncryptionCodec, ITransientDependency +{ + internal static readonly byte[] Magic = { (byte)'A', (byte)'B', (byte)'P', (byte)'E' }; + + internal const byte FormatVersion = 1; + internal const byte AlgorithmAesGcm = 1; + internal const int MinKdfIterations = 100_000; + internal const int MaxKdfIterations = 600_000; // reader cap: bounded headroom above the writer constant + internal const int KdfSaltSize = 16; + internal const int ChunkSize = 64 * 1024; + internal const int MaxChunkSize = 1024 * 1024; // reader cap: bounds allocations driven by the (pre-authentication) header + internal const int BaseNonceSize = 8; + internal const int HeaderSize = 34; // algorithm(1) + keySource(1) + iterations(4) + salt(16) + chunkSize(4) + baseNonce(8) + internal const int ChunkLengthPrefixSize = 4; + internal const int GcmNonceSize = 12; + internal const int GcmTagSize = 16; + + // Rejects invalid UTF-16 instead of silently replacing it: the default encoder + // folds different unpaired surrogates into the same replacement bytes, which + // would let two different names produce the same authenticated identity + private static readonly Encoding StrictUtf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + + protected IBlobEncryptionKeyProvider KeyProvider { get; } + + protected AbpBlobStoringEncryptionOptions Options { get; } + + public BlobEncryptionCodec( + IBlobEncryptionKeyProvider keyProvider, + IOptions options) + { + KeyProvider = keyProvider; + Options = options.Value; + } + + /// + // The key is fully resolved before the stream is returned, so the resolution scope can be released. + public virtual async Task CreateEncryptingStreamAsync( + [NotNull] BlobContainerConfiguration configuration, + [NotNull] string containerName, + [NotNull] string blobName, + Guid? tenantId, + [NotNull] Stream plainStream, + CancellationToken cancellationToken = default) + { + Check.NotNull(configuration, nameof(configuration)); + Check.NotNullOrWhiteSpace(containerName, nameof(containerName)); + Check.NotNullOrWhiteSpace(blobName, nameof(blobName)); + Check.NotNull(plainStream, nameof(plainStream)); + +#if NETSTANDARD2_0 + // Fail before any output is produced, so no partial (corrupted) data is ever written. + throw new PlatformNotSupportedException("BLOB encryption requires AES-GCM, which is not available on .NET Standard 2.0!"); +#else +#if NET8_0_OR_GREATER + if (!AesGcm.IsSupported) + { + throw new PlatformNotSupportedException("AES-GCM is not supported on this platform!"); + } +#else + // netstandard2.1 has no AesGcm.IsSupported; constructing an instance is the only + // probe that fails here instead of after the provider has opened the target + using (CreateAesGcm(new byte[32])) + { + } +#endif + var kdfIterations = Options.KdfIterations; + if (kdfIterations < MinKdfIterations || kdfIterations > MaxKdfIterations) + { + throw new AbpException( + $"{nameof(AbpBlobStoringEncryptionOptions)}.{nameof(AbpBlobStoringEncryptionOptions.KdfIterations)} " + + $"must be between {MinKdfIterations} and {MaxKdfIterations}!"); + } + + var key = await KeyProvider.ResolveForEncryptionAsync( + new BlobEncryptionKeyContext(configuration, containerName, blobName, tenantId), + cancellationToken); + + var salt = new byte[KdfSaltSize]; + var baseNonce = new byte[BaseNonceSize]; + using (var random = RandomNumberGenerator.Create()) + { + random.GetBytes(salt); + random.GetBytes(baseNonce); + } + + var header = BuildHeader(key.Source, kdfIterations, salt, ChunkSize, baseNonce); + var blobPrefix = CreateBlobPrefix(header); + // The AAD can reject invalid names; build it before deriving the key, so + // no derived key is left un-zeroed on the rejection path + var associatedDataPrefix = BuildAssociatedDataPrefix(blobPrefix, containerName, blobName, tenantId); + var encryptedLength = TryCalculateEncryptedLength(plainStream, ChunkSize); + var keyBytes = DeriveKeyBytesOrThrowIfCancelled(key.PassPhrase, salt, kdfIterations, cancellationToken); + + return new ChunkedEncryptingReadStream( + plainStream, + blobPrefix, + associatedDataPrefix, + keyBytes, + baseNonce, + ChunkSize, + encryptedLength + ); +#endif + } + + /// + public virtual async Task CreateDecryptingStreamAsync( + [NotNull] BlobContainerConfiguration configuration, + [NotNull] string containerName, + [NotNull] string blobName, + Guid? tenantId, + [NotNull] Stream cipherStream, + CancellationToken cancellationToken = default) + { + Check.NotNull(configuration, nameof(configuration)); + Check.NotNullOrWhiteSpace(containerName, nameof(containerName)); + Check.NotNullOrWhiteSpace(blobName, nameof(blobName)); + Check.NotNull(cipherStream, nameof(cipherStream)); + + var prefix = await ReadUpToAsync(cipherStream, Magic.Length + 1, cancellationToken); + if (!StartsWithMagic(prefix)) + { + if (BlobEncryptionConfiguration.IsLegacyPlainTextAllowed(configuration)) + { + return new PrefixingReadStream(prefix, cipherStream); + } + + throw new AbpException( + "The BLOB does not have the encrypted BLOB format. If it was stored before encryption " + + "was enabled for the container, enable reading legacy plaintext BLOBs explicitly " + + "(see the UseEncryption extension method). Otherwise the BLOB is corrupted or tampered." + ); + } + + // The full magic already identifies the encrypted format: content truncated + // right after it must fail as corrupted, not fall back to legacy plaintext + if (prefix.Length < Magic.Length + 1) + { + throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: missing format version!"); + } + + if (prefix[Magic.Length] != FormatVersion) + { + throw new AbpException($"Unsupported encrypted BLOB format version: {prefix[Magic.Length]}!"); + } + +#if NETSTANDARD2_0 + throw new PlatformNotSupportedException("BLOB decryption requires AES-GCM, which is not available on .NET Standard 2.0!"); +#else +#if NET8_0_OR_GREATER + if (!AesGcm.IsSupported) + { + throw new PlatformNotSupportedException("AES-GCM is not supported on this platform!"); + } +#else + // netstandard2.1 has no AesGcm.IsSupported; constructing an instance is the only + // probe that fails here instead of after the key is resolved and derived + using (CreateAesGcm(new byte[32])) + { + } +#endif + var header = await ReadExactlyAsync(cipherStream, HeaderSize, cancellationToken); + if (header == null) + { + throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: missing header!"); + } + + if (header[0] != AlgorithmAesGcm) + { + throw new AbpException($"Unsupported encrypted BLOB algorithm: {header[0]}!"); + } + + var keySource = header[1]; + if (keySource < (byte)BlobEncryptionKeySource.Container || keySource > (byte)BlobEncryptionKeySource.Global) + { + throw new AbpException($"Unknown BLOB encryption key source: {keySource}!"); + } + + var iterations = ReadInt32BigEndian(header, 2); + if (iterations < MinKdfIterations || iterations > MaxKdfIterations) + { + // Accepting fewer iterations than any legitimate writer ever used would let + // attacker-crafted content turn reads into a cheap passphrase-guessing oracle + throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid KDF iteration count!"); + } + + var salt = new byte[KdfSaltSize]; + Array.Copy(header, 6, salt, 0, KdfSaltSize); + + var chunkSize = ReadInt32BigEndian(header, 22); + if (chunkSize <= 0 || chunkSize > MaxChunkSize) + { + throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid chunk size!"); + } + + var baseNonce = new byte[BaseNonceSize]; + Array.Copy(header, 26, baseNonce, 0, BaseNonceSize); + + var blobPrefix = new byte[Magic.Length + 1 + HeaderSize]; + Array.Copy(prefix, 0, blobPrefix, 0, Magic.Length + 1); + Array.Copy(header, 0, blobPrefix, Magic.Length + 1, HeaderSize); + // The AAD can reject invalid names; build it before deriving the key, so + // no derived key is left un-zeroed on the rejection path + var associatedDataPrefix = BuildAssociatedDataPrefix(blobPrefix, containerName, blobName, tenantId); + + var passPhrase = await KeyProvider.ResolveForDecryptionAsync( + (BlobEncryptionKeySource)keySource, + new BlobEncryptionKeyContext(configuration, containerName, blobName, tenantId), + cancellationToken + ); + var keyBytes = DeriveKeyBytesOrThrowIfCancelled(passPhrase, salt, iterations, cancellationToken); + + return new ChunkedDecryptingReadStream( + cipherStream, + associatedDataPrefix, + keyBytes, + baseNonce, + chunkSize + ); +#endif + } + + internal static byte[] BuildHeader(BlobEncryptionKeySource keySource, int iterations, byte[] salt, int chunkSize, byte[] baseNonce) + { + var header = new byte[HeaderSize]; + header[0] = AlgorithmAesGcm; + header[1] = (byte)keySource; + WriteInt32BigEndian(header, 2, iterations); + Array.Copy(salt, 0, header, 6, KdfSaltSize); + WriteInt32BigEndian(header, 22, chunkSize); + Array.Copy(baseNonce, 0, header, 26, BaseNonceSize); + return header; + } + + // Length-prefixed identity fields: a validly encrypted BLOB can not be read + // from another BLOB name, container or tenant. + internal static byte[] BuildAssociatedDataPrefix(byte[] blobPrefix, string containerName, string blobName, Guid? tenantId) + { + byte[] containerNameBytes; + byte[] blobNameBytes; + try + { + containerNameBytes = StrictUtf8.GetBytes(containerName); + blobNameBytes = StrictUtf8.GetBytes(blobName); + } + catch (EncoderFallbackException ex) + { + throw new AbpException("The container/BLOB name contains invalid characters (unpaired surrogates), so it can not be bound to the encrypted content!", ex); + } + var tenantIdBytes = tenantId?.ToByteArray() ?? Array.Empty(); + + var prefix = new byte[blobPrefix.Length + 4 + containerNameBytes.Length + 4 + blobNameBytes.Length + 4 + tenantIdBytes.Length]; + var offset = 0; + + Array.Copy(blobPrefix, 0, prefix, offset, blobPrefix.Length); + offset += blobPrefix.Length; + + offset = WriteLengthPrefixed(prefix, offset, containerNameBytes); + offset = WriteLengthPrefixed(prefix, offset, blobNameBytes); + WriteLengthPrefixed(prefix, offset, tenantIdBytes); + + return prefix; + } + + private static int WriteLengthPrefixed(byte[] buffer, int offset, byte[] bytes) + { + WriteInt32BigEndian(buffer, offset, bytes.Length); + Array.Copy(bytes, 0, buffer, offset + 4, bytes.Length); + return offset + 4 + bytes.Length; + } + + internal static byte[] CreateBlobPrefix(byte[] header) + { + var prefix = new byte[Magic.Length + 1 + header.Length]; + Magic.CopyTo(prefix, 0); + prefix[Magic.Length] = FormatVersion; + Array.Copy(header, 0, prefix, Magic.Length + 1, header.Length); + return prefix; + } + + // The synchronous PBKDF2 can not observe the token itself; check before the + // (expensive) derivation and once more after it, so a cancellation during the + // derivation is not lost + private static byte[] DeriveKeyBytesOrThrowIfCancelled(string passPhrase, byte[] salt, int iterations, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var keyBytes = DeriveKeyBytes(passPhrase, salt, iterations); + + if (cancellationToken.IsCancellationRequested) + { +#if !NETSTANDARD2_0 + CryptographicOperations.ZeroMemory(keyBytes); +#endif + cancellationToken.ThrowIfCancellationRequested(); + } + + return keyBytes; + } + + internal static byte[] DeriveKeyBytes(string passPhrase, byte[] salt, int iterations) + { +#if NETSTANDARD2_0 + throw new PlatformNotSupportedException("BLOB encryption requires AES-GCM, which is not available on .NET Standard 2.0!"); +#else + // Encode the passphrase to bytes with strict UTF-8 explicitly, so every target + // framework derives the same key and an invalid passphrase (unpaired surrogates) + // is rejected the same way — the string overloads differ across frameworks (net8+ + // throws on invalid UTF-16, netstandard2.1 silently replaces it) + byte[] passwordBytes; + try + { + passwordBytes = StrictUtf8.GetBytes(passPhrase); + } + catch (EncoderFallbackException ex) + { + throw new AbpException("The BLOB encryption passphrase contains invalid characters (unpaired surrogates)!", ex); + } + + try + { +#if NET8_0_OR_GREATER + return Rfc2898DeriveBytes.Pbkdf2(passwordBytes, salt, iterations, HashAlgorithmName.SHA256, 32); +#else + using var password = new Rfc2898DeriveBytes(passwordBytes, salt, iterations, HashAlgorithmName.SHA256); + return password.GetBytes(32); +#endif + } + finally + { + CryptographicOperations.ZeroMemory(passwordBytes); + } +#endif + } + + // One AES-GCM instance is bound to the per-BLOB key and reused for every chunk, so a + // stream sets up the key schedule once instead of per chunk. Typed as IDisposable so the + // streams that hold it still compile on netstandard2.0 (where creation throws first). + internal static IDisposable CreateChunkCipher(byte[] keyBytes) + { +#if NETSTANDARD2_0 + throw new PlatformNotSupportedException("AES-GCM is not available on .NET Standard 2.0!"); +#else + return CreateAesGcm(keyBytes); +#endif + } + + internal static byte[] EncryptChunk(byte[] keyBytes, byte[] associatedDataPrefix, byte[] baseNonce, int chunkIndex, byte[] plainChunk, int plainChunkLength) + { + using (var cipher = CreateChunkCipher(keyBytes)) + { + return EncryptChunkCore(cipher, CreateChunkAssociatedData(associatedDataPrefix, chunkIndex), CreateChunkNonce(baseNonce, chunkIndex), plainChunk, plainChunkLength); + } + } + + // The cipher, associated data and nonce are passed in fully built so the streams can reuse + // one of each and only rewrite the trailing chunk index, instead of reconstructing the + // AES-GCM key schedule and reallocating the whole identity (which grows with the + // container/BLOB name) for every chunk + internal static byte[] EncryptChunkCore(IDisposable cipher, byte[] associatedData, byte[] nonce, byte[] plainChunk, int plainChunkLength) + { +#if NETSTANDARD2_0 + throw new PlatformNotSupportedException("AES-GCM is not available on .NET Standard 2.0!"); +#else + var record = new byte[ChunkLengthPrefixSize + plainChunkLength + GcmTagSize]; + WriteInt32BigEndian(record, 0, plainChunkLength); + + ((AesGcm)cipher).Encrypt( + nonce, + plainChunk.AsSpan(0, plainChunkLength), + record.AsSpan(ChunkLengthPrefixSize, plainChunkLength), + record.AsSpan(ChunkLengthPrefixSize + plainChunkLength, GcmTagSize), + associatedData + ); + + return record; +#endif + } + + internal static byte[] DecryptChunk(byte[] keyBytes, byte[] associatedDataPrefix, byte[] baseNonce, int chunkIndex, byte[] cipherChunk, byte[] tag) + { + using (var cipher = CreateChunkCipher(keyBytes)) + { + return DecryptChunkCore(cipher, CreateChunkAssociatedData(associatedDataPrefix, chunkIndex), CreateChunkNonce(baseNonce, chunkIndex), cipherChunk, tag); + } + } + + internal static byte[] DecryptChunkCore(IDisposable cipher, byte[] associatedData, byte[] nonce, byte[] cipherChunk, byte[] tag) + { +#if NETSTANDARD2_0 + throw new PlatformNotSupportedException("AES-GCM is not available on .NET Standard 2.0!"); +#else + var plainChunk = new byte[cipherChunk.Length]; + // Throws CryptographicException if the authentication tag is invalid. + ((AesGcm)cipher).Decrypt(nonce, cipherChunk, tag, plainChunk, associatedData); + + return plainChunk; +#endif + } + + // The authenticated terminal record makes truncation of complete chunks detectable + internal static byte[] CreateTerminalRecord(byte[] keyBytes, byte[] associatedDataPrefix, byte[] baseNonce, int chunkIndex) + { + using (var cipher = CreateChunkCipher(keyBytes)) + { + return CreateTerminalRecordCore(cipher, CreateChunkAssociatedData(associatedDataPrefix, chunkIndex), CreateChunkNonce(baseNonce, chunkIndex)); + } + } + + internal static byte[] CreateTerminalRecordCore(IDisposable cipher, byte[] associatedData, byte[] nonce) + { +#if NETSTANDARD2_0 + throw new PlatformNotSupportedException("AES-GCM is not available on .NET Standard 2.0!"); +#else + var record = new byte[ChunkLengthPrefixSize + GcmTagSize]; + ((AesGcm)cipher).Encrypt( + nonce, + Array.Empty(), + Array.Empty(), + record.AsSpan(ChunkLengthPrefixSize, GcmTagSize), + associatedData + ); + + return record; +#endif + } + + internal static void VerifyTerminalRecord(byte[] keyBytes, byte[] associatedDataPrefix, byte[] baseNonce, int chunkIndex, byte[] tag) + { + using (var cipher = CreateChunkCipher(keyBytes)) + { + VerifyTerminalRecordCore(cipher, CreateChunkAssociatedData(associatedDataPrefix, chunkIndex), CreateChunkNonce(baseNonce, chunkIndex), tag); + } + } + + internal static void VerifyTerminalRecordCore(IDisposable cipher, byte[] associatedData, byte[] nonce, byte[] tag) + { +#if NETSTANDARD2_0 + throw new PlatformNotSupportedException("AES-GCM is not available on .NET Standard 2.0!"); +#else + // Throws CryptographicException if the tag is invalid. + ((AesGcm)cipher).Decrypt(nonce, Array.Empty(), tag, Array.Empty(), associatedData); +#endif + } + + // Nonce = 8-byte random base + 4-byte chunk index; the per-BLOB key (random salt) + // makes cross-BLOB reuse harmless and the index keeps it unique within the BLOB. + internal static byte[] CreateChunkNonce(byte[] baseNonce, int chunkIndex) + { + if (chunkIndex < 0) + { + // A wrapped chunk index would repeat a nonce for the same key, which breaks AES-GCM. + throw new AbpException("The data is too large: the maximum chunk count has been exceeded!"); + } + + var nonce = new byte[GcmNonceSize]; + Array.Copy(baseNonce, 0, nonce, 0, BaseNonceSize); + WriteInt32BigEndian(nonce, BaseNonceSize, chunkIndex); + return nonce; + } + + internal static byte[] CreateChunkAssociatedData(byte[] associatedDataPrefix, int chunkIndex) + { + var associatedData = new byte[associatedDataPrefix.Length + 4]; + Array.Copy(associatedDataPrefix, 0, associatedData, 0, associatedDataPrefix.Length); + WriteInt32BigEndian(associatedData, associatedDataPrefix.Length, chunkIndex); + return associatedData; + } + + // A stream builds one nonce and one associated-data buffer with these, then rewrites only + // the trailing chunk index per chunk with WriteChunkIndex; both hold the index as their + // last 4 bytes, so the fixed prefix is copied once instead of once per chunk + internal static byte[] CreateReusableChunkNonce(byte[] baseNonce) + { + var nonce = new byte[GcmNonceSize]; + Array.Copy(baseNonce, 0, nonce, 0, BaseNonceSize); + return nonce; + } + + internal static byte[] CreateReusableAssociatedData(byte[] associatedDataPrefix) + { + var associatedData = new byte[associatedDataPrefix.Length + 4]; + Array.Copy(associatedDataPrefix, 0, associatedData, 0, associatedDataPrefix.Length); + return associatedData; + } + + internal static void WriteChunkIndex(byte[] nonceOrAssociatedData, int chunkIndex) + { + if (chunkIndex < 0) + { + // A wrapped chunk index would repeat a nonce for the same key, which breaks AES-GCM. + throw new AbpException("The data is too large: the maximum chunk count has been exceeded!"); + } + + WriteInt32BigEndian(nonceOrAssociatedData, nonceOrAssociatedData.Length - 4, chunkIndex); + } + + internal static int GetCipherChunkSize(byte[] lengthPrefix, int maxCipherChunkSize) + { + if (lengthPrefix.Length == 0) + { + throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: missing terminal record!"); + } + + if (lengthPrefix.Length < ChunkLengthPrefixSize) + { + throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: truncated chunk!"); + } + + var cipherChunkSize = ReadInt32BigEndian(lengthPrefix, 0); + if (cipherChunkSize < 0 || cipherChunkSize > maxCipherChunkSize) + { + throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid chunk length!"); + } + + return cipherChunkSize; + } + + internal static byte[]? ReadExactly(Stream stream, int count) + { + var buffer = ReadUpTo(stream, count); + return buffer.Length == count ? buffer : null; + } + + internal static byte[] ReadUpTo(Stream stream, int count) + { + var buffer = new byte[count]; + var totalReadCount = 0; + while (totalReadCount < count) + { + var readCount = stream.Read(buffer, totalReadCount, count - totalReadCount); + if (readCount == 0) + { + break; + } + + totalReadCount += readCount; + } + + if (totalReadCount == count) + { + return buffer; + } + + var result = new byte[totalReadCount]; + Array.Copy(buffer, 0, result, 0, totalReadCount); + return result; + } + + internal static async Task ReadExactlyAsync(Stream stream, int count, CancellationToken cancellationToken = default) + { + var buffer = await ReadUpToAsync(stream, count, cancellationToken); + return buffer.Length == count ? buffer : null; + } + + internal static async Task ReadUpToAsync(Stream stream, int count, CancellationToken cancellationToken = default) + { + var buffer = new byte[count]; + var totalReadCount = 0; + while (totalReadCount < count) + { +#if NETSTANDARD2_0 + var readCount = await stream.ReadAsync(buffer, totalReadCount, count - totalReadCount, cancellationToken); +#else + // The modern overload dispatches correctly for streams that only + // implement ReadAsync(Memory) + var readCount = await stream.ReadAsync(buffer.AsMemory(totalReadCount, count - totalReadCount), cancellationToken); +#endif + if (readCount == 0) + { + break; + } + + totalReadCount += readCount; + } + + if (totalReadCount == count) + { + return buffer; + } + + var result = new byte[totalReadCount]; + Array.Copy(buffer, 0, result, 0, totalReadCount); + return result; + } + + private static bool StartsWithMagic(byte[] prefix) + { + if (prefix.Length < Magic.Length) + { + return false; + } + + for (var i = 0; i < Magic.Length; i++) + { + if (prefix[i] != Magic[i]) + { + return false; + } + } + + return true; + } + + private static long? TryCalculateEncryptedLength(Stream plainStream, int chunkSize) + { + // Not gated on CanSeek: a forward-only stream can still report Length/Position. + // Both are required: without Position the remaining length is unknown (the stream + // may already be partially consumed), and guessing it would report a wrong + // ciphertext length and cause a short write on length-strict providers. + try + { + var plainLength = plainStream.Length - plainStream.Position; + if (plainLength < 0) + { + return null; + } + + var fullChunkCount = plainLength / chunkSize; + var chunkRecordCount = fullChunkCount + (plainLength % chunkSize > 0 ? 1 : 0) + 1; // +1: terminal record + + // The chunk index (including the terminal record) is a 32-bit value; fail + // before any output instead of after writing terabytes of ciphertext + if (chunkRecordCount - 1 > int.MaxValue) + { + throw new AbpException("The content is too large for the encrypted BLOB format (chunk index overflow)!"); + } + + checked + { + return Magic.Length + 1L + HeaderSize + plainLength + + chunkRecordCount * (ChunkLengthPrefixSize + GcmTagSize); + } + } + catch (Exception ex) when (ex is NotSupportedException || ex is IOException) + { + // The length is optional; a probe failure must not fail the save + return null; + } + catch (OverflowException) + { + return null; + } + } + +#if !NETSTANDARD2_0 + private static AesGcm CreateAesGcm(byte[] keyBytes) + { +#if NET8_0_OR_GREATER + return new AesGcm(keyBytes, GcmTagSize); +#else + return new AesGcm(keyBytes); +#endif + } +#endif + + private static void WriteInt32BigEndian(byte[] buffer, int offset, int value) + { + buffer[offset] = (byte)(value >> 24); + buffer[offset + 1] = (byte)(value >> 16); + buffer[offset + 2] = (byte)(value >> 8); + buffer[offset + 3] = (byte)value; + } + + private static int ReadInt32BigEndian(byte[] buffer, int offset) + { + return (buffer[offset] << 24) | (buffer[offset + 1] << 16) | (buffer[offset + 2] << 8) | buffer[offset + 3]; + } +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionConfiguration.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionConfiguration.cs new file mode 100644 index 0000000000..c1e95bfb70 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionConfiguration.cs @@ -0,0 +1,25 @@ +namespace Volo.Abp.BlobStoring; + +/// +/// Reads the encryption values of a container configuration (set by the +/// UseEncryption/DisableEncryption extension methods, inherited over the fallback chain). +/// +internal static class BlobEncryptionConfiguration +{ + public static bool IsEnabled(BlobContainerConfiguration configuration) + { + return configuration.GetConfigurationOrDefault(BlobEncryptionConfigurationNames.Enabled, false); + } + + public static string? GetPassPhraseOrNull(BlobContainerConfiguration configuration) + { + // An explicit empty value shadows an inherited passphrase (see UseEncryption). + var passPhrase = configuration.GetConfigurationOrDefault(BlobEncryptionConfigurationNames.PassPhrase); + return string.IsNullOrWhiteSpace(passPhrase) ? null : passPhrase; + } + + public static bool IsLegacyPlainTextAllowed(BlobContainerConfiguration configuration) + { + return configuration.GetConfigurationOrDefault(BlobEncryptionConfigurationNames.AllowLegacyPlainText, false); + } +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionConfigurationNames.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionConfigurationNames.cs new file mode 100644 index 0000000000..b870c36f82 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionConfigurationNames.cs @@ -0,0 +1,8 @@ +namespace Volo.Abp.BlobStoring; + +public static class BlobEncryptionConfigurationNames +{ + public const string Enabled = "BlobEncryption.Enabled"; + public const string PassPhrase = "BlobEncryption.PassPhrase"; + public const string AllowLegacyPlainText = "BlobEncryption.AllowLegacyPlainText"; +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionKey.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionKey.cs new file mode 100644 index 0000000000..da6527ee09 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionKey.cs @@ -0,0 +1,39 @@ +using System; +using JetBrains.Annotations; + +namespace Volo.Abp.BlobStoring; + +/// +/// The passphrase resolved for encrypting a BLOB, together with its source. +/// +public class BlobEncryptionKey +{ + /// + /// The source the passphrase was resolved from; it is recorded in the encrypted + /// BLOB and routes the BLOB back to the same source while decrypting. + /// + public BlobEncryptionKeySource Source { get; } + + /// + /// The passphrase the encryption key of the BLOB is derived from. + /// + [NotNull] + public string PassPhrase { get; } + + /// + /// Creates the resolved key; must be a defined + /// value and the passphrase non-empty. + /// + public BlobEncryptionKey(BlobEncryptionKeySource source, [NotNull] string passPhrase) + { + if (source < BlobEncryptionKeySource.Container || source > BlobEncryptionKeySource.Global) + { + // The source is stored in the BLOB header and validated while reading; + // an unknown value would make the BLOB permanently unreadable. + throw new ArgumentException($"Unknown BLOB encryption key source: {source}!", nameof(source)); + } + + Source = source; + PassPhrase = Check.NotNullOrWhiteSpace(passPhrase, nameof(passPhrase)); + } +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionKeyContext.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionKeyContext.cs new file mode 100644 index 0000000000..30a8049772 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionKeyContext.cs @@ -0,0 +1,51 @@ +using System; +using JetBrains.Annotations; + +namespace Volo.Abp.BlobStoring; + +/// +/// The identity of the BLOB an encryption key is resolved for. It lets a custom +/// select the key by the container, the +/// BLOB name or the tenant — not only by the container configuration. +/// +public class BlobEncryptionKeyContext +{ + /// + /// The configuration of the container the BLOB belongs to (with the container + /// passphrase, if one was set with UseEncryption). + /// + [NotNull] + public BlobContainerConfiguration Configuration { get; } + + /// + /// The normalized container name. + /// + [NotNull] + public string ContainerName { get; } + + /// + /// The normalized BLOB name. + /// + [NotNull] + public string BlobName { get; } + + /// + /// The tenant of the BLOB operation (null for the host or a shared container). + /// + public Guid? TenantId { get; } + + /// + /// Creates the context; the names are expected in their normalized form. + /// + public BlobEncryptionKeyContext( + [NotNull] BlobContainerConfiguration configuration, + [NotNull] string containerName, + [NotNull] string blobName, + Guid? tenantId) + { + Configuration = Check.NotNull(configuration, nameof(configuration)); + ContainerName = Check.NotNullOrWhiteSpace(containerName, nameof(containerName)); + BlobName = Check.NotNullOrWhiteSpace(blobName, nameof(blobName)); + TenantId = tenantId; + } +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionKeySource.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionKeySource.cs new file mode 100644 index 0000000000..4794220970 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionKeySource.cs @@ -0,0 +1,27 @@ +namespace Volo.Abp.BlobStoring; + +/// +/// Identifies where the encryption passphrase of a BLOB comes from. The value is +/// stored in the BLOB header, so decryption uses the same source again even if +/// other sources are configured later. +/// +public enum BlobEncryptionKeySource : byte +{ + /// + /// The container-specific passphrase, set with + /// . + /// + Container = 1, + + /// + /// A tenant-specific passphrase, provided by a custom + /// ; unused by the default provider. + /// + Tenant = 2, + + /// + /// The global passphrase, from + /// . + /// + Global = 3 +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobPipelineContext.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobPipelineContext.cs new file mode 100644 index 0000000000..2d52dd5c1d --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobPipelineContext.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using JetBrains.Annotations; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BlobStoring; + +/// +/// The context an works on. A contributor +/// transforms the content by replacing with a wrapper; +/// see for the stream ownership contract. +/// +public class BlobPipelineContext : IServiceProviderAccessor +{ + /// + /// The scoped service provider of the pipeline. While saving, the scope stays + /// alive until the save operation completes; while getting, until the stream + /// returned to the caller is disposed — so lazily transforming wrappers can + /// keep using their scoped services. + /// + [NotNull] + public IServiceProvider ServiceProvider { get; } + + /// + /// The normalized container name. + /// + [NotNull] + public string ContainerName { get; } + + /// + /// The normalized BLOB name. + /// + [NotNull] + public string BlobName { get; } + + /// + /// The configuration of the container the BLOB belongs to. + /// + [NotNull] + public BlobContainerConfiguration Configuration { get; } + + /// + /// The tenant of the BLOB operation (null for the host or a shared container). + /// + public Guid? TenantId { get; } + + /// + /// The cancellation token of the BLOB operation. Pass it to any I/O the contributor + /// performs while / + /// runs. A lazy read wrapper + /// returned from OnGettingAsync must instead honor the token passed to each of + /// its own Read/ReadAsync calls (this token is captured once at + /// GetAsync time and is not updated per read). + /// + public CancellationToken CancellationToken { get; } + + /// + /// The content stream. Replace it with a (typically lazily transforming, + /// read-only) wrapper to transform the content. + /// + [NotNull] + public Stream BlobStream { + get => _blobStream; + set + { + _blobStream = Check.NotNull(value, nameof(value)); + TrackCreatedStream(value); + } + } + private Stream _blobStream; + + private readonly Stream _initialStream; + + // While saving, every stream the pipeline creates is collected here (at + // assignment, so intermediate replacements within one contributor call are + // not lost) to be disposed after the save; the initial (caller-owned) + // stream is never collected. Null while getting. + internal List? CreatedStreams { get; set; } + + /// + /// Creates the context; the names are expected in their normalized form and + /// is the initial (untransformed) content. + /// + public BlobPipelineContext( + [NotNull] IServiceProvider serviceProvider, + [NotNull] string containerName, + [NotNull] string blobName, + [NotNull] BlobContainerConfiguration configuration, + Guid? tenantId, + [NotNull] Stream blobStream, + CancellationToken cancellationToken = default) + { + ServiceProvider = Check.NotNull(serviceProvider, nameof(serviceProvider)); + ContainerName = Check.NotNullOrWhiteSpace(containerName, nameof(containerName)); + BlobName = Check.NotNullOrWhiteSpace(blobName, nameof(blobName)); + Configuration = Check.NotNull(configuration, nameof(configuration)); + TenantId = tenantId; + _initialStream = _blobStream = Check.NotNull(blobStream, nameof(blobStream)); + CancellationToken = cancellationToken; + } + + private void TrackCreatedStream(Stream stream) + { + if (CreatedStreams == null || ReferenceEquals(stream, _initialStream)) + { + return; + } + + foreach (var existingStream in CreatedStreams) + { + if (ReferenceEquals(existingStream, stream)) + { + return; + } + } + + CreatedStreams.Add(stream); + } +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobPipelineScopeStream.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobPipelineScopeStream.cs new file mode 100644 index 0000000000..a726797ed4 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobPipelineScopeStream.cs @@ -0,0 +1,488 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Threading; + +namespace Volo.Abp.BlobStoring; + +/// +/// Keeps the service scope and the tenant context of the pipeline contributors +/// until the returned (lazily transforming) stream is disposed: every member the +/// wrappers may run work in — including the disposal of the scope itself — executes +/// in the tenant the BLOB belongs to, not in the ambient tenant of the caller. +/// +internal sealed class BlobPipelineScopeStream : Stream +{ + private readonly Stream _inner; + private readonly AsyncServiceScope _scope; + private readonly ICurrentTenant _currentTenant; + private readonly Guid? _tenantId; + private readonly IBlobAuthenticatedEndStream? _authenticatedEndSource; + private bool _authenticatedEndChecked; + private bool _faulted; + private bool _disposed; + + public BlobPipelineScopeStream( + Stream inner, + AsyncServiceScope scope, + ICurrentTenant currentTenant, + Guid? tenantId, + IBlobAuthenticatedEndStream? authenticatedEndSource = null) + { + _inner = inner; + _scope = scope; + _currentTenant = currentTenant; + _tenantId = tenantId; + // When encryption is enabled, this is the innermost decrypting stream. Its + // terminal record is verified when this composed stream reaches EOF, so a + // contributor that stops before the content ends can not hide a truncation. + _authenticatedEndSource = authenticatedEndSource; + } + + public override bool CanRead + { + get + { + if (_disposed) + { + return false; + } + + using (_currentTenant.Change(_tenantId)) + { + return _inner.CanRead; + } + } + } + + private void EnsureNotDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(GetType().FullName); + } + } + + public override bool CanSeek + { + get + { + if (_disposed) + { + return false; + } + + using (_currentTenant.Change(_tenantId)) + { + return _inner.CanSeek; + } + } + } + + public override bool CanWrite => false; + + public override bool CanTimeout + { + get + { + if (_disposed) + { + return false; + } + + using (_currentTenant.Change(_tenantId)) + { + return _inner.CanTimeout; + } + } + } + + public override int ReadTimeout + { + get + { + EnsureNotDisposed(); + using (_currentTenant.Change(_tenantId)) + { + return _inner.ReadTimeout; + } + } + set + { + EnsureNotDisposed(); + using (_currentTenant.Change(_tenantId)) + { + _inner.ReadTimeout = value; + } + } + } + + public override long Length + { + get + { + EnsureNotDisposed(); + using (_currentTenant.Change(_tenantId)) + { + return _inner.Length; + } + } + } + + public override long Position + { + get + { + EnsureNotDisposed(); + using (_currentTenant.Change(_tenantId)) + { + return _inner.Position; + } + } + set + { + EnsureNotDisposed(); + using (_currentTenant.Change(_tenantId)) + { + _inner.Position = value; + } + } + } + + public override void Flush() + { + EnsureNotDisposed(); + using (_currentTenant.Change(_tenantId)) + { + _inner.Flush(); + } + } + + public override async Task FlushAsync(CancellationToken cancellationToken) + { + EnsureNotDisposed(); + using (_currentTenant.Change(_tenantId)) + { + await _inner.FlushAsync(cancellationToken); + } + } + + public override int Read(byte[] buffer, int offset, int count) + { + EnsureNotDisposed(); + EnsureNotFaulted(); + using (_currentTenant.Change(_tenantId)) + { + int read; + try + { + read = _inner.Read(buffer, offset, count); + } + catch + { + // A failed read faults permanently, so a retry layer can not silently continue + // from a position where an inner contributor already consumed bytes + _faulted = true; + throw; + } + + VerifyAuthenticatedEndIfNeeded(read, count); + return read; + } + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + EnsureNotDisposed(); + EnsureNotFaulted(); + // A token cancelled before any I/O leaves the stream untouched (and healthy for a + // retry); once a read has started, any failure faults it permanently + cancellationToken.ThrowIfCancellationRequested(); + using (_currentTenant.Change(_tenantId)) + { + int read; + try + { +#if NETSTANDARD2_0 + read = await _inner.ReadAsync(buffer, offset, count, cancellationToken); +#else + // Dispatch over the modern overload, so a wrapper that only implements + // ReadAsync(Memory) also works for callers of the old overload + read = await _inner.ReadAsync(buffer.AsMemory(offset, count), cancellationToken); +#endif + } + catch + { + _faulted = true; + throw; + } + + await VerifyAuthenticatedEndIfNeededAsync(read, count, cancellationToken); + return read; + } + } + + // Runs when the composed stream reaches EOF on a real (non-zero-count) read. A + // legitimate partial read (stopping early and disposing) never reaches EOF, so it + // is not affected. A failed check faults the stream permanently, so a read-retry + // layer can not swallow the integrity error and later see a normal EOF. + private void VerifyAuthenticatedEndIfNeeded(int read, int count) + { + if (read != 0 || count == 0 || _authenticatedEndChecked || _authenticatedEndSource == null) + { + return; + } + + try + { + _authenticatedEndSource.EnsureReadToAuthenticatedEnd(); + _authenticatedEndChecked = true; + } + catch + { + // The check ran and failed on integrity: mark it done and fault permanently + _authenticatedEndChecked = true; + _faulted = true; + throw; + } + } + + private async ValueTask VerifyAuthenticatedEndIfNeededAsync(int read, int count, CancellationToken cancellationToken) + { + if (read != 0 || count == 0 || _authenticatedEndChecked || _authenticatedEndSource == null) + { + return; + } + + // A token cancelled before the check runs leaves it un-run and the stream healthy, so + // a retry with a live token can still verify the end + cancellationToken.ThrowIfCancellationRequested(); + try + { + await _authenticatedEndSource.EnsureReadToAuthenticatedEndAsync(cancellationToken); + _authenticatedEndChecked = true; + } + catch (OperationCanceledException) + { + // The decrypting stream owns the consumption state and faults itself on a mid-read + // cancellation; a cancellation it lets through without faulting (for example the + // token trips in the gap after the check above) leaves it healthy, so the outer + // must not fault either — a retry with a live token can still verify the end + throw; + } + catch + { + // A real integrity failure is permanent, so a read-retry layer can not swallow it + // and later see a normal EOF + _authenticatedEndChecked = true; + _faulted = true; + throw; + } + } + + private void EnsureNotFaulted() + { + if (_faulted) + { + throw new AbpException("The stream can not be read anymore, because a previous read operation has failed!"); + } + } + +#if !NETSTANDARD2_0 + // Forwarded so a wrapper that only implements the modern overloads is not + // degraded to the byte[] fallback of the base class + public override int Read(Span buffer) + { + EnsureNotDisposed(); + EnsureNotFaulted(); + using (_currentTenant.Change(_tenantId)) + { + int read; + try + { + read = _inner.Read(buffer); + } + catch + { + _faulted = true; + throw; + } + + VerifyAuthenticatedEndIfNeeded(read, buffer.Length); + return read; + } + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + EnsureNotDisposed(); + EnsureNotFaulted(); + cancellationToken.ThrowIfCancellationRequested(); + using (_currentTenant.Change(_tenantId)) + { + int read; + try + { + read = await _inner.ReadAsync(buffer, cancellationToken); + } + catch + { + _faulted = true; + throw; + } + + await VerifyAuthenticatedEndIfNeededAsync(read, buffer.Length, cancellationToken); + return read; + } + } +#endif + + public override long Seek(long offset, SeekOrigin origin) + { + EnsureNotDisposed(); + using (_currentTenant.Change(_tenantId)) + { + return _inner.Seek(offset, origin); + } + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + protected override void Dispose(bool disposing) + { + if (disposing && !_disposed) + { + _disposed = true; + + // The tenant context is best-effort: failing to enter it must not skip the + // resource release (which would leak the provider stream, the scope and the + // derived key), and a later dispose can not recover it since _disposed is set + IDisposable? tenantChange = null; + try + { + tenantChange = _currentTenant.Change(_tenantId); + } + catch + { + // ignored: release the resources below without the tenant context + } + + try + { + DisposeInnerAndScope(); + } + finally + { + tenantChange?.Dispose(); + } + } + + base.Dispose(disposing); + } + + private void DisposeInnerAndScope() + { + try + { +#if NETSTANDARD2_0 + // Stream has no DisposeAsync on netstandard2.0, but the inner stream + // may still implement IAsyncDisposable for its async-only cleanup + if (_inner is IAsyncDisposable innerAsyncDisposable) + { + AsyncHelper.RunSync(() => innerAsyncDisposable.DisposeAsync().AsTask()); + } + else + { + _inner.Dispose(); + } +#else + // Also covers wrappers that only implement DisposeAsync + AsyncHelper.RunSync(() => _inner.DisposeAsync().AsTask()); +#endif + } + catch + { + // The stream failure is the root cause; a scope dispose + // failure on top of it must not replace it + try + { + AsyncHelper.RunSync(() => _scope.DisposeAsync().AsTask()); + } + catch + { + // ignored + } + + throw; + } + + // A synchronous scope dispose throws when a scoped service only + // implements IAsyncDisposable, so always release it asynchronously + AsyncHelper.RunSync(() => _scope.DisposeAsync().AsTask()); + } + +#if !NETSTANDARD2_0 + public override async ValueTask DisposeAsync() + { + if (!_disposed) + { + _disposed = true; + + IDisposable? tenantChange = null; + try + { + tenantChange = _currentTenant.Change(_tenantId); + } + catch + { + // ignored: release the resources below without the tenant context + } + + try + { + await DisposeInnerAndScopeAsync(); + } + finally + { + tenantChange?.Dispose(); + } + } + + await base.DisposeAsync(); + } + + private async ValueTask DisposeInnerAndScopeAsync() + { + try + { + await _inner.DisposeAsync(); + } + catch + { + try + { + await _scope.DisposeAsync(); + } + catch + { + // ignored: the stream failure is the root cause + } + + throw; + } + + await _scope.DisposeAsync(); + } +#endif +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/ChunkedCryptoReadStream.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/ChunkedCryptoReadStream.cs new file mode 100644 index 0000000000..f0b91bb07b --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/ChunkedCryptoReadStream.cs @@ -0,0 +1,181 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Volo.Abp.BlobStoring; + +/// +/// A read-only, non-seekable stream that serves output produced chunk by chunk. +/// +internal abstract class ChunkedCryptoReadStream : SequentialReadStream +{ + private readonly long? _length; + private byte[]? _outputBuffer; + private int _outputBufferPosition; + private long _position; + private bool _finished; + + protected ChunkedCryptoReadStream(long? length = null) + { + _length = length; + } + + public override long Length => _length ?? throw new NotSupportedException(); + + // Some storage SDKs (like AWS S3) compute the upload size as Length - Position, + // so the getter reports the number of bytes served so far instead of throwing. + public override long Position + { + get => _position; + set => throw new NotSupportedException(); + } + + protected sealed override int ReadCore(byte[] buffer, int offset, int count) + { + while (true) + { + var copiedCount = TryCopyFromOutputBuffer(buffer, offset, count); + if (copiedCount > 0 || _finished) + { + return copiedCount; + } + + SetOutputBuffer(ProduceNext()); + } + } + + protected sealed override async Task ReadCoreAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + while (true) + { + var copiedCount = TryCopyFromOutputBuffer(buffer, offset, count); + if (copiedCount > 0 || _finished) + { + return copiedCount; + } + + SetOutputBuffer(await ProduceNextAsync(cancellationToken)); + } + } + + /// + /// Produces the next output bytes, or null when there is no more output. + /// + protected abstract byte[]? ProduceNext(); + + protected abstract Task ProduceNextAsync(CancellationToken cancellationToken); + + internal void EnsureReadToAuthenticatedEndCore() + { + // A previous read that faulted the stream (for example an authentication failure + // a contributor swallowed) must not be recovered by re-entering ProduceNext here + EnsureCanServe(); + try + { + if (IsAtAuthenticatedEnd()) + { + return; + } + + // Producing the next record either verifies the terminal record (null) or + // returns another content chunk, which means the consumer stopped early + ThrowIfMoreContent(ProduceNext()); + } + catch + { + // Fault the stream so the failure can not be swallowed by reading again + MarkFaulted(); + throw; + } + } + + internal async ValueTask EnsureReadToAuthenticatedEndCoreAsync(CancellationToken cancellationToken) + { + EnsureCanServe(); + // A token cancelled before any I/O leaves the stream untouched, so it can stay + // healthy for a retry (the same rule the normal read path applies). Once a read has + // started, any failure must fault: a mid-read cancellation already consumed and + // discarded bytes of the non-seekable cipher stream, so a retry that resumed from the + // middle of the terminal record would misreport a valid BLOB as corrupt + cancellationToken.ThrowIfCancellationRequested(); + try + { + if (IsAtAuthenticatedEnd()) + { + return; + } + + ThrowIfMoreContent(await ProduceNextAsync(cancellationToken)); + } + catch + { + MarkFaulted(); + throw; + } + } + + private bool IsAtAuthenticatedEnd() + { + if (_finished) + { + return true; + } + + if (_outputBuffer != null && _outputBufferPosition < _outputBuffer.Length) + { + throw new AbpException( + "The encrypted BLOB was not read to its authenticated end, so its completeness can not be verified " + + "(a content-pipeline contributor stopped reading the content before the end)."); + } + + return false; + } + + private void ThrowIfMoreContent(byte[]? next) + { + if (next != null) + { + throw new AbpException( + "The encrypted BLOB was not read to its authenticated end, so its completeness can not be verified " + + "(a content-pipeline contributor stopped reading the content before the end)."); + } + + SetOutputBuffer(null); + } + + protected override void Dispose(bool disposing) + { + if (disposing && _outputBuffer != null) + { + Array.Clear(_outputBuffer, 0, _outputBuffer.Length); + _outputBuffer = null; + } + + base.Dispose(disposing); + } + + private int TryCopyFromOutputBuffer(byte[] buffer, int offset, int count) + { + if (_outputBuffer == null || _outputBufferPosition >= _outputBuffer.Length) + { + return 0; + } + + var toCopy = Math.Min(count, _outputBuffer.Length - _outputBufferPosition); + Array.Copy(_outputBuffer, _outputBufferPosition, buffer, offset, toCopy); + _outputBufferPosition += toCopy; + _position += toCopy; + return toCopy; + } + + private void SetOutputBuffer(byte[]? outputBuffer) + { + _outputBuffer = outputBuffer; + _outputBufferPosition = 0; + + if (outputBuffer == null) + { + _finished = true; + } + } +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/ChunkedDecryptingReadStream.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/ChunkedDecryptingReadStream.cs new file mode 100644 index 0000000000..5063c4f406 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/ChunkedDecryptingReadStream.cs @@ -0,0 +1,180 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Volo.Abp.Threading; + +namespace Volo.Abp.BlobStoring; + +/// +/// Decrypts the cipher stream chunk by chunk while being read. It is the only stream +/// with an authenticated terminal record, so it is the one implementing +/// . +/// +internal class ChunkedDecryptingReadStream : ChunkedCryptoReadStream, IBlobAuthenticatedEndStream +{ + private readonly Stream _cipherStream; + private readonly byte[] _associatedData; + private readonly byte[] _keyBytes; + private readonly IDisposable _chunkCipher; + private readonly byte[] _nonce; + private readonly int _chunkSize; + private int _chunkIndex; + private bool _disposed; + + public ChunkedDecryptingReadStream( + Stream cipherStream, + byte[] associatedDataPrefix, + byte[] keyBytes, + byte[] baseNonce, + int chunkSize) + { + _cipherStream = cipherStream; + // One reusable cipher and buffer each; only the trailing chunk index changes per chunk + _associatedData = BlobEncryptionCodec.CreateReusableAssociatedData(associatedDataPrefix); + _keyBytes = keyBytes; + _chunkCipher = BlobEncryptionCodec.CreateChunkCipher(keyBytes); + _nonce = BlobEncryptionCodec.CreateReusableChunkNonce(baseNonce); + _chunkSize = chunkSize; + } + + public void EnsureReadToAuthenticatedEnd() + { + EnsureReadToAuthenticatedEndCore(); + } + + public ValueTask EnsureReadToAuthenticatedEndAsync(CancellationToken cancellationToken = default) + { + return EnsureReadToAuthenticatedEndCoreAsync(cancellationToken); + } + + protected override byte[]? ProduceNext() + { + var cipherChunkSize = BlobEncryptionCodec.GetCipherChunkSize( + BlobEncryptionCodec.ReadUpTo(_cipherStream, BlobEncryptionCodec.ChunkLengthPrefixSize), + _chunkSize + ); + if (cipherChunkSize == 0) + { + var terminalTag = BlobEncryptionCodec.ReadExactly(_cipherStream, BlobEncryptionCodec.GcmTagSize); + if (terminalTag == null || BlobEncryptionCodec.ReadUpTo(_cipherStream, 1).Length != 0) + { + throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid terminal record!"); + } + + SetChunkIndex(_chunkIndex); + BlobEncryptionCodec.VerifyTerminalRecordCore(_chunkCipher, _associatedData, _nonce, terminalTag); + return null; + } + + return DecryptPayload( + BlobEncryptionCodec.ReadExactly(_cipherStream, cipherChunkSize), + BlobEncryptionCodec.ReadExactly(_cipherStream, BlobEncryptionCodec.GcmTagSize) + ); + } + + protected override async Task ProduceNextAsync(CancellationToken cancellationToken) + { + var cipherChunkSize = BlobEncryptionCodec.GetCipherChunkSize( + await BlobEncryptionCodec.ReadUpToAsync(_cipherStream, BlobEncryptionCodec.ChunkLengthPrefixSize, cancellationToken), + _chunkSize + ); + if (cipherChunkSize == 0) + { + var terminalTag = await BlobEncryptionCodec.ReadExactlyAsync(_cipherStream, BlobEncryptionCodec.GcmTagSize, cancellationToken); + if (terminalTag == null || (await BlobEncryptionCodec.ReadUpToAsync(_cipherStream, 1, cancellationToken)).Length != 0) + { + throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid terminal record!"); + } + + SetChunkIndex(_chunkIndex); + BlobEncryptionCodec.VerifyTerminalRecordCore(_chunkCipher, _associatedData, _nonce, terminalTag); + return null; + } + + return DecryptPayload( + await BlobEncryptionCodec.ReadExactlyAsync(_cipherStream, cipherChunkSize, cancellationToken), + await BlobEncryptionCodec.ReadExactlyAsync(_cipherStream, BlobEncryptionCodec.GcmTagSize, cancellationToken) + ); + } + + private byte[] DecryptPayload(byte[]? cipherChunk, byte[]? tag) + { + if (cipherChunk == null || tag == null) + { + throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: truncated chunk!"); + } + + SetChunkIndex(_chunkIndex); + var plainChunk = BlobEncryptionCodec.DecryptChunkCore(_chunkCipher, _associatedData, _nonce, cipherChunk, tag); + _chunkIndex++; + return plainChunk; + } + + private void SetChunkIndex(int chunkIndex) + { + BlobEncryptionCodec.WriteChunkIndex(_nonce, chunkIndex); + BlobEncryptionCodec.WriteChunkIndex(_associatedData, chunkIndex); + } + + protected override void Dispose(bool disposing) + { + if (disposing && !_disposed) + { + _disposed = true; + _chunkCipher.Dispose(); + ClearKeyBytes(); + try + { +#if NETSTANDARD2_0 + _cipherStream.Dispose(); +#else + // Also covers a provider stream that only implements DisposeAsync + AsyncHelper.RunSync(() => _cipherStream.DisposeAsync().AsTask()); +#endif + } + finally + { + base.Dispose(disposing); + } + + return; + } + + base.Dispose(disposing); + } + +#if !NETSTANDARD2_0 + public override async ValueTask DisposeAsync() + { + if (!_disposed) + { + _disposed = true; + _chunkCipher.Dispose(); + ClearKeyBytes(); + try + { + await _cipherStream.DisposeAsync(); + } + finally + { + await base.DisposeAsync(); + } + + return; + } + + await base.DisposeAsync(); + } +#endif + + private void ClearKeyBytes() + { +#if NETSTANDARD2_0 + Array.Clear(_keyBytes, 0, _keyBytes.Length); +#else + CryptographicOperations.ZeroMemory(_keyBytes); +#endif + } +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/ChunkedEncryptingReadStream.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/ChunkedEncryptingReadStream.cs new file mode 100644 index 0000000000..06167326a0 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/ChunkedEncryptingReadStream.cs @@ -0,0 +1,124 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; + +namespace Volo.Abp.BlobStoring; + +/// +/// Encrypts the source stream chunk by chunk while being read. +/// +internal class ChunkedEncryptingReadStream : ChunkedCryptoReadStream +{ + private readonly Stream _plainStream; + private readonly byte[] _prefix; + private readonly byte[] _associatedData; + private readonly byte[] _keyBytes; + private readonly IDisposable _chunkCipher; + private readonly byte[] _nonce; + private readonly int _chunkSize; + private bool _prefixEmitted; + private bool _terminalEmitted; + private int _chunkIndex; + + public ChunkedEncryptingReadStream( + Stream plainStream, + byte[] prefix, + byte[] associatedDataPrefix, + byte[] keyBytes, + byte[] baseNonce, + int chunkSize, + long? encryptedLength) + : base(encryptedLength) + { + _plainStream = plainStream; + _prefix = prefix; + // One reusable cipher and buffer each; only the trailing chunk index changes per chunk + _associatedData = BlobEncryptionCodec.CreateReusableAssociatedData(associatedDataPrefix); + _keyBytes = keyBytes; + _chunkCipher = BlobEncryptionCodec.CreateChunkCipher(keyBytes); + _nonce = BlobEncryptionCodec.CreateReusableChunkNonce(baseNonce); + _chunkSize = chunkSize; + } + + protected override byte[]? ProduceNext() + { + var prefix = TryProducePrefix(); + if (prefix != null) + { + return prefix; + } + + return ProducePayload(BlobEncryptionCodec.ReadUpTo(_plainStream, _chunkSize)); + } + + protected override async Task ProduceNextAsync(CancellationToken cancellationToken) + { + var prefix = TryProducePrefix(); + if (prefix != null) + { + return prefix; + } + + return ProducePayload(await BlobEncryptionCodec.ReadUpToAsync(_plainStream, _chunkSize, cancellationToken)); + } + + private byte[]? TryProducePrefix() + { + if (_prefixEmitted) + { + return null; + } + + _prefixEmitted = true; + return _prefix; + } + + private byte[]? ProducePayload(byte[] plainChunk) + { + if (plainChunk.Length == 0) + { + if (_terminalEmitted) + { + return null; + } + + _terminalEmitted = true; + SetChunkIndex(_chunkIndex); + return BlobEncryptionCodec.CreateTerminalRecordCore(_chunkCipher, _associatedData, _nonce); + } + + SetChunkIndex(_chunkIndex); + var chunkBytes = BlobEncryptionCodec.EncryptChunkCore(_chunkCipher, _associatedData, _nonce, plainChunk, plainChunk.Length); + _chunkIndex++; + return chunkBytes; + } + + private void SetChunkIndex(int chunkIndex) + { + BlobEncryptionCodec.WriteChunkIndex(_nonce, chunkIndex); + BlobEncryptionCodec.WriteChunkIndex(_associatedData, chunkIndex); + } + + protected override void Dispose(bool disposing) + { + // Do not dispose the plain stream; it is owned by the caller. + if (disposing) + { + _chunkCipher.Dispose(); + ClearKeyBytes(); + } + + base.Dispose(disposing); + } + + private void ClearKeyBytes() + { +#if NETSTANDARD2_0 + Array.Clear(_keyBytes, 0, _keyBytes.Length); +#else + CryptographicOperations.ZeroMemory(_keyBytes); +#endif + } +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/DefaultBlobEncryptionKeyProvider.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/DefaultBlobEncryptionKeyProvider.cs new file mode 100644 index 0000000000..5623468864 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/DefaultBlobEncryptionKeyProvider.cs @@ -0,0 +1,97 @@ +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Microsoft.Extensions.Options; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BlobStoring; + +/// +/// Resolves the container passphrase first, then the global +/// ; decryption uses +/// only the source recorded in the BLOB header. Replace this service for +/// tenant-specific or externally stored passphrases. +/// +public class DefaultBlobEncryptionKeyProvider : IBlobEncryptionKeyProvider, ITransientDependency +{ + protected AbpBlobStoringEncryptionOptions Options { get; } + + public DefaultBlobEncryptionKeyProvider(IOptions options) + { + Options = options.Value; + } + + /// + public virtual Task ResolveForEncryptionAsync( + [NotNull] BlobEncryptionKeyContext context, + CancellationToken cancellationToken = default) + { + Check.NotNull(context, nameof(context)); + cancellationToken.ThrowIfCancellationRequested(); + + var containerPassPhrase = GetContainerPassPhraseOrNull(context.Configuration); + if (!string.IsNullOrWhiteSpace(containerPassPhrase)) + { + return Task.FromResult(new BlobEncryptionKey(BlobEncryptionKeySource.Container, containerPassPhrase!)); + } + + if (!string.IsNullOrWhiteSpace(Options.DefaultPassPhrase)) + { + return Task.FromResult(new BlobEncryptionKey(BlobEncryptionKeySource.Global, Options.DefaultPassPhrase!)); + } + + throw new AbpException( + "BLOB encryption is enabled, but no passphrase could be resolved. " + + "Pass a passphrase to the UseEncryption extension method or configure " + + $"{nameof(AbpBlobStoringEncryptionOptions)}.{nameof(AbpBlobStoringEncryptionOptions.DefaultPassPhrase)}." + ); + } + + /// + public virtual Task ResolveForDecryptionAsync( + BlobEncryptionKeySource keySource, + [NotNull] BlobEncryptionKeyContext context, + CancellationToken cancellationToken = default) + { + Check.NotNull(context, nameof(context)); + cancellationToken.ThrowIfCancellationRequested(); + + string? passPhrase; + switch (keySource) + { + case BlobEncryptionKeySource.Container: + passPhrase = GetContainerPassPhraseOrNull(context.Configuration); + break; + case BlobEncryptionKeySource.Tenant: + throw new AbpException( + "The BLOB was encrypted with a tenant-specific passphrase, but the default " + + $"key provider does not supply tenant keys. Replace the {nameof(IBlobEncryptionKeyProvider)} " + + "service with the implementation that was used to encrypt the BLOB." + ); + case BlobEncryptionKeySource.Global: + passPhrase = Options.DefaultPassPhrase; + break; + default: + throw new AbpException($"Unknown BLOB encryption key source: {keySource}!"); + } + + if (string.IsNullOrWhiteSpace(passPhrase)) + { + throw new AbpException( + $"The BLOB was encrypted with the '{keySource}' passphrase, " + + "but that passphrase is not available anymore, so the BLOB can not be decrypted." + ); + } + + return Task.FromResult(passPhrase!); + } + + /// + /// Returns the container-specific passphrase, so derived providers can keep it + /// as the highest-priority source. + /// + protected virtual string? GetContainerPassPhraseOrNull(BlobContainerConfiguration configuration) + { + return BlobEncryptionConfiguration.GetPassPhraseOrNull(configuration); + } +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobAuthenticatedEndStream.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobAuthenticatedEndStream.cs new file mode 100644 index 0000000000..62497ddc9a --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobAuthenticatedEndStream.cs @@ -0,0 +1,26 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Volo.Abp.BlobStoring; + +/// +/// Implemented by a read stream (like the decrypting stream) that can verify it was +/// read to an authenticated end. The content pipeline calls it when the composed +/// stream returned by GetAsync reaches EOF, so a contributor stopping before +/// the end can not hide a truncation. A stream that wraps such a stream (for example +/// a custom CreateDecryptingStreamAsync override) should implement this +/// interface too and forward the calls to the wrapped stream, or the end verification +/// is skipped for pipeline reads. +/// +public interface IBlobAuthenticatedEndStream +{ + /// + /// Throws if the stream has not been consumed up to its authenticated end. + /// + void EnsureReadToAuthenticatedEnd(); + + /// + /// Throws if the stream has not been consumed up to its authenticated end. + /// + ValueTask EnsureReadToAuthenticatedEndAsync(CancellationToken cancellationToken = default); +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobEncryptionCodec.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobEncryptionCodec.cs new file mode 100644 index 0000000000..fa02a58039 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobEncryptionCodec.cs @@ -0,0 +1,41 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; + +namespace Volo.Abp.BlobStoring; + +/// +/// Encrypts and decrypts the BLOB content stream (authenticated, chunked AES-256-GCM). +/// Replace this service to change the encryption format or algorithm; the built-in +/// implements version 1 of the format. +/// +public interface IBlobEncryptionCodec +{ + /// + /// Wraps in a read-only stream that encrypts the + /// content while it is read. The container and BLOB names are expected in their + /// normalized form. + /// + Task CreateEncryptingStreamAsync( + [NotNull] BlobContainerConfiguration configuration, + [NotNull] string containerName, + [NotNull] string blobName, + Guid? tenantId, + [NotNull] Stream plainStream, + CancellationToken cancellationToken = default); + + /// + /// Wraps in a read-only stream that decrypts the + /// content while it is read. The container and BLOB names are expected in their + /// normalized form. + /// + Task CreateDecryptingStreamAsync( + [NotNull] BlobContainerConfiguration configuration, + [NotNull] string containerName, + [NotNull] string blobName, + Guid? tenantId, + [NotNull] Stream cipherStream, + CancellationToken cancellationToken = default); +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobEncryptionKeyProvider.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobEncryptionKeyProvider.cs new file mode 100644 index 0000000000..90212acd8a --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobEncryptionKeyProvider.cs @@ -0,0 +1,32 @@ +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; + +namespace Volo.Abp.BlobStoring; + +/// +/// Resolves the passphrase used to encrypt/decrypt the BLOBs of a container. +/// Replace this service to read the passphrases from another source, like a vault +/// or another secret store (the provider must be able to return the passphrase +/// itself; hardware-backed non-exportable keys are not supported). The +/// carries the container/BLOB name and the +/// tenant, so the passphrase can be selected by the BLOB identity too. +/// +public interface IBlobEncryptionKeyProvider +{ + /// + /// Resolves the passphrase (and its source) to encrypt a new BLOB; throws if none is available. + /// + Task ResolveForEncryptionAsync( + [NotNull] BlobEncryptionKeyContext context, + CancellationToken cancellationToken = default); + + /// + /// Resolves the passphrase for the key source recorded in the BLOB header; + /// throws if it is not available anymore. + /// + Task ResolveForDecryptionAsync( + BlobEncryptionKeySource keySource, + [NotNull] BlobEncryptionKeyContext context, + CancellationToken cancellationToken = default); +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobPipelineContributor.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobPipelineContributor.cs new file mode 100644 index 0000000000..6be5778758 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobPipelineContributor.cs @@ -0,0 +1,41 @@ +using System.Threading.Tasks; +using JetBrains.Annotations; + +namespace Volo.Abp.BlobStoring; + +/// +/// Transforms the BLOB content stream (compression, watermarking, validation...) +/// while it is saved and read. Contributors are configured per container with +/// and run in the +/// configuration order while saving and in the reverse order while reading. +/// The built-in encryption always runs after the contributors while saving (and +/// before them while reading), so contributors always work on the plain content. +/// +public interface IBlobPipelineContributor +{ + /// + /// Transform the content by replacing : + /// with a lazily transforming read-only wrapper (best for large content), or with an + /// eagerly materialized stream. A replacement must leave the stream it received open: + /// every stream assigned to is disposed + /// after the save, while the original stream stays owned by the caller. A stream is only + /// tracked from the moment it is assigned, so if you create a stream and then do work + /// that may fail before assigning it, dispose it yourself on the failure path. + /// + /// Not replacing the stream is only valid for a contributor that does not consume the + /// content (for example a metadata check). A contributor that reads the content to + /// validate it must return a pass-through wrapper that validates the bytes as they + /// flow (or an eagerly materialized replacement) — reading the content without + /// replacing the stream would leave an empty/truncated stream for the provider. + /// + /// + Task OnSavingAsync([NotNull] BlobPipelineContext context); + + /// + /// Reverse the save-time transformation by replacing + /// the same way. Here a replacement + /// must dispose the stream it received when it is disposed, since the composed + /// stream is returned to the caller as a whole. + /// + Task OnGettingAsync([NotNull] BlobPipelineContext context); +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/PrefixingReadStream.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/PrefixingReadStream.cs new file mode 100644 index 0000000000..edd4be4b45 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/PrefixingReadStream.cs @@ -0,0 +1,155 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Volo.Abp.Threading; + +namespace Volo.Abp.BlobStoring; + +/// +/// Serves the already-consumed prefix bytes first, then the rest of the underlying stream. +/// +internal sealed class PrefixingReadStream : SequentialReadStream +{ + private readonly byte[] _prefix; + private readonly Stream _stream; + private readonly long? _length; + private int _prefixPosition; + + public PrefixingReadStream(byte[] prefix, Stream stream) + { + _prefix = prefix; + _stream = stream; + + // What this stream serves is the prefix plus whatever remains of the underlying + // stream from its current position — not the underlying total length, which + // would overstate it when the provider stream did not start at position 0 + try + { + _length = _prefix.Length + (_stream.Length - _stream.Position); + } + catch (Exception ex) when (ex is NotSupportedException || ex is IOException) + { + _length = null; + } + } + + public override bool CanRead => !IsDisposed && _stream.CanRead; + + // Legacy plaintext BLOBs had a usable Length before encryption was enabled; it is + // known when the underlying stream reports both its length and position. Position + // reports the bytes served, so Length - Position stays meaningful for length-aware + // consumers (like re-encrypting the legacy content) + public override long Length => _length ?? throw new NotSupportedException(); + + public override long Position + { + get => _position; + set => throw new NotSupportedException(); + } + + private long _position; + + protected override int ReadCore(byte[] buffer, int offset, int count) + { + var prefixReadCount = TryCopyFromPrefix(buffer, offset, count); + if (prefixReadCount > 0) + { + _position += prefixReadCount; + return prefixReadCount; + } + + var readCount = _stream.Read(buffer, offset, count); + _position += readCount; + return readCount; + } + + protected override async Task ReadCoreAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + var prefixReadCount = TryCopyFromPrefix(buffer, offset, count); + if (prefixReadCount > 0) + { + _position += prefixReadCount; + return prefixReadCount; + } + +#if NETSTANDARD2_0 + var readCount = await _stream.ReadAsync(buffer, offset, count, cancellationToken); +#else + // The modern overload dispatches correctly for streams that only + // implement ReadAsync(Memory) + var readCount = await _stream.ReadAsync(buffer.AsMemory(offset, count), cancellationToken); +#endif + _position += readCount; + return readCount; + } + + private int TryCopyFromPrefix(byte[] buffer, int offset, int count) + { + if (_prefixPosition >= _prefix.Length) + { + return 0; + } + + var readCount = Math.Min(count, _prefix.Length - _prefixPosition); + Array.Copy(_prefix, _prefixPosition, buffer, offset, readCount); + _prefixPosition += readCount; + return readCount; + } + + protected override void Dispose(bool disposing) + { + if (disposing && !IsDisposed) + { + IsDisposed = true; + try + { +#if NETSTANDARD2_0 + // Stream has no DisposeAsync on netstandard2.0, but the provider stream + // may still implement IAsyncDisposable for its async-only cleanup + if (_stream is IAsyncDisposable asyncDisposable) + { + AsyncHelper.RunSync(() => asyncDisposable.DisposeAsync().AsTask()); + } + else + { + _stream.Dispose(); + } +#else + // Also covers a provider stream that only implements DisposeAsync + AsyncHelper.RunSync(() => _stream.DisposeAsync().AsTask()); +#endif + } + finally + { + base.Dispose(disposing); + } + + return; + } + + base.Dispose(disposing); + } + +#if !NETSTANDARD2_0 + public override async ValueTask DisposeAsync() + { + if (!IsDisposed) + { + IsDisposed = true; + try + { + await _stream.DisposeAsync(); + } + finally + { + await base.DisposeAsync(); + } + + return; + } + + await base.DisposeAsync(); + } +#endif +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/SequentialReadStream.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/SequentialReadStream.cs new file mode 100644 index 0000000000..c4f1919a8d --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/SequentialReadStream.cs @@ -0,0 +1,146 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Volo.Abp.BlobStoring; + +/// +/// A read-only, non-seekable, forward-only stream; a failed read faults it permanently. +/// +internal abstract class SequentialReadStream : Stream +{ + private bool _faulted; + + protected bool IsDisposed { get; set; } + + // Once faulted, no further read can succeed; a failed authenticated-end check + // uses it so the failure can not be swallowed by a read-retry layer + protected void MarkFaulted() + { + _faulted = true; + } + + // False once disposed, so it stays consistent with Read throwing ObjectDisposedException + public override bool CanRead => !IsDisposed; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + ValidateReadArguments(buffer, offset, count); + EnsureCanServe(); + + if (count == 0) + { + return 0; + } + + try + { + return ReadCore(buffer, offset, count); + } + catch + { + _faulted = true; + throw; + } + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ValidateReadArguments(buffer, offset, count); + EnsureCanServe(); + cancellationToken.ThrowIfCancellationRequested(); + + if (count == 0) + { + return 0; + } + + try + { + return await ReadCoreAsync(buffer, offset, count, cancellationToken); + } + catch + { + _faulted = true; + throw; + } + } + + protected abstract int ReadCore(byte[] buffer, int offset, int count); + + protected abstract Task ReadCoreAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken); + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException(); + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + protected override void Dispose(bool disposing) + { + IsDisposed = true; + base.Dispose(disposing); + } + + protected void EnsureCanServe() + { + if (IsDisposed) + { + throw new ObjectDisposedException(GetType().FullName); + } + + if (_faulted) + { + throw new AbpException("The stream can not be read anymore, because a previous read operation has failed!"); + } + } + + private static void ValidateReadArguments(byte[] buffer, int offset, int count) + { + if (buffer == null) + { + throw new ArgumentNullException(nameof(buffer)); + } + + if (offset < 0) + { + throw new ArgumentOutOfRangeException(nameof(offset)); + } + + if (count < 0) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + if (buffer.Length - offset < count) + { + throw new ArgumentException("The sum of offset and count is larger than the buffer length!"); + } + } +} diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/Building/UiFramework.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/Building/UiFramework.cs index ae8c2b4a07..dfd74e3e2e 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/Building/UiFramework.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/Building/UiFramework.cs @@ -9,5 +9,6 @@ public enum UiFramework Blazor = 4, BlazorServer = 5, MauiBlazor = 6, - BlazorWebApp = 7 + BlazorWebApp = 7, + React = 8 } diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/Building/UiFrameworkExtensions.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/Building/UiFrameworkExtensions.cs index cb592444e6..a31725f575 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/Building/UiFrameworkExtensions.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/Building/UiFrameworkExtensions.cs @@ -12,6 +12,7 @@ public static class UiFrameworkExtensions case UiFramework.Blazor: return "blazor"; case UiFramework.BlazorServer: return "blazor-server"; case UiFramework.BlazorWebApp: return "blazor-webapp"; + case UiFramework.React: return "react"; case UiFramework.NotSpecified: return "NotSpecified"; default: return "NotSpecified"; } diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/TemplateProjectBuilder.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/TemplateProjectBuilder.cs index 1a27ae84d0..e2af3f5d43 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/TemplateProjectBuilder.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/TemplateProjectBuilder.cs @@ -59,6 +59,15 @@ public class TemplateProjectBuilder : IProjectBuilder, ITransientDependency NormalizeArgs(args, templateInfo); + // Checked after NormalizeArgs so a NotSpecified UI resolved to React is caught too. React is only + // produced by ABP Studio's modern solution templates; the classic builders below have no React + // handling, so fail fast instead of emitting a solution with an unconfigured UI. + if (args.UiFramework == UiFramework.React) + { + throw new UserFriendlyException( + "The React UI is generated by ABP Studio's modern solution templates and is not supported by the classic template builder."); + } + await EventBus.PublishAsync(new ProjectCreationProgressEvent { Message = "Downloading the solution template" }, false); diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/HttpMethodHelper.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/HttpMethodHelper.cs index 78ee75e901..1ef6961c29 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/HttpMethodHelper.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/HttpMethodHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; @@ -8,15 +8,25 @@ namespace Volo.Abp.Http; public static class HttpMethodHelper { - public const string DefaultHttpVerb = "POST"; + public const string Get = "GET"; + public const string Post = "POST"; + public const string Put = "PUT"; + public const string Delete = "DELETE"; + public const string Patch = "PATCH"; + public const string Head = "HEAD"; + public const string Options = "OPTIONS"; + public const string Trace = "TRACE"; + public const string Query = "QUERY"; + + public const string DefaultHttpVerb = Post; public static Dictionary ConventionalPrefixes { get; set; } = new Dictionary { - {"GET", new[] {"GetList", "GetAll", "Get"}}, - {"PUT", new[] {"Put", "Update"}}, - {"DELETE", new[] {"Delete", "Remove"}}, - {"POST", new[] {"Create", "Add", "Insert", "Post"}}, - {"PATCH", new[] {"Patch"}} + {Get, new[] {"GetList", "GetAll", "Get"}}, + {Put, new[] {"Put", "Update"}}, + {Delete, new[] {"Delete", "Remove"}}, + {Post, new[] {"Create", "Add", "Insert", "Post"}}, + {Patch, new[] {"Patch"}} }; public static string GetConventionalVerbForMethodName(string methodName) @@ -50,24 +60,44 @@ public static class HttpMethodHelper { switch (httpMethod?.ToUpperInvariant()) { - case "GET": + case Get: return HttpMethod.Get; - case "POST": + case Post: return HttpMethod.Post; - case "PUT": + case Put: return HttpMethod.Put; - case "DELETE": + case Delete: return HttpMethod.Delete; - case "OPTIONS": + case Options: return HttpMethod.Options; - case "TRACE": + case Trace: return HttpMethod.Trace; - case "HEAD": + case Head: return HttpMethod.Head; - case "PATCH": - return new HttpMethod("PATCH"); + case Patch: + return new HttpMethod(Patch); + case Query: + return new HttpMethod(Query); default: throw new AbpException("Unknown HTTP METHOD: " + httpMethod); } } + + public static bool IsGet(string? httpMethod) => string.Equals(httpMethod, Get, StringComparison.OrdinalIgnoreCase); + + public static bool IsPost(string? httpMethod) => string.Equals(httpMethod, Post, StringComparison.OrdinalIgnoreCase); + + public static bool IsPut(string? httpMethod) => string.Equals(httpMethod, Put, StringComparison.OrdinalIgnoreCase); + + public static bool IsDelete(string? httpMethod) => string.Equals(httpMethod, Delete, StringComparison.OrdinalIgnoreCase); + + public static bool IsPatch(string? httpMethod) => string.Equals(httpMethod, Patch, StringComparison.OrdinalIgnoreCase); + + public static bool IsHead(string? httpMethod) => string.Equals(httpMethod, Head, StringComparison.OrdinalIgnoreCase); + + public static bool IsOptions(string? httpMethod) => string.Equals(httpMethod, Options, StringComparison.OrdinalIgnoreCase); + + public static bool IsTrace(string? httpMethod) => string.Equals(httpMethod, Trace, StringComparison.OrdinalIgnoreCase); + + public static bool IsQuery(string? httpMethod) => string.Equals(httpMethod, Query, StringComparison.OrdinalIgnoreCase); } diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Auditing/AuditTestController_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Auditing/AuditTestController_Tests.cs index dac9259368..02093e0343 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Auditing/AuditTestController_Tests.cs +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Auditing/AuditTestController_Tests.cs @@ -58,6 +58,20 @@ public class AuditTestController_Tests : AspNetCoreMvcTestBase await _auditingStore.Received().DidNotReceive().SaveAsync(Arg.Any()); } + [Fact] + public async Task Should_Disable_AuditLog_For_Query_Requests() + { + _options.IsEnabledForGetRequests = false; + + using (var requestMessage = new HttpRequestMessage(new HttpMethod("QUERY"), "api/audit-test/audit-success")) + { + var response = await Client.SendAsync(requestMessage); + response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK); + } + + await _auditingStore.Received().DidNotReceive().SaveAsync(Arg.Any()); + } + [Fact] public async Task Should_Trigger_Middleware_And_AuditLog_Success_For_GetRequests() { diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkMiddleware_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkMiddleware_Tests.cs index 57e01df4f9..05e5d7b524 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkMiddleware_Tests.cs +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkMiddleware_Tests.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System.Net.Http; +using System.Threading.Tasks; using Shouldly; using Xunit; @@ -18,4 +19,12 @@ public class UnitOfWorkMiddleware_Tests : AspNetCoreMvcTestBase var result = await Client.PostAsync("/api/unitofwork-test/ActionRequiresUowPost", null); result.IsSuccessStatusCode.ShouldBeTrue(); } + + [Fact] + public async Task Query_Actions_Should_Not_Be_Transactional() + { + using var requestMessage = new HttpRequestMessage(new HttpMethod("QUERY"), "/api/unitofwork-test/ActionRequiresUowQuery"); + var result = await Client.SendAsync(requestMessage); + result.IsSuccessStatusCode.ShouldBeTrue(); + } } diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkPageFilter_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkPageFilter_Tests.cs index 6de88f20a8..43bca30e20 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkPageFilter_Tests.cs +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkPageFilter_Tests.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System.Net.Http; +using System.Threading.Tasks; using Shouldly; using Xunit; @@ -18,4 +19,12 @@ public class UnitOfWorkPageFilter_Tests : AspNetCoreMvcTestBase var result = await Client.PostAsync("/Uow/UnitOfWorkTestPage?handler=RequiresUow", null); result.IsSuccessStatusCode.ShouldBeTrue(); } + + [Fact] + public async Task Query_Actions_Should_Not_Be_Transactional() + { + using var requestMessage = new HttpRequestMessage(new HttpMethod("QUERY"), "/Uow/UnitOfWorkTestPage?handler=RequiresUow"); + var result = await Client.SendAsync(requestMessage); + result.IsSuccessStatusCode.ShouldBeTrue(); + } } diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestController.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestController.cs index bf05c55a31..ebf2c12a6a 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestController.cs +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestController.cs @@ -34,6 +34,16 @@ public class UnitOfWorkTestController : AbpController return Content("OK"); } + [AcceptVerbs("QUERY")] + [Route("ActionRequiresUowQuery")] + public ActionResult ActionRequiresUowQuery() + { + CurrentUnitOfWork.ShouldNotBeNull(); + CurrentUnitOfWork.Options.IsTransactional.ShouldBeFalse(); + + return Content("OK"); + } + [HttpGet] [Route("HandledException")] [UnitOfWork(isTransactional: true)] diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestPage.cshtml.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestPage.cshtml.cs index ebf9e6cfd4..b0ce556405 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestPage.cshtml.cs +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestPage.cshtml.cs @@ -31,6 +31,14 @@ public class UnitOfWorkTestPage : AbpPageModel return Content("OK"); } + public IActionResult OnQueryRequiresUow() + { + CurrentUnitOfWork.ShouldNotBeNull(); + CurrentUnitOfWork.Options.IsTransactional.ShouldBeFalse(); + + return Content("OK"); + } + [UnitOfWork(isTransactional: true)] public ObjectResult OnGetHandledException() { diff --git a/framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Uow/AspNetCoreUnitOfWorkTransactionBehaviourProvider_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Uow/AspNetCoreUnitOfWorkTransactionBehaviourProvider_Tests.cs new file mode 100644 index 0000000000..e5369e688e --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Uow/AspNetCoreUnitOfWorkTransactionBehaviourProvider_Tests.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Shouldly; +using Xunit; + +namespace Volo.Abp.AspNetCore.Uow; + +public class AspNetCoreUnitOfWorkTransactionBehaviourProvider_Tests +{ + private static AspNetCoreUnitOfWorkTransactionBehaviourProvider CreateProvider(string method) + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = method; + + return new AspNetCoreUnitOfWorkTransactionBehaviourProvider( + new HttpContextAccessor { HttpContext = httpContext }, + Microsoft.Extensions.Options.Options.Create(new AspNetCoreUnitOfWorkTransactionBehaviourProviderOptions())); + } + + [Theory] + [InlineData("GET", false)] + [InlineData("QUERY", false)] + [InlineData("query", false)] + [InlineData("HEAD", true)] + [InlineData("POST", true)] + [InlineData("PUT", true)] + [InlineData("DELETE", true)] + public void IsTransactional_Should_Treat_Get_And_Query_As_Non_Transactional(string method, bool expected) + { + CreateProvider(method).IsTransactional.ShouldBe(expected); + } +} diff --git a/framework/test/Volo.Abp.Auditing.Tests/Volo/Abp/Auditing/AuditingInterceptor_HttpMethod_Tests.cs b/framework/test/Volo.Abp.Auditing.Tests/Volo/Abp/Auditing/AuditingInterceptor_HttpMethod_Tests.cs new file mode 100644 index 0000000000..8216448333 --- /dev/null +++ b/framework/test/Volo.Abp.Auditing.Tests/Volo/Abp/Auditing/AuditingInterceptor_HttpMethod_Tests.cs @@ -0,0 +1,64 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using NSubstitute; +using Xunit; + +namespace Volo.Abp.Auditing; + +public class AuditingInterceptor_HttpMethod_Tests : AbpAuditingTestBase +{ + protected IAuditingStore AuditingStore; + + private string? _httpMethod; + + protected override void AfterAddApplication(IServiceCollection services) + { + AuditingStore = Substitute.For(); + services.Replace(ServiceDescriptor.Singleton(AuditingStore)); + + services.Configure(options => + { + options.IsEnabledForGetRequests = false; + options.Contributors.Add(new TestHttpMethodAuditContributor(() => _httpMethod)); + }); + } + + [Fact] + public async Task Should_Not_Write_AuditLog_For_Query_Http_Method_Without_Explicit_Scope() + { + _httpMethod = "QUERY"; + + var auditedObject = GetRequiredService(); + await auditedObject.DoItAsync(new Auditing_Tests.InputObject { Value1 = "x", Value2 = 1 }); + + await AuditingStore.DidNotReceive().SaveAsync(Arg.Any()); + } + + [Fact] + public async Task Should_Write_AuditLog_For_Post_Http_Method_Without_Explicit_Scope() + { + _httpMethod = "POST"; + + var auditedObject = GetRequiredService(); + await auditedObject.DoItAsync(new Auditing_Tests.InputObject { Value1 = "x", Value2 = 1 }); + + await AuditingStore.Received().SaveAsync(Arg.Any()); + } + + public class TestHttpMethodAuditContributor : AuditLogContributor + { + private readonly Func _httpMethodFactory; + + public TestHttpMethodAuditContributor(Func httpMethodFactory) + { + _httpMethodFactory = httpMethodFactory; + } + + public override void PreContribute(AuditLogContributionContext context) + { + context.AuditInfo.HttpMethod = _httpMethodFactory(); + } + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobProviderUploadDecision_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobProviderUploadDecision_Tests.cs new file mode 100644 index 0000000000..a5d9b3cf7d --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobProviderUploadDecision_Tests.cs @@ -0,0 +1,104 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Shouldly; +using Xunit; + +namespace Volo.Abp.BlobStoring.Aws; + +public class AwsBlobProviderUploadDecision_Tests +{ + private readonly ExposedAwsBlobProvider _provider = new ExposedAwsBlobProvider(); + + [Fact] + public void Should_Keep_The_Plain_PutObject_Behavior_For_Untransformed_Containers() + { + // A non-seekable stream of a container without encryption/pipeline + // must be uploaded exactly like before + var args = CreateArgs(new BlobContainerConfiguration(), new NonSeekableStream()); + + _provider.RequiresRetrySafeUploadPublic(args).ShouldBeFalse(); + } + + [Fact] + public void Should_Use_The_Retry_Safe_Upload_For_An_Encrypted_Container() + { + var configuration = new BlobContainerConfiguration().UseEncryption("test-passphrase"); + var args = CreateArgs(configuration, new NonSeekableStream()); + + _provider.RequiresRetrySafeUploadPublic(args).ShouldBeTrue(); + } + + [Fact] + public void Should_Use_The_Retry_Safe_Upload_For_A_Container_With_PipelineContributors() + { + var configuration = new BlobContainerConfiguration(); + configuration.PipelineContributors.Add(); + var args = CreateArgs(configuration, new NonSeekableStream()); + + _provider.RequiresRetrySafeUploadPublic(args).ShouldBeTrue(); + } + + [Fact] + public void Should_Not_Use_The_Retry_Safe_Upload_For_A_Seekable_Stream() + { + var configuration = new BlobContainerConfiguration().UseEncryption("test-passphrase"); + var args = CreateArgs(configuration, new MemoryStream()); + + _provider.RequiresRetrySafeUploadPublic(args).ShouldBeFalse(); + } + + private static BlobProviderSaveArgs CreateArgs(BlobContainerConfiguration configuration, Stream stream) + { + return new BlobProviderSaveArgs("test-container", configuration, "test-blob", stream); + } + + private sealed class ExposedAwsBlobProvider : AwsBlobProvider + { + public ExposedAwsBlobProvider() + : base(null!, null!, null!) + { + } + + public bool RequiresRetrySafeUploadPublic(BlobProviderSaveArgs args) + { + return RequiresRetrySafeUpload(args); + } + } + + private sealed class FakePipelineContributor : IBlobPipelineContributor + { + public Task OnSavingAsync(BlobPipelineContext context) + { + return Task.CompletedTask; + } + + public Task OnGettingAsync(BlobPipelineContext context) + { + return Task.CompletedTask; + } + } + + private sealed class NonSeekableStream : Stream + { + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) => 0; + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobProviderUploadRequest_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobProviderUploadRequest_Tests.cs new file mode 100644 index 0000000000..2386c0b710 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobProviderUploadRequest_Tests.cs @@ -0,0 +1,84 @@ +using System.IO; +using Amazon.S3.Model; +using Amazon.S3.Transfer; +using Shouldly; +using Xunit; + +namespace Volo.Abp.BlobStoring.Aws; + +public class AwsBlobProviderUploadRequest_Tests +{ + private readonly ExposedAwsBlobProvider _provider = new ExposedAwsBlobProvider(); + + [Fact] + public void Should_Wrap_The_Stream_And_Disable_Auto_Close_For_A_Multipart_Request() + { + // The SDK's non-seekable multipart path ignores AutoCloseStream and disposes the + // input, so the source must be protected by the leave-open wrapper + var source = new MemoryStream(); + var configuration = new AwsBlobProviderConfiguration(new BlobContainerConfiguration()) { DisablePayloadSigning = true }; + + var request = _provider.CreateMultipartUploadRequestPublic("bucket", "key", source, configuration); + + request.InputStream.ShouldBeOfType(); + request.AutoCloseStream.ShouldBeFalse(); + request.DisablePayloadSigning.ShouldBe(true); + request.BucketName.ShouldBe("bucket"); + request.Key.ShouldBe("key"); + } + + [Fact] + public void Should_Keep_The_Source_Open_After_The_Multipart_Input_Is_Disposed() + { + var source = new MemoryStream(); + var configuration = new AwsBlobProviderConfiguration(new BlobContainerConfiguration()); + + var request = _provider.CreateMultipartUploadRequestPublic("bucket", "key", source, configuration); + request.InputStream.Dispose(); // the SDK disposes the input on the multipart path + + source.CanRead.ShouldBeTrue(); // the wrapper must have left the source open + } + + [Fact] + public void Should_Propagate_Disable_Payload_Signing_And_Keep_Ownership_For_A_Put_Object_Request() + { + var source = new MemoryStream(); + var configuration = new AwsBlobProviderConfiguration(new BlobContainerConfiguration()) { DisablePayloadSigning = true }; + + var request = _provider.CreatePutObjectRequestPublic("bucket", "key", source, configuration); + + request.InputStream.ShouldBeSameAs(source); + request.AutoCloseStream.ShouldBeFalse(); + request.DisablePayloadSigning.ShouldBe(true); + } + + [Fact] + public void Should_Keep_Disable_Payload_Signing_Off_By_Default_For_A_Put_Object_Request() + { + var configuration = new AwsBlobProviderConfiguration(new BlobContainerConfiguration()); + + var request = _provider.CreatePutObjectRequestPublic("bucket", "key", new MemoryStream(), configuration); + + request.DisablePayloadSigning.ShouldBe(false); + } + + private sealed class ExposedAwsBlobProvider : AwsBlobProvider + { + public ExposedAwsBlobProvider() + : base(null!, null!, null!) + { + } + + public TransferUtilityUploadRequest CreateMultipartUploadRequestPublic( + string containerName, string blobName, Stream blobStream, AwsBlobProviderConfiguration configuration) + { + return CreateMultipartUploadRequest(containerName, blobName, blobStream, configuration); + } + + public PutObjectRequest CreatePutObjectRequestPublic( + string containerName, string blobName, Stream blobStream, AwsBlobProviderConfiguration configuration) + { + return CreatePutObjectRequest(containerName, blobName, blobStream, configuration); + } + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/LeaveOpenStreamWrapper_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/LeaveOpenStreamWrapper_Tests.cs new file mode 100644 index 0000000000..3af5a739f8 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/LeaveOpenStreamWrapper_Tests.cs @@ -0,0 +1,54 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Shouldly; +using Volo.Abp.BlobStoring.Fakes; +using Xunit; + +namespace Volo.Abp.BlobStoring.Aws; + +public class LeaveOpenStreamWrapper_Tests +{ + [Fact] + public async Task Should_Bridge_The_Old_Read_Overload_To_The_Modern_One_And_Leave_The_Stream_Open() + { + var content = new byte[1000]; + new Random(42).NextBytes(content); + using var inner = new FakeModernAsyncOnlyStream(new MemoryStream(content)); + var wrapper = new LeaveOpenStreamWrapper(inner); + + // The SDK reads over the old overload; the inner stream only supports the modern one + var buffer = new byte[content.Length]; + var totalReadCount = 0; + while (totalReadCount < buffer.Length) + { + var readCount = await wrapper.ReadAsync(buffer, totalReadCount, buffer.Length - totalReadCount, default); + if (readCount == 0) + { + break; + } + + totalReadCount += readCount; + } + + totalReadCount.ShouldBe(content.Length); + buffer.SequenceEqual(content).ShouldBeTrue(); + + wrapper.Dispose(); + + // The wrapped stream stays open (a disposed one would throw here) + (await inner.ReadAsync(new byte[1].AsMemory(), default)).ShouldBe(0); + } + + [Fact] + public void Should_Translate_A_Failing_Length_Probe_For_The_Sdk() + { + // The SDK only handles NotSupportedException while probing the content length + using var inner = new FakeIoFailingLengthStream(new MemoryStream(new byte[10])); + var wrapper = new LeaveOpenStreamWrapper(inner); + + Should.Throw(() => wrapper.Length); + Should.Throw(() => wrapper.Position); + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.FileSystem.Tests/Volo/Abp/BlobStoring/FileSystem/FileSystemBlobEncryption_Tests.cs b/framework/test/Volo.Abp.BlobStoring.FileSystem.Tests/Volo/Abp/BlobStoring/FileSystem/FileSystemBlobEncryption_Tests.cs new file mode 100644 index 0000000000..5c1bfba625 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.FileSystem.Tests/Volo/Abp/BlobStoring/FileSystem/FileSystemBlobEncryption_Tests.cs @@ -0,0 +1,576 @@ +#nullable enable +using System; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Shouldly; +using Volo.Abp.BlobStoring.TestObjects; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; +using Xunit; + +namespace Volo.Abp.BlobStoring.FileSystem; + +public class FileSystemBlobEncryption_Tests : AbpBlobStoringFileSystemTestBase +{ + private readonly IBlobContainer _container4; // UseEncryption("container4-passphrase") + private readonly IBlobContainer _container5; // UseEncryption() -> key provider (tenant setting / global options) + private readonly IBlobContainer _container6; // UseEncryption("container6-passphrase", allowLegacyPlainText: true) + private readonly IBlobFilePathCalculator _filePathCalculator; + private readonly IBlobContainerConfigurationProvider _configurationProvider; + private readonly ICurrentTenant _currentTenant; + + public FileSystemBlobEncryption_Tests() + { + _container4 = GetRequiredService>(); + _container5 = GetRequiredService>(); + _container6 = GetRequiredService>(); + _filePathCalculator = GetRequiredService(); + _configurationProvider = GetRequiredService(); + _currentTenant = GetRequiredService(); + } + + [Fact] + public async Task Should_Store_Encrypted_Bytes_On_Disk_And_Read_Them_Back() + { + var blobName = "fs-encrypted-roundtrip"; + var testContent = "file system test content".GetBytes(); + + await _container4.SaveAsync(blobName, testContent); + + var fileBytes = await File.ReadAllBytesAsync(GetFilePath(blobName)); + fileBytes.SequenceEqual(testContent).ShouldBeFalse(); + Encoding.ASCII.GetString(fileBytes.Take(4).ToArray()).ShouldBe("ABPE"); + + (await _container4.GetAllBytesAsync(blobName)).SequenceEqual(testContent).ShouldBeTrue(); + } + + [Fact] + public async Task Should_Save_And_Get_Empty_And_Multi_Chunk_Blobs() + { + await _container4.SaveAsync("fs-empty", Array.Empty()); + (await _container4.GetAllBytesAsync("fs-empty")).ShouldBeEmpty(); + + var largeContent = new byte[3 * 1024 * 1024 + 123]; // Spans many 64 KB chunks + new Random(42).NextBytes(largeContent); + + await _container4.SaveAsync("fs-large", largeContent); + + (await _container4.GetAllBytesAsync("fs-large")).SequenceEqual(largeContent).ShouldBeTrue(); + } + + [Fact] + public async Task Should_Override_An_Existing_Encrypted_Blob() + { + var blobName = "fs-override"; + await _container4.SaveAsync(blobName, "first content".GetBytes()); + await _container4.SaveAsync(blobName, "second content".GetBytes(), overrideExisting: true); + + (await _container4.GetAllBytesAsync(blobName)).ShouldBe("second content".GetBytes()); + } + + [Fact] + public async Task Should_Save_From_Async_Only_Source_To_Disk() + { + var blobName = "fs-async-only"; + var testContent = new byte[192 * 1024]; + new Random(42).NextBytes(testContent); + + await _container4.SaveAsync(blobName, new AsyncOnlyStream(testContent)); + + using var result = await _container4.GetAsync(blobName); + using var output = new MemoryStream(); + await result.CopyToAsync(output); + + output.ToArray().ShouldBe(testContent); + } + + [Fact] + public async Task Should_Read_Legacy_Plaintext_File_When_Allowed() + { + var blobName = "fs-legacy"; + var legacyContent = "plaintext file from before encryption".GetBytes(); + WriteRawFile(blobName, legacyContent); + + (await _container6.GetAllBytesAsync(blobName)).SequenceEqual(legacyContent).ShouldBeTrue(); + } + + [Fact] + public async Task Should_Reject_Legacy_Plaintext_File_By_Default() + { + var blobName = "fs-legacy-rejected"; + WriteRawFile(blobName, "plaintext file".GetBytes()); + + await Assert.ThrowsAsync(async () => + { + using var stream = await _container4.GetAsync(blobName); + }); + } + + [Fact] + public async Task Should_Detect_Tampered_File_On_Disk() + { + var blobName = "fs-tampered"; + var content = new byte[128 * 1024]; + new Random(42).NextBytes(content); + await _container4.SaveAsync(blobName, content); + + var filePath = GetFilePath(blobName); + var fileBytes = await File.ReadAllBytesAsync(filePath); + fileBytes[100] ^= 0xFF; // Inside the first cipher chunk + await File.WriteAllBytesAsync(filePath, fileBytes); + + using var stream = await _container4.GetAsync(blobName); + using var output = new MemoryStream(); + + Assert.ThrowsAny(() => stream.CopyTo(output)); + } + + [Fact] + public async Task Should_Detect_Truncated_File_On_Disk() + { + var blobName = "fs-truncated"; + await _container4.SaveAsync(blobName, new byte[128 * 1024]); + + var filePath = GetFilePath(blobName); + var fileBytes = await File.ReadAllBytesAsync(filePath); + Array.Resize(ref fileBytes, fileBytes.Length - 20); // Cut the terminal record + await File.WriteAllBytesAsync(filePath, fileBytes); + + using var stream = await _container4.GetAsync(blobName); + using var output = new MemoryStream(); + + Should.Throw(() => stream.CopyTo(output)); + } + + [Fact] + public async Task Should_Fail_Closed_When_File_Magic_Is_Tampered() + { + var blobName = "fs-tampered-magic"; + await _container4.SaveAsync(blobName, "secret".GetBytes()); + + var filePath = GetFilePath(blobName); + var fileBytes = await File.ReadAllBytesAsync(filePath); + fileBytes[0] ^= 0xFF; + await File.WriteAllBytesAsync(filePath, fileBytes); + + await Assert.ThrowsAsync(async () => + { + using var stream = await _container4.GetAsync(blobName); + }); + } + + [Fact] + public async Task Should_Support_Exists_And_Delete_For_Encrypted_Blobs() + { + var blobName = "fs-exists-delete"; + await _container4.SaveAsync(blobName, "content".GetBytes()); + + (await _container4.ExistsAsync(blobName)).ShouldBeTrue(); + (await _container4.DeleteAsync(blobName)).ShouldBeTrue(); + (await _container4.ExistsAsync(blobName)).ShouldBeFalse(); + (await _container4.GetOrNullAsync(blobName)).ShouldBeNull(); + } + + [Fact] + public async Task Should_Isolate_Tenant_Blobs_In_Separate_Files() + { + var blobName = "fs-tenant-isolation"; + var tenant1 = Guid.NewGuid(); + var tenant2 = Guid.NewGuid(); + + using (_currentTenant.Change(tenant1)) + { + await _container5.SaveAsync(blobName, "tenant 1 content".GetBytes()); + } + + using (_currentTenant.Change(tenant2)) + { + await _container5.SaveAsync(blobName, "tenant 2 content".GetBytes()); + } + + string tenant1Path, tenant2Path; + using (_currentTenant.Change(tenant1)) + { + tenant1Path = GetFilePath(blobName); + (await _container5.GetAllBytesAsync(blobName)).ShouldBe("tenant 1 content".GetBytes()); + } + + using (_currentTenant.Change(tenant2)) + { + tenant2Path = GetFilePath(blobName); + (await _container5.GetAllBytesAsync(blobName)).ShouldBe("tenant 2 content".GetBytes()); + } + + tenant1Path.ShouldNotBe(tenant2Path); + File.Exists(tenant1Path).ShouldBeTrue(); + File.Exists(tenant2Path).ShouldBeTrue(); + } + + [Fact] + public async Task Should_Reject_A_File_Moved_Between_Tenants() + { + var blobName = "fs-moved-between-tenants"; + var tenant1 = Guid.NewGuid(); + var tenant2 = Guid.NewGuid(); + + string tenant1Path, tenant2Path; + using (_currentTenant.Change(tenant1)) + { + await _container4.SaveAsync(blobName, "tenant 1 secret".GetBytes()); + tenant1Path = GetFilePath(blobName); + } + + using (_currentTenant.Change(tenant2)) + { + tenant2Path = GetFilePath(blobName); + } + + // Same container passphrase for both tenants: only the identity binding + // makes the copied file unreadable at the new location. + Directory.CreateDirectory(Path.GetDirectoryName(tenant2Path)!); + File.Copy(tenant1Path, tenant2Path); + + using (_currentTenant.Change(tenant2)) + { + using var stream = await _container4.GetAsync(blobName); + using var output = new MemoryStream(); + + Assert.ThrowsAny(() => stream.CopyTo(output)); + } + } + + [Fact] + public async Task Should_Use_Global_PassPhrase_On_Disk_Without_Tenant() + { + var blobName = "fs-global-key"; + await _container5.SaveAsync(blobName, "global content".GetBytes()); + + var fileBytes = await File.ReadAllBytesAsync(GetFilePath(blobName)); + fileBytes[6].ShouldBe((byte)BlobEncryptionKeySource.Global); + + (await _container5.GetAllBytesAsync(blobName)).ShouldBe("global content".GetBytes()); + } + + [Fact] + public async Task Should_Retry_And_Produce_A_Complete_File_For_A_Replayable_Source() + { + // TestContainer8 is not encrypted, so the seekable source reaches the provider directly + var container8 = GetRequiredService>(); + var content = new byte[64 * 1024]; + new Random(42).NextBytes(content); + var source = new FaultOnceSeekableStream(content); + + await container8.SaveAsync("fs-retry-replayable", source, overrideExisting: true); + + source.FaultsInjected.ShouldBe(1); // First attempt failed, the retry succeeded + (await container8.GetAllBytesAsync("fs-retry-replayable")).SequenceEqual(content).ShouldBeTrue(); + } + + [Fact] + public async Task Should_Not_Retry_A_Non_Replayable_Encrypted_Save() + { + // The encrypting wrapper is not seekable, so a mid-write failure must not be retried + var content = new byte[64 * 1024]; + new Random(42).NextBytes(content); + var source = new FaultOnceSeekableStream(content, reportSeekable: false); + + await Assert.ThrowsAsync(async () => + { + await _container4.SaveAsync("fs-retry-non-replayable", source, overrideExisting: true); + }); + + source.FaultsInjected.ShouldBe(1); // No second attempt + } + + [Fact] + public async Task Should_Retry_A_Transient_Failure_Before_The_Target_Is_Opened() + { + // The encrypting wrapper is not seekable, but nothing is consumed while the + // target file can not even be opened, so such a failure is safe to retry + var blobName = "fs-retry-open-phase"; + await _container4.SaveAsync(blobName, "first content".GetBytes(), overrideExisting: true); + + using (var fileLock = File.Open(GetFilePath(blobName), FileMode.Open, FileAccess.Read, FileShare.None)) + { + var saveTask = _container4.SaveAsync(blobName, "second content".GetBytes(), overrideExisting: true); + await Task.Delay(300); // The first attempt fails while the file is locked + fileLock.Dispose(); + await saveTask; + } + + (await _container4.GetAllBytesAsync(blobName)).ShouldBe("second content".GetBytes()); + } + + [Fact] + public async Task Should_Not_Retry_A_New_Save_After_The_Target_File_Was_Created() + { + // A failed CreateNew attempt leaves the file behind, so retrying would only + // hit the leftover; even a seekable source must fail after a mid-write fault + var container8 = GetRequiredService>(); + var content = new byte[64 * 1024]; + new Random(42).NextBytes(content); + var source = new FaultOnceSeekableStream(content); + + await Assert.ThrowsAsync(async () => + { + await container8.SaveAsync("fs-retry-create-new", source); + }); + + source.FaultsInjected.ShouldBe(1); // No second attempt + } + + [Fact] + public async Task Should_Retry_A_Failed_Open_For_A_New_Save() + { + var content = new byte[64 * 1024]; + new Random(42).NextBytes(content); + var source = new MemoryStream(content); + var provider = new FaultingOpenFileSystemBlobProvider(_filePathCalculator, source); + + await provider.SaveAsync(new BlobProviderSaveArgs( + BlobContainerNameAttribute.GetContainerName(), + _configurationProvider.Get(), + "fs-retry-open-create-new", + source + )); + + provider.OpenAttempts.ShouldBe(2); // The first open failed, the retry succeeded + provider.SourcePositionAtFirstFault.ShouldBe(0); // Nothing was consumed before the failure + var fileBytes = await File.ReadAllBytesAsync(GetFilePath("fs-retry-open-create-new")); + fileBytes.SequenceEqual(content).ShouldBeTrue(); + } + + [DisableConventionalRegistration] + private sealed class FaultingOpenFileSystemBlobProvider : FileSystemBlobProvider + { + private readonly Stream _source; + + public int OpenAttempts { get; private set; } + + public long SourcePositionAtFirstFault { get; private set; } = -1; + + public FaultingOpenFileSystemBlobProvider(IBlobFilePathCalculator filePathCalculator, Stream source) + : base(filePathCalculator) + { + _source = source; + } + + protected override Stream OpenFileStream(string filePath, FileMode fileMode) + { + OpenAttempts++; + if (OpenAttempts == 1) + { + SourcePositionAtFirstFault = _source.Position; + throw new IOException("Injected open failure!"); + } + + return base.OpenFileStream(filePath, fileMode); + } + } + + [Fact] + public async Task Should_Save_When_The_Position_Probe_Of_A_Seekable_Stream_Fails() + { + // A failing probe degrades to a single, non-replayable attempt instead of failing the save + var container8 = GetRequiredService>(); + var content = "position probe failure content".GetBytes(); + using var source = new PositionThrowingSeekableStream(content); + + await container8.SaveAsync("fs-position-probe", source, overrideExisting: true); + + (await container8.GetAllBytesAsync("fs-position-probe")).ShouldBe(content); + } + + private sealed class PositionThrowingSeekableStream : Stream + { + private readonly MemoryStream _stream; + private bool _positionFaultInjected; + + public PositionThrowingSeekableStream(byte[] bytes) + { + _stream = new MemoryStream(bytes); + } + + public override bool CanRead => true; + public override bool CanSeek => true; + public override bool CanWrite => false; + public override long Length => _stream.Length; + + public override long Position + { + get + { + // Fail once, transiently, on the first probe + if (!_positionFaultInjected) + { + _positionFaultInjected = true; + throw new IOException("The position is not available!"); + } + + return _stream.Position; + } + set => _stream.Position = value; + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) => _stream.Read(buffer, offset, count); + public override long Seek(long offset, SeekOrigin origin) => _stream.Seek(offset, origin); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _stream.Dispose(); + } + + base.Dispose(disposing); + } + } + + private sealed class FaultOnceSeekableStream : Stream + { + private readonly MemoryStream _stream; + private readonly bool _reportSeekable; + private bool _faulted; + + public int FaultsInjected { get; private set; } + + public FaultOnceSeekableStream(byte[] bytes, bool reportSeekable = true) + { + _stream = new MemoryStream(bytes); + _reportSeekable = reportSeekable; + } + + public override bool CanRead => true; + public override bool CanSeek => _reportSeekable; + public override bool CanWrite => false; + public override long Length => _reportSeekable ? _stream.Length : throw new NotSupportedException(); + + public override long Position + { + get => _stream.Position; + set => _stream.Position = value; + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + return ReadCore(() => _stream.Read(buffer, offset, count)); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return Task.FromResult(ReadCore(() => _stream.Read(buffer, offset, count))); + } + + private int ReadCore(Func read) + { + // Fail once in the middle of the content + if (!_faulted && _stream.Position >= _stream.Length / 2) + { + _faulted = true; + FaultsInjected++; + throw new IOException("Injected I/O failure!"); + } + + return read(); + } + + public override long Seek(long offset, SeekOrigin origin) + { + return _reportSeekable ? _stream.Seek(offset, origin) : throw new NotSupportedException(); + } + + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _stream.Dispose(); + } + + base.Dispose(disposing); + } + } + + private string GetFilePath(string blobName) + { + return _filePathCalculator.Calculate( + new BlobProviderGetArgs( + BlobContainerNameAttribute.GetContainerName(), + _configurationProvider.Get(), + blobName + ) + ); + } + + private void WriteRawFile(string blobName, byte[] bytes) + { + var filePath = GetFilePath(blobName); + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + File.WriteAllBytes(filePath, bytes); + } + + private sealed class AsyncOnlyStream : Stream + { + private readonly MemoryStream _stream; + + public AsyncOnlyStream(byte[] bytes) + { + _stream = new MemoryStream(bytes); + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + throw new InvalidOperationException("Synchronous reads are not allowed on this stream!"); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return _stream.ReadAsync(buffer, offset, count, cancellationToken); + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _stream.Dispose(); + } + + base.Dispose(disposing); + } + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Minio.Tests/Volo/Abp/BlobStoring/Minio/MinioBlobEncryption_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Minio.Tests/Volo/Abp/BlobStoring/Minio/MinioBlobEncryption_Tests.cs new file mode 100644 index 0000000000..d4316500f6 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Minio.Tests/Volo/Abp/BlobStoring/Minio/MinioBlobEncryption_Tests.cs @@ -0,0 +1,127 @@ +#nullable enable +/* +//Please set the correct connection string in secrets.json and continue the test. +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Shouldly; +using Volo.Abp.BlobStoring.TestObjects; +using Xunit; + +namespace Volo.Abp.BlobStoring.Minio; + +public class MinioBlobEncryption_Tests : AbpBlobStoringMinioTestBase +{ + private readonly IBlobContainer _container4; // UseEncryption("container4-passphrase") + + public MinioBlobEncryption_Tests() + { + _container4 = GetRequiredService>(); + } + + [Fact] + public async Task Should_Save_And_Get_Encrypted_Blob() + { + var blobName = "minio-encrypted-roundtrip"; + var testContent = "minio test content".GetBytes(); + + await _container4.SaveAsync(blobName, testContent); + + (await _container4.GetAllBytesAsync(blobName)).SequenceEqual(testContent).ShouldBeTrue(); + } + + [Fact] + public async Task Should_Save_And_Get_Empty_And_Multi_Chunk_Blobs() + { + await _container4.SaveAsync("minio-empty", Array.Empty()); + (await _container4.GetAllBytesAsync("minio-empty")).ShouldBeEmpty(); + + // MinIO reads BlobStream.Length before uploading, so this verifies the + // exact encrypted length calculation against a real object store. + var largeContent = new byte[3 * 1024 * 1024 + 123]; // Spans many 64 KB chunks + new Random(42).NextBytes(largeContent); + + await _container4.SaveAsync("minio-large", largeContent); + + (await _container4.GetAllBytesAsync("minio-large")).SequenceEqual(largeContent).ShouldBeTrue(); + } + + [Fact] + public async Task Should_Override_An_Existing_Encrypted_Blob() + { + var blobName = "minio-override"; + await _container4.SaveAsync(blobName, "first content".GetBytes()); + await _container4.SaveAsync(blobName, "second content".GetBytes(), overrideExisting: true); + + (await _container4.GetAllBytesAsync(blobName)).ShouldBe("second content".GetBytes()); + } + + [Fact] + public async Task Should_Support_Exists_And_Delete_For_Encrypted_Blobs() + { + var blobName = "minio-exists-delete"; + await _container4.SaveAsync(blobName, "content".GetBytes()); + + (await _container4.ExistsAsync(blobName)).ShouldBeTrue(); + (await _container4.DeleteAsync(blobName)).ShouldBeTrue(); + (await _container4.ExistsAsync(blobName)).ShouldBeFalse(); + (await _container4.GetOrNullAsync(blobName)).ShouldBeNull(); + } + + [Fact] + public async Task Should_Reject_Non_Seekable_Source_Because_Minio_Requires_The_Length() + { + // The MinIO provider reads BlobStream.Length; for a non-seekable source the + // encrypted length is unknown, so saving fails (same as without encryption). + await Assert.ThrowsAsync(async () => + { + await _container4.SaveAsync("minio-non-seekable", new NonSeekableStream("content".GetBytes())); + }); + } + + private sealed class NonSeekableStream : Stream + { + private readonly MemoryStream _stream; + + public NonSeekableStream(byte[] bytes) + { + _stream = new MemoryStream(bytes); + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + return _stream.Read(buffer, offset, count); + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _stream.Dispose(); + } + + base.Dispose(disposing); + } + } +} +*/ diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/AbpBlobStoringTestModule.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/AbpBlobStoringTestModule.cs index 93523a8c88..3f89cb3e44 100644 --- a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/AbpBlobStoringTestModule.cs +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/AbpBlobStoringTestModule.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using NSubstitute; using Volo.Abp.Autofac; using Volo.Abp.BlobStoring.Fakes; @@ -19,6 +20,18 @@ public class AbpBlobStoringTestModule : AbpModule context.Services.AddSingleton(Substitute.For()); context.Services.AddSingleton(Substitute.For()); + context.Services.AddSingleton(); + context.Services.AddSingleton( + serviceProvider => serviceProvider.GetRequiredService() + ); + + context.Services.Replace(ServiceDescriptor.Transient()); + + Configure(options => + { + options.DefaultPassPhrase = "default-global-passphrase"; + }); + Configure(options => { options.Containers @@ -40,6 +53,110 @@ public class AbpBlobStoringTestModule : AbpModule .Configure(container => { container.IsMultiTenant = false; + }) + .Configure(container => + { + container.ProviderType = typeof(FakeInMemoryBlobProvider); + container.UseEncryption("container4-passphrase"); + }) + .Configure(container => + { + container.ProviderType = typeof(FakeInMemoryBlobProvider); + container.UseEncryption(); + }) + .Configure(container => + { + container.ProviderType = typeof(FakeInMemoryBlobProvider); + container.UseEncryption("container6-passphrase", allowLegacyPlainText: true); + }) + .Configure(container => + { + container.ProviderType = typeof(FakeInMemoryBlobProvider); + container.IsMultiTenant = false; + container.UseEncryption("container7-shared-passphrase"); + }) + .Configure(container => + { + container.ProviderType = typeof(FakeInMemoryBlobProvider); + }) + .Configure("pipeline-markers", container => + { + container.ProviderType = typeof(FakeInMemoryBlobProvider); + container.PipelineContributors.Add(); + container.PipelineContributors.Add(); + }) + .Configure("pipeline-encrypted", container => + { + container.ProviderType = typeof(FakeInMemoryBlobProvider); + container.UseEncryption("pipeline-passphrase"); + container.PipelineContributors.Add(); + }) + .Configure("pipeline-scoped", container => + { + container.ProviderType = typeof(FakeInMemoryBlobProvider); + container.PipelineContributors.Add(); + }) + .Configure("get-bad-encryption-config", container => + { + container.ProviderType = typeof(FakeInMemoryBlobProvider); + // A mis-typed value makes reading the encryption flag throw while getting + container.SetConfiguration(BlobEncryptionConfigurationNames.Enabled, "not-a-bool"); + }) + .Configure("pipeline-failing-get", container => + { + container.ProviderType = typeof(FakeInMemoryBlobProvider); + container.PipelineContributors.Add(); + }) + .Configure("pipeline-set-throw-save", container => + { + container.ProviderType = typeof(FakeInMemoryBlobProvider); + container.PipelineContributors.Add(); + }) + .Configure("pipeline-partial-get", container => + { + container.ProviderType = typeof(FakeInMemoryBlobProvider); + container.PipelineContributors.Add(); + container.PipelineContributors.Add(); + }) + .Configure("pipeline-dispose-throw", container => + { + container.ProviderType = typeof(FakeInMemoryBlobProvider); + container.PipelineContributors.Add(); + container.PipelineContributors.Add(); + }) + .Configure("pipeline-async-scoped", container => + { + container.ProviderType = typeof(FakeInMemoryBlobProvider); + container.PipelineContributors.Add(); + }) + .Configure("pipeline-encrypted-earlystop", container => + { + container.ProviderType = typeof(FakeInMemoryBlobProvider); + container.UseEncryption("earlystop-passphrase"); + container.PipelineContributors.Add(); + }) + .Configure("pipeline-unwrap", container => + { + container.ProviderType = typeof(FakeInMemoryBlobProvider); + container.PipelineContributors.Add(); + container.PipelineContributors.Add(); + }) + .Configure("pipeline-async-dispose", container => + { + container.ProviderType = typeof(FakeInMemoryBlobProvider); + container.PipelineContributors.Add(); + }) + .Configure("pipeline-modern-async", container => + { + container.ProviderType = typeof(FakeInMemoryBlobProvider); + container.PipelineContributors.Add(); + }) + .Configure("pipeline-shared-tenant", container => + { + container.ProviderType = typeof(FakeInMemoryBlobProvider); + container.IsMultiTenant = false; + container.PipelineContributors.Add(); + container.PipelineContributors.Add(); }); }); } diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerConfiguration_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerConfiguration_Tests.cs index f47faa1f25..f9ed4563ab 100644 --- a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerConfiguration_Tests.cs +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerConfiguration_Tests.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using Shouldly; using Volo.Abp.BlobStoring.Fakes; @@ -59,4 +60,140 @@ public class BlobContainerConfiguration_Tests effective.ShouldContain(typeof(AnotherFakeNamingNormalizer)); effective.ShouldNotContain(typeof(FakeNamingNormalizer)); } + + [Fact] + public void Should_Inherit_Encryption_From_Default_Container() + { + var defaultConfig = new BlobContainerConfiguration(); + defaultConfig.UseEncryption(); + + var namedConfig = new BlobContainerConfiguration(defaultConfig); + + BlobEncryptionConfiguration.IsEnabled(namedConfig).ShouldBeTrue(); + BlobEncryptionConfiguration.IsEnabled(defaultConfig).ShouldBeTrue(); + } + + [Fact] + public void Should_Disable_Inherited_Encryption_For_A_Single_Container() + { + var defaultConfig = new BlobContainerConfiguration(); + defaultConfig.UseEncryption(); + + var namedConfig = new BlobContainerConfiguration(defaultConfig); + namedConfig.DisableEncryption(); + + BlobEncryptionConfiguration.IsEnabled(namedConfig).ShouldBeFalse(); + BlobEncryptionConfiguration.IsEnabled(defaultConfig).ShouldBeTrue(); + } + + [Fact] + public void Should_Enable_Encryption_Again_After_Disabling() + { + var defaultConfig = new BlobContainerConfiguration(); + defaultConfig.UseEncryption(); + + var namedConfig = new BlobContainerConfiguration(defaultConfig); + namedConfig.DisableEncryption(); + namedConfig.UseEncryption("named-passphrase"); + + BlobEncryptionConfiguration.IsEnabled(namedConfig).ShouldBeTrue(); + BlobEncryptionConfiguration.GetPassPhraseOrNull(namedConfig).ShouldBe("named-passphrase"); + } + + [Fact] + public void Should_Keep_The_Configured_PassPhrase_When_UseEncryption_Is_Called_Again() + { + var configuration = new BlobContainerConfiguration(); + configuration.UseEncryption("first-passphrase", allowLegacyPlainText: true); + + // Another module just ensuring that encryption is enabled must not + // change the configured key or the legacy option. + configuration.UseEncryption(); + + BlobEncryptionConfiguration.GetPassPhraseOrNull(configuration).ShouldBe("first-passphrase"); + BlobEncryptionConfiguration.IsLegacyPlainTextAllowed(configuration).ShouldBeTrue(); + } + + [Fact] + public void Should_Shadow_The_PassPhrase_Inherited_From_The_Default_Container() + { + var defaultConfig = new BlobContainerConfiguration(); + defaultConfig.UseEncryption("default-passphrase"); + + var namedConfig = new BlobContainerConfiguration(defaultConfig); + namedConfig.UseEncryption(); + namedConfig.ClearEncryptionPassPhrase(); + + // The named container explicitly opted out of the inherited passphrase + BlobEncryptionConfiguration.GetPassPhraseOrNull(namedConfig).ShouldBeNull(); + BlobEncryptionConfiguration.GetPassPhraseOrNull(defaultConfig).ShouldBe("default-passphrase"); + } + + [Fact] + public void Should_Compose_Default_And_Named_Container_PipelineContributors() + { + var defaultConfig = new BlobContainerConfiguration(); + defaultConfig.PipelineContributors.Add(); + + var namedConfig = new BlobContainerConfiguration(defaultConfig); + namedConfig.PipelineContributors.Add(); + + namedConfig.GetEffectivePipelineContributors() + .ShouldBe([typeof(FakeAPipelineContributor), typeof(FakeBPipelineContributor)]); + defaultConfig.GetEffectivePipelineContributors().ShouldBe([typeof(FakeAPipelineContributor)]); + } + + [Fact] + public void Should_Keep_Inherited_PipelineContributors_When_The_Provider_Is_Overridden() + { + var defaultConfig = new BlobContainerConfiguration(); + defaultConfig.ProviderType = typeof(FakeBlobProvider1); + defaultConfig.PipelineContributors.Add(); + + var namedConfig = new BlobContainerConfiguration(defaultConfig); + namedConfig.ProviderType = typeof(FakeBlobProvider2); + + namedConfig.GetEffectivePipelineContributors().ShouldBe([typeof(FakeAPipelineContributor)]); + } + + [Fact] + public void Should_Not_Duplicate_A_PipelineContributor_Configured_On_Both_Levels() + { + var defaultConfig = new BlobContainerConfiguration(); + defaultConfig.PipelineContributors.Add(); + defaultConfig.PipelineContributors.Add(); + + var namedConfig = new BlobContainerConfiguration(defaultConfig); + namedConfig.PipelineContributors.Add(); + + // A contributor type runs once, on every configuration level + defaultConfig.GetEffectivePipelineContributors().ShouldBe([typeof(FakeAPipelineContributor)]); + namedConfig.GetEffectivePipelineContributors().ShouldBe([typeof(FakeAPipelineContributor)]); + } + + [Fact] + public void Should_Opt_Out_Of_The_Inherited_PipelineContributors() + { + var defaultConfig = new BlobContainerConfiguration(); + defaultConfig.PipelineContributors.Add(); + + var namedConfig = new BlobContainerConfiguration(defaultConfig); + namedConfig.InheritPipelineContributors = false; + namedConfig.PipelineContributors.Add(); + + namedConfig.GetEffectivePipelineContributors().ShouldBe([typeof(FakeBPipelineContributor)]); + } + + [Fact] + public void Should_Reject_Empty_PassPhrase() + { + var configuration = new BlobContainerConfiguration(); + + Assert.ThrowsAny(() => configuration.UseEncryption("")); + Assert.ThrowsAny(() => configuration.UseEncryption(" ", allowLegacyPlainText: true)); + + // A failed call must not leave the configuration partially modified + BlobEncryptionConfiguration.IsEnabled(configuration).ShouldBeFalse(); + BlobEncryptionConfiguration.IsLegacyPlainTextAllowed(configuration).ShouldBeFalse(); + } } diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerEncryption_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerEncryption_Tests.cs new file mode 100644 index 0000000000..8273cd808e --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerEncryption_Tests.cs @@ -0,0 +1,1550 @@ +#nullable enable +using System; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using Shouldly; +using Volo.Abp.BlobStoring.Fakes; +using Volo.Abp.BlobStoring.TestObjects; +using Volo.Abp.MultiTenancy; +using Xunit; + +namespace Volo.Abp.BlobStoring; + +public class BlobContainerEncryption_Tests : AbpBlobStoringTestBase +{ + private readonly IBlobContainer _container4; // UseEncryption("container4-passphrase") + private readonly IBlobContainer _container5; // UseEncryption() -> key provider (tenant setting / global options) + private readonly IBlobContainer _container6; // UseEncryption("container6-passphrase", allowLegacyPlainText: true) + private readonly FakeInMemoryBlobProvider _provider; + private readonly ICurrentTenant _currentTenant; + + public BlobContainerEncryption_Tests() + { + _container4 = GetRequiredService>(); + _container5 = GetRequiredService>(); + _container6 = GetRequiredService>(); + _provider = GetRequiredService(); + _currentTenant = GetRequiredService(); + } + + [Fact] + public async Task Should_Save_Encrypted_And_Get_Decrypted_Blob() + { + var blobName = "test-blob-encrypted-1"; + var testContent = "test content".GetBytes(); + + await _container4.SaveAsync(blobName, testContent); + + var rawBytes = GetRawBytes(blobName); + rawBytes.ShouldNotBeNull(); + rawBytes!.SequenceEqual(testContent).ShouldBeFalse(); + Encoding.ASCII.GetString(rawBytes.Take(4).ToArray()).ShouldBe("ABPE"); + + var result = await _container4.GetAllBytesAsync(blobName); + result.SequenceEqual(testContent).ShouldBeTrue(); + } + + [Fact] + public async Task Should_Save_And_Get_Empty_Blob() + { + var blobName = "test-blob-empty"; + + await _container4.SaveAsync(blobName, Array.Empty()); + + (await _container4.GetAllBytesAsync(blobName)).ShouldBeEmpty(); + } + + [Fact] + public async Task Should_Record_Key_Source_In_The_Header() + { + var blobName = "test-blob-key-source"; + await _container4.SaveAsync(blobName, "content".GetBytes()); + + var rawBytes = GetRawBytes(blobName)!; + + // magic(4) + version(1) + algorithm(1), then the key source byte + rawBytes[6].ShouldBe((byte)BlobEncryptionKeySource.Container); + } + + [Fact] + public async Task Should_Encrypt_With_Tenant_Specific_PassPhrase() + { + var tenantId = Guid.NewGuid(); + var blobName = "test-blob-encrypted-tenant"; + var testContent = "test content".GetBytes(); + + using (_currentTenant.Change(tenantId)) + { + await _container5.SaveAsync(blobName, testContent); + + var rawBytes = GetRawBytes(blobName); + rawBytes.ShouldNotBeNull(); + rawBytes![6].ShouldBe((byte)BlobEncryptionKeySource.Tenant); + + (await _container5.GetAllBytesAsync(blobName)).SequenceEqual(testContent).ShouldBeTrue(); + } + } + + [Fact] + public async Task Should_Produce_Different_CipherText_For_Different_Tenants() + { + var testContent = "test content".GetBytes(); + + var tenantId1 = Guid.NewGuid(); + using (_currentTenant.Change(tenantId1)) + { + await _container5.SaveAsync("test-blob-tenant-1", testContent); + } + + var tenantId2 = Guid.NewGuid(); + using (_currentTenant.Change(tenantId2)) + { + await _container5.SaveAsync("test-blob-tenant-2", testContent); + } + + var rawBytes1 = GetRawBytes("test-blob-tenant-1")!; + var rawBytes2 = GetRawBytes("test-blob-tenant-2")!; + rawBytes1.SequenceEqual(rawBytes2).ShouldBeFalse(); + + // Each tenant can only read its own BLOB + using (_currentTenant.Change(tenantId1)) + { + (await _container5.GetAllBytesAsync("test-blob-tenant-1")).SequenceEqual(testContent).ShouldBeTrue(); + } + + using (_currentTenant.Change(tenantId2)) + { + (await _container5.GetAllBytesAsync("test-blob-tenant-2")).SequenceEqual(testContent).ShouldBeTrue(); + } + } + + [Fact] + public async Task Should_Fall_Back_To_Global_PassPhrase_Without_Tenant() + { + var blobName = "test-blob-encrypted-global"; + var testContent = "test content".GetBytes(); + + await _container5.SaveAsync(blobName, testContent); + + var rawBytes = GetRawBytes(blobName); + rawBytes.ShouldNotBeNull(); + rawBytes![6].ShouldBe((byte)BlobEncryptionKeySource.Global); + + (await _container5.GetAllBytesAsync(blobName)).SequenceEqual(testContent).ShouldBeTrue(); + } + + [Fact] + public async Task Should_Decrypt_With_The_Recorded_Key_Source_Even_If_Other_Keys_Appear_Later() + { + var codec = GetRequiredService(); + var testContent = "written with the global key".GetBytes(); + + // Encrypted while only the global passphrase was available + var globalConfiguration = new BlobContainerConfiguration().UseEncryption(); + using var cipherBytes = new MemoryStream(); + using (var encryptingStream = await codec.CreateEncryptingStreamAsync(globalConfiguration, "routing-container", "routing-blob", null, new MemoryStream(testContent))) + { + encryptingStream.CopyTo(cipherBytes); + } + + // A container passphrase is configured later: the header still routes + // this BLOB to the global key, so it stays readable. + var laterConfiguration = new BlobContainerConfiguration().UseEncryption("container-passphrase-added-later"); + using var decryptingStream = await codec.CreateDecryptingStreamAsync(laterConfiguration, "routing-container", "routing-blob", null, new MemoryStream(cipherBytes.ToArray())); + using var output = new MemoryStream(); + decryptingStream.CopyTo(output); + + output.ToArray().ShouldBe(testContent); + } + + [Fact] + public async Task Should_Read_Legacy_Plaintext_Blob_When_Allowed() + { + var blobName = "test-blob-legacy"; + var legacyContent = "legacy plain content, stored before encryption was enabled".GetBytes(); + SetRawBytes(blobName, legacyContent); + + (await _container6.GetAllBytesAsync(blobName)).SequenceEqual(legacyContent).ShouldBeTrue(); + } + + [Fact] + public async Task Should_Reject_Legacy_Plaintext_Blob_By_Default() + { + var blobName = "test-blob-legacy-rejected"; + SetRawBytes(blobName, "legacy plain content".GetBytes()); + + await Assert.ThrowsAsync(async () => + { + using var stream = await _container4.GetAsync(blobName); + }); + } + + [Fact] + public async Task Should_Reject_Encrypted_Blob_With_Tampered_Magic() + { + var blobName = "test-blob-tampered-magic"; + await _container4.SaveAsync(blobName, "secret content".GetBytes()); + + var rawBytes = GetRawBytes(blobName)!; + rawBytes[0] ^= 0xFF; + SetRawBytes(blobName, rawBytes); + + // Without legacy plaintext enabled this must fail closed instead of + // returning the raw ciphertext bytes. + await Assert.ThrowsAsync(async () => + { + using var stream = await _container4.GetAsync(blobName); + }); + } + + [Fact] + public async Task Should_Fault_Stream_After_Tampered_Chunk() + { + var blobName = "test-blob-tampered-chunk"; + var content = new byte[128 * 1024]; // Spans multiple chunks + new Random(42).NextBytes(content); + await _container4.SaveAsync(blobName, content); + + var rawBytes = GetRawBytes(blobName)!; + rawBytes[60] ^= 0xFF; // Inside the first cipher chunk + SetRawBytes(blobName, rawBytes); + + using var stream = await _container4.GetAsync(blobName); + var buffer = new byte[256 * 1024]; + + Assert.ThrowsAny(() => + { + while (stream.Read(buffer, 0, buffer.Length) > 0) + { + } + }); + + // The stream must stay unreadable; otherwise a caller swallowing the first + // exception could read the chunks after the tampered one. + Should.Throw(() => stream.Read(buffer, 0, buffer.Length)); + } + + [Fact] + public async Task Should_Reject_Encrypted_Blob_Without_Terminal_Record() + { + var blobName = "test-blob-truncated-terminal"; + await _container4.SaveAsync(blobName, new byte[128 * 1024]); + + var rawBytes = GetRawBytes(blobName)!; + Array.Resize(ref rawBytes, rawBytes.Length - 20); // terminal record: 4-byte marker + 16-byte tag + SetRawBytes(blobName, rawBytes); + + using var stream = await _container4.GetAsync(blobName); + using var output = new MemoryStream(); + + Should.Throw(() => stream.CopyTo(output)); + } + + [Fact] + public async Task Should_Reject_Blob_Encrypted_With_Another_PassPhrase() + { + var blobName = "test-blob-wrong-key"; + await _container6.SaveAsync(blobName, "content".GetBytes()); + + // Same raw bytes, read over a container with a different passphrase + SetRawBytes(blobName, GetRawBytes(blobName)!); + + using var stream = await _container4.GetAsync(blobName); + using var output = new MemoryStream(); + + Assert.ThrowsAny(() => stream.CopyTo(output)); + } + + [Fact] + public async Task Should_Save_And_Get_Encrypted_Blob_With_Async_Only_Streams() + { + var blobName = "test-blob-async-only"; + var testContent = new byte[192 * 1024]; // Spans multiple encryption chunks + new Random(42).NextBytes(testContent); + + // Simulates sources like the ASP.NET Core request body, where synchronous reads throw + await _container4.SaveAsync(blobName, new AsyncOnlyStream(testContent)); + + using var result = await _container4.GetAsync(blobName); + using var output = new MemoryStream(); + await result.CopyToAsync(output); + + output.ToArray().ShouldBe(testContent); + } + + [Fact] + public async Task Should_Not_Consume_Data_On_Zero_Byte_Reads() + { + var blobName = "test-blob-zero-read"; + var content = new byte[100 * 1024]; + new Random(42).NextBytes(content); + await _container4.SaveAsync(blobName, content); + + using var stream = await _container4.GetAsync(blobName); + using var collected = new MemoryStream(); + var buffer = new byte[8 * 1024]; + + stream.Read(buffer, 0, 0).ShouldBe(0); + int readCount; + while ((readCount = stream.Read(buffer, 0, buffer.Length)) > 0) + { + collected.Write(buffer, 0, readCount); + (await stream.ReadAsync(buffer, 0, 0)).ShouldBe(0); + } + + collected.ToArray().ShouldBe(content); + } + + [Fact] + public async Task Should_Not_Allow_Reading_After_Dispose() + { + var blobName = "test-blob-dispose"; + await _container4.SaveAsync(blobName, "dispose then read".GetBytes()); + + var stream = await _container4.GetAsync(blobName); + var buffer = new byte[1024]; + stream.Read(buffer, 0, buffer.Length); + stream.Dispose(); + + Should.Throw(() => stream.Read(buffer, 0, buffer.Length)); + } + + [Fact] + public async Task Should_Dispose_Underlying_Stream_Only_Once_On_Mixed_Dispose() + { + var codec = GetRequiredService(); + var configuration = new BlobContainerConfiguration().UseEncryption("dispose-passphrase"); + + using var cipherBytes = new MemoryStream(); + using (var encryptingStream = await codec.CreateEncryptingStreamAsync(configuration, "test-container", "dispose-blob", null, new MemoryStream("dispose once".GetBytes()))) + { + encryptingStream.CopyTo(cipherBytes); + } + + var source = new TrackingNonSeekableStream(cipherBytes.ToArray()); + var decryptingStream = await codec.CreateDecryptingStreamAsync(configuration, "test-container", "dispose-blob", null, source); + + await decryptingStream.DisposeAsync(); + decryptingStream.Dispose(); + + source.DisposeCount.ShouldBe(1); + } + + [Fact] + public async Task Should_Expose_Exact_Encrypted_Length_For_Seekable_Input() + { + var codec = GetRequiredService(); + var configuration = new BlobContainerConfiguration().UseEncryption("length-passphrase"); + + foreach (var length in new[] { 0, 1, 64 * 1024, 64 * 1024 + 1 }) + { + using var encryptedStream = await codec.CreateEncryptingStreamAsync(configuration, "test-container", "length-blob", null, new MemoryStream(new byte[length])); + var reportedLength = encryptedStream.Length; + using var output = new MemoryStream(); + + encryptedStream.CopyTo(output); + + reportedLength.ShouldBe(output.Length); + } + } + + [Fact] + public async Task Should_Expose_Exact_Encrypted_Length_For_A_Length_Aware_Forward_Only_Input() + { + // CanSeek is false, but Length/Position are readable; MinIO-like providers need the length + var codec = GetRequiredService(); + var configuration = new BlobContainerConfiguration().UseEncryption("length-passphrase"); + var source = new LengthAwareForwardOnlyStream(new byte[64 * 1024 + 1]); + + using var encryptedStream = await codec.CreateEncryptingStreamAsync(configuration, "test-container", "length-blob-fo", null, source); + var reportedLength = encryptedStream.Length; + using var output = new MemoryStream(); + + encryptedStream.CopyTo(output); + + reportedLength.ShouldBe(output.Length); + } + + private sealed class LengthAwareForwardOnlyStream : Stream + { + private readonly MemoryStream _stream; + + public LengthAwareForwardOnlyStream(byte[] bytes) + { + _stream = new MemoryStream(bytes); + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => _stream.Length; + + public override long Position + { + get => _stream.Position; + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) => _stream.Read(buffer, offset, count); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _stream.Dispose(); + } + + base.Dispose(disposing); + } + } + + [Fact] + public async Task Should_Reject_A_Blob_Copied_To_Another_Name() + { + await _container4.SaveAsync("identity-a", "content of a".GetBytes()); + await _container4.SaveAsync("identity-b", "content of b".GetBytes()); + + // Same container, same passphrase: only the AAD identity binding can catch this + SetRawBytes("identity-a", GetRawBytes("identity-b")!); + + using var stream = await _container4.GetAsync("identity-a"); + using var output = new MemoryStream(); + + Assert.ThrowsAny(() => stream.CopyTo(output)); + } + + [Fact] + public async Task Should_Reject_A_Blob_Served_From_Another_Tenants_Location() + { + var blobName = "identity-tenant"; + using (_currentTenant.Change(Guid.NewGuid())) + { + await _container4.SaveAsync(blobName, "tenant content".GetBytes()); + } + + // The fake provider stores host and tenant blobs in the same slot, so this + // simulates moving the encrypted bytes to the host location. Same passphrase, + // different identity: must fail. + using var stream = await _container4.GetAsync(blobName); + using var output = new MemoryStream(); + + Assert.ThrowsAny(() => stream.CopyTo(output)); + } + + [Fact] + public async Task Should_Share_Blobs_Of_A_Non_MultiTenant_Container_Between_Tenants() + { + var container7 = GetRequiredService>(); // IsMultiTenant = false + var blobName = "shared-container-blob"; + var testContent = "shared content".GetBytes(); + + using (_currentTenant.Change(Guid.NewGuid())) + { + await container7.SaveAsync(blobName, testContent); + } + + // A shared container always uses the host identity, so every tenant + // (and the host) reads the same BLOB. + using (_currentTenant.Change(Guid.NewGuid())) + { + (await container7.GetAllBytesAsync(blobName)).SequenceEqual(testContent).ShouldBeTrue(); + } + + (await container7.GetAllBytesAsync(blobName)).SequenceEqual(testContent).ShouldBeTrue(); + } + + [Fact] + public async Task Should_Throw_When_Cancelled_Before_Saving() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync(async () => + { + await _container4.SaveAsync("cancelled-save", new MemoryStream("content".GetBytes()), cancellationToken: cts.Token); + }); + } + + [Fact] + public async Task Should_Throw_When_Cancelled_Before_Reading() + { + await _container4.SaveAsync("cancelled-read", "content".GetBytes()); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync(async () => + { + using var stream = await _container4.GetAsync("cancelled-read", cts.Token); + }); + } + + [Fact] + public async Task Should_Reject_Tenant_Key_Source_With_The_Default_Key_Provider() + { + var defaultProvider = new DefaultBlobEncryptionKeyProvider( + Microsoft.Extensions.Options.Options.Create(new AbpBlobStoringEncryptionOptions()) + ); + + await Assert.ThrowsAsync(async () => + { + await defaultProvider.ResolveForDecryptionAsync( + BlobEncryptionKeySource.Tenant, + new BlobEncryptionKeyContext(new BlobContainerConfiguration(), "c", "b", null)); + }); + } + + [Fact] + public async Task Should_Throw_When_No_PassPhrase_Can_Be_Resolved() + { + var defaultProvider = new DefaultBlobEncryptionKeyProvider( + Microsoft.Extensions.Options.Options.Create(new AbpBlobStoringEncryptionOptions()) + ); + var configuration = new BlobContainerConfiguration().UseEncryption(); + + await Assert.ThrowsAsync(async () => + { + await defaultProvider.ResolveForEncryptionAsync( + new BlobEncryptionKeyContext(configuration, "c", "b", null)); + }); + } + + [Fact] + public async Task Should_Return_Raw_Bytes_When_Encryption_Is_Not_Enabled_For_The_Container() + { + // Simulates reading an encrypted BLOB over a container without encryption + // (like after DisableEncryption): the stored bytes are returned as-is. + var blobName = "raw-ciphertext-readback"; + await _container4.SaveAsync(blobName, "content".GetBytes()); + var encryptedBytes = GetRawBytes(blobName)!; + + var container8 = GetRequiredService>(); // no encryption + _provider.SetRawBytes( + BlobContainerNameAttribute.GetContainerName(), + blobName, + encryptedBytes + ); + + (await container8.GetAllBytesAsync(blobName)).SequenceEqual(encryptedBytes).ShouldBeTrue(); + } + + [Fact] + public async Task Should_Reject_An_Unknown_Format_Version() + { + var blobName = "unknown-format-version"; + await _container4.SaveAsync(blobName, "content".GetBytes()); + + var rawBytes = GetRawBytes(blobName)!; + rawBytes[4] = 2; // Format version byte + SetRawBytes(blobName, rawBytes); + + await Assert.ThrowsAsync(async () => + { + using var stream = await _container4.GetAsync(blobName); + }); + } + + [Fact] + public async Task Should_Read_A_Legacy_Blob_Shorter_Than_The_Format_Magic() + { + var blobName = "tiny-legacy-blob"; + var tinyContent = new byte[] { 1, 2, 3 }; + SetRawBytes(blobName, tinyContent); // allowLegacyPlainText: true + + (await _container6.GetAllBytesAsync(blobName)).SequenceEqual(tinyContent).ShouldBeTrue(); + } + + [Fact] + public async Task Should_Use_The_Configured_Kdf_Iterations() + { + var options = Microsoft.Extensions.Options.Options.Create(new AbpBlobStoringEncryptionOptions + { + DefaultPassPhrase = "iterations-passphrase", + KdfIterations = 150_000 + }); + var codec = new BlobEncryptionCodec(new DefaultBlobEncryptionKeyProvider(options), options); + var configuration = new BlobContainerConfiguration().UseEncryption(); + + using var cipherBytes = new MemoryStream(); + using (var encryptingStream = await codec.CreateEncryptingStreamAsync(configuration, "iter-container", "iter-blob", null, new MemoryStream("content".GetBytes()))) + { + encryptingStream.CopyTo(cipherBytes); + } + + var rawBytes = cipherBytes.ToArray(); + var recordedIterations = (rawBytes[7] << 24) | (rawBytes[8] << 16) | (rawBytes[9] << 8) | rawBytes[10]; + recordedIterations.ShouldBe(150_000); + + using var decryptingStream = await codec.CreateDecryptingStreamAsync(configuration, "iter-container", "iter-blob", null, new MemoryStream(rawBytes)); + using var output = new MemoryStream(); + decryptingStream.CopyTo(output); + output.ToArray().ShouldBe("content".GetBytes()); + } + + [Fact] + public async Task Should_Reject_Kdf_Iterations_Out_Of_The_Allowed_Range() + { + foreach (var iterations in new[] { 50_000, 700_000 }) + { + var options = Microsoft.Extensions.Options.Options.Create(new AbpBlobStoringEncryptionOptions + { + DefaultPassPhrase = "iterations-passphrase", + KdfIterations = iterations + }); + var codec = new BlobEncryptionCodec(new DefaultBlobEncryptionKeyProvider(options), options); + var configuration = new BlobContainerConfiguration().UseEncryption(); + + await Assert.ThrowsAsync(async () => + { + await codec.CreateEncryptingStreamAsync(configuration, "iter-container", "iter-blob", null, new MemoryStream("content".GetBytes())); + }); + } + } + + [Fact] + public void Should_Reject_Wrapped_Chunk_Index() + { + // The production streams write the chunk index into a reusable nonce/AAD buffer with + // WriteChunkIndex, so a wrapped (negative) index must be rejected there + Should.Throw(() => BlobEncryptionCodec.WriteChunkIndex(new byte[BlobEncryptionCodec.GcmNonceSize], -1)); + } + + [Fact] + public void Should_Reject_Unknown_Key_Source() + { + // An unknown source would be written into the header and make the BLOB unreadable + Assert.ThrowsAny(() => new BlobEncryptionKey((BlobEncryptionKeySource)99, "passphrase")); + Assert.ThrowsAny(() => new BlobEncryptionKey(0, "passphrase")); + } + + [Fact] + public async Task Should_Reject_Tampered_Kdf_Iterations() + { + var blobName = "test-blob-tampered-iterations"; + await _container4.SaveAsync(blobName, "content".GetBytes()); + + var rawBytes = GetRawBytes(blobName)!; + rawBytes[7] = 0x7F; // Iterations field: far above the allowed maximum + SetRawBytes(blobName, rawBytes); + + // Must be rejected before deriving the key (a huge iteration count would be a CPU DoS) + await Assert.ThrowsAsync(async () => + { + using var stream = await _container4.GetAsync(blobName); + }); + } + + [Fact] + public async Task Should_Reject_Tampered_Header_Salt() + { + var blobName = "test-blob-tampered-salt"; + await _container4.SaveAsync(blobName, "content".GetBytes()); + + var rawBytes = GetRawBytes(blobName)!; + rawBytes[12] ^= 0xFF; // Inside the KDF salt; the header is bound to every chunk as AAD + SetRawBytes(blobName, rawBytes); + + using var stream = await _container4.GetAsync(blobName); + using var output = new MemoryStream(); + + Assert.ThrowsAny(() => stream.CopyTo(output)); + } + + [Fact] + public async Task Should_Not_Decrypt_Another_Tenants_Blob() + { + var blobName = "test-blob-cross-tenant"; + var testContent = "tenant 1 secret".GetBytes(); + + using (_currentTenant.Change(Guid.NewGuid())) + { + await _container5.SaveAsync(blobName, testContent); + } + + // Another tenant resolves its own passphrase for the same key source + using (_currentTenant.Change(Guid.NewGuid())) + { + using var stream = await _container5.GetAsync(blobName); + using var output = new MemoryStream(); + + Assert.ThrowsAny(() => stream.CopyTo(output)); + } + } + + [Fact] + public async Task Should_Keep_The_V1_Format_Stable() + { + // Golden vector: deterministic ciphertext built from fixed inputs. + // If this test breaks, the on-disk format changed and existing + // encrypted BLOBs would become unreadable. + const string expected = + "QUJQRQEBAQABhqABAgMEBQYHCAkKCwwNDg8QAAEAAKChoqOkpaanAAAAFaa92MyQIqeyxK979tS+roEOv5MJTSc3BsF2sVmrewTHYbj3/UsAAAAAgMPye1nJF2Vd05yIpGw5Kg=="; + + var salt = new byte[16]; + var baseNonce = new byte[8]; + for (var i = 0; i < 16; i++) salt[i] = (byte)(i + 1); + for (var i = 0; i < 8; i++) baseNonce[i] = (byte)(0xA0 + i); + + var keyBytes = BlobEncryptionCodec.DeriveKeyBytes("golden-passphrase", salt, 100_000); + var header = BlobEncryptionCodec.BuildHeader(BlobEncryptionKeySource.Container, 100_000, salt, 64 * 1024, baseNonce); + var prefix = BlobEncryptionCodec.CreateBlobPrefix(header); + var aad = BlobEncryptionCodec.BuildAssociatedDataPrefix(prefix, "golden-container", "golden-blob", null); + var plain = "golden vector content"u8.ToArray(); + + using var cipher = new MemoryStream(); + cipher.Write(prefix, 0, prefix.Length); + var chunkRecord = BlobEncryptionCodec.EncryptChunk(keyBytes, aad, baseNonce, 0, plain, plain.Length); + cipher.Write(chunkRecord, 0, chunkRecord.Length); + var terminalRecord = BlobEncryptionCodec.CreateTerminalRecord(keyBytes, aad, baseNonce, 1); + cipher.Write(terminalRecord, 0, terminalRecord.Length); + + Convert.ToBase64String(cipher.ToArray()).ShouldBe(expected); + + // And the golden ciphertext must keep decrypting to the original content + cipher.Position = prefix.Length; + using var decryptingStream = new ChunkedDecryptingReadStream(cipher, aad, keyBytes, baseNonce, 64 * 1024); + using var output = new MemoryStream(); + decryptingStream.CopyTo(output); + output.ToArray().ShouldBe(plain); + + // The full production reader must also keep reading historical v1 data: + // header parsing, key routing and the decrypting state machine included + var codec = GetRequiredService(); + var configuration = new BlobContainerConfiguration().UseEncryption("golden-passphrase"); + using var historicalBlob = new MemoryStream(Convert.FromBase64String(expected)); + using var readerStream = await codec.CreateDecryptingStreamAsync(configuration, "golden-container", "golden-blob", null, historicalBlob); + using var readerOutput = new MemoryStream(); + await readerStream.CopyToAsync(readerOutput); + readerOutput.ToArray().ShouldBe(plain); + } + + [Fact] + public async Task Should_Keep_The_V1_Format_Stable_For_Tenant_Blobs() + { + // Pins the AAD encoding of the tenant id: BLOBs of a tenant, stored with + // the v1 format, must stay readable by the current production reader + const string expected = + "QUJQRQEBAQABhqABAgMEBQYHCAkKCwwNDg8QAAEAAKChoqOkpaanAAAAHKa92MyQIqewxKJu99K+u4sDv5kVGZfI/vGgGbUD0JfeWzh4JbOYAQz5Axr1AAAAAIAHZhGwF81jmA7v8d0GZF0="; + + var salt = new byte[16]; + var baseNonce = new byte[8]; + for (var i = 0; i < 16; i++) salt[i] = (byte)(i + 1); + for (var i = 0; i < 8; i++) baseNonce[i] = (byte)(0xA0 + i); + var tenantId = new Guid("11111111-2222-3333-4444-555555555555"); + + var keyBytes = BlobEncryptionCodec.DeriveKeyBytes("golden-passphrase", salt, 100_000); + var header = BlobEncryptionCodec.BuildHeader(BlobEncryptionKeySource.Container, 100_000, salt, 64 * 1024, baseNonce); + var prefix = BlobEncryptionCodec.CreateBlobPrefix(header); + var aad = BlobEncryptionCodec.BuildAssociatedDataPrefix(prefix, "golden-container", "golden-blob", tenantId); + var plain = "golden tenant vector content"u8.ToArray(); + + using var cipher = new MemoryStream(); + cipher.Write(prefix, 0, prefix.Length); + var chunkRecord = BlobEncryptionCodec.EncryptChunk(keyBytes, aad, baseNonce, 0, plain, plain.Length); + cipher.Write(chunkRecord, 0, chunkRecord.Length); + var terminalRecord = BlobEncryptionCodec.CreateTerminalRecord(keyBytes, aad, baseNonce, 1); + cipher.Write(terminalRecord, 0, terminalRecord.Length); + + Convert.ToBase64String(cipher.ToArray()).ShouldBe(expected); + + var codec = GetRequiredService(); + var configuration = new BlobContainerConfiguration().UseEncryption("golden-passphrase"); + using var historicalBlob = new MemoryStream(Convert.FromBase64String(expected)); + using var readerStream = await codec.CreateDecryptingStreamAsync(configuration, "golden-container", "golden-blob", tenantId, historicalBlob); + using var output = new MemoryStream(); + await readerStream.CopyToAsync(output); + output.ToArray().ShouldBe(plain); + } + + [Fact] + public async Task Should_Encrypt_And_Decrypt_A_Modern_Async_Only_Source() + { + var content = new byte[100_000]; + new Random(42).NextBytes(content); + + using (var source = new FakeModernAsyncOnlyStream(new MemoryStream(content))) + { + await _container4.SaveAsync("modern-source", source, overrideExisting: true); + } + + (await _container4.GetAllBytesAsync("modern-source")).SequenceEqual(content).ShouldBeTrue(); + + // A modern-async-only cipher stream (like a modern provider response) works too + var codec = GetRequiredService(); + var configuration = new BlobContainerConfiguration().UseEncryption("modern-passphrase"); + byte[] cipherBytes; + using (var encryptingStream = await codec.CreateEncryptingStreamAsync(configuration, "modern-container", "modern-blob", null, new MemoryStream(content))) + using (var cipherBuffer = new MemoryStream()) + { + await encryptingStream.CopyToAsync(cipherBuffer); + cipherBytes = cipherBuffer.ToArray(); + } + + using var decryptingStream = await codec.CreateDecryptingStreamAsync( + configuration, "modern-container", "modern-blob", null, new FakeModernAsyncOnlyStream(new MemoryStream(cipherBytes))); + using var output = new MemoryStream(); + await decryptingStream.CopyToAsync(output); + output.ToArray().SequenceEqual(content).ShouldBeTrue(); + } + + [Fact] + public async Task Should_Reject_Content_Truncated_Right_After_The_Magic_Even_When_Legacy_Is_Allowed() + { + // A full "ABPE" magic already identifies the encrypted format: it must fail + // as corrupted instead of being returned as (unauthenticated) legacy plaintext + SetRawBytes("magic-only", "ABPE"u8.ToArray()); + + var exception = await Assert.ThrowsAsync(async () => + { + await _container6.GetAsync("magic-only"); + }); + + exception.Message.ShouldContain("missing format version"); + } + + [Fact] + public async Task Should_Reject_A_Header_With_Fewer_Iterations_Than_The_Writer_Minimum() + { + // Accepting fewer iterations than any legitimate writer ever used would let + // attacker-crafted content turn reads into a cheap passphrase-guessing oracle + var salt = new byte[16]; + var baseNonce = new byte[8]; + const int lowIterations = 50_000; + + var keyBytes = BlobEncryptionCodec.DeriveKeyBytes("oracle-passphrase", salt, lowIterations); + var header = BlobEncryptionCodec.BuildHeader(BlobEncryptionKeySource.Container, lowIterations, salt, 64 * 1024, baseNonce); + var prefix = BlobEncryptionCodec.CreateBlobPrefix(header); + var aad = BlobEncryptionCodec.BuildAssociatedDataPrefix(prefix, "oracle-container", "oracle-blob", null); + var plain = "oracle content"u8.ToArray(); + + using var cipher = new MemoryStream(); + cipher.Write(prefix, 0, prefix.Length); + var chunkRecord = BlobEncryptionCodec.EncryptChunk(keyBytes, aad, baseNonce, 0, plain, plain.Length); + cipher.Write(chunkRecord, 0, chunkRecord.Length); + cipher.Position = 0; + + var codec = GetRequiredService(); + var configuration = new BlobContainerConfiguration().UseEncryption("oracle-passphrase"); + + var exception = await Assert.ThrowsAsync(async () => + { + await codec.CreateDecryptingStreamAsync(configuration, "oracle-container", "oracle-blob", null, cipher); + }); + + exception.Message.ShouldContain("invalid KDF iteration count"); + } + + [Fact] + public async Task Should_Not_Expose_A_Length_When_The_Position_Is_Not_Readable() + { + // Length is known but Position throws: the remaining length is genuinely + // unknown (the stream may be partially consumed), so no length is exposed + // rather than a guessed (possibly too-long) one that would short-write + var codec = GetRequiredService(); + var configuration = new BlobContainerConfiguration().UseEncryption("length-passphrase"); + var content = new byte[64 * 1024 + 1]; + using var source = new LengthOnlyStream(content); + + using var encryptedStream = await codec.CreateEncryptingStreamAsync(configuration, "length-container", "length-blob", null, source); + + Should.Throw(() => encryptedStream.Length); + + // The content still round-trips correctly, only the length is unknown + using var output = new MemoryStream(); + await encryptedStream.CopyToAsync(output); + output.Length.ShouldBeGreaterThan(content.Length); + } + + private sealed class LengthOnlyStream : Stream + { + private readonly MemoryStream _inner; + + public LengthOnlyStream(byte[] bytes) + { + _inner = new MemoryStream(bytes); + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => _inner.Length; + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _inner.Dispose(); + } + + base.Dispose(disposing); + } + } + + [Fact] + public async Task Should_Save_A_Source_Whose_Length_Probe_Fails() + { + var content = "length probe failure content".GetBytes(); + using var source = new FakeIoFailingLengthStream(new MemoryStream(content)); + + await _container4.SaveAsync("length-probe-failure", source, overrideExisting: true); + + (await _container4.GetAllBytesAsync("length-probe-failure")).ShouldBe(content); + } + + [Fact] + public async Task Should_Keep_The_V1_Writer_Output_Stable_For_Multi_Chunk_Content() + { + // Pins the production writer state machine (prefix, chunk framing, terminal + // record) on multi-chunk content, and that the production reader reads it + const string expected = + "QUJQRQEBAQABhqABAgMEBQYHCAkKCwwNDg8QAAAAEKChoqOkpaanAAAAEKa92MyQIqep1KB78Ib9pZvT8su7m9hTNktwSiqr1SWvAAAAEEnATYRJsL9GiPFOI/UsQ+rri8bl03Het8U62zMtQuKGAAAACALRHX4ndOvYmGqf4ahSHre7Jn85GCHKZAAAAAATN3tj7GdkC3MxveC+K07f"; + + var salt = new byte[16]; + var baseNonce = new byte[8]; + for (var i = 0; i < 16; i++) salt[i] = (byte)(i + 1); + for (var i = 0; i < 8; i++) baseNonce[i] = (byte)(0xA0 + i); + + var keyBytes = BlobEncryptionCodec.DeriveKeyBytes("golden-passphrase", salt, 100_000); + var header = BlobEncryptionCodec.BuildHeader(BlobEncryptionKeySource.Container, 100_000, salt, 16, baseNonce); + var prefix = BlobEncryptionCodec.CreateBlobPrefix(header); + var aad = BlobEncryptionCodec.BuildAssociatedDataPrefix(prefix, "golden-container", "golden-blob", null); + var plain = "golden multi chunk writer vector content"u8.ToArray(); // 40 bytes -> 3 chunks of 16 + + using var writerStream = new ChunkedEncryptingReadStream(new MemoryStream(plain), prefix, aad, (byte[])keyBytes.Clone(), baseNonce, 16, null); + using var cipher = new MemoryStream(); + await writerStream.CopyToAsync(cipher); + + Convert.ToBase64String(cipher.ToArray()).ShouldBe(expected); + + var codec = GetRequiredService(); + var configuration = new BlobContainerConfiguration().UseEncryption("golden-passphrase"); + using var historicalBlob = new MemoryStream(Convert.FromBase64String(expected)); + using var readerStream = await codec.CreateDecryptingStreamAsync(configuration, "golden-container", "golden-blob", null, historicalBlob); + using var output = new MemoryStream(); + await readerStream.CopyToAsync(output); + output.ToArray().ShouldBe(plain); + } + + [Fact] + public async Task Should_Keep_The_Exact_Length_When_Re_Encrypting_A_Legacy_Stream() + { + var content = new byte[1000]; + new Random(42).NextBytes(content); + + // Simulate the legacy replay stream: the first bytes were consumed as the format probe + var underlying = new MemoryStream(content); + var probeBytes = new byte[5]; + underlying.Read(probeBytes, 0, probeBytes.Length); + using var legacyStream = new PrefixingReadStream(probeBytes, underlying); + + var codec = GetRequiredService(); + var configuration = new BlobContainerConfiguration().UseEncryption("legacy-reencrypt-passphrase"); + using var encryptingStream = await codec.CreateEncryptingStreamAsync(configuration, "legacy-container", "legacy-blob", null, legacyStream); + + var reportedLength = encryptingStream.Length; // Length - Position of the legacy stream is known + using var output = new MemoryStream(); + await encryptingStream.CopyToAsync(output); + + reportedLength.ShouldBe(output.Length); + } + + [Fact] + public async Task Should_Reject_Content_Too_Large_For_The_Chunk_Index_Upfront() + { + var codec = GetRequiredService(); + var configuration = new BlobContainerConfiguration().UseEncryption("huge-passphrase"); + + var exception = await Assert.ThrowsAsync(async () => + { + await codec.CreateEncryptingStreamAsync(configuration, "huge-container", "huge-blob", null, new HugeLengthStream()); + }); + + exception.Message.ShouldContain("too large"); + } + + private sealed class HugeLengthStream : Stream + { + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => 200_000_000_000_000_000; // Far beyond int.MaxValue chunks + + public override long Position + { + get => 0; + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) => 0; + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + + [Fact] + public async Task Should_Reject_A_Blob_Name_With_Invalid_Utf16() + { + // Different unpaired surrogates would fold into the same replacement bytes, + // giving two different names the same authenticated identity + var codec = GetRequiredService(); + var configuration = new BlobContainerConfiguration().UseEncryption("surrogate-passphrase"); + + var exception = await Assert.ThrowsAsync(async () => + { + await codec.CreateEncryptingStreamAsync(configuration, "surrogate-container", "x\uD800", null, new MemoryStream()); + }); + + exception.Message.ShouldContain("invalid characters"); + } + + [Fact] + public void Should_Report_The_Served_Length_Of_A_Legacy_Prefixing_Stream_When_The_Underlying_Is_Offset() + { + // The underlying provider stream is not at position 0: the prefixing wrapper + // must report prefix + remaining, not the underlying total length + var content = new byte[1000]; + new Random(42).NextBytes(content); + var underlying = new MemoryStream(content); + underlying.Position = 2; // Simulate a provider stream that did not start at 0 + + var probe = new byte[5]; + underlying.Read(probe, 0, probe.Length); // The magic probe consumed 5 more bytes + + using var prefixing = new PrefixingReadStream(probe, underlying); + + // Served = prefix (5) + remaining underlying (1000 - 7) = 998 + prefixing.Length.ShouldBe(content.Length - 2); + + using var output = new MemoryStream(); + prefixing.CopyTo(output); + output.Length.ShouldBe(content.Length - 2); + } + + [Fact] + public void Should_Report_The_Bytes_Served_As_The_Position_Of_A_Prefixing_Stream() + { + var underlying = new MemoryStream(new byte[100]); + using var prefixing = new PrefixingReadStream(new byte[5], underlying); + + var buffer = new byte[105]; + var read = prefixing.Read(buffer, 0, buffer.Length); + + (prefixing.Length - prefixing.Position).ShouldBe(prefixing.Length - read); + } + + [Fact] + public async Task Should_Pass_The_Blob_Identity_To_The_Key_Provider() + { + // The key context carries the normalized container/BLOB name and the tenant, + // so a custom provider can select the key by the BLOB identity + // The codec is a public, replaceable service; construct it with a recording key + // provider directly instead of reaching into its internals + var recordingProvider = new RecordingKeyProvider(); + var codec = new BlobEncryptionCodec( + recordingProvider, + GetRequiredService>()); + + var tenantId = Guid.NewGuid(); + var configuration = new BlobContainerConfiguration().UseEncryption("identity-passphrase"); + using var stream = await codec.CreateEncryptingStreamAsync(configuration, "the-container", "the-blob", tenantId, new MemoryStream()); + using var output = new MemoryStream(); + await stream.CopyToAsync(output); + + recordingProvider.LastContext.ShouldNotBeNull(); + recordingProvider.LastContext!.ContainerName.ShouldBe("the-container"); + recordingProvider.LastContext.BlobName.ShouldBe("the-blob"); + recordingProvider.LastContext.TenantId.ShouldBe(tenantId); + } + + private sealed class RecordingKeyProvider : IBlobEncryptionKeyProvider + { + public BlobEncryptionKeyContext? LastContext { get; private set; } + + public Task ResolveForEncryptionAsync(BlobEncryptionKeyContext context, CancellationToken cancellationToken = default) + { + LastContext = context; + return Task.FromResult(new BlobEncryptionKey(BlobEncryptionKeySource.Container, "identity-passphrase")); + } + + public Task ResolveForDecryptionAsync(BlobEncryptionKeySource keySource, BlobEncryptionKeyContext context, CancellationToken cancellationToken = default) + { + LastContext = context; + return Task.FromResult("identity-passphrase"); + } + } + + [Fact] + public async Task Should_Not_Recover_A_Faulted_Decrypting_Stream_In_The_End_Check() + { + // Insert a forged chunk record (no key needed) before the terminal record. Reading + // it faults the stream without incrementing the chunk index, so the original + // terminal would still verify at the same index — the end check must keep the fault + var codec = GetRequiredService(); + var configuration = new BlobContainerConfiguration().UseEncryption("fault-passphrase"); + + byte[] cipher; + using (var encrypting = await codec.CreateEncryptingStreamAsync(configuration, "fault-container", "fault-blob", null, new MemoryStream())) + using (var buffer = new MemoryStream()) + { + await encrypting.CopyToAsync(buffer); + cipher = buffer.ToArray(); + } + + // A forged content record: 4-byte length + 16 random cipher bytes + 16 random tag + var forged = new byte[4 + 16 + 16]; + forged[0] = 0; forged[1] = 0; forged[2] = 0; forged[3] = 16; + new Random(7).NextBytes(forged.AsSpan(4)); + + // Splice it in right before the 20-byte terminal record + var tampered = new byte[cipher.Length + forged.Length]; + Array.Copy(cipher, 0, tampered, 0, cipher.Length - 20); + Array.Copy(forged, 0, tampered, cipher.Length - 20, forged.Length); + Array.Copy(cipher, cipher.Length - 20, tampered, cipher.Length - 20 + forged.Length, 20); + + using var decrypting = await codec.CreateDecryptingStreamAsync(configuration, "fault-container", "fault-blob", null, new MemoryStream(tampered)); + var readBuffer = new byte[1024]; + + // Reading the forged record faults the stream + Assert.ThrowsAny(() => + { + while (decrypting.Read(readBuffer, 0, readBuffer.Length) > 0) + { + } + }); + + // The end check must not "recover" the faulted stream by verifying the terminal + Should.Throw(() => ((IBlobAuthenticatedEndStream)decrypting).EnsureReadToAuthenticatedEnd()); + } + + [Fact] + public async Task Should_Reject_Data_Appended_After_The_Terminal_Record() + { + // The terminal record marks the authenticated end; any trailing bytes after it mean + // the stored ciphertext was extended, so reading to the end must fail + var codec = GetRequiredService(); + var configuration = new BlobContainerConfiguration().UseEncryption("append-passphrase"); + var content = "content that ends cleanly".GetBytes(); + + byte[] cipher; + using (var encrypting = await codec.CreateEncryptingStreamAsync(configuration, "append-container", "append-blob", null, new MemoryStream(content))) + using (var buffer = new MemoryStream()) + { + await encrypting.CopyToAsync(buffer); + cipher = buffer.ToArray(); + } + + // Append one byte after the valid terminal record + var appended = new byte[cipher.Length + 1]; + Array.Copy(cipher, appended, cipher.Length); + appended[cipher.Length] = 0x42; + + using var decrypting = await codec.CreateDecryptingStreamAsync(configuration, "append-container", "append-blob", null, new MemoryStream(appended)); + var readBuffer = new byte[content.Length + 1024]; + + await Assert.ThrowsAsync(async () => + { + while (await decrypting.ReadAsync(readBuffer, 0, readBuffer.Length) > 0) + { + } + }); + } + + [Fact] + public async Task Should_Not_Fault_A_Healthy_Stream_When_The_End_Check_Is_Cancelled() + { + var codec = GetRequiredService(); + var configuration = new BlobContainerConfiguration().UseEncryption("cancel-passphrase"); + var content = "cancel end-check content".GetBytes(); + + byte[] cipher; + using (var encrypting = await codec.CreateEncryptingStreamAsync(configuration, "cancel-container", "cancel-blob", null, new MemoryStream(content))) + using (var buffer = new MemoryStream()) + { + await encrypting.CopyToAsync(buffer); + cipher = buffer.ToArray(); + } + + var cancelSource = new CancellationHonoringStream(new MemoryStream(cipher)); + using var decryptingStream = await codec.CreateDecryptingStreamAsync(configuration, "cancel-container", "cancel-blob", null, cancelSource); + var decrypting = (IBlobAuthenticatedEndStream)decryptingStream; + + // Consume all content, but not the terminal record yet + var readBuffer = new byte[content.Length]; + var total = 0; + while (total < readBuffer.Length) + { + var read = await decryptingStream.ReadAsync(readBuffer.AsMemory(total, readBuffer.Length - total)); + total += read; + } + readBuffer.ShouldBe(content); + + // The terminal read is cancelled: it must not permanently fault the healthy stream + cancelSource.HonorCancellation = true; + await Assert.ThrowsAnyAsync(async () => + { + await decrypting.EnsureReadToAuthenticatedEndAsync(new CancellationToken(canceled: true)); + }); + + // A retry with a live token still verifies the terminal record + cancelSource.HonorCancellation = false; + await decrypting.EnsureReadToAuthenticatedEndAsync(CancellationToken.None); + } + + [Fact] + public async Task Should_Fault_When_The_End_Check_Is_Cancelled_After_Consuming_Part_Of_The_Terminal_Record() + { + // A cancellation before any I/O leaves the stream healthy (see the test above), but a + // cancellation after the terminal record was partially consumed can not: the consumed + // bytes are gone from the non-seekable cipher stream, so a retry would parse from the + // middle of the record and misreport a valid BLOB as corrupt. The stream must fault + var codec = GetRequiredService(); + var configuration = new BlobContainerConfiguration().UseEncryption("partial-cancel-passphrase"); + var content = "partial cancel end-check content".GetBytes(); + + byte[] cipher; + using (var encrypting = await codec.CreateEncryptingStreamAsync(configuration, "partial-cancel-container", "partial-cancel-blob", null, new MemoryStream(content))) + using (var buffer = new MemoryStream()) + { + await encrypting.CopyToAsync(buffer); + cipher = buffer.ToArray(); + } + + var cancelSource = new PartialReadThenCancelStream(new MemoryStream(cipher)); + using var decryptingStream = await codec.CreateDecryptingStreamAsync(configuration, "partial-cancel-container", "partial-cancel-blob", null, cancelSource); + var decrypting = (IBlobAuthenticatedEndStream)decryptingStream; + + // Consume all content, but not the terminal record yet + var readBuffer = new byte[content.Length]; + var total = 0; + while (total < readBuffer.Length) + { + var read = await decryptingStream.ReadAsync(readBuffer.AsMemory(total, readBuffer.Length - total)); + total += read; + } + readBuffer.ShouldBe(content); + + // The terminal read consumes one byte and is then cancelled mid-record + cancelSource.TripOnNextReads = true; + await Assert.ThrowsAnyAsync(async () => + { + await decrypting.EnsureReadToAuthenticatedEndAsync(CancellationToken.None); + }); + + // The stream must now be faulted: a retry must report the fault, not resume parsing + // from the middle of the terminal record and surface a false corruption error + cancelSource.TripOnNextReads = false; + var retry = await Assert.ThrowsAsync(async () => + { + await decrypting.EnsureReadToAuthenticatedEndAsync(CancellationToken.None); + }); + retry.Message.ShouldContain("a previous read operation has failed"); + } + + private sealed class PartialReadThenCancelStream : Stream + { + private readonly Stream _inner; + private int _tripStep; + + // When set, the next read returns a single byte and the read after it throws + // OperationCanceledException, simulating a provider that consumes part of the + // terminal record and is then cancelled mid-read + public bool TripOnNextReads { get; set; } + + public PartialReadThenCancelStream(Stream inner) + { + _inner = inner; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return ReadTrippedAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + return ReadTrippedAsync(buffer, cancellationToken); + } + + private ValueTask ReadTrippedAsync(Memory buffer, CancellationToken cancellationToken) + { + if (TripOnNextReads) + { + if (_tripStep == 0) + { + _tripStep++; + // Consume a single byte of the terminal record before the cancellation + return _inner.ReadAsync(buffer.Slice(0, Math.Min(1, buffer.Length)), cancellationToken); + } + + throw new OperationCanceledException(); + } + + return _inner.ReadAsync(buffer, cancellationToken); + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _inner.Dispose(); + } + + base.Dispose(disposing); + } + } + + private sealed class CancellationHonoringStream : Stream + { + private readonly Stream _inner; + + public bool HonorCancellation { get; set; } + + public CancellationHonoringStream(Stream inner) + { + _inner = inner; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (HonorCancellation && cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(cancellationToken); + } + + return _inner.ReadAsync(buffer, cancellationToken); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + if (HonorCancellation && cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(cancellationToken); + } + + return _inner.ReadAsync(buffer, offset, count, cancellationToken); + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _inner.Dispose(); + } + + base.Dispose(disposing); + } + } + + [Fact] + public async Task Should_Not_Expose_A_Legacy_Plaintext_Stream_As_Authenticated_End() + { + // Legacy plaintext has no authenticated terminal, so its stream must not claim to + var codec = GetRequiredService(); + var configuration = new BlobContainerConfiguration().UseEncryption("legacy-passphrase", allowLegacyPlainText: true); + + using var legacy = await codec.CreateDecryptingStreamAsync(configuration, "legacy-container", "legacy-blob", null, new MemoryStream("plain content".GetBytes())); + + legacy.ShouldNotBeAssignableTo(); + } + + [Fact] + public async Task Should_Reject_A_PassPhrase_With_Invalid_Utf16() + { + // Consistent across target frameworks: an unpaired surrogate passphrase is rejected + var codec = GetRequiredService(); + var configuration = new BlobContainerConfiguration().UseEncryption("x\uD800"); + + var exception = await Assert.ThrowsAsync(async () => + { + await codec.CreateEncryptingStreamAsync(configuration, "surrogate-pass-container", "b", null, new MemoryStream()); + }); + + exception.Message.ShouldContain("invalid characters"); + } + + private byte[]? GetRawBytes(string blobName) + { + return _provider.GetRawBytesOrNull( + BlobContainerNameAttribute.GetContainerName(), + blobName + ); + } + + private void SetRawBytes(string blobName, byte[] bytes) + { + _provider.SetRawBytes( + BlobContainerNameAttribute.GetContainerName(), + blobName, + bytes + ); + } + + private sealed class TrackingNonSeekableStream : Stream + { + private readonly MemoryStream _stream; + + public int DisposeCount { get; private set; } + + public TrackingNonSeekableStream(byte[] bytes) + { + _stream = new MemoryStream(bytes); + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + return _stream.Read(buffer, offset, count); + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + DisposeCount++; + _stream.Dispose(); + } + + base.Dispose(disposing); + } + } + + private sealed class AsyncOnlyStream : Stream + { + private readonly MemoryStream _stream; + + public AsyncOnlyStream(byte[] bytes) + { + _stream = new MemoryStream(bytes); + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + throw new InvalidOperationException("Synchronous reads are not allowed on this stream!"); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return _stream.ReadAsync(buffer, offset, count, cancellationToken); + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _stream.Dispose(); + } + + base.Dispose(disposing); + } + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerPipeline_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerPipeline_Tests.cs new file mode 100644 index 0000000000..25efb8996c --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerPipeline_Tests.cs @@ -0,0 +1,618 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.BlobStoring.Fakes; +using Xunit; + +namespace Volo.Abp.BlobStoring; + +public class BlobContainerPipeline_Tests : AbpBlobStoringTestBase +{ + private readonly IBlobContainerFactory _blobContainerFactory; + private readonly FakeInMemoryBlobProvider _fakeProvider; + + public BlobContainerPipeline_Tests() + { + _blobContainerFactory = GetRequiredService(); + _fakeProvider = GetRequiredService(); + } + + [Fact] + public async Task Should_Transform_While_Saving_And_Restore_While_Getting() + { + var container = _blobContainerFactory.Create("pipeline-markers"); + var content = "pipeline content".GetBytes(); + using var source = new MemoryStream(content); + + await container.SaveAsync("markers-blob", source); + + // Contributors run in the configuration order while saving: A wraps first, + // B wraps the result, so the stored form starts with the marker of B + var rawBytes = _fakeProvider.GetRawBytesOrNull("pipeline-markers", "markers-blob"); + rawBytes.ShouldNotBeNull(); + Encoding.UTF8.GetString(rawBytes, 0, 4).ShouldBe("B>A>"); + + source.CanRead.ShouldBeTrue(); // The caller keeps the ownership of the original stream + + (await container.GetAllBytesAsync("markers-blob")).ShouldBe(content); + } + + [Fact] + public async Task Should_Run_Contributors_On_The_Plain_Content_When_Encryption_Is_Enabled() + { + var container = _blobContainerFactory.Create("pipeline-encrypted"); + var content = "encrypted pipeline content".GetBytes(); + + await container.SaveAsync("encrypted-blob", content); + + // The encryption always runs after the contributors, so the stored form is ciphertext + var rawBytes = _fakeProvider.GetRawBytesOrNull("pipeline-encrypted", "encrypted-blob"); + rawBytes.ShouldNotBeNull(); + Encoding.ASCII.GetString(rawBytes, 0, 4).ShouldBe("ABPE"); + + (await container.GetAllBytesAsync("encrypted-blob")).ShouldBe(content); + } + + [Fact] + public async Task Should_Keep_The_Contributor_Scope_Alive_Until_The_Returned_Stream_Is_Disposed() + { + var container = _blobContainerFactory.Create("pipeline-scoped"); + var content = "scoped pipeline content".GetBytes(); + + await container.SaveAsync("scoped-blob", content); + + var stream = await container.GetAsync("scoped-blob"); + + // The scoped service is used lazily here, after GetAsync already returned + using var result = new MemoryStream(); + await stream.CopyToAsync(result); + result.ToArray().ShouldBe(content); + + var disposedCountBefore = FakeScopedMarkerService.DisposedCount; + stream.Dispose(); + FakeScopedMarkerService.DisposedCount.ShouldBe(disposedCountBefore + 1); + } + + [Fact] + public async Task Should_Dispose_The_Provider_Stream_When_Reading_The_Configuration_Fails() + { + var container = _blobContainerFactory.Create("get-bad-encryption-config"); + + // Save through a plain (well-configured) container to the same provider key, + // then read through the mis-configured one so the get fails after the provider + // stream was obtained + _fakeProvider.SetRawBytes("get-bad-encryption-config", "config-fail-blob", "content".GetBytes()); + + await Assert.ThrowsAnyAsync(async () => + { + await container.GetAsync("config-fail-blob"); + }); + + _fakeProvider.LastServedStream.ShouldNotBeNull(); + _fakeProvider.LastServedStream!.Disposed.ShouldBeTrue(); + } + + [Fact] + public async Task Should_Dispose_The_Provider_Stream_When_A_Get_Contributor_Fails() + { + var container = _blobContainerFactory.Create("pipeline-failing-get"); + + await container.SaveAsync("failing-blob", "failing content".GetBytes()); + + await Assert.ThrowsAsync(async () => + { + await container.GetAsync("failing-blob"); + }); + + _fakeProvider.LastServedStream.ShouldNotBeNull(); + _fakeProvider.LastServedStream!.Disposed.ShouldBeTrue(); + } + + [Fact] + public async Task Should_Dispose_The_Stream_Of_A_Contributor_That_Fails_After_Replacing_It() + { + var container = _blobContainerFactory.Create("pipeline-set-throw-save"); + + await Assert.ThrowsAsync(async () => + { + await container.SaveAsync("set-throw-blob", "content".GetBytes()); + }); + + FakeSetThenThrowPipelineContributor.LastCreatedStream.ShouldNotBeNull(); + FakeSetThenThrowPipelineContributor.LastCreatedStream!.Disposed.ShouldBeTrue(); + } + + [Fact] + public async Task Should_Dispose_The_Whole_Chain_When_A_Later_Get_Contributor_Fails() + { + // The first contributor already wrapped the provider stream when the second one fails + var container = _blobContainerFactory.Create("pipeline-partial-get"); + + await container.SaveAsync("partial-get-blob", "partial content".GetBytes()); + + await Assert.ThrowsAsync(async () => + { + await container.GetAsync("partial-get-blob"); + }); + + _fakeProvider.LastServedStream.ShouldNotBeNull(); + _fakeProvider.LastServedStream!.Disposed.ShouldBeTrue(); + } + + [Fact] + public async Task Should_Release_The_Remaining_Streams_And_The_Scope_When_A_Dispose_Fails() + { + var container = _blobContainerFactory.Create("pipeline-dispose-throw"); + var disposedCountBefore = FakeScopedMarkerService.DisposedCount; + + // The save itself succeeds; the injected failure surfaces from the cleanup + var exception = await Assert.ThrowsAsync(async () => + { + await container.SaveAsync("dispose-throw-blob", "content".GetBytes()); + }); + + exception.Message.ShouldContain("Injected dispose failure"); + FakeScopedMarkerService.DisposedCount.ShouldBe(disposedCountBefore + 1); // The scope was still released + } + + [Fact] + public async Task Should_Keep_The_Container_Tenant_Context_While_The_Returned_Stream_Is_Read() + { + // A tenant reads a shared (IsMultiTenant = false) container: the lazy + // transformation must still run in the host context of the container + var container = _blobContainerFactory.Create("pipeline-shared-tenant"); + var currentTenant = GetRequiredService(); + var content = "shared tenant content".GetBytes(); + + using (currentTenant.Change(Guid.NewGuid())) + { + await container.SaveAsync("shared-tenant-blob", content); + + using var stream = await container.GetAsync("shared-tenant-blob"); + using var result = new MemoryStream(); + await stream.CopyToAsync(result); // The wrapper asserts the tenant context here + + result.ToArray().ShouldBe(content); + } + } + + [Fact] + public async Task Should_Not_Degrade_A_Modern_Async_Only_Wrapper_Stream() + { + var container = _blobContainerFactory.Create("pipeline-modern-async"); + var content = "modern async content".GetBytes(); + + await container.SaveAsync("modern-async-blob", content); + + using var stream = await container.GetAsync("modern-async-blob"); + using var result = new MemoryStream(); + await stream.CopyToAsync(result); // Uses ReadAsync(Memory) on modern runtimes + + result.ToArray().ShouldBe(content); + + // Callers of the old overload must get the same bridging + using var oldOverloadStream = await container.GetAsync("modern-async-blob"); + var buffer = new byte[content.Length]; + var totalReadCount = 0; + while (totalReadCount < buffer.Length) + { + var readCount = await oldOverloadStream.ReadAsync(buffer, totalReadCount, buffer.Length - totalReadCount, default); + if (readCount == 0) + { + break; + } + + totalReadCount += readCount; + } + + buffer.ShouldBe(content); + } + + [Fact] + public async Task Should_Dispose_The_Contributor_Scope_In_The_Container_Tenant_Context() + { + var container = _blobContainerFactory.Create("pipeline-shared-tenant"); + var currentTenant = GetRequiredService(); + var content = "scope dispose tenant content".GetBytes(); + + using (currentTenant.Change(Guid.NewGuid())) + { + await container.SaveAsync("scope-dispose-tenant-blob", content, overrideExisting: true); + + var stream = await container.GetAsync("scope-dispose-tenant-blob"); + using (var result = new MemoryStream()) + { + await stream.CopyToAsync(result); + } + + FakeTenantRecordingScopedService.Reset(); + stream.Dispose(); // Synchronous dispose from the tenant context + + FakeTenantRecordingScopedService.HasRecordedDispose.ShouldBeTrue(); + FakeTenantRecordingScopedService.LastDisposeTenantId.ShouldBeNull(); // The container is shared (host) + + var asyncStream = await container.GetAsync("scope-dispose-tenant-blob"); + using (var result = new MemoryStream()) + { + await asyncStream.CopyToAsync(result); + } + + FakeTenantRecordingScopedService.Reset(); + await asyncStream.DisposeAsync(); // Asynchronous dispose from the tenant context + + FakeTenantRecordingScopedService.HasRecordedDispose.ShouldBeTrue(); + FakeTenantRecordingScopedService.LastDisposeTenantId.ShouldBeNull(); + } + } + + [Fact] + public async Task Should_Not_Dispose_The_Original_Stream_When_A_Contributor_Sets_It_Back() + { + // A wraps the original, then the second contributor sets the original back: + // the pipeline must not treat the caller-owned stream as its own + var container = _blobContainerFactory.Create("pipeline-unwrap"); + var content = "unwrap content".GetBytes(); + using var source = new MemoryStream(content); + FakeOriginalRestoringPipelineContributor.RestoreTo = source; + try + { + await container.SaveAsync("unwrap-blob", source); + } + finally + { + FakeOriginalRestoringPipelineContributor.RestoreTo = null; + } + + source.CanRead.ShouldBeTrue(); // The caller keeps the ownership of the original stream + _fakeProvider.GetRawBytesOrNull("pipeline-unwrap", "unwrap-blob").ShouldBe(content); + } + + [Fact] + public async Task Should_Release_A_Pipeline_Stream_That_Only_Cleans_Up_In_DisposeAsync() + { + var container = _blobContainerFactory.Create("pipeline-async-dispose"); + + await container.SaveAsync("async-dispose-blob", "async dispose content".GetBytes()); + + FakeAsyncDisposePipelineContributor.LastSaveStream.ShouldNotBeNull(); + FakeAsyncDisposePipelineContributor.LastSaveStream!.AsyncDisposed.ShouldBeTrue(); + + // The intermediate stream created within the same contributor call is disposed too + FakeAsyncDisposePipelineContributor.IntermediateSaveStream.ShouldNotBeNull(); + FakeAsyncDisposePipelineContributor.IntermediateSaveStream!.AsyncDisposed.ShouldBeTrue(); + } + + [Fact] + public void Should_Bridge_A_Synchronous_Dispose_To_The_Async_Cleanup_Of_The_Cipher_Stream() + { + // The decrypting stream owns the provider (cipher) stream; a synchronous + // Dispose of it must still run the async-only cleanup of that stream + var cipherStream = new AsyncOnlyDisposeStream(); + var decryptingStream = new ChunkedDecryptingReadStream( + cipherStream, new byte[16], new byte[32], new byte[8], 64 * 1024); + + decryptingStream.Dispose(); + + cipherStream.AsyncDisposed.ShouldBeTrue(); + } + + private sealed class AsyncOnlyDisposeStream : Stream + { + public bool AsyncDisposed { get; private set; } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) => 0; + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + // The cleanup only happens asynchronously; a synchronous Dispose does nothing, + // so the test fails if the decrypting stream does not bridge to DisposeAsync + public override ValueTask DisposeAsync() + { + AsyncDisposed = true; + return default; + } + } + + [Fact] + public async Task Should_Bridge_A_Synchronous_Dispose_To_The_Async_Cleanup_Of_A_Get_Wrapper() + { + var container = _blobContainerFactory.Create("pipeline-async-dispose"); + var content = "sync dispose bridge content".GetBytes(); + + await container.SaveAsync("sync-bridge-blob", content, overrideExisting: true); + + using (var stream = await container.GetAsync("sync-bridge-blob")) + { + using var result = new MemoryStream(); + await stream.CopyToAsync(result); + result.ToArray().ShouldBe(content); + } // The synchronous using must still trigger the async-only cleanup + + FakeAsyncDisposePipelineContributor.LastGetStream.ShouldNotBeNull(); + FakeAsyncDisposePipelineContributor.LastGetStream!.AsyncDisposed.ShouldBeTrue(); + _fakeProvider.LastServedStream.ShouldNotBeNull(); + _fakeProvider.LastServedStream!.Disposed.ShouldBeTrue(); + } + + [Fact] + public async Task Should_Forward_The_Timeout_Capability_Of_The_Wrapped_Stream() + { + var currentTenant = GetRequiredService(); + var scope = GetRequiredService().CreateAsyncScope(); + var inner = new TimeoutCapableStream(); + await using var stream = new BlobPipelineScopeStream(inner, scope, currentTenant, null); + + stream.CanTimeout.ShouldBeTrue(); + stream.ReadTimeout = 1234; + stream.ReadTimeout.ShouldBe(1234); + } + + private sealed class TimeoutCapableStream : MemoryStream + { + private int _readTimeout = -1; + + public override bool CanTimeout => true; + + public override int ReadTimeout + { + get => _readTimeout; + set => _readTimeout = value; + } + } + + [Fact] + public async Task Should_Not_Fault_The_Composed_Stream_When_The_End_Source_Cancels_Without_Faulting() + { + // The decrypting stream lets a cancellation before any I/O through while staying + // healthy (so a retry can still verify). The composed outer stream must mirror that: + // it must not permanently fault on such a cancellation, or the retry can never verify + var currentTenant = GetRequiredService(); + var scope = GetRequiredService().CreateAsyncScope(); + var endSource = new CancelOnceHealthyAuthenticatedEndStream(); + await using var stream = new BlobPipelineScopeStream(endSource, scope, currentTenant, null, endSource); + + var buffer = new byte[16]; + + // The first read reaches EOF and runs the end check, which cancels while staying healthy + await Assert.ThrowsAnyAsync(async () => + { + await stream.ReadAsync(buffer, 0, buffer.Length); + }); + + // A retry must still be able to run (and pass) the end check, not hit a faulted stream + var read = await stream.ReadAsync(buffer, 0, buffer.Length); + read.ShouldBe(0); + endSource.EndCheckAttempts.ShouldBe(2); + } + + private sealed class CancelOnceHealthyAuthenticatedEndStream : Stream, IBlobAuthenticatedEndStream + { + public int EndCheckAttempts { get; private set; } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) => 0; + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => Task.FromResult(0); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + => new ValueTask(0); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public void EnsureReadToAuthenticatedEnd() + { + } + + public ValueTask EnsureReadToAuthenticatedEndAsync(CancellationToken cancellationToken = default) + { + EndCheckAttempts++; + if (EndCheckAttempts == 1) + { + // Cancelled before any I/O: the source stays healthy, exactly like the real + // decrypting stream when the token trips just after the outer's own check + throw new OperationCanceledException(); + } + + return default; + } + } + + [Fact] + public async Task Should_Fault_The_Composed_Stream_When_An_Inner_Read_Fails_After_Consuming() + { + // An inner contributor consumes a byte from the stream below it and then fails: the + // composed stream must fault so a read-retry layer can not silently continue from the + // consumed position and hand the caller content that is missing that byte + var currentTenant = GetRequiredService(); + var scope = GetRequiredService().CreateAsyncScope(); + var underlying = new MemoryStream(new byte[] { 1, 2, 3, 4 }); + var inner = new ConsumeThenThrowStream(underlying); + await using var stream = new BlobPipelineScopeStream(inner, scope, currentTenant, null); + + var buffer = new byte[16]; + await Assert.ThrowsAnyAsync(async () => + { + await stream.ReadAsync(buffer, 0, buffer.Length); + }); + + // The failed read must have faulted the stream permanently + var retry = await Assert.ThrowsAsync(async () => + { + await stream.ReadAsync(buffer, 0, buffer.Length); + }); + retry.Message.ShouldContain("a previous read operation has failed"); + } + + private sealed class ConsumeThenThrowStream : Stream + { + private readonly Stream _inner; + private bool _thrown; + + public ConsumeThenThrowStream(Stream inner) + { + _inner = inner; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (!_thrown) + { + _thrown = true; + // Consume one byte from the stream below, then fail without handing it up + _ = _inner.Read(new byte[1], 0, 1); + throw new OperationCanceledException(); + } + + return _inner.ReadAsync(buffer, cancellationToken); + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _inner.Dispose(); + } + + base.Dispose(disposing); + } + } + + [Fact] + public async Task Should_Verify_The_Authenticated_End_Through_A_Contributor_That_Stops_Early() + { + var container = _blobContainerFactory.Create("pipeline-encrypted-earlystop"); + var content = new byte[100_000]; + new Random(42).NextBytes(content); + + await container.SaveAsync("earlystop-blob", content, overrideExisting: true); + + // The normal round trip works: reading to the end verifies the terminal record + (await container.GetAllBytesAsync("earlystop-blob")).ShouldBe(content); + + // Strip the 20-byte terminal record from the stored ciphertext + var raw = _fakeProvider.GetRawBytesOrNull("pipeline-encrypted-earlystop", "earlystop-blob"); + raw.ShouldNotBeNull(); + var truncated = new byte[raw!.Length - 20]; + Array.Copy(raw, truncated, truncated.Length); + _fakeProvider.SetRawBytes("pipeline-encrypted-earlystop", "earlystop-blob", truncated); + + // Even though the contributor stops at its own declared length, reading the + // composed stream to EOF must now fail because the terminal record is gone + using var stream = await container.GetAsync("earlystop-blob"); + var buffer = new byte[content.Length + 1024]; + + await Assert.ThrowsAsync(async () => + { + await ReadAllAsync(stream, buffer); + }); + + // The failure must be permanent: reading again must not swallow it and return + // a normal EOF (which a read-retry layer would treat as a complete read) + await Assert.ThrowsAsync(async () => + { + await stream.ReadAsync(buffer, 0, buffer.Length); + }); + } + + private static async Task ReadAllAsync(Stream stream, byte[] buffer) + { + int read; + var offset = 0; + while ((read = await stream.ReadAsync(buffer, offset, buffer.Length - offset)) > 0) + { + offset += read; + } + } + + [Fact] + public void Should_Not_Forward_Reads_After_The_Scope_Stream_Is_Disposed() + { + // After dispose the contributor scope is gone; reads must not reach the inner + // stream (a use-after-scope), and CanRead must be consistent with that + var currentTenant = GetRequiredService(); + var scope = GetRequiredService().CreateAsyncScope(); + var inner = new MemoryStream(new byte[10]); + var stream = new BlobPipelineScopeStream(inner, scope, currentTenant, null); + + stream.Dispose(); + + stream.CanRead.ShouldBeFalse(); + Should.Throw(() => stream.Read(new byte[1], 0, 1)); + } + + [Fact] + public async Task Should_Support_A_Synchronous_Dispose_With_An_Async_Only_Scoped_Service() + { + var container = _blobContainerFactory.Create("pipeline-async-scoped"); + var content = "async scoped content".GetBytes(); + + await container.SaveAsync("async-scoped-blob", content); + + var stream = await container.GetAsync("async-scoped-blob"); + using var result = new MemoryStream(); + await stream.CopyToAsync(result); + result.ToArray().ShouldBe(content); + + var asyncDisposedCountBefore = FakeAsyncOnlyDisposableService.AsyncDisposedCount; + stream.Dispose(); // Must not throw although the scoped service is async-only disposable + FakeAsyncOnlyDisposableService.AsyncDisposedCount.ShouldBe(asyncDisposedCountBefore + 1); + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeAPipelineContributor.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeAPipelineContributor.cs new file mode 100644 index 0000000000..ac8478d2b1 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeAPipelineContributor.cs @@ -0,0 +1,11 @@ +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BlobStoring.Fakes; + +public class FakeAPipelineContributor : FakeMarkerPipelineContributorBase, ITransientDependency +{ + public FakeAPipelineContributor() + : base("A>") + { + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeAsyncDisposePipelineContributor.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeAsyncDisposePipelineContributor.cs new file mode 100644 index 0000000000..aff5ab0e56 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeAsyncDisposePipelineContributor.cs @@ -0,0 +1,89 @@ +#nullable enable +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BlobStoring.Fakes; + +/// +/// Wraps the content with a stream whose cleanup only happens in DisposeAsync, +/// so tests can verify the pipeline releases its streams asynchronously — on the +/// save side and also when the caller disposes the returned stream synchronously. +/// +public class FakeAsyncDisposePipelineContributor : IBlobPipelineContributor, ITransientDependency +{ + public static AsyncDisposeOnlyStream? LastSaveStream { get; private set; } + + public static AsyncDisposeOnlyStream? LastGetStream { get; private set; } + + public static AsyncDisposeOnlyStream? IntermediateSaveStream { get; private set; } + + public Task OnSavingAsync(BlobPipelineContext context) + { + // Two replacements in one call: the intermediate stream must also be collected + context.BlobStream = IntermediateSaveStream = new AsyncDisposeOnlyStream(context.BlobStream, ownsInner: false); + context.BlobStream = LastSaveStream = new AsyncDisposeOnlyStream(context.BlobStream, ownsInner: false); + return Task.CompletedTask; + } + + public Task OnGettingAsync(BlobPipelineContext context) + { + // The get-side contract: the wrapper owns the received stream + context.BlobStream = LastGetStream = new AsyncDisposeOnlyStream(context.BlobStream, ownsInner: true); + return Task.CompletedTask; + } + + public sealed class AsyncDisposeOnlyStream : Stream + { + private readonly Stream _inner; + private readonly bool _ownsInner; + + public bool AsyncDisposed { get; private set; } + + public AsyncDisposeOnlyStream(Stream inner, bool ownsInner) + { + _inner = inner; + _ownsInner = ownsInner; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + return _inner.ReadAsync(buffer, cancellationToken); + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + // The cleanup only happens asynchronously; the synchronous Dispose is a no-op + public override async ValueTask DisposeAsync() + { + AsyncDisposed = true; + if (_ownsInner) + { + await _inner.DisposeAsync(); + } + + await base.DisposeAsync(); + } + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeAsyncOnlyDisposableService.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeAsyncOnlyDisposableService.cs new file mode 100644 index 0000000000..6f576796e1 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeAsyncOnlyDisposableService.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BlobStoring.Fakes; + +/// +/// A scoped service that only implements : a synchronous +/// dispose of the owning scope would throw for such a service. +/// +public class FakeAsyncOnlyDisposableService : IScopedDependency, IAsyncDisposable +{ + private static int _asyncDisposedCount; + + public static int AsyncDisposedCount => Volatile.Read(ref _asyncDisposedCount); + + public ValueTask DisposeAsync() + { + Interlocked.Increment(ref _asyncDisposedCount); + return default; + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeAsyncScopedPipelineContributor.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeAsyncScopedPipelineContributor.cs new file mode 100644 index 0000000000..ac0a78c603 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeAsyncScopedPipelineContributor.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BlobStoring.Fakes; + +/// +/// Holds an async-only disposable scoped service without transforming the content. +/// +public class FakeAsyncScopedPipelineContributor : IBlobPipelineContributor, ITransientDependency +{ + // ReSharper disable once NotAccessedField.Local + private readonly FakeAsyncOnlyDisposableService _service; + + public FakeAsyncScopedPipelineContributor(FakeAsyncOnlyDisposableService service) + { + _service = service; + } + + public Task OnSavingAsync(BlobPipelineContext context) + { + return Task.CompletedTask; + } + + public Task OnGettingAsync(BlobPipelineContext context) + { + return Task.CompletedTask; + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeBPipelineContributor.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeBPipelineContributor.cs new file mode 100644 index 0000000000..7f34880b71 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeBPipelineContributor.cs @@ -0,0 +1,11 @@ +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BlobStoring.Fakes; + +public class FakeBPipelineContributor : FakeMarkerPipelineContributorBase, ITransientDependency +{ + public FakeBPipelineContributor() + : base("B>") + { + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeDisposeThrowingPipelineContributor.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeDisposeThrowingPipelineContributor.cs new file mode 100644 index 0000000000..85ea556491 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeDisposeThrowingPipelineContributor.cs @@ -0,0 +1,59 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BlobStoring.Fakes; + +/// +/// Wraps the stream with a pass-through wrapper that fails on Dispose, so tests +/// can verify the best-effort cleanup of the pipeline. +/// +public class FakeDisposeThrowingPipelineContributor : IBlobPipelineContributor, ITransientDependency +{ + public Task OnSavingAsync(BlobPipelineContext context) + { + context.BlobStream = new DisposeThrowingStream(context.BlobStream); + return Task.CompletedTask; + } + + public Task OnGettingAsync(BlobPipelineContext context) + { + return Task.CompletedTask; + } + + private sealed class DisposeThrowingStream : Stream + { + private readonly Stream _inner; + + public DisposeThrowingStream(Stream inner) + { + _inner = inner; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + throw new IOException("Injected dispose failure!"); + } + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeEarlyStopPipelineContributor.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeEarlyStopPipelineContributor.cs new file mode 100644 index 0000000000..e593e0312c --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeEarlyStopPipelineContributor.cs @@ -0,0 +1,120 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BlobStoring.Fakes; + +/// +/// On saving, prepends the 4-byte content length; on getting, returns a wrapper that +/// stops at that declared length — reproducing a contributor that reaches its own EOF +/// before the decryption stream's authenticated end. +/// +public class FakeEarlyStopPipelineContributor : IBlobPipelineContributor, ITransientDependency +{ + public async Task OnSavingAsync(BlobPipelineContext context) + { + using var content = new MemoryStream(); + await context.BlobStream.CopyToAsync(content, 81920, context.CancellationToken); + + var output = new MemoryStream(); + var lengthPrefix = BitConverter.GetBytes((int)content.Length); + output.Write(lengthPrefix, 0, lengthPrefix.Length); + content.Position = 0; + await content.CopyToAsync(output, 81920, context.CancellationToken); + output.Position = 0; + context.BlobStream = output; + } + + public async Task OnGettingAsync(BlobPipelineContext context) + { + var lengthPrefix = new byte[4]; + await ReadExactlyAsync(context.BlobStream, lengthPrefix, context.CancellationToken); + var contentLength = BitConverter.ToInt32(lengthPrefix, 0); + context.BlobStream = new LengthLimitedStream(context.BlobStream, contentLength); + } + + private static async Task ReadExactlyAsync(Stream stream, byte[] buffer, CancellationToken cancellationToken) + { + var total = 0; + while (total < buffer.Length) + { + var read = await stream.ReadAsync(buffer.AsMemory(total, buffer.Length - total), cancellationToken); + if (read == 0) + { + throw new AbpException("Unexpected end of stream while reading the length prefix!"); + } + + total += read; + } + } + + private sealed class LengthLimitedStream : Stream + { + private readonly Stream _inner; + private long _remaining; + + public LengthLimitedStream(Stream inner, long length) + { + _inner = inner; + _remaining = length; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + // Stops at the declared length without reading the rest of the inner stream + public override int Read(byte[] buffer, int offset, int count) + { + if (_remaining <= 0) + { + return 0; + } + + var toRead = (int)Math.Min(count, _remaining); + var read = _inner.Read(buffer, offset, toRead); + _remaining -= read; + return read; + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (_remaining <= 0) + { + return 0; + } + + var toRead = (int)Math.Min(buffer.Length, _remaining); + var read = await _inner.ReadAsync(buffer.Slice(0, toRead), cancellationToken); + _remaining -= read; + return read; + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _inner.Dispose(); + } + + base.Dispose(disposing); + } + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeFailingGetPipelineContributor.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeFailingGetPipelineContributor.cs new file mode 100644 index 0000000000..5c29303f4d --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeFailingGetPipelineContributor.cs @@ -0,0 +1,18 @@ +using System; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BlobStoring.Fakes; + +public class FakeFailingGetPipelineContributor : IBlobPipelineContributor, ITransientDependency +{ + public Task OnSavingAsync(BlobPipelineContext context) + { + return Task.CompletedTask; + } + + public Task OnGettingAsync(BlobPipelineContext context) + { + throw new InvalidOperationException("This contributor can not read content back!"); + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeInMemoryBlobProvider.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeInMemoryBlobProvider.cs new file mode 100644 index 0000000000..80c1a35981 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeInMemoryBlobProvider.cs @@ -0,0 +1,83 @@ +#nullable enable +using System.Collections.Concurrent; +using System.IO; +using System.Threading.Tasks; + +namespace Volo.Abp.BlobStoring.Fakes; + +/// +/// A real in-memory provider (not a substitute), so tests can inspect the raw stored bytes. +/// +public class FakeInMemoryBlobProvider : BlobProviderBase +{ + private readonly ConcurrentDictionary _blobs = new ConcurrentDictionary(); + + public override async Task SaveAsync(BlobProviderSaveArgs args) + { + var key = GetKey(args.ContainerName, args.BlobName); + + if (!args.OverrideExisting && _blobs.ContainsKey(key)) + { + throw new BlobAlreadyExistsException( + $"Saving BLOB '{args.BlobName}' does already exists in the container '{args.ContainerName}'!"); + } + + using (var memoryStream = new MemoryStream()) + { + await args.BlobStream.CopyToAsync(memoryStream); + _blobs[key] = memoryStream.ToArray(); + } + } + + public override Task DeleteAsync(BlobProviderDeleteArgs args) + { + return Task.FromResult(_blobs.TryRemove(GetKey(args.ContainerName, args.BlobName), out _)); + } + + public override Task ExistsAsync(BlobProviderExistsArgs args) + { + return Task.FromResult(_blobs.ContainsKey(GetKey(args.ContainerName, args.BlobName))); + } + + public TrackingMemoryStream? LastServedStream { get; private set; } + + public override Task GetOrNullAsync(BlobProviderGetArgs args) + { + return Task.FromResult( + _blobs.TryGetValue(GetKey(args.ContainerName, args.BlobName), out var bytes) + ? LastServedStream = new TrackingMemoryStream(bytes) + : null + ); + } + + public byte[]? GetRawBytesOrNull(string containerName, string blobName) + { + return _blobs.TryGetValue(GetKey(containerName, blobName), out var bytes) ? bytes : null; + } + + public void SetRawBytes(string containerName, string blobName, byte[] bytes) + { + _blobs[GetKey(containerName, blobName)] = bytes; + } + + private static string GetKey(string containerName, string blobName) + { + return containerName + "/" + blobName; + } + + public sealed class TrackingMemoryStream : MemoryStream + { + public bool Disposed { get; private set; } + + public TrackingMemoryStream(byte[] bytes) + : base(bytes) + { + } + + protected override void Dispose(bool disposing) + { + Disposed = true; + base.Dispose(disposing); + } + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeIoFailingLengthStream.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeIoFailingLengthStream.cs new file mode 100644 index 0000000000..6e0253eef0 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeIoFailingLengthStream.cs @@ -0,0 +1,48 @@ +using System; +using System.IO; + +namespace Volo.Abp.BlobStoring.Fakes; + +/// +/// A readable, non-seekable stream whose length/position probes fail with an +/// — a legal stream shape the optional probes must tolerate. +/// +public sealed class FakeIoFailingLengthStream : Stream +{ + private readonly Stream _inner; + + public FakeIoFailingLengthStream(Stream inner) + { + _inner = inner; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new IOException("The length is not available!"); + + public override long Position + { + get => throw new IOException("The position is not available!"); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _inner.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeMarkerPipelineContributorBase.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeMarkerPipelineContributorBase.cs new file mode 100644 index 0000000000..a1bba25110 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeMarkerPipelineContributorBase.cs @@ -0,0 +1,156 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; + +namespace Volo.Abp.BlobStoring.Fakes; + +/// +/// Prepends a marker while saving and verifies/strips it while getting, +/// so tests can observe the raw stored form and the execution order. +/// +public abstract class FakeMarkerPipelineContributorBase : IBlobPipelineContributor +{ + private readonly byte[] _marker; + + protected FakeMarkerPipelineContributorBase(string marker) + { + _marker = Encoding.UTF8.GetBytes(marker); + } + + public Task OnSavingAsync(BlobPipelineContext context) + { + context.BlobStream = new MarkerPrependingStream(_marker, context.BlobStream); + return Task.CompletedTask; + } + + public Task OnGettingAsync(BlobPipelineContext context) + { + context.BlobStream = new MarkerStrippingStream(_marker, context.BlobStream); + return Task.CompletedTask; + } + + private sealed class MarkerPrependingStream : Stream + { + private readonly byte[] _marker; + private readonly Stream _inner; + private int _markerPosition; + + public MarkerPrependingStream(byte[] marker, Stream inner) + { + _marker = marker; + _inner = inner; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + if (_markerPosition < _marker.Length) + { + var toCopy = Math.Min(count, _marker.Length - _markerPosition); + Array.Copy(_marker, _markerPosition, buffer, offset, toCopy); + _markerPosition += toCopy; + return toCopy; + } + + return _inner.Read(buffer, offset, count); + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + // The save-side contract: the wrapper leaves the received stream open + } + + private sealed class MarkerStrippingStream : Stream + { + private readonly byte[] _marker; + private readonly Stream _inner; + private bool _markerConsumed; + + public MarkerStrippingStream(byte[] marker, Stream inner) + { + _marker = marker; + _inner = inner; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + ConsumeMarker(); + return _inner.Read(buffer, offset, count); + } + + private void ConsumeMarker() + { + if (_markerConsumed) + { + return; + } + + _markerConsumed = true; + + var markerBytes = new byte[_marker.Length]; + var readCount = 0; + while (readCount < markerBytes.Length) + { + var read = _inner.Read(markerBytes, readCount, markerBytes.Length - readCount); + if (read <= 0) + { + break; + } + + readCount += read; + } + + if (readCount != _marker.Length || !((ReadOnlySpan)markerBytes).SequenceEqual(_marker)) + { + throw new AbpException($"The expected content marker '{Encoding.UTF8.GetString(_marker)}' was not found!"); + } + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + // The get-side contract: the wrapper owns the received stream + if (disposing) + { + _inner.Dispose(); + } + + base.Dispose(disposing); + } + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeModernAsyncOnlyStream.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeModernAsyncOnlyStream.cs new file mode 100644 index 0000000000..c613db7b40 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeModernAsyncOnlyStream.cs @@ -0,0 +1,59 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Volo.Abp.BlobStoring.Fakes; + +/// +/// A stream that only supports the modern async read overload; the synchronous +/// read throws, like some modern network/response streams. +/// +public sealed class FakeModernAsyncOnlyStream : Stream +{ + private readonly Stream _inner; + + public FakeModernAsyncOnlyStream(Stream inner) + { + _inner = inner; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + throw new InvalidOperationException("This stream only supports the modern async read overload!"); + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + return _inner.ReadAsync(buffer, cancellationToken); + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _inner.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeModernAsyncPipelineContributor.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeModernAsyncPipelineContributor.cs new file mode 100644 index 0000000000..590e1dc85c --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeModernAsyncPipelineContributor.cs @@ -0,0 +1,23 @@ +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BlobStoring.Fakes; + +/// +/// Wraps the content with a stream that only supports the modern async read +/// overload, so tests can verify the pipeline does not degrade such a stream +/// to the synchronous byte[] fallback of the Stream base class. +/// +public class FakeModernAsyncPipelineContributor : IBlobPipelineContributor, ITransientDependency +{ + public Task OnSavingAsync(BlobPipelineContext context) + { + return Task.CompletedTask; + } + + public Task OnGettingAsync(BlobPipelineContext context) + { + context.BlobStream = new FakeModernAsyncOnlyStream(context.BlobStream); + return Task.CompletedTask; + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeOriginalRestoringPipelineContributor.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeOriginalRestoringPipelineContributor.cs new file mode 100644 index 0000000000..7a4586156b --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeOriginalRestoringPipelineContributor.cs @@ -0,0 +1,30 @@ +#nullable enable +using System.IO; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BlobStoring.Fakes; + +/// +/// Sets the content stream back to a stream chosen by the test (the caller's +/// original), so tests can verify the pipeline never treats it as its own. +/// +public class FakeOriginalRestoringPipelineContributor : IBlobPipelineContributor, ITransientDependency +{ + public static Stream? RestoreTo { get; set; } + + public Task OnSavingAsync(BlobPipelineContext context) + { + if (RestoreTo != null) + { + context.BlobStream = RestoreTo; + } + + return Task.CompletedTask; + } + + public Task OnGettingAsync(BlobPipelineContext context) + { + return Task.CompletedTask; + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeScopedMarkerService.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeScopedMarkerService.cs new file mode 100644 index 0000000000..58e4860e4a --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeScopedMarkerService.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BlobStoring.Fakes; + +/// +/// A scoped service used by to prove +/// that the contributor scope stays alive while the returned stream is being read. +/// +public class FakeScopedMarkerService : IScopedDependency, IDisposable +{ + private static int _disposedCount; + + public static int DisposedCount => Volatile.Read(ref _disposedCount); + + private bool _disposed; + + public byte Transform(byte value) + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(FakeScopedMarkerService)); + } + + return (byte)(value ^ 0x5A); + } + + public void Dispose() + { + if (!_disposed) + { + _disposed = true; + Interlocked.Increment(ref _disposedCount); + } + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeScopedXorPipelineContributor.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeScopedXorPipelineContributor.cs new file mode 100644 index 0000000000..b5dee3a315 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeScopedXorPipelineContributor.cs @@ -0,0 +1,86 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BlobStoring.Fakes; + +/// +/// XOR-transforms the content through a scoped service that is used lazily, +/// while the stream is being read. +/// +public class FakeScopedXorPipelineContributor : IBlobPipelineContributor, ITransientDependency +{ + private readonly FakeScopedMarkerService _markerService; + + public FakeScopedXorPipelineContributor(FakeScopedMarkerService markerService) + { + _markerService = markerService; + } + + public Task OnSavingAsync(BlobPipelineContext context) + { + context.BlobStream = new XorStream(_markerService, context.BlobStream, ownsInner: false); + return Task.CompletedTask; + } + + public Task OnGettingAsync(BlobPipelineContext context) + { + context.BlobStream = new XorStream(_markerService, context.BlobStream, ownsInner: true); + return Task.CompletedTask; + } + + private sealed class XorStream : Stream + { + private readonly FakeScopedMarkerService _markerService; + private readonly Stream _inner; + private readonly bool _ownsInner; + + public XorStream(FakeScopedMarkerService markerService, Stream inner, bool ownsInner) + { + _markerService = markerService; + _inner = inner; + _ownsInner = ownsInner; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + var readCount = _inner.Read(buffer, offset, count); + for (var i = 0; i < readCount; i++) + { + buffer[offset + i] = _markerService.Transform(buffer[offset + i]); + } + + return readCount; + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing && _ownsInner) + { + _inner.Dispose(); + } + + base.Dispose(disposing); + } + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeSetThenThrowPipelineContributor.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeSetThenThrowPipelineContributor.cs new file mode 100644 index 0000000000..1296c02604 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeSetThenThrowPipelineContributor.cs @@ -0,0 +1,65 @@ +#nullable enable +using System; +using System.IO; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BlobStoring.Fakes; + +/// +/// Replaces the stream and then fails, so tests can verify that the +/// already-created stream is not leaked. +/// +public class FakeSetThenThrowPipelineContributor : IBlobPipelineContributor, ITransientDependency +{ + public static TrackableStream? LastCreatedStream { get; private set; } + + public Task OnSavingAsync(BlobPipelineContext context) + { + context.BlobStream = LastCreatedStream = new TrackableStream(context.BlobStream); + throw new InvalidOperationException("This contributor fails after replacing the stream!"); + } + + public Task OnGettingAsync(BlobPipelineContext context) + { + return Task.CompletedTask; + } + + public sealed class TrackableStream : Stream + { + private readonly Stream _inner; + + public bool Disposed { get; private set; } + + public TrackableStream(Stream inner) + { + _inner = inner; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + Disposed = true; + base.Dispose(disposing); + } + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeTenantAssertingPipelineContributor.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeTenantAssertingPipelineContributor.cs new file mode 100644 index 0000000000..f7a4c45d6c --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeTenantAssertingPipelineContributor.cs @@ -0,0 +1,100 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; + +namespace Volo.Abp.BlobStoring.Fakes; + +/// +/// Asserts, lazily while the content is read, that the ambient tenant is the +/// tenant the BLOB operation belongs to. +/// +public class FakeTenantAssertingPipelineContributor : IBlobPipelineContributor, ITransientDependency +{ + private readonly ICurrentTenant _currentTenant; + + public FakeTenantAssertingPipelineContributor(ICurrentTenant currentTenant) + { + _currentTenant = currentTenant; + } + + public Task OnSavingAsync(BlobPipelineContext context) + { + return Task.CompletedTask; + } + + public Task OnGettingAsync(BlobPipelineContext context) + { + context.BlobStream = new TenantAssertingStream(context.BlobStream, _currentTenant, context.TenantId); + return Task.CompletedTask; + } + + private sealed class TenantAssertingStream : Stream + { + private readonly Stream _inner; + private readonly ICurrentTenant _currentTenant; + private readonly Guid? _expectedTenantId; + + public TenantAssertingStream(Stream inner, ICurrentTenant currentTenant, Guid? expectedTenantId) + { + _inner = inner; + _currentTenant = currentTenant; + _expectedTenantId = expectedTenantId; + } + + public override bool CanRead + { + get + { + // Stream.CopyToAsync reads CanRead before the first ReadAsync call + AssertTenant(); + return true; + } + } + + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + AssertTenant(); + return _inner.Read(buffer, offset, count); + } + + private void AssertTenant() + { + if (_currentTenant.Id != _expectedTenantId) + { + throw new AbpException( + $"The lazy transformation ran in the tenant '{_currentTenant.Id?.ToString() ?? "host"}' " + + $"instead of the tenant of the BLOB operation ('{_expectedTenantId?.ToString() ?? "host"}')!"); + } + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _inner.Dispose(); + } + + base.Dispose(disposing); + } + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeTenantBlobEncryptionKeyProvider.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeTenantBlobEncryptionKeyProvider.cs new file mode 100644 index 0000000000..f8b2b33162 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeTenantBlobEncryptionKeyProvider.cs @@ -0,0 +1,60 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; + +namespace Volo.Abp.BlobStoring.Fakes; + +/// +/// A custom key provider giving each tenant its own passphrase, selected from the +/// tenant carried by the . +/// +public class FakeTenantBlobEncryptionKeyProvider : DefaultBlobEncryptionKeyProvider +{ + public const string PassPhrasePrefix = "tenant-passphrase-"; + + public FakeTenantBlobEncryptionKeyProvider( + IOptions options) + : base(options) + { + } + + public override Task ResolveForEncryptionAsync( + BlobEncryptionKeyContext context, + CancellationToken cancellationToken = default) + { + var containerPassPhrase = GetContainerPassPhraseOrNull(context.Configuration); + if (string.IsNullOrWhiteSpace(containerPassPhrase) && context.TenantId.HasValue) + { + return Task.FromResult(new BlobEncryptionKey( + BlobEncryptionKeySource.Tenant, + GetPassPhrase(context.TenantId.Value) + )); + } + + return base.ResolveForEncryptionAsync(context, cancellationToken); + } + + public override Task ResolveForDecryptionAsync( + BlobEncryptionKeySource keySource, + BlobEncryptionKeyContext context, + CancellationToken cancellationToken = default) + { + if (keySource == BlobEncryptionKeySource.Tenant) + { + if (!context.TenantId.HasValue) + { + throw new AbpException("The BLOB was encrypted with a tenant-specific passphrase, but there is no current tenant!"); + } + + return Task.FromResult(GetPassPhrase(context.TenantId.Value)); + } + + return base.ResolveForDecryptionAsync(keySource, context, cancellationToken); + } + + public static string GetPassPhrase(Guid tenantId) + { + return PassPhrasePrefix + tenantId.ToString("N"); + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeTenantRecordingScopedPipelineContributor.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeTenantRecordingScopedPipelineContributor.cs new file mode 100644 index 0000000000..db888964a8 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeTenantRecordingScopedPipelineContributor.cs @@ -0,0 +1,29 @@ +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BlobStoring.Fakes; + +/// +/// Holds a in the contributor scope +/// without transforming the content. +/// +public class FakeTenantRecordingScopedPipelineContributor : IBlobPipelineContributor, ITransientDependency +{ + // ReSharper disable once NotAccessedField.Local + private readonly FakeTenantRecordingScopedService _service; + + public FakeTenantRecordingScopedPipelineContributor(FakeTenantRecordingScopedService service) + { + _service = service; + } + + public Task OnSavingAsync(BlobPipelineContext context) + { + return Task.CompletedTask; + } + + public Task OnGettingAsync(BlobPipelineContext context) + { + return Task.CompletedTask; + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeTenantRecordingScopedService.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeTenantRecordingScopedService.cs new file mode 100644 index 0000000000..4f59317157 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeTenantRecordingScopedService.cs @@ -0,0 +1,35 @@ +using System; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; + +namespace Volo.Abp.BlobStoring.Fakes; + +/// +/// Records the ambient tenant at the moment the owning scope disposes it, so tests +/// can verify the scope is released in the tenant of the BLOB operation. +/// +public class FakeTenantRecordingScopedService : IScopedDependency, IDisposable +{ + public static Guid? LastDisposeTenantId { get; private set; } + + public static bool HasRecordedDispose { get; private set; } + + private readonly ICurrentTenant _currentTenant; + + public FakeTenantRecordingScopedService(ICurrentTenant currentTenant) + { + _currentTenant = currentTenant; + } + + public static void Reset() + { + LastDisposeTenantId = null; + HasRecordedDispose = false; + } + + public void Dispose() + { + LastDisposeTenantId = _currentTenant.Id; + HasRecordedDispose = true; + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer4.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer4.cs new file mode 100644 index 0000000000..c98e55a967 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer4.cs @@ -0,0 +1,6 @@ +namespace Volo.Abp.BlobStoring.TestObjects; + +public class TestContainer4 +{ + +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer5.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer5.cs new file mode 100644 index 0000000000..0a5119be3b --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer5.cs @@ -0,0 +1,6 @@ +namespace Volo.Abp.BlobStoring.TestObjects; + +public class TestContainer5 +{ + +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer6.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer6.cs new file mode 100644 index 0000000000..0b68d27f81 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer6.cs @@ -0,0 +1,6 @@ +namespace Volo.Abp.BlobStoring.TestObjects; + +public class TestContainer6 +{ + +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer7.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer7.cs new file mode 100644 index 0000000000..ac1d088f16 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer7.cs @@ -0,0 +1,5 @@ +namespace Volo.Abp.BlobStoring.TestObjects; + +public class TestContainer7 +{ +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer8.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer8.cs new file mode 100644 index 0000000000..a5883111d1 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer8.cs @@ -0,0 +1,5 @@ +namespace Volo.Abp.BlobStoring.TestObjects; + +public class TestContainer8 +{ +} diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs index fdf279e1ce..06965e2f0b 100644 --- a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs @@ -43,6 +43,8 @@ public interface IRegularTestController Task PostObjectWithQueryAsync(Car bodyValue); + Task QueryObjectWithBodyAsync(Car bodyValue); + Task GetObjectWithUrlAsync(Car bodyValue); Task GetObjectandIdAsync(int id, Car bodyValue); diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs index 1d0864991b..5a86077126 100644 --- a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs @@ -150,6 +150,13 @@ public class RegularTestController : AbpController, IRegularTestController return Task.FromResult(bodyValue); } + [AcceptVerbs("QUERY")] + [Route("query-object-with-body")] + public Task QueryObjectWithBodyAsync([FromBody] Car bodyValue) + { + return Task.FromResult(bodyValue); + } + [HttpGet] [Route("post-object-with-url/bodyValue")] public Task GetObjectWithUrlAsync(Car bodyValue) diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestControllerClientProxy_Tests.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestControllerClientProxy_Tests.cs index 01ec97d734..2e1af7ada0 100644 --- a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestControllerClientProxy_Tests.cs +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestControllerClientProxy_Tests.cs @@ -97,6 +97,14 @@ public class RegularTestControllerClientProxy_Tests : AbpHttpClientTestBase result.Model.ShouldBe("Ford"); } + [Fact] + public async Task QueryObjectWithBodyAsync() + { + var result = await _controller.QueryObjectWithBodyAsync(new Car { Year = 1976, Model = "Ford", FirstReleaseDate = new DateTime(1976, 02, 22, 15, 0, 6, 22) }); + result.Year.ShouldBe(1976); + result.Model.ShouldBe("Ford"); + } + [Fact] public async Task PostObjectWithQueryAsync_With_Different_Culture() { diff --git a/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/HttpMethodHelper_Tests.cs b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/HttpMethodHelper_Tests.cs new file mode 100644 index 0000000000..ab6376c727 --- /dev/null +++ b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/HttpMethodHelper_Tests.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using Shouldly; +using Xunit; + +namespace Volo.Abp.Http; + +public class HttpMethodHelper_Tests +{ + private static readonly Dictionary> Predicates = new() + { + [HttpMethodHelper.Get] = HttpMethodHelper.IsGet, + [HttpMethodHelper.Post] = HttpMethodHelper.IsPost, + [HttpMethodHelper.Put] = HttpMethodHelper.IsPut, + [HttpMethodHelper.Delete] = HttpMethodHelper.IsDelete, + [HttpMethodHelper.Patch] = HttpMethodHelper.IsPatch, + [HttpMethodHelper.Head] = HttpMethodHelper.IsHead, + [HttpMethodHelper.Options] = HttpMethodHelper.IsOptions, + [HttpMethodHelper.Trace] = HttpMethodHelper.IsTrace, + [HttpMethodHelper.Query] = HttpMethodHelper.IsQuery + }; + + [Theory] + [InlineData(HttpMethodHelper.Get)] + [InlineData(HttpMethodHelper.Post)] + [InlineData(HttpMethodHelper.Put)] + [InlineData(HttpMethodHelper.Delete)] + [InlineData(HttpMethodHelper.Patch)] + [InlineData(HttpMethodHelper.Head)] + [InlineData(HttpMethodHelper.Options)] + [InlineData(HttpMethodHelper.Trace)] + [InlineData(HttpMethodHelper.Query)] + public void Is_Predicates_Should_Match_Only_Their_Own_Verb_Ignoring_Case(string verb) + { + foreach (var (name, predicate) in Predicates) + { + var shouldMatch = name == verb; + predicate(verb).ShouldBe(shouldMatch); + predicate(verb.ToLowerInvariant()).ShouldBe(shouldMatch); + } + } + + [Fact] + public void Is_Predicates_Should_Return_False_For_Null() + { + foreach (var predicate in Predicates.Values) + { + predicate(null).ShouldBeFalse(); + } + } + + [Theory] + [InlineData("QUERY", true)] + [InlineData("query", true)] + [InlineData("Query", true)] + [InlineData("GET", false)] + [InlineData("POST", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void IsQuery_Should_Match_Query_Method_Ignoring_Case(string? httpMethod, bool expected) + { + HttpMethodHelper.IsQuery(httpMethod).ShouldBe(expected); + } + + [Fact] + public void ConvertToHttpMethod_Should_Support_Query() + { + HttpMethodHelper.ConvertToHttpMethod("QUERY").Method.ShouldBe("QUERY"); + HttpMethodHelper.ConvertToHttpMethod("query").Method.ShouldBe("QUERY"); + } + + [Theory] + [InlineData("GET")] + [InlineData("POST")] + [InlineData("PUT")] + [InlineData("DELETE")] + [InlineData("PATCH")] + [InlineData("QUERY")] + public void ConvertToHttpMethod_Should_Not_Throw_For_Known_Methods(string httpMethod) + { + Should.NotThrow(() => HttpMethodHelper.ConvertToHttpMethod(httpMethod)); + } + + [Fact] + public void ConvertToHttpMethod_Should_Throw_For_Unknown_Method() + { + Should.Throw(() => HttpMethodHelper.ConvertToHttpMethod("UNKNOWN")); + } + + [Theory] + [InlineData("GetFooAsync", "GET")] + [InlineData("GetListAsync", "GET")] + [InlineData("CreateFooAsync", "POST")] + [InlineData("UpdateFooAsync", "PUT")] + [InlineData("DeleteFooAsync", "DELETE")] + [InlineData("PatchFooAsync", "PATCH")] + [InlineData("DoSomethingAsync", "POST")] + // QUERY is intentionally NOT a naming convention: an action must opt in explicitly + // with [AcceptVerbs("QUERY")]. A method named Query* still maps to the default verb. + [InlineData("QueryFooAsync", "POST")] + public void GetConventionalVerbForMethodName_Should_Not_Map_Query_By_Name(string methodName, string expectedVerb) + { + HttpMethodHelper.GetConventionalVerbForMethodName(methodName).ShouldBe(expectedVerb); + } +} diff --git a/modules/docs/src/Volo.Docs.Admin.HttpApi.Client/Volo.Docs.Admin.HttpApi.Client.csproj b/modules/docs/src/Volo.Docs.Admin.HttpApi.Client/Volo.Docs.Admin.HttpApi.Client.csproj index cb93207d13..3eef37fcf7 100644 --- a/modules/docs/src/Volo.Docs.Admin.HttpApi.Client/Volo.Docs.Admin.HttpApi.Client.csproj +++ b/modules/docs/src/Volo.Docs.Admin.HttpApi.Client/Volo.Docs.Admin.HttpApi.Client.csproj @@ -13,6 +13,7 @@ + diff --git a/modules/docs/src/Volo.Docs.Admin.HttpApi.Client/Volo/Docs/Admin/DocsAdminHttpApiClientModule.cs b/modules/docs/src/Volo.Docs.Admin.HttpApi.Client/Volo/Docs/Admin/DocsAdminHttpApiClientModule.cs index 81e86cf8a6..be924a758d 100644 --- a/modules/docs/src/Volo.Docs.Admin.HttpApi.Client/Volo/Docs/Admin/DocsAdminHttpApiClientModule.cs +++ b/modules/docs/src/Volo.Docs.Admin.HttpApi.Client/Volo/Docs/Admin/DocsAdminHttpApiClientModule.cs @@ -6,6 +6,7 @@ using Volo.Abp.VirtualFileSystem; namespace Volo.Docs.Admin { [DependsOn( + typeof(DocsCommonHttpApiClientModule), typeof(DocsAdminApplicationContractsModule), typeof(AbpHttpClientModule))] public class DocsAdminHttpApiClientModule : AbpModule diff --git a/modules/docs/src/Volo.Docs.HttpApi.Client/Volo.Docs.HttpApi.Client.csproj b/modules/docs/src/Volo.Docs.HttpApi.Client/Volo.Docs.HttpApi.Client.csproj index 41dd8005a3..c5f1e90f45 100644 --- a/modules/docs/src/Volo.Docs.HttpApi.Client/Volo.Docs.HttpApi.Client.csproj +++ b/modules/docs/src/Volo.Docs.HttpApi.Client/Volo.Docs.HttpApi.Client.csproj @@ -13,6 +13,7 @@ + diff --git a/modules/docs/src/Volo.Docs.HttpApi.Client/Volo/Docs/DocsHttpApiClientModule.cs b/modules/docs/src/Volo.Docs.HttpApi.Client/Volo/Docs/DocsHttpApiClientModule.cs index c9797fca73..16147857e3 100644 --- a/modules/docs/src/Volo.Docs.HttpApi.Client/Volo/Docs/DocsHttpApiClientModule.cs +++ b/modules/docs/src/Volo.Docs.HttpApi.Client/Volo/Docs/DocsHttpApiClientModule.cs @@ -6,6 +6,7 @@ using Volo.Abp.VirtualFileSystem; namespace Volo.Docs { [DependsOn( + typeof(DocsCommonHttpApiClientModule), typeof(DocsApplicationContractsModule), typeof(AbpHttpClientModule) )] diff --git a/npm/ng-packs/packages/components/extensible/src/lib/components/extensible-form/extensible-form-prop.component.html b/npm/ng-packs/packages/components/extensible/src/lib/components/extensible-form/extensible-form-prop.component.html index 705c14bffb..30624a5768 100644 --- a/npm/ng-packs/packages/components/extensible/src/lib/components/extensible-form/extensible-form-prop.component.html +++ b/npm/ng-packs/packages/components/extensible/src/lib/components/extensible-form/extensible-form-prop.component.html @@ -162,11 +162,7 @@ @if (prop().displayTextResolver) { {{ prop().displayTextResolver(data()) | abpLocalization }} } @else { - @if (prop().isExtra) { - {{ '::' + prop().displayName | abpLocalization }} - } @else { - {{ prop().displayName | abpLocalization }} - } + {{ prop().displayName | abpLocalization }} } {{ asterisk() }} @if (prop().tooltip) { diff --git a/npm/ng-packs/packages/components/extensible/src/lib/components/extensible-table/extensible-table.component.html b/npm/ng-packs/packages/components/extensible/src/lib/components/extensible-table/extensible-table.component.html index e19808ce49..fa77b526bd 100644 --- a/npm/ng-packs/packages/components/extensible/src/lib/components/extensible-table/extensible-table.component.html +++ b/npm/ng-packs/packages/components/extensible/src/lib/components/extensible-table/extensible-table.component.html @@ -136,7 +136,7 @@ *abpVisible="prop.columnVisible(getInjected)" [width]="columnWidths()[i] || 200" [canAutoResize]="!columnWidths()[i]" - [name]="(prop.isExtra ? '::' + prop.displayName : prop.displayName) | abpLocalization" + [name]="prop.displayName | abpLocalization" [prop]="prop.name" [sortable]="prop.sortable" > diff --git a/npm/ng-packs/packages/core/src/lib/services/rest.service.ts b/npm/ng-packs/packages/core/src/lib/services/rest.service.ts index 4121eaa75a..958e06dfc3 100644 --- a/npm/ng-packs/packages/core/src/lib/services/rest.service.ts +++ b/npm/ng-packs/packages/core/src/lib/services/rest.service.ts @@ -1,5 +1,6 @@ import { HttpClient, HttpHeaders, HttpParameterCodec, HttpParams, HttpRequest } from '@angular/common/http'; -import { Injectable, inject } from '@angular/core'; +import { Injectable, Signal, inject } from '@angular/core'; +import { rxResource } from '@angular/core/rxjs-interop'; import { Observable, from, of, throwError } from 'rxjs'; import { catchError, map, switchMap } from 'rxjs/operators'; import { ExternalHttpClient } from '../clients/http.client'; @@ -66,6 +67,18 @@ export class RestService { }), ); } + + requestResource( + request: Signal>, + config?: Rest.Config, + api?: string, + ) { + return rxResource({ + params: () => request(), + stream: ({ params }) => this.request(params, config, api), + }); + } + private getHttpClient(isExternal: boolean) { return isExternal ? this.externalHttp : this.http; } diff --git a/npm/ng-packs/packages/core/src/lib/tests/rest.service.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/rest.service.spec.ts index 3009b0d6dc..a2fcbdee56 100644 --- a/npm/ng-packs/packages/core/src/lib/tests/rest.service.spec.ts +++ b/npm/ng-packs/packages/core/src/lib/tests/rest.service.spec.ts @@ -1,4 +1,6 @@ import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; +import { effect, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; import { createHttpFactory, HttpMethod, SpectatorHttp, SpyObject } from '@ngneat/spectator/vitest'; import { OAuthService } from 'angular-oauth2-oidc'; import { of, throwError } from 'rxjs'; @@ -92,6 +94,20 @@ describe('HttpClient testing', () => { await completionPromise; }); + test('should create a resource-based request that still uses the ABP request pipeline', () => { + const resource = TestBed.runInInjectionContext(() => { + const resource = spectator.service.requestResource(signal({ method: HttpMethod.GET, url: '/test' })); + effect(() => { + resource.value(); + }); + return resource; + }); + + expect(typeof resource.reload).toBe('function'); + expect(typeof resource.value).toBe('function'); + expect(typeof resource.hasValue).toBe('function'); + }); + test('should handle the error', () => { const spy = vi.spyOn(httpErrorReporter, 'reportError'); diff --git a/npm/ng-packs/packages/core/src/lib/utils/localization-utils.ts b/npm/ng-packs/packages/core/src/lib/utils/localization-utils.ts index 890a83c2e4..5d40bb84f1 100644 --- a/npm/ng-packs/packages/core/src/lib/utils/localization-utils.ts +++ b/npm/ng-packs/packages/core/src/lib/utils/localization-utils.ts @@ -13,11 +13,17 @@ export function createLocalizer(localization: ApplicationLocalizationConfigurati return (resourceName: string, key: string, defaultValue: string | null) => { if (resourceName === '_') return key; - const resource = localization?.values?.[resourceName]; + const resource = localization?.resources?.[resourceName]; - if (!resource) return defaultValue; + if (!resource) { + const fallbackResource = localization?.values?.[resourceName]; - return resource[key] || defaultValue; + if (!fallbackResource) return defaultValue; + + return fallbackResource[key] || defaultValue; + } + + return resource.texts?.[key] || defaultValue; }; } diff --git a/npm/ng-packs/packages/generators/src/generators/generate-proxy/schema.d.ts b/npm/ng-packs/packages/generators/src/generators/generate-proxy/schema.d.ts index cbc2924c78..e0b167dc2a 100644 --- a/npm/ng-packs/packages/generators/src/generators/generate-proxy/schema.d.ts +++ b/npm/ng-packs/packages/generators/src/generators/generate-proxy/schema.d.ts @@ -5,5 +5,6 @@ export interface GenerateProxyGeneratorSchema { target: string; url: string; serviceType: string; + resourceApi: boolean; entryPoint: string; } diff --git a/npm/ng-packs/packages/generators/src/generators/generate-proxy/schema.json b/npm/ng-packs/packages/generators/src/generators/generate-proxy/schema.json index 51f3916848..b9263f58b7 100644 --- a/npm/ng-packs/packages/generators/src/generators/generate-proxy/schema.json +++ b/npm/ng-packs/packages/generators/src/generators/generate-proxy/schema.json @@ -80,6 +80,11 @@ ] } }, + "resourceApi": { + "description": "Generate Resource API helpers for read endpoints", + "type": "boolean", + "default": false + }, "entryPoint": { "description": "Target Angular project to place the generated code", "type": "string", diff --git a/npm/ng-packs/packages/schematics/src/commands/api/files-service/proxy/__namespace@dir__/__name@kebab__.service.ts.template b/npm/ng-packs/packages/schematics/src/commands/api/files-service/proxy/__namespace@dir__/__name@kebab__.service.ts.template index 22443a5a88..aaa4220d9b 100644 --- a/npm/ng-packs/packages/schematics/src/commands/api/files-service/proxy/__namespace@dir__/__name@kebab__.service.ts.template +++ b/npm/ng-packs/packages/schematics/src/commands/api/files-service/proxy/__namespace@dir__/__name@kebab__.service.ts.template @@ -9,12 +9,45 @@ export class <%= name %>Service { apiName = '<%= apiName %>';<% for (let {body, signature} of methods) { %> <% - const isBlob = body.isBlobMethod() ; - const responseType = isBlob ? "Blob":body.responseType; + const isBlob = body.isBlobMethod(); + const responseType = isBlob ? 'Blob' : body.responseType; const httpResponseType = body.httpResponseType; const acceptHeader = body.acceptHeader; + const resourceParameters = signature.parameters.filter(p => p.name !== 'config'); + const resourceType = resourceParameters.length + ? `{ ${resourceParameters.map(p => `${p.name}${p.optional}: ${p.type}`).join('; ')} }` + : ''; + const resourceArgs = resourceParameters.map(p => `params.${p.name}`).join(', '); %> + <% if (resourceApi && body.method === 'GET') { %> + <%= camel(signature.name) %> = (<% + if (resourceParameters.length) { %> + params: Signal<<%= resourceType %>>,<% } %> + config?: Partial + ): ResourceRef<<%= responseType %> | undefined> => this.restService.requestResource( + computed(() => {<% + if (resourceParameters.length) { %> + const { <%= resourceParameters.map(p => p.name).join(', ') %> } = params();<% } %> + return ({ + method: '<%= body.method %>',<% + if (httpResponseType && httpResponseType !== 'json') { %> + responseType: '<%= httpResponseType %>',<% } %><% + if (acceptHeader) { %> + headers: { Accept: '<%= acceptHeader %>' },<% } %> + url: <%= body.url %>,<% + if (body.dictParamVar && !body.params.length) { %> + params: <%= body.dictParamVar %>,<% } %><% + if (body.dictParamVar && body.params.length) { %> + params: { ...<%= body.dictParamVar %>, <%= body.params.join(', ') %> },<% } %><% + if (!body.dictParamVar && body.params.length) { %> + params: { <%= body.params.join(', ') %> },<% } + if (body.body) { %> + body: <%= body.body %>,<% } %> + }); + }), + { apiName: this.apiName, ...config }, + );<% } else { %> <%= camel(signature.name) %> = (<%= serializeParameters(signature.parameters) %>) => this.restService.request<<%= body.requestType %>, <%= responseType %>>({ method: '<%= body.method %>',<% @@ -32,5 +65,6 @@ export class <%= name %>Service { if (body.body) { %> body: <%= body.body %>,<% } %> }, - { apiName: this.apiName,...config });<% } %> -} \ No newline at end of file + { apiName: this.apiName,...config });<% } %><% + } %> +} diff --git a/npm/ng-packs/packages/schematics/src/commands/api/index.ts b/npm/ng-packs/packages/schematics/src/commands/api/index.ts index 15dfb8ab2d..ee759cb9cf 100644 --- a/npm/ng-packs/packages/schematics/src/commands/api/index.ts +++ b/npm/ng-packs/packages/schematics/src/commands/api/index.ts @@ -56,6 +56,7 @@ export default function (schema: GenerateProxySchema) { const types = data.types; const modules = data.modules; const serviceType = schema.serviceType || defaultEServiceType; + const resourceApi = schema.resourceApi ?? false; if (!types || !modules) { throw new SchematicsException(Exception.InvalidApiDefinition); @@ -82,6 +83,7 @@ export default function (schema: GenerateProxySchema) { apiName, controllers, serviceImports, + resourceApi, }); const modelImports: Record = {}; @@ -177,7 +179,7 @@ function createModelGenerator(params: ModelGeneratorParams) { } function createServiceGenerator(params: ServiceGeneratorParams) { - const { targetPath, controllers, serviceImports } = params; + const { targetPath, controllers, serviceImports, resourceApi } = params; const mapControllerToService = createControllerToServiceMapper(params); return chain( @@ -195,6 +197,7 @@ function createServiceGenerator(params: ServiceGeneratorParams) { applyTemplates({ ...cases, serializeParameters, + resourceApi, ...service, }), move(normalize(targetPath)), diff --git a/npm/ng-packs/packages/schematics/src/commands/api/schema.json b/npm/ng-packs/packages/schematics/src/commands/api/schema.json index b5fde3ad79..ec2a49d0b3 100644 --- a/npm/ng-packs/packages/schematics/src/commands/api/schema.json +++ b/npm/ng-packs/packages/schematics/src/commands/api/schema.json @@ -79,6 +79,11 @@ } ] } + }, + "resourceApi": { + "description": "Generate Resource API helpers for read endpoints", + "type": "boolean", + "default": false } }, diff --git a/npm/ng-packs/packages/schematics/src/commands/proxy-add/index.ts b/npm/ng-packs/packages/schematics/src/commands/proxy-add/index.ts index 3c621ddb87..4cf3b9fe73 100644 --- a/npm/ng-packs/packages/schematics/src/commands/proxy-add/index.ts +++ b/npm/ng-packs/packages/schematics/src/commands/proxy-add/index.ts @@ -11,6 +11,7 @@ import { createProxyWarningSaver, mergeAndAllowDelete, removeDefaultPlaceholders, + resolveProxyResourceApi, resolveProject, } from '../../utils'; @@ -24,17 +25,21 @@ export default function (schema: GenerateProxySchema) { const targetPath = buildTargetPath(target.definition, params.entryPoint); const readProxyConfig = createProxyConfigReader(targetPath); let generated: string[] = []; + let previousResourceApi = false; try { - generated = readProxyConfig(host).generated; + const previousConfig = readProxyConfig(host); + generated = previousConfig.generated; + previousResourceApi = resolveProxyResourceApi(params, previousConfig); const index = generated.findIndex(m => m === moduleName); if (index < 0) generated.push(moduleName); } catch (_) { generated.push(moduleName); + previousResourceApi = resolveProxyResourceApi(params); } const getApiDefinition = createApiDefinitionGetter(params); - const data = { generated, ...(await getApiDefinition(host)) }; + const data = { generated, resourceApi: previousResourceApi, ...(await getApiDefinition(host)) }; data.generated = []; const clearProxy = createProxyClearer(targetPath); @@ -43,7 +48,7 @@ export default function (schema: GenerateProxySchema) { const saveProxyWarning = createProxyWarningSaver(targetPath); - const generateApis = createApisGenerator(schema, generated); + const generateApis = createApisGenerator({ ...schema, resourceApi: previousResourceApi }, generated); const generateIndex = createProxyIndexGenerator(targetPath); diff --git a/npm/ng-packs/packages/schematics/src/commands/proxy-add/schema.json b/npm/ng-packs/packages/schematics/src/commands/proxy-add/schema.json index 8c3ca7fe53..7746641315 100644 --- a/npm/ng-packs/packages/schematics/src/commands/proxy-add/schema.json +++ b/npm/ng-packs/packages/schematics/src/commands/proxy-add/schema.json @@ -80,6 +80,11 @@ ] } }, + "resourceApi": { + "description": "Generate Resource API helpers for read endpoints", + "type": "boolean", + "default": false + }, "entryPoint": { "description": "Target Angular project to place the generated code", "type": "string", diff --git a/npm/ng-packs/packages/schematics/src/commands/proxy-refresh/index.ts b/npm/ng-packs/packages/schematics/src/commands/proxy-refresh/index.ts index dcea70bb39..ac4b50ae4c 100644 --- a/npm/ng-packs/packages/schematics/src/commands/proxy-refresh/index.ts +++ b/npm/ng-packs/packages/schematics/src/commands/proxy-refresh/index.ts @@ -10,6 +10,7 @@ import { createProxyIndexGenerator, mergeAndAllowDelete, removeDefaultPlaceholders, + resolveProxyResourceApi, resolveProject, } from '../../utils'; @@ -21,17 +22,19 @@ export default function (schema: GenerateProxySchema) { const targetPath = buildTargetPath(target.definition, params.entryPoint); const readProxyConfig = createProxyConfigReader(targetPath); - const { generated } = readProxyConfig(host); + const previousConfig = readProxyConfig(host); + const { generated } = previousConfig; + const resourceApi = resolveProxyResourceApi(params, previousConfig); const getApiDefinition = createApiDefinitionGetter(params); - const data = { generated, ...(await getApiDefinition(host)) }; + const data = { generated, resourceApi, ...(await getApiDefinition(host)) }; data.generated = []; const clearProxy = createProxyClearer(targetPath); const saveProxyConfig = createProxyConfigSaver(data, targetPath); - const generateApis = createApisGenerator(schema, generated); + const generateApis = createApisGenerator({ ...schema, resourceApi }, generated); const generateIndex = createProxyIndexGenerator(targetPath); diff --git a/npm/ng-packs/packages/schematics/src/commands/proxy-refresh/schema.json b/npm/ng-packs/packages/schematics/src/commands/proxy-refresh/schema.json index c5fd51f9b4..68a7958eb7 100644 --- a/npm/ng-packs/packages/schematics/src/commands/proxy-refresh/schema.json +++ b/npm/ng-packs/packages/schematics/src/commands/proxy-refresh/schema.json @@ -49,6 +49,11 @@ }, "x-prompt": "Please enter URL for api definition (default: API Name's url in environment file)" }, + "resourceApi": { + "description": "Generate Resource API helpers for read endpoints", + "type": "boolean", + "default": false + }, "entryPoint": { "description": "Target Angular project to place the generated code", "type": "string", diff --git a/npm/ng-packs/packages/schematics/src/commands/proxy-remove/index.ts b/npm/ng-packs/packages/schematics/src/commands/proxy-remove/index.ts index 23864b730d..24acb351c0 100644 --- a/npm/ng-packs/packages/schematics/src/commands/proxy-remove/index.ts +++ b/npm/ng-packs/packages/schematics/src/commands/proxy-remove/index.ts @@ -10,6 +10,7 @@ import { createProxyIndexGenerator, mergeAndAllowDelete, removeDefaultPlaceholders, + resolveProxyResourceApi, resolveProject, } from '../../utils'; @@ -23,21 +24,23 @@ export default function (schema: GenerateProxySchema) { const targetPath = buildTargetPath(target.definition, params.entryPoint); const readProxyConfig = createProxyConfigReader(targetPath); - const { generated } = readProxyConfig(host); + const previousConfig = readProxyConfig(host); + const { generated } = previousConfig; + const resourceApi = resolveProxyResourceApi(params, previousConfig); const index = generated.findIndex(m => m === moduleName); if (index < 0) return host; generated.splice(index, 1); const getApiDefinition = createApiDefinitionGetter(params); - const data = { generated, ...(await getApiDefinition(host)) }; + const data = { generated, resourceApi, ...(await getApiDefinition(host)) }; data.generated = []; const clearProxy = createProxyClearer(targetPath); const saveProxyConfig = createProxyConfigSaver(data, targetPath); - const generateApis = createApisGenerator(schema, generated); + const generateApis = createApisGenerator({ ...schema, resourceApi }, generated); const generateIndex = createProxyIndexGenerator(targetPath); diff --git a/npm/ng-packs/packages/schematics/src/commands/proxy-remove/schema.json b/npm/ng-packs/packages/schematics/src/commands/proxy-remove/schema.json index 253ac0b0c3..f0ed7c7b95 100644 --- a/npm/ng-packs/packages/schematics/src/commands/proxy-remove/schema.json +++ b/npm/ng-packs/packages/schematics/src/commands/proxy-remove/schema.json @@ -49,6 +49,11 @@ }, "x-prompt": "Please enter URL for API definition (default: API Name's url in environment file)" }, + "resourceApi": { + "description": "Generate Resource API helpers for read endpoints", + "type": "boolean", + "default": false + }, "entryPoint": { "description": "Target Angular project to place the generated code", "type": "string", diff --git a/npm/ng-packs/packages/schematics/src/models/generate-proxy-schema.ts b/npm/ng-packs/packages/schematics/src/models/generate-proxy-schema.ts index 6d4c67bcd8..de4390e658 100644 --- a/npm/ng-packs/packages/schematics/src/models/generate-proxy-schema.ts +++ b/npm/ng-packs/packages/schematics/src/models/generate-proxy-schema.ts @@ -31,4 +31,9 @@ export interface GenerateProxySchema { */ entryPoint?: string; serviceType?: eServiceType; + + /** + * Generate Resource API helpers for read operations. + */ + resourceApi?: boolean; } diff --git a/npm/ng-packs/packages/schematics/src/models/proxy-config.ts b/npm/ng-packs/packages/schematics/src/models/proxy-config.ts index 226833da12..34d2c933ff 100644 --- a/npm/ng-packs/packages/schematics/src/models/proxy-config.ts +++ b/npm/ng-packs/packages/schematics/src/models/proxy-config.ts @@ -2,4 +2,5 @@ import { ApiDefinition } from './api-definition'; export interface ProxyConfig extends ApiDefinition { generated: string[]; + resourceApi?: boolean; } diff --git a/npm/ng-packs/packages/schematics/src/models/service.ts b/npm/ng-packs/packages/schematics/src/models/service.ts index 98a18bddd8..de020e370b 100644 --- a/npm/ng-packs/packages/schematics/src/models/service.ts +++ b/npm/ng-packs/packages/schematics/src/models/service.ts @@ -10,6 +10,7 @@ export interface ServiceGeneratorParams { apiName: string; controllers: Controller[]; serviceImports: Record; + resourceApi?: boolean; } export class Service { diff --git a/npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts b/npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts index 04b070ae61..611c0ef599 100644 --- a/npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts +++ b/npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts @@ -36,14 +36,20 @@ function render(context: Record): string { return compiled(context); } -function buildContext(body: Partial) { +function buildContext(body: Partial, resourceApi = false) { return { apiName: 'Default', name: 'Sample', namespace: 'app', + resourceApi, imports: [ { keyword: 'import', specifiers: ['RestService', 'Rest'], path: '@abp/ng.core' }, { keyword: 'import', specifiers: ['Injectable', 'inject'], path: '@angular/core' }, + ...(resourceApi + ? [ + { keyword: 'import', specifiers: ['Signal', 'computed', 'ResourceRef'], path: '@angular/core' }, + ] + : []), ], methods: [ { @@ -100,6 +106,38 @@ describe('proxy service template — rendered output', () => { expect(output).not.toContain('headers:'); }); + test('resource api mode emits requestResource helper for GET methods', () => { + const ctx = buildContext({ + responseType: 'MyDto', + responseTypeWithNamespace: 'My.Project.MyDto', + }, true); + ctx.methods[0].signature.parameters = [ + { name: 'input', type: 'GetSampleListInput' } as any, + { name: 'config', type: 'Partial' } as any, + ]; + const output = render(ctx); + + expect(output).toContain('Signal'); + expect(output).toContain('computed(() => {'); + expect(output).toContain('const { input } = params();'); + expect(output).toContain('this.restService.requestResource('); + expect(output).toContain('{ apiName: this.apiName, ...config }'); + expect(output).toContain('getSampleAsync = ('); + expect(output).not.toContain('getSampleAsyncResource'); + expect(output).not.toContain('this.restService.request'); + }); + + test('resource api mode does not emit helpers for non-GET methods', () => { + const output = render(buildContext({ + method: 'POST', + responseType: 'MyDto', + responseTypeWithNamespace: 'My.Project.MyDto', + }, true)); + + expect(output).toContain('getSampleAsync = ('); + expect(output).not.toContain('requestResource('); + }); + test('json httpResponseType emits Accept but no responseType (default is json)', () => { const output = render(buildContext({ responseType: 'string', @@ -191,6 +229,109 @@ describe('proxy service template — rendered output', () => { expect(output.match(/}/g)!.length).toBeGreaterThanOrEqual(3); }); + test('resource api rendered service compiles cleanly', () => { + const ts = require('typescript'); + const ctx = buildContext({ + responseType: 'string', + responseTypeWithNamespace: 'string', + httpResponseType: 'json', + acceptHeader: 'application/json', + }, true); + ctx.methods[0].signature.parameters = [ + { name: 'config', type: 'Partial' } as any, + ]; + const output = render(ctx); + + const abpStub = ` + declare module '@abp/ng.core' { + export namespace Rest { + export interface Config { + apiName?: string; + observe?: any; + skipHandleError?: boolean; + responseType?: string; + [key: string]: any; + } + export type Observe = any; + } + export class RestService { + request(req: any, config?: any): import('rxjs').Observable; + requestResource(request: any, config?: any, api?: string): any; + } + } + `; + const angularCoreStub = ` + declare module '@angular/core' { + export function Injectable(opts?: any): ClassDecorator; + export function inject(token: { new (...args: any[]): T }): T; + export function inject(token: any): T; + export interface Signal { (): T; } + export function computed(fn: () => T): Signal; + export interface ResourceRef { value?: T; } + } + `; + const rxjsStub = ` + declare module 'rxjs' { + export class Observable { subscribe(...args: any[]): unknown; } + } + `; + + const ambient = abpStub + angularCoreStub + rxjsStub; + const sources: Record = { + '/proxy/sample.service.ts': output, + '/proxy/ambient.d.ts': ambient, + }; + + const compilerOptions: any = { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.ES2020, + moduleResolution: ts.ModuleResolutionKind.NodeJs, + ignoreDeprecations: '6.0', + experimentalDecorators: true, + emitDecoratorMetadata: true, + strict: true, + noEmit: true, + skipLibCheck: true, + }; + + const baseHost = ts.createCompilerHost(compilerOptions, true); + const host: any = { + ...baseHost, + getSourceFile: (fileName: string, languageVersion: any, onError: any) => { + if (sources[fileName]) { + return ts.createSourceFile(fileName, sources[fileName], languageVersion, true); + } + return baseHost.getSourceFile(fileName, languageVersion, onError); + }, + fileExists: (fileName: string) => + sources[fileName] != null || baseHost.fileExists(fileName), + readFile: (fileName: string) => + sources[fileName] ?? baseHost.readFile(fileName), + }; + + const program = ts.createProgram(Object.keys(sources), compilerOptions, host); + const errors = ts + .getPreEmitDiagnostics(program) + .filter((d: any) => d.category === ts.DiagnosticCategory.Error && d.code !== 6053); + + if (errors.length) { + const messages = errors + .map((d: any) => { + const where = d.file + ? (() => { + const p = d.file.getLineAndCharacterOfPosition(d.start ?? 0); + const lineText = d.file.text.split('\n')[p.line]; + return `${d.file.fileName}:${p.line + 1}:${p.character + 1}\n>>> ${lineText}\n>>> ${' '.repeat(p.character)}^`; + })() + : '(no file)'; + return `[${where}] TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, '\n')}`; + }) + .join('\n---\n'); + throw new Error(`Resource proxy did not compile:\n${output}\n=== diagnostics ===\n${messages}`); + } + expect(errors).toHaveLength(0); + }); + test.each([ { name: 'string + json Accept', body: { responseType: 'string', responseTypeWithNamespace: 'string', httpResponseType: 'json', acceptHeader: 'application/problem+json' } }, { name: 'string + text Accept', body: { responseType: 'string', responseTypeWithNamespace: 'string', httpResponseType: 'text', acceptHeader: 'text/csv' } }, @@ -244,6 +385,7 @@ describe('proxy service template — rendered output', () => { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.ES2020, moduleResolution: ts.ModuleResolutionKind.NodeJs, + ignoreDeprecations: '6.0', experimentalDecorators: true, emitDecoratorMetadata: true, strict: true, @@ -365,6 +507,7 @@ describe('proxy service template — rendered output', () => { } export class RestService { request(req: any, config?: any): import('rxjs').Observable; + requestResource(request: any, config?: any, api?: string): any; } } `; @@ -373,6 +516,8 @@ describe('proxy service template — rendered output', () => { export function Injectable(opts?: any): ClassDecorator; export function inject(token: { new (...args: any[]): T }): T; export function inject(token: any): T; + export function computed(fn: () => T): any; + export interface ResourceRef { value?: T; } } `; const rxjsStub = ` @@ -397,6 +542,7 @@ describe('proxy service template — rendered output', () => { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.ES2020, moduleResolution: ts.ModuleResolutionKind.NodeJs, + ignoreDeprecations: '6.0', experimentalDecorators: true, emitDecoratorMetadata: true, strict: true, diff --git a/npm/ng-packs/packages/schematics/src/utils/service.ts b/npm/ng-packs/packages/schematics/src/utils/service.ts index ee1ed4630d..0932de02bb 100644 --- a/npm/ng-packs/packages/schematics/src/utils/service.ts +++ b/npm/ng-packs/packages/schematics/src/utils/service.ts @@ -35,6 +35,7 @@ export function createControllerToServiceMapper({ solution, types, apiName, + resourceApi, }: ServiceGeneratorParams) { const mapActionToMethod = createActionToMethodMapper(); @@ -49,6 +50,11 @@ export function createControllerToServiceMapper({ ); imports.push(new Import({ path: '@abp/ng.core', specifiers: ['RestService', 'Rest'] })); imports.push(new Import({ path: '@angular/core', specifiers: ['Injectable', 'inject'] })); + if (resourceApi) { + appendImportSpec(imports, '@angular/core', 'computed'); + appendImportSpec(imports, '@angular/core', 'Signal'); + appendImportSpec(imports, '@angular/core', 'ResourceRef'); + } sortImports(imports); const methods = actions.map(mapActionToMethod); sortMethods(methods); @@ -56,6 +62,16 @@ export function createControllerToServiceMapper({ }; } +function appendImportSpec(imports: Import[], path: string, specifier: string) { + const existing = imports.find(x => x.path === path); + if (!existing) { + imports.push(new Import({ path, specifiers: [specifier] })); + return; + } + + existing.specifiers = [...new Set([...existing.specifiers, specifier])]; +} + function getTypesWithoutIRemoteStreamContent(types: Record) { const newType = { ...types }; VOLO_REMOTE_STREAM_CONTENT.forEach(fileType => { diff --git a/npm/ng-packs/packages/schematics/src/utils/source.ts b/npm/ng-packs/packages/schematics/src/utils/source.ts index 392653b43e..9386cf35e0 100644 --- a/npm/ng-packs/packages/schematics/src/utils/source.ts +++ b/npm/ng-packs/packages/schematics/src/utils/source.ts @@ -108,6 +108,13 @@ export function createProxyConfigReader(targetPath: string) { }; } +export function resolveProxyResourceApi( + params: GenerateProxySchema, + previousConfig?: ProxyConfig, +) { + return params.resourceApi ?? previousConfig?.resourceApi ?? false; +} + export function createProxyClearer(targetPath: string) { targetPath += PROXY_PATH; const proxyIndexPath = `${targetPath}/index.ts`;