Create solution

This commit is contained in:
warrenchen 2026-04-24 18:21:28 +09:00
commit a6ee5113c1
109 changed files with 78081 additions and 0 deletions

33
.gitignore vendored Normal file
View File

@ -0,0 +1,33 @@
# Build outputs
bin/
obj/
# Test and coverage artifacts
TestResults/
coverage/
*.coverage
*.coveragexml
# User and IDE files
*.user
*.suo
*.userosscache
*.sln.docstates
.idea/
.vs/
.vscode/*
# OS files
.DS_Store
Thumbs.db
# Logs
*.log
logs/
# Local environment / secrets
.env
.env.*
# Local storage/output generated by API
src/FileAccessAgent.Api/data/

8
AGENTS.md Normal file
View File

@ -0,0 +1,8 @@
# Project Notes
- Keep `README.md` current when changing purpose, flows, APIs, scopes, configuration, deployment, or implementation status.
- Treat `README.md` as the first project source of truth until a dedicated `docs/` structure exists.
- The implementation language is C# / ASP.NET Core.
- Member Center remains the OAuth2/OIDC issuer and JWKS provider. This project is the file access boundary for `file_access_api`.
- `tenant_id` is part of the File Access boundary model.
- Delegated download token issuing and validation live in Member Center; this project must integrate with its validation endpoint rather than becoming a second token issuer.

55
FileAccessAgent.sln Normal file
View File

@ -0,0 +1,55 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{090C3CDA-DAF6-4EE7-A3B6-B88FBB11AB27}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FileAccessAgent.Domain", "src\FileAccessAgent.Domain\FileAccessAgent.Domain.csproj", "{037AA25A-7D32-4F0D-9C4E-8F20AC10B96F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FileAccessAgent.Application", "src\FileAccessAgent.Application\FileAccessAgent.Application.csproj", "{C28F19B4-0BA3-4C0F-86F8-EC849EFCB934}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FileAccessAgent.Infrastructure", "src\FileAccessAgent.Infrastructure\FileAccessAgent.Infrastructure.csproj", "{92F15EB5-4844-4DE2-A639-F5A821FC6050}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FileAccessAgent.Api", "src\FileAccessAgent.Api\FileAccessAgent.Api.csproj", "{778D27D0-96B8-43AA-B10A-F34C53A72668}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FileAccessAgent.TestSite", "src\FileAccessAgent.TestSite\FileAccessAgent.TestSite.csproj", "{41900A76-B331-461D-B902-86DDE9D0BDD7}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{037AA25A-7D32-4F0D-9C4E-8F20AC10B96F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{037AA25A-7D32-4F0D-9C4E-8F20AC10B96F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{037AA25A-7D32-4F0D-9C4E-8F20AC10B96F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{037AA25A-7D32-4F0D-9C4E-8F20AC10B96F}.Release|Any CPU.Build.0 = Release|Any CPU
{C28F19B4-0BA3-4C0F-86F8-EC849EFCB934}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C28F19B4-0BA3-4C0F-86F8-EC849EFCB934}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C28F19B4-0BA3-4C0F-86F8-EC849EFCB934}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C28F19B4-0BA3-4C0F-86F8-EC849EFCB934}.Release|Any CPU.Build.0 = Release|Any CPU
{92F15EB5-4844-4DE2-A639-F5A821FC6050}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{92F15EB5-4844-4DE2-A639-F5A821FC6050}.Debug|Any CPU.Build.0 = Debug|Any CPU
{92F15EB5-4844-4DE2-A639-F5A821FC6050}.Release|Any CPU.ActiveCfg = Release|Any CPU
{92F15EB5-4844-4DE2-A639-F5A821FC6050}.Release|Any CPU.Build.0 = Release|Any CPU
{778D27D0-96B8-43AA-B10A-F34C53A72668}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{778D27D0-96B8-43AA-B10A-F34C53A72668}.Debug|Any CPU.Build.0 = Debug|Any CPU
{778D27D0-96B8-43AA-B10A-F34C53A72668}.Release|Any CPU.ActiveCfg = Release|Any CPU
{778D27D0-96B8-43AA-B10A-F34C53A72668}.Release|Any CPU.Build.0 = Release|Any CPU
{41900A76-B331-461D-B902-86DDE9D0BDD7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{41900A76-B331-461D-B902-86DDE9D0BDD7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{41900A76-B331-461D-B902-86DDE9D0BDD7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{41900A76-B331-461D-B902-86DDE9D0BDD7}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{037AA25A-7D32-4F0D-9C4E-8F20AC10B96F} = {090C3CDA-DAF6-4EE7-A3B6-B88FBB11AB27}
{C28F19B4-0BA3-4C0F-86F8-EC849EFCB934} = {090C3CDA-DAF6-4EE7-A3B6-B88FBB11AB27}
{92F15EB5-4844-4DE2-A639-F5A821FC6050} = {090C3CDA-DAF6-4EE7-A3B6-B88FBB11AB27}
{778D27D0-96B8-43AA-B10A-F34C53A72668} = {090C3CDA-DAF6-4EE7-A3B6-B88FBB11AB27}
{41900A76-B331-461D-B902-86DDE9D0BDD7} = {090C3CDA-DAF6-4EE7-A3B6-B88FBB11AB27}
EndGlobalSection
EndGlobal

229
README.md Normal file
View File

@ -0,0 +1,229 @@
# File Access Agent
File Access Agent 是檔案存取代理服務,用來控管 bucket / file space 裡檔案的上傳、下載、metadata 讀取與刪除權限。
本專案已決定使用 C# / ASP.NET Core。目標與授權模型依 `../member_center` 最新文件整理,後續實作會以此為準落地。
## 已確認決策
- 部署模型採 single-tenant instance一個 File Access Agent instance 只服務一個 tenant。
- `tenant_id` 仍是 token 與授權邊界的一部分,但服務端會驗證其必須等於本 instance 的固定 tenant。
- 服務預設為無 DB、stateless access gateway。
- 每個 request 只處理單檔upload/download/metadata/delete不提供結構化查詢。
- 版本管理由上游服務負責;不同版本在 bucket 中視為不同單檔(不同 `object_key` 或等價識別)。
- Agent 可只以 `object_key``file_id` 作為單檔識別,不保存完整 metadata。
## 專案目的
此服務位於業務服務與實際檔案儲存 bucket 之間,負責檔案存取的安全邊界。
核心目的:
- 驗證由 Member Center 簽發的 OAuth2/JWT access token。
- 依 `scope``tenant_id``aud`、檔案識別與 HTTP method 判斷是否允許存取。
- 讓後端服務可以用 S2S token 上傳檔案。
- 讓後端服務在完成自身商業規則檢查後,向 Member Center 取得可給 client 使用的短效下載授權。
- 避免把一般 S2S access token 直接暴露給 client。
- 將 bucket provider 的直接存取權限集中在此服務或其 storage adapter 內。
## Agent 角色定義
File Access Agent 是 access permission gateway不是檔案主資料服務。
負責:
- 驗證 token 與 request 邊界(`tenant_id``scope``aud``method``object_key/file_id`
- 代理單檔 upload/download/metadata/delete
- 呼叫 Member Center delegated token validation endpoint
不負責:
- delegated token 簽發(由 Member Center 負責)
- 商業規則與版本管理(由上游服務負責)
- 結構化查詢、報表或資料分析
- metadata 主資料治理(預設不落地 DB
## 與 Member Center 的關係
Member Center 是 OAuth2/OIDC issuer 與 JWKS 提供者。File Access Agent 不負責會員登入、OAuth client 管理或長效 access token 簽發。
`../member_center` 目前已規劃並落地下列 File Access 相關授權設定:
- audience / resource`file_access_api`
- OAuth client usage`file_api`
- scopes
- `files:upload.write`
- `files:download.read`
- `files:download.delegate`
- `files:metadata.read`
- `files:delete`
最新版 `member_center` 文件已明確把 File Access 納入同一套租戶授權模型:`file_api` client 必須綁定 `tenant_id`upload token 與 delegated download token 也都必須帶 `tenant_id`
File Access Agent 應驗證:
- upload 的 S2S JWT透過 Member Center JWKS 驗 `iss``aud=file_access_api``exp``scope``tenant_id`
- download 的 delegated token以 Member Center validation endpoint 線上驗證
- token 中的 `tenant_id` 是否符合實際 request
- token 中的 `object_key``file_id` 是否符合實際 request
- token 中的 `method` 是否符合實際 request
待確認的檔案邊界 claim
- `user_id`delegated download token 目前由 Member Center 文件定義為必帶,用於對應業務服務已驗過的使用者身分。
- `owner_id` / `subject` / `client_id`:可用於紀錄或限定上傳者。
- `bucket` / `namespace` / `object_prefix`:若 storage 需要多 bucket 或 prefix 隔離,應優先明確化。
- `file_id` / `object_key`:下載 token 必須綁定其中一種檔案識別。
## 預期流程
### Upload: service to File Access Agent
1. 業務服務以 `client_credentials` 向 Member Center 取得 access token。
2. token 需包含 `aud=file_access_api``tenant_id``files:upload.write`
3. 業務服務帶 Bearer token 呼叫 File Access Agent 上傳檔案。
4. File Access Agent 驗 token、`tenant_id` 與檔案邊界後,寫入 bucket / file space。
### Download: service to client to File Access Agent
1. client 向業務服務要求下載檔案。
2. 業務服務自行驗證商業規則與檔案權限。
3. 業務服務以 `files:download.delegate` 呼叫 Member Center `POST /file-access/download-tokens` 取得短效 download token。
4. download token 至少綁定 `tenant_id``user_id``object_key``file_id``method=GET`、短效 `exp`
5. client 帶短效 token 直接向 File Access Agent 下載檔案。
6. File Access Agent 以 `files:download.read` 呼叫 Member Center `POST /file-access/download-tokens/validate`,帶上 token 與實際 GET request 邊界,驗證通過後放行。
責任邊界已確認:
- Member Center簽發 upload 用的 S2S JWT、簽發 delegated short-lived download token、提供 download token validation endpoint。
- File Access Agent驗證 upload JWT、呼叫 validation endpoint 驗 delegated download token、控管實際 bucket / file space 的讀寫。
- 業務服務:先驗商業規則與使用者檔案權限,再向 Member Center 申請 delegated download token。
## 初步 API 邊界
實際路由可在實作時調整,但能力邊界應維持清楚:
- `PUT /files/{object_key}`:上傳檔案,需 `files:upload.write`
- `GET /files/{object_key}`:下載檔案,需 `files:download.read`
- `HEAD /files/{object_key}``GET /files/metadata/{object_key}`:讀 metadata`files:metadata.read`
- `DELETE /files/{object_key}`:刪除檔案,需 `files:delete`
本服務不負責提供 `POST /download-tokens`。該責任已在 Member Center
- `POST /file-access/download-tokens`
- `POST /file-access/download-tokens/validate`
## 非目標
- 不負責會員註冊、登入、OAuth consent 或 OAuth client 管理。
- 不取代 Member Center 的 token issuer 職責。
- 不把 bucket credentials 發給 client。
- 不由 File Access Agent 判斷完整商業規則;業務服務仍需先判斷使用者是否可存取指定檔案。
- 不在語言與 storage provider 尚未決定前鎖死特定 SDK 或框架。
## 上傳命名與重複策略
- Agent 不做複雜命名規則;`object_key` 由介接服務決定。
- 相同 `object_key` 再次上傳時Agent 視為覆蓋overwrite不自動版本化。
- 若要避免檔名重複,介接服務應自行把 `timestamp/uuid/業務主鍵` 放入 `object_key`
- 若要版本管理,介接服務需為每個版本使用不同 `object_key`(例如 `v0001`, `v0002`)。
- 詳細命名建議請見 `docs/API.md` 的 Upload 區段。
## 待決策
- bucket / object storage provider例如 S3、GCS、Cloudflare R2、MinIO 或本機檔案系統。
- 是否需要「可選」持久化token `jti`、審計紀錄或特殊索引(預設不啟用)。
- object key 命名規則與 bucket / namespace / prefix 隔離策略。
- 檔案大小限制、content-type allowlist、病毒掃描與保留期限。
- 是否支援 presigned URL或所有流量都經由 access agent proxy。
## Storage 設定
API 支援兩種 provider
- `Storage:Provider=local`:使用本機檔案系統(`Instance:StorageRoot`
- `Storage:Provider=minio`:使用 MinIOS3 compatible
MinIO 設定(`src/FileAccessAgent.Api/appsettings.json`
- `Minio:Endpoint`:例如 `localhost:9000`
- `Minio:AccessKey`
- `Minio:SecretKey`
- `Minio:BucketName`
- `Minio:UseSsl``true|false`
- `Minio:Region`:可空
## 實作語言
本專案已決定使用 C# / ASP.NET Core。
### C# / ASP.NET Core
- 與 `member_center` 同語言,跨專案概念與維運一致。
- ASP.NET Core 對大檔串流、middleware、auth policy、DI、設定管理很成熟。
- 編譯期檢查較強,適合安全邊界服務。
- 可直接沿用 `member_center` 的設定、OpenAPI、JWT / OAuth 與部署習慣。
目前實作方向會以 ASP.NET Core Web API 為主,並保留 storage adapter 抽象,避免過早綁死特定 bucket provider。
## 文件維護規則
每次改動功能、流程、API、scope、設定或部署方式時都要同步更新文件。
優先更新順序:
1. `README.md`:專案目的、目前決策、執行方式與重要限制。
2. `docs/`若後續建立更細文件放設計、API、流程、部署、決策紀錄。
3. API 規格:一旦路由與 payload 穩定,補 OpenAPI 或等價規格。
如果程式碼與文件不一致,應先修正 README 或在 README 標示尚未決定,避免讓文件變成過期假設。
目前文件:
- `docs/API.md`File Access Agent API 規格與 Member Center 整合契約
- `docs/openapi.yaml`:本專案 OpenAPI 草案
- `docs/DATA_MODEL.md`:可選持久化擴充(非預設)
## 測試專案
- `src/FileAccessAgent.TestSite`Redirect login 測試站OIDC Authorization Code + PKCE
- 目的:透過 Member Center `web_login` client 登入,取得並顯示 token/claims並直接測試 Agent APIupload / download / head / metadata / delete / health
- 測試站採雙 token 模式:
- `web_login` token只用於身份展示claims / user context
- `file_api` service tokenclient credentials用於呼叫 Agent 與 Member Center delegated token API
最小設定(`src/FileAccessAgent.TestSite/appsettings.json`
- `MemberCenter:Authority`
- `MemberCenter:WebBaseUrl`Member Center Web預設 `http://localhost:5080`,供 redirect logout 使用)
- `MemberCenter:ClientId`web_login
- `MemberCenter:ClientSecret`web_login public client 可留空)
- `MemberCenter:CallbackPath`(需與 Member Center OAuth client redirect URI 一致)
- `MemberCenter:SignedOutCallbackPath`OIDC middleware callback建議保留預設 `/signout-callback-oidc`,不要與 direct logout callback 共用)
- `MemberCenter:Scopes`(建議 `openid email profile`
- `MemberCenter:ServiceClientId`file_api
- `MemberCenter:ServiceClientSecret`file_api
- `MemberCenter:ServiceScopes`(至少 `files:upload.write files:metadata.read files:delete files:download.delegate`
- `FileAccessAgent:BaseUrl`
- `FileAccessAgent:TenantId`
啟動:
- `dotnet run --project src/FileAccessAgent.TestSite/FileAccessAgent.TestSite.csproj`
- 進入首頁後點 `Login`,會導到 Member Center再 redirect 回測試站
- `Logout` 會走 Member Center Web `GET /account/logout?returnUrl=...`,再 callback 回 TestSite `/auth/logout-callback`
- 若 logout 後未回到 TestSite而停在 Member Center需在 Member Center Web 設定:
- `Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5091/`
- 登入後可在同一頁直接執行 `Upload``Download``Head``Get Metadata``Delete``Health`
- `Upload` 改為檔案挑選上傳;`Object Key` 可選填(留空會自動產生 `demo/uploads/<timestamp>-<filename>`
- 上傳成功後會保留回傳的 `object_key` 供後續 `Metadata/Head/Download/Delete` 直接測試
- 另提供 `Download (Invalid Token)` 測試,故意帶錯 token 驗證 Agent 拒絕邏輯
- `Download` 會先呼叫 Member Center `POST /file-access/download-tokens` 申請 delegated token再帶 token 呼叫 Agent 下載
- `Download File` 走 sample 的直連模式TestSite 僅向 Member Center 取 delegated token接著把 client 轉址到 Agent 下載 URL含短效 `access_token`),下載流量不經 TestSite
## 目前狀態
- 專案目標已依 `../member_center` 文件整理。
- 已確認使用 C# / ASP.NET Core。
- `tenant_id` 與 delegated download token 分工已依最新版 `member_center` 文件校正。
- 目前目錄中的 .NET skeleton 可作為正式起點,但仍需依最新流程補上 Member Center validation 串接。

364
docs/API.md Normal file
View File

@ -0,0 +1,364 @@
# API Spec
本文件定義 File Access Agent 對外 API 邊界,以及它依賴的 Member Center File Access 授權契約。
目前狀態:
- File Access Agent API本文件定義為本專案目標契約。
- Member Center File Access API`../member_center/docs/openapi.yaml` 與相關文件整理,作為本專案整合依據。
## 0. 部署與資料假設
- Single-tenant instance一個 File Access Agent instance 只服務一個 tenant。
- 服務啟動時需設定固定 `INSTANCE_TENANT_ID`;所有請求中的 `tenant_id`token 或 validation 結果)都必須與此值一致。
- 預設無 DB服務維持 stateless。
- Metadata 讀取以 bucket provider 為主,不以本地 DB 作為權威來源。
- 若未來出現審計、replay 防護或特殊索引需求,可再加可選持久化層。
## 1. 角色分工
### File Access Agent
負責:
- 驗證 upload 用的 Member Center JWT access token
- 接收檔案上傳、下載、metadata 查詢、刪除
- 對 delegated download token 向 Member Center 做線上驗證
- 控制實際 bucket / file space 存取
不負責:
- OAuth client 管理
- access token 簽發
- delegated download token 簽發
- 商業規則判斷與版本管理
- 結構化查詢或 metadata 主資料治理
### Member Center
負責:
- 簽發 upload 用的 S2S JWT access token
- 簽發 delegated short-lived opaque download token
- 提供 delegated token validation endpoint
## 2. 授權模型
### 2.1 Upload
- token 來源Member Center `client_credentials`
- audience`file_access_api`
- 必要 scope`files:upload.write`
- 必要 claim`tenant_id`
- 驗證方式File Access Agent 以 Member Center JWKS 驗簽 JWT
### 2.2 Download
- token 來源:業務服務以 `files:download.delegate` 呼叫 Member Center 申請
- token 類型delegated short-lived opaque token
- token 需綁定:
- `tenant_id`
- `user_id`
- `file_id``object_key`
- `method=GET`
- 短效 `exp`
- 驗證方式File Access Agent 呼叫 Member Center validation endpoint 線上驗證
### 2.3 Metadata
建議沿用 upload 類型的 S2S JWT
- audience`file_access_api`
- 必要 scope`files:metadata.read`
- 必要 claim`tenant_id`
### 2.4 Delete
建議沿用 upload 類型的 S2S JWT
- audience`file_access_api`
- 必要 scope`files:delete`
- 必要 claim`tenant_id`
## 3. File Access Agent API
Base path 暫定:`/`
### 3.1 Upload File
- Method: `PUT`
- Path: `/files/{objectKey}`
- Auth: `Bearer` JWT
- Required scope: `files:upload.write`
用途:
- 由業務服務上傳檔案到 File Access Agent
Path parameters
- `objectKey`URL-encoded object key
Headers
- `Authorization: Bearer <access_token>`
- `Content-Type: <mime-type>`
Optional headers
- `X-File-Id: <string>`
Request body
- binary stream
Validation
- JWT signature / `iss` / `aud=file_access_api` / `exp`
- token `tenant_id`
- `scope=files:upload.write`
- request `objectKey` 是否符合允許的 tenant 邊界策略
Object key 命名建議(介接規範):
- Agent 不做複雜命名治理,`objectKey` 由介接服務自行決定。
- 建議 key 結構:`{domain}/{entity}/{yyyy}/{MM}/{dd}/{id-or-uuid}/{sanitized-filename}`
- 建議至少包含一個高唯一性段(例如 `uuid` 或資料庫主鍵),避免只用原始檔名。
- 建議避免使用空白、反斜線與 `..`;上傳前先做檔名 sanitize保留副檔名
- 建議全部使用小寫與固定分隔字元(`/``-`),便於跨服務對齊。
避免重複檔名與版本策略(責任邊界):
- 若上傳相同 `objectKey`Agent 視為覆蓋overwrite不自動做版本保留。
- Agent 不負責「同名檔案改名」或「自動遞增版本號」。
- 若要避免覆蓋,介接服務應在上傳前自行產生新 key例如 `timestamp + uuid`)。
- 若要做版本管理,請由介接服務把每個版本寫成不同 `objectKey`(例如 `.../v0001/...``.../v0002/...`)。
- 若要保留「原始檔名查詢」能力,請在介接服務自己的資料表維護 `display_name -> objectKey` 對照。
Responses
- `201 Created`
- `400 Bad Request`
- `401 Unauthorized`
- `403 Forbidden`
- `409 Conflict`
- `413 Payload Too Large`
Success body
```json
{
"tenant_id": "uuid",
"file_id": "optional-string",
"object_key": "reports/2026/04/file.pdf",
"content_type": "application/pdf",
"size": 12345,
"etag": "optional-string",
"last_modified_at": "2026-04-24T03:10:00Z"
}
```
### 3.2 Download File
- Method: `GET`
- Path: `/files/{objectKey}`
- Auth: delegated download token
用途:
- 由 client 使用 delegated token 下載檔案
Path parameters
- `objectKey`URL-encoded object key
Headers
- `Authorization: Bearer <delegated_download_token>`
Validation
- 將 token 與實際 `tenant_id + file_id/object_key + method=GET` 邊界送到 Member Center validation endpoint
- validation 成功後才允許下載
Responses
- `200 OK` with file stream
- `401 Unauthorized`
- `403 Forbidden`
- `404 Not Found`
### 3.3 Get File Metadata
- Method: `GET`
- Path: `/files/metadata/{objectKey}`
- Auth: `Bearer` JWT
- Required scope: `files:metadata.read`
用途:
- 由業務服務讀取檔案 metadata不回傳檔案內容
Responses
- `200 OK`
- `401 Unauthorized`
- `403 Forbidden`
- `404 Not Found`
Success body
```json
{
"tenant_id": "uuid",
"file_id": "optional-string",
"object_key": "reports/2026/04/file.pdf",
"content_type": "application/pdf",
"size": 12345,
"etag": "optional-string",
"last_modified_at": "2026-04-24T03:10:00Z"
}
```
### 3.4 Head File
- Method: `HEAD`
- Path: `/files/{objectKey}`
- Auth: `Bearer` JWT
- Required scope: `files:metadata.read`
用途:
- 提供較輕量的 existence / metadata header 檢查
Responses
- `200 OK`
- `401 Unauthorized`
- `403 Forbidden`
- `404 Not Found`
### 3.5 Delete File
- Method: `DELETE`
- Path: `/files/{objectKey}`
- Auth: `Bearer` JWT
- Required scope: `files:delete`
用途:
- 由業務服務刪除檔案
Responses
- `204 No Content`
- `401 Unauthorized`
- `403 Forbidden`
- `404 Not Found`
### 3.6 Health
- Method: `GET`
- Path: `/health`
- Auth: none
用途:
- liveness / readiness 的最小檢查
Responses
- `200 OK`
Success body
```json
{
"status": "ok"
}
```
## 4. 與 Member Center 的整合 API
以下端點不屬於本專案提供,但 File Access Agent 需要依賴。
### 4.1 Issue Delegated Download Token
- Provider: Member Center
- Method: `POST`
- Path: `/file-access/download-tokens`
- Required scope: `files:download.delegate`
Request body
```json
{
"tenant_id": "uuid",
"user_id": "uuid",
"file_id": "optional-string",
"object_key": "reports/2026/04/file.pdf",
"method": "GET",
"expires_in_seconds": 300
}
```
Response body
- 由 Member Center 定義;目前至少會回傳 delegated download token 與到期資訊
### 4.2 Validate Delegated Download Token
- Provider: Member Center
- Method: `POST`
- Path: `/file-access/download-tokens/validate`
- Required scope: `files:download.read`
Request body
```json
{
"token": "opaque-token",
"tenant_id": "uuid",
"file_id": "optional-string",
"object_key": "reports/2026/04/file.pdf",
"method": "GET"
}
```
Response body
- 由 Member Center 定義;目前至少需包含 validation success / active 狀態與對應邊界資訊
## 5. 共通錯誤格式
建議本專案統一採用:
```json
{
"error": "string_code",
"message": "human readable message",
"request_id": "uuid"
}
```
建議錯誤碼:
- `invalid_token`
- `insufficient_scope`
- `tenant_mismatch`
- `object_key_mismatch`
- `method_mismatch`
- `file_not_found`
- `payload_too_large`
- `unsupported_media_type`
- `storage_unavailable`
## 6. 待補細節
- `tenant_id` 從 request path、header 或 object key prefix 如何對齊
- `file_id` 是否作為正式主 key或僅保留 `object_key`
- `HEAD /files/{objectKey}` 是否保留,或改以 `GET /files/metadata/{objectKey}`
- upload 是否需要支援 overwrite 策略 header
- 下載是否要支援 `Range` request
- metadata / delete 是否允許 delegated token或只接受 S2S JWT
- 可選持久化是否需要最小 audit / jti 表(預設不啟用)

128
docs/DATA_MODEL.md Normal file
View File

@ -0,0 +1,128 @@
# Data Model
本文件定義 File Access Agent 的「可選」持久化模型,不是預設必備。
## 1. 設計前提
- 預設模式為無 DBmetadata 由 bucket provider 提供。
- 部署採 single-tenant instance同一個 instance 只處理一個 tenant 的資料。
- 若啟用持久化,`tenant_id` 仍保存在資料中,作為稽核與跨環境遷移邊界欄位。
## 2. 何時才需要持久化
只有在下列需求出現時,才建議加 DB
- 審計落地(合規或追蹤)
- token `jti` replay 防護
- 額外索引需求(例如 `file_id``object_key` 快取映射)
## 3. 最小可選資料表
建議最小可選表:`file_object_refs`
用途:
- 保存 `file_id``object_key` 的輕量映射(若上游要求)
欄位:
- `id`UUID 或 ULIDinternal id
- `tenant_id`UUID應等於 instance 固定 tenant
- `file_id`string唯一
- `object_key`string唯一
- `created_at`timestamp
- `updated_at`timestamp
索引:
- unique(`file_id`)
- unique(`object_key`)
- index(`updated_at`)
## 4. 其他可選資料表
### 3.1 `file_access_audit_logs`
用途:追蹤 upload/download/metadata/delete 操作
欄位:
- `id`
- `tenant_id`
- `object_key`
- `action` (`upload|download|metadata|delete`)
- `actor_type` (`service|client`)
- `actor_id` nullable
- `result` (`allow|deny`)
- `reason_code` nullable
- `created_at`
### 3.2 `token_jti_registry`
用途:若之後要做 delegated token replay 防護
欄位:
- `jti`
- `tenant_id`
- `expires_at`
- `consumed_at` nullable
## 5. 儲存技術建議
### 4.1 SQLite建議起步
適用(啟用持久化時):
- 單 instance
- 中低流量
- 結構固定、查詢簡單
優點:
- 部署成本最低
- C# 生態成熟EF Core + SQLite
風險:
- 高併發寫入能力有限
- 多副本部署需額外設計一致性
### 4.2 文件型 NoSQL
適用(啟用持久化時):
- 主要是 key lookup關聯少
- 僅存映射或審計文件,不做複雜 join
優點:
- schema 彈性高
- `metadata_json` 可原生存取
風險:
- 交易與一致性語意通常較弱
- 後續做複雜查詢或審計統計可能要補額外索引設計
### 4.3 PostgreSQL
適用(啟用持久化時):
- 需要高併發與穩定維運
- 需要更完整審計與分析查詢
優點:
- 可靠、可擴展、與 Member Center 堆疊一致
- `jsonb` 可兼顧結構化與彈性欄位
風險:
- 維運成本高於 SQLite
## 6. 建議路線
1. `v1`:無 DB預設
2. `v1.5`:若需要映射或審計,先上 SQLite + `file_object_refs`(必要時加 `file_access_audit_logs`
3. `v2`:若流量或治理需求提升,再評估 PostgreSQL 或 NoSQL

304
docs/openapi.yaml Normal file
View File

@ -0,0 +1,304 @@
openapi: 3.1.0
info:
title: File Access Agent API
version: 0.1.0
description: >
File Access Agent controls access to files stored in bucket / file space.
Upload uses Member Center JWT access tokens. Download uses delegated short-lived
tokens issued and validated by Member Center. Deployment model is single-tenant
per instance, and service is stateless by default without a required DB.
servers:
- url: http://localhost:5081
description: Local development
tags:
- name: Files
- name: System
paths:
/files/{objectKey}:
put:
tags: [Files]
summary: Upload file
description: >
Upload a file through File Access Agent. Requires a Member Center JWT access token
with audience `file_access_api`, scope `files:upload.write`, and claim `tenant_id`.
security:
- BearerAuth: [files:upload.write]
parameters:
- in: path
name: objectKey
required: true
schema:
type: string
description: URL-encoded object key
- in: header
name: X-File-Id
required: false
schema:
type: string
requestBody:
required: true
content:
application/octet-stream:
schema:
type: string
format: binary
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
responses:
'201':
description: File uploaded
content:
application/json:
schema:
$ref: '#/components/schemas/FileObjectResponse'
'400':
description: Invalid request
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: Forbidden
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: Conflict
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'413':
description: Payload too large
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
get:
tags: [Files]
summary: Download file
description: >
Download a file through File Access Agent using a delegated short-lived token
previously issued by Member Center.
security:
- BearerAuth: [files:download.read]
parameters:
- in: path
name: objectKey
required: true
schema:
type: string
description: URL-encoded object key
responses:
'200':
description: File stream
content:
application/octet-stream:
schema:
type: string
format: binary
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: Forbidden
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: File not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
head:
tags: [Files]
summary: Check file metadata headers
description: >
Return file existence and metadata headers without the body.
Requires a Member Center JWT access token with scope `files:metadata.read`.
security:
- BearerAuth: [files:metadata.read]
parameters:
- in: path
name: objectKey
required: true
schema:
type: string
responses:
'200':
description: Metadata headers returned
'401':
description: Unauthorized
'403':
description: Forbidden
'404':
description: File not found
delete:
tags: [Files]
summary: Delete file
description: >
Delete a file through File Access Agent. Requires a Member Center JWT access token
with scope `files:delete`.
security:
- BearerAuth: [files:delete]
parameters:
- in: path
name: objectKey
required: true
schema:
type: string
responses:
'204':
description: File deleted
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: Forbidden
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: File not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/files/metadata/{objectKey}:
get:
tags: [Files]
summary: Get file metadata
description: >
Read file metadata without returning the file body.
Requires a Member Center JWT access token with scope `files:metadata.read`.
security:
- BearerAuth: [files:metadata.read]
parameters:
- in: path
name: objectKey
required: true
schema:
type: string
responses:
'200':
description: File metadata
content:
application/json:
schema:
$ref: '#/components/schemas/FileObjectResponse'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: Forbidden
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: File not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/health:
get:
tags: [System]
summary: Health check
responses:
'200':
description: Service healthy
content:
application/json:
schema:
$ref: '#/components/schemas/HealthResponse'
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT or delegated token
schemas:
FileObjectResponse:
type: object
required:
- tenant_id
- object_key
- content_type
- size
- last_modified_at
properties:
tenant_id:
type: string
format: uuid
file_id:
type: string
nullable: true
object_key:
type: string
content_type:
type: string
size:
type: integer
format: int64
etag:
type: string
nullable: true
last_modified_at:
type: string
format: date-time
ErrorResponse:
type: object
required:
- error
- message
- request_id
properties:
error:
type: string
message:
type: string
request_id:
type: string
format: uuid
HealthResponse:
type: object
required: [status]
properties:
status:
type: string
example: ok

View File

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.12" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.23" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FileAccessAgent.Application\FileAccessAgent.Application.csproj" />
<ProjectReference Include="..\FileAccessAgent.Domain\FileAccessAgent.Domain.csproj" />
<ProjectReference Include="..\FileAccessAgent.Infrastructure\FileAccessAgent.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,31 @@
@FileAccessAgent_Api_HostAddress = http://localhost:5081
@AccessToken = REPLACE_WITH_MEMBER_CENTER_ACCESS_TOKEN
@DelegatedToken = REPLACE_WITH_DELEGATED_DOWNLOAD_TOKEN
### Health
GET {{FileAccessAgent_Api_HostAddress}}/health
Accept: application/json
### Upload file
PUT {{FileAccessAgent_Api_HostAddress}}/files/reports/2026/04/demo.txt
Authorization: Bearer {{AccessToken}}
Content-Type: text/plain
hello from file-access-agent
### Get metadata
GET {{FileAccessAgent_Api_HostAddress}}/files/metadata/reports/2026/04/demo.txt
Authorization: Bearer {{AccessToken}}
Accept: application/json
### Head metadata
HEAD {{FileAccessAgent_Api_HostAddress}}/files/reports/2026/04/demo.txt
Authorization: Bearer {{AccessToken}}
### Download file
GET {{FileAccessAgent_Api_HostAddress}}/files/reports/2026/04/demo.txt
Authorization: Bearer {{DelegatedToken}}
### Delete file
DELETE {{FileAccessAgent_Api_HostAddress}}/files/reports/2026/04/demo.txt
Authorization: Bearer {{AccessToken}}

View File

@ -0,0 +1,384 @@
using System.Security.Claims;
using FileAccessAgent.Application.Abstractions;
using FileAccessAgent.Application.Models;
using FileAccessAgent.Domain;
using FileAccessAgent.Infrastructure.Configuration;
using FileAccessAgent.Infrastructure.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.Configure<InstanceOptions>(builder.Configuration.GetSection(InstanceOptions.SectionName));
builder.Services.Configure<StorageOptions>(builder.Configuration.GetSection(StorageOptions.SectionName));
builder.Services.Configure<MinioOptions>(builder.Configuration.GetSection(MinioOptions.SectionName));
builder.Services.Configure<MemberCenterOptions>(builder.Configuration.GetSection(MemberCenterOptions.SectionName));
builder.Services.Configure<AuthOptions>(builder.Configuration.GetSection(AuthOptions.SectionName));
builder.Services.AddHttpClient();
var storageOptions = builder.Configuration.GetSection(StorageOptions.SectionName).Get<StorageOptions>() ?? new StorageOptions();
if (string.Equals(storageOptions.Provider, "minio", StringComparison.OrdinalIgnoreCase))
{
builder.Services.AddSingleton<IFileStorage, MinioFileStorage>();
}
else
{
builder.Services.AddSingleton<IFileStorage, LocalFileStorage>();
}
builder.Services.AddSingleton<IDelegatedDownloadTokenValidator, MemberCenterDelegatedDownloadTokenValidator>();
var authOptions = builder.Configuration.GetSection(AuthOptions.SectionName).Get<AuthOptions>() ?? new AuthOptions();
if (string.IsNullOrWhiteSpace(authOptions.Issuer))
{
throw new InvalidOperationException("Auth:Issuer is required.");
}
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = authOptions.Issuer;
options.Audience = authOptions.Audience;
options.RequireHttpsMetadata = authOptions.RequireHttpsMetadata;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidAudience = authOptions.Audience,
ValidateLifetime = true
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
var instanceOptions = app.Services.GetRequiredService<IOptions<InstanceOptions>>().Value;
if (instanceOptions.TenantId == Guid.Empty)
{
throw new InvalidOperationException("Instance:TenantId is required.");
}
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/health", () => Results.Ok(new { status = "ok" }))
.WithName("Health")
.WithOpenApi();
app.MapGet("/files/metadata/{**objectKey}", async (
HttpContext context,
string objectKey,
IFileStorage storage,
CancellationToken cancellationToken) =>
{
var denied = EnsureJwtScopeAndTenant(context, FileAccessScopes.MetadataRead, instanceOptions.TenantId);
if (denied is not null)
{
return denied;
}
FileObjectResult? metadata;
try
{
metadata = await storage.GetMetadataAsync(instanceOptions.TenantId, objectKey, cancellationToken);
}
catch (InvalidOperationException ex)
{
return BadRequest(context, "invalid_object_key", ex.Message);
}
if (metadata is null)
{
return NotFound(context, "file_not_found", "File not found.");
}
return Results.Ok(ToResponse(metadata));
})
.RequireAuthorization()
.WithName("GetFileMetadata")
.WithOpenApi();
app.MapMethods("/files/{**objectKey}", new[] { "HEAD" }, async (
HttpContext context,
string objectKey,
IFileStorage storage,
CancellationToken cancellationToken) =>
{
var denied = EnsureJwtScopeAndTenant(context, FileAccessScopes.MetadataRead, instanceOptions.TenantId);
if (denied is not null)
{
return denied;
}
FileObjectResult? metadata;
try
{
metadata = await storage.GetMetadataAsync(instanceOptions.TenantId, objectKey, cancellationToken);
}
catch (InvalidOperationException ex)
{
return BadRequest(context, "invalid_object_key", ex.Message);
}
if (metadata is null)
{
return NotFound(context, "file_not_found", "File not found.");
}
context.Response.ContentType = metadata.ContentType;
context.Response.ContentLength = metadata.Length;
context.Response.Headers.ETag = metadata.ETag;
context.Response.Headers.LastModified = metadata.LastModified.ToString("R");
return Results.Empty;
})
.RequireAuthorization()
.WithName("HeadFile")
.WithOpenApi();
app.MapPut("/files/{**objectKey}", async (
HttpContext context,
string objectKey,
IFileStorage storage,
CancellationToken cancellationToken) =>
{
var denied = EnsureJwtScopeAndTenant(context, FileAccessScopes.UploadWrite, instanceOptions.TenantId);
if (denied is not null)
{
return denied;
}
FileObjectResult file;
try
{
file = await storage.UploadAsync(
instanceOptions.TenantId,
objectKey,
context.Request.Body,
context.Request.ContentLength,
context.Request.ContentType,
cancellationToken);
}
catch (InvalidOperationException ex)
{
return BadRequest(context, "invalid_object_key", ex.Message);
}
return Results.Created($"/files/{file.ObjectKey}", ToResponse(file));
})
.RequireAuthorization()
.WithName("UploadFile")
.WithOpenApi();
app.MapGet("/files/{**objectKey}", async (
HttpContext context,
string objectKey,
IFileStorage storage,
IDelegatedDownloadTokenValidator tokenValidator,
CancellationToken cancellationToken) =>
{
if (!TryReadAccessToken(context, out var delegatedToken))
{
return Unauthorized(context, "invalid_token", "Bearer token is required.");
}
DelegatedDownloadTokenValidationResult validationResult;
try
{
validationResult = await tokenValidator.ValidateAsync(
new DelegatedDownloadTokenValidationRequest(
delegatedToken!,
instanceOptions.TenantId,
FileId: null,
ObjectKey: objectKey,
Method: "GET"),
cancellationToken);
}
catch (Exception)
{
return Forbidden(context, "validation_unavailable", "Cannot validate download token.");
}
if (!validationResult.Active)
{
return Unauthorized(context, "invalid_token", "Download token is invalid.");
}
if (validationResult.TenantId.HasValue && validationResult.TenantId.Value != instanceOptions.TenantId)
{
return Forbidden(context, "tenant_mismatch", "Token tenant does not match instance tenant.");
}
if (!string.IsNullOrWhiteSpace(validationResult.ObjectKey)
&& !string.Equals(validationResult.ObjectKey, objectKey, StringComparison.Ordinal))
{
return Forbidden(context, "object_key_mismatch", "Token object key does not match request.");
}
if (!string.IsNullOrWhiteSpace(validationResult.Method)
&& !string.Equals(validationResult.Method, "GET", StringComparison.OrdinalIgnoreCase))
{
return Forbidden(context, "method_mismatch", "Token method does not match request.");
}
FileDownloadResult? file;
try
{
file = await storage.OpenReadAsync(instanceOptions.TenantId, objectKey, cancellationToken);
}
catch (InvalidOperationException ex)
{
return BadRequest(context, "invalid_object_key", ex.Message);
}
if (file is null)
{
return NotFound(context, "file_not_found", "File not found.");
}
context.Response.Headers.ETag = file.ETag;
return Results.File(file.Content, file.ContentType, enableRangeProcessing: true, lastModified: file.LastModified);
})
.WithName("DownloadFile")
.WithOpenApi();
app.MapDelete("/files/{**objectKey}", async (
HttpContext context,
string objectKey,
IFileStorage storage,
CancellationToken cancellationToken) =>
{
var denied = EnsureJwtScopeAndTenant(context, FileAccessScopes.Delete, instanceOptions.TenantId);
if (denied is not null)
{
return denied;
}
bool deleted;
try
{
deleted = await storage.DeleteAsync(instanceOptions.TenantId, objectKey, cancellationToken);
}
catch (InvalidOperationException ex)
{
return BadRequest(context, "invalid_object_key", ex.Message);
}
if (!deleted)
{
return NotFound(context, "file_not_found", "File not found.");
}
return Results.NoContent();
})
.RequireAuthorization()
.WithName("DeleteFile")
.WithOpenApi();
app.Run();
static IResult? EnsureJwtScopeAndTenant(HttpContext context, string requiredScope, Guid instanceTenantId)
{
if (context.User.Identity?.IsAuthenticated != true)
{
return Unauthorized(context, "invalid_token", "Authentication required.");
}
if (!HasScope(context.User, requiredScope))
{
return Forbidden(context, "insufficient_scope", $"Required scope '{requiredScope}'.");
}
var tenantRaw = context.User.FindFirst("tenant_id")?.Value;
if (!Guid.TryParse(tenantRaw, out var tokenTenantId))
{
return Forbidden(context, "tenant_missing", "tenant_id claim is required.");
}
if (tokenTenantId != instanceTenantId)
{
return Forbidden(context, "tenant_mismatch", "Token tenant does not match instance tenant.");
}
return null;
}
static bool HasScope(ClaimsPrincipal user, string expectedScope)
{
var scopes = user.FindAll("scope")
.SelectMany(claim => claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
return scopes.Contains(expectedScope, StringComparer.Ordinal);
}
static bool TryReadAccessToken(HttpContext context, out string? token)
{
token = null;
var value = context.Request.Headers.Authorization.ToString();
if (value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
token = value["Bearer ".Length..].Trim();
return !string.IsNullOrWhiteSpace(token);
}
if (context.Request.Query.TryGetValue("access_token", out var queryToken))
{
token = queryToken.ToString().Trim();
return !string.IsNullOrWhiteSpace(token);
}
return false;
}
static object ToResponse(FileObjectResult file)
{
return new
{
tenant_id = file.TenantId,
file_id = file.FileId,
object_key = file.ObjectKey,
content_type = file.ContentType,
size = file.Length,
etag = file.ETag,
last_modified_at = file.LastModified
};
}
static IResult BadRequest(HttpContext context, string error, string message)
{
return Results.Json(new { error, message, request_id = context.TraceIdentifier }, statusCode: StatusCodes.Status400BadRequest);
}
static IResult Unauthorized(HttpContext context, string error, string message)
{
return Results.Json(new { error, message, request_id = context.TraceIdentifier }, statusCode: StatusCodes.Status401Unauthorized);
}
static IResult Forbidden(HttpContext context, string error, string message)
{
return Results.Json(new { error, message, request_id = context.TraceIdentifier }, statusCode: StatusCodes.Status403Forbidden);
}
static IResult NotFound(HttpContext context, string error, string message)
{
return Results.Json(new { error, message, request_id = context.TraceIdentifier }, statusCode: StatusCodes.Status404NotFound);
}
public sealed class AuthOptions
{
public const string SectionName = "Auth";
public string Issuer { get; set; } = string.Empty;
public string Audience { get; set; } = "file_access_api";
public bool RequireHttpsMetadata { get; set; } = true;
}

View File

@ -0,0 +1,31 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:6807",
"sslPort": 0
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5133",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -0,0 +1,11 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Auth": {
"RequireHttpsMetadata": false
}
}

View File

@ -0,0 +1,37 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Instance": {
"TenantId": "d8107508-e6b9-4630-b982-c7bbb0cb228f",
"StorageRoot": "./data/storage"
},
"Storage": {
"Provider": "minio"
},
"Minio": {
"Endpoint": "localhost:9000",
"AccessKey": "minioadmin",
"SecretKey": "minioadmin",
"BucketName": "file-access-agent",
"UseSsl": false,
"Region": ""
},
"Auth": {
"Issuer": "http://localhost:7850/",
"Audience": "file_access_api",
"RequireHttpsMetadata": false
},
"MemberCenter": {
"BaseUrl": "http://localhost:7850/",
"TokenEndpointPath": "/oauth/token",
"DownloadTokenValidationPath": "/file-access/download-tokens/validate",
"ClientId": "f3806ab779d2440d8ff8f26aa01e995d",
"ClientSecret": "P5myv2PmFlKBNR7ke+zrmjmTpV+ob/53VBtOHRMfXTU=",
"Scope": "files:download.read"
},
"AllowedHosts": "*"
}

View File

@ -0,0 +1,10 @@
using FileAccessAgent.Application.Models;
namespace FileAccessAgent.Application.Abstractions;
public interface IDelegatedDownloadTokenValidator
{
Task<DelegatedDownloadTokenValidationResult> ValidateAsync(
DelegatedDownloadTokenValidationRequest request,
CancellationToken cancellationToken);
}

View File

@ -0,0 +1,11 @@
using FileAccessAgent.Application.Models;
namespace FileAccessAgent.Application.Abstractions;
public interface IFileStorage
{
Task<FileObjectResult> UploadAsync(Guid tenantId, string objectKey, Stream content, long? contentLength, string? contentType, CancellationToken cancellationToken);
Task<FileObjectResult?> GetMetadataAsync(Guid tenantId, string objectKey, CancellationToken cancellationToken);
Task<FileDownloadResult?> OpenReadAsync(Guid tenantId, string objectKey, CancellationToken cancellationToken);
Task<bool> DeleteAsync(Guid tenantId, string objectKey, CancellationToken cancellationToken);
}

View File

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\FileAccessAgent.Domain\FileAccessAgent.Domain.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,8 @@
namespace FileAccessAgent.Application.Models;
public sealed record DelegatedDownloadTokenValidationRequest(
string Token,
Guid TenantId,
string? FileId,
string? ObjectKey,
string Method);

View File

@ -0,0 +1,10 @@
namespace FileAccessAgent.Application.Models;
public sealed record DelegatedDownloadTokenValidationResult(
bool Active,
Guid? TenantId = null,
Guid? UserId = null,
string? FileId = null,
string? ObjectKey = null,
string? Method = null,
DateTimeOffset? ExpiresAt = null);

View File

@ -0,0 +1,8 @@
namespace FileAccessAgent.Application.Models;
public sealed record FileDownloadResult(
Stream Content,
string ContentType,
long Length,
DateTimeOffset LastModified,
string? ETag = null);

View File

@ -0,0 +1,10 @@
namespace FileAccessAgent.Application.Models;
public sealed record FileObjectResult(
Guid TenantId,
string ObjectKey,
long Length,
string ContentType,
DateTimeOffset LastModified,
string? FileId = null,
string? ETag = null);

View File

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,10 @@
namespace FileAccessAgent.Domain;
public static class FileAccessScopes
{
public const string UploadWrite = "files:upload.write";
public const string DownloadRead = "files:download.read";
public const string DownloadDelegate = "files:download.delegate";
public const string MetadataRead = "files:metadata.read";
public const string Delete = "files:delete";
}

View File

@ -0,0 +1,9 @@
namespace FileAccessAgent.Infrastructure.Configuration;
public sealed class InstanceOptions
{
public const string SectionName = "Instance";
public Guid TenantId { get; set; }
public string StorageRoot { get; set; } = "./data/storage";
}

View File

@ -0,0 +1,13 @@
namespace FileAccessAgent.Infrastructure.Configuration;
public sealed class MemberCenterOptions
{
public const string SectionName = "MemberCenter";
public string BaseUrl { get; set; } = "http://localhost:7850";
public string TokenEndpointPath { get; set; } = "/oauth/token";
public string DownloadTokenValidationPath { get; set; } = "/file-access/download-tokens/validate";
public string ClientId { get; set; } = string.Empty;
public string ClientSecret { get; set; } = string.Empty;
public string Scope { get; set; } = "files:download.read";
}

View File

@ -0,0 +1,13 @@
namespace FileAccessAgent.Infrastructure.Configuration;
public sealed class MinioOptions
{
public const string SectionName = "Minio";
public string Endpoint { get; set; } = "localhost:9000";
public string AccessKey { get; set; } = string.Empty;
public string SecretKey { get; set; } = string.Empty;
public string BucketName { get; set; } = string.Empty;
public bool UseSsl { get; set; }
public string? Region { get; set; }
}

View File

@ -0,0 +1,8 @@
namespace FileAccessAgent.Infrastructure.Configuration;
public sealed class StorageOptions
{
public const string SectionName = "Storage";
public string Provider { get; set; } = "local";
}

View File

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="3.7.413" />
<PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.3.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FileAccessAgent.Application\FileAccessAgent.Application.csproj" />
<ProjectReference Include="..\FileAccessAgent.Domain\FileAccessAgent.Domain.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,153 @@
using FileAccessAgent.Application.Abstractions;
using FileAccessAgent.Application.Models;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Options;
using FileAccessAgent.Infrastructure.Configuration;
namespace FileAccessAgent.Infrastructure.Services;
public sealed class LocalFileStorage : IFileStorage
{
private static readonly FileExtensionContentTypeProvider ContentTypes = new();
private readonly string _storageRoot;
public LocalFileStorage(IOptions<InstanceOptions> options)
{
var configuredRoot = options.Value.StorageRoot;
_storageRoot = Path.GetFullPath(string.IsNullOrWhiteSpace(configuredRoot) ? "./data/storage" : configuredRoot);
Directory.CreateDirectory(_storageRoot);
}
public async Task<FileObjectResult> UploadAsync(
Guid tenantId,
string objectKey,
Stream content,
long? contentLength,
string? contentType,
CancellationToken cancellationToken)
{
var normalizedKey = ObjectKeyHelper.Normalize(objectKey);
var destinationPath = BuildPath(tenantId, normalizedKey);
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
await using var destination = new FileStream(
destinationPath,
FileMode.Create,
FileAccess.Write,
FileShare.None,
bufferSize: 1024 * 64,
useAsync: true);
await content.CopyToAsync(destination, cancellationToken);
await destination.FlushAsync(cancellationToken);
var file = new FileInfo(destinationPath);
var resolvedType = ResolveContentType(normalizedKey, contentType);
return new FileObjectResult(
tenantId,
normalizedKey,
file.Length,
resolvedType,
file.LastWriteTimeUtc,
ETag: CreateETag(file));
}
public Task<FileObjectResult?> GetMetadataAsync(Guid tenantId, string objectKey, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var normalizedKey = ObjectKeyHelper.Normalize(objectKey);
var path = BuildPath(tenantId, normalizedKey);
if (!File.Exists(path))
{
return Task.FromResult<FileObjectResult?>(null);
}
var file = new FileInfo(path);
var result = new FileObjectResult(
tenantId,
normalizedKey,
file.Length,
ResolveContentType(normalizedKey, null),
file.LastWriteTimeUtc,
ETag: CreateETag(file));
return Task.FromResult<FileObjectResult?>(result);
}
public Task<FileDownloadResult?> OpenReadAsync(Guid tenantId, string objectKey, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var normalizedKey = ObjectKeyHelper.Normalize(objectKey);
var path = BuildPath(tenantId, normalizedKey);
if (!File.Exists(path))
{
return Task.FromResult<FileDownloadResult?>(null);
}
var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 1024 * 64,
useAsync: true);
var file = new FileInfo(path);
var result = new FileDownloadResult(
stream,
ResolveContentType(normalizedKey, null),
file.Length,
file.LastWriteTimeUtc,
CreateETag(file));
return Task.FromResult<FileDownloadResult?>(result);
}
public Task<bool> DeleteAsync(Guid tenantId, string objectKey, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var normalizedKey = ObjectKeyHelper.Normalize(objectKey);
var path = BuildPath(tenantId, normalizedKey);
if (!File.Exists(path))
{
return Task.FromResult(false);
}
File.Delete(path);
return Task.FromResult(true);
}
private string BuildPath(Guid tenantId, string objectKey)
{
var tenantRoot = Path.Combine(_storageRoot, tenantId.ToString("D"));
var relativePath = objectKey.Replace('/', Path.DirectorySeparatorChar);
var fullPath = Path.GetFullPath(Path.Combine(tenantRoot, relativePath));
if (!fullPath.StartsWith(tenantRoot, StringComparison.Ordinal))
{
throw new InvalidOperationException("Invalid object key.");
}
return fullPath;
}
private static string ResolveContentType(string objectKey, string? requestedContentType)
{
if (!string.IsNullOrWhiteSpace(requestedContentType))
{
return requestedContentType;
}
return ContentTypes.TryGetContentType(objectKey, out var contentType)
? contentType
: "application/octet-stream";
}
private static string CreateETag(FileInfo file)
{
return $"\"{file.Length:x}-{file.LastWriteTimeUtc.Ticks:x}\"";
}
}

View File

@ -0,0 +1,160 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using FileAccessAgent.Application.Abstractions;
using FileAccessAgent.Application.Models;
using FileAccessAgent.Infrastructure.Configuration;
using Microsoft.Extensions.Options;
namespace FileAccessAgent.Infrastructure.Services;
public sealed class MemberCenterDelegatedDownloadTokenValidator : IDelegatedDownloadTokenValidator
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly MemberCenterOptions _options;
private readonly SemaphoreSlim _tokenLock = new(1, 1);
private string? _cachedAccessToken;
private DateTimeOffset _cachedAccessTokenExpiresAt;
public MemberCenterDelegatedDownloadTokenValidator(
IHttpClientFactory httpClientFactory,
IOptions<MemberCenterOptions> options)
{
_httpClientFactory = httpClientFactory;
_options = options.Value;
}
public async Task<DelegatedDownloadTokenValidationResult> ValidateAsync(
DelegatedDownloadTokenValidationRequest request,
CancellationToken cancellationToken)
{
var accessToken = await GetServiceAccessTokenAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(accessToken))
{
return new DelegatedDownloadTokenValidationResult(false);
}
var http = _httpClientFactory.CreateClient(nameof(MemberCenterDelegatedDownloadTokenValidator));
http.BaseAddress = BuildBaseUri(_options.BaseUrl);
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var payload = new ValidateFileDownloadTokenPayload(
request.Token,
request.TenantId,
request.FileId,
request.ObjectKey,
request.Method);
using var response = await http.PostAsJsonAsync(
_options.DownloadTokenValidationPath,
payload,
cancellationToken);
if (!response.IsSuccessStatusCode)
{
return new DelegatedDownloadTokenValidationResult(false);
}
var result = await response.Content.ReadFromJsonAsync<ValidateFileDownloadTokenResponse>(cancellationToken: cancellationToken);
if (result is null || !result.Active)
{
return new DelegatedDownloadTokenValidationResult(false);
}
return new DelegatedDownloadTokenValidationResult(
result.Active,
result.TenantId,
result.UserId,
result.FileId,
result.ObjectKey,
result.Method,
result.ExpiresAt);
}
private async Task<string?> GetServiceAccessTokenAsync(CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(_cachedAccessToken) && _cachedAccessTokenExpiresAt > DateTimeOffset.UtcNow.AddSeconds(15))
{
return _cachedAccessToken;
}
await _tokenLock.WaitAsync(cancellationToken);
try
{
if (!string.IsNullOrWhiteSpace(_cachedAccessToken) && _cachedAccessTokenExpiresAt > DateTimeOffset.UtcNow.AddSeconds(15))
{
return _cachedAccessToken;
}
if (string.IsNullOrWhiteSpace(_options.ClientId) || string.IsNullOrWhiteSpace(_options.ClientSecret))
{
return null;
}
var http = _httpClientFactory.CreateClient($"{nameof(MemberCenterDelegatedDownloadTokenValidator)}.Token");
http.BaseAddress = BuildBaseUri(_options.BaseUrl);
var form = new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = _options.ClientId,
["client_secret"] = _options.ClientSecret,
["scope"] = _options.Scope
};
using var response = await http.PostAsync(
_options.TokenEndpointPath,
new FormUrlEncodedContent(form),
cancellationToken);
if (!response.IsSuccessStatusCode)
{
return null;
}
var token = await response.Content.ReadFromJsonAsync<TokenResponse>(cancellationToken: cancellationToken);
if (token is null || string.IsNullOrWhiteSpace(token.AccessToken))
{
return null;
}
_cachedAccessToken = token.AccessToken;
_cachedAccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(30, token.ExpiresIn));
return _cachedAccessToken;
}
finally
{
_tokenLock.Release();
}
}
private static Uri BuildBaseUri(string baseUrl)
{
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri))
{
throw new InvalidOperationException("MemberCenter:BaseUrl is invalid.");
}
return uri;
}
private sealed record TokenResponse(
[property: JsonPropertyName("access_token")] string AccessToken,
[property: JsonPropertyName("expires_in")] int ExpiresIn);
private sealed record ValidateFileDownloadTokenPayload(
[property: JsonPropertyName("token")] string Token,
[property: JsonPropertyName("tenant_id")] Guid TenantId,
[property: JsonPropertyName("file_id")] string? FileId,
[property: JsonPropertyName("object_key")] string? ObjectKey,
[property: JsonPropertyName("method")] string Method);
private sealed record ValidateFileDownloadTokenResponse(
[property: JsonPropertyName("active")] bool Active,
[property: JsonPropertyName("tenant_id")] Guid? TenantId,
[property: JsonPropertyName("user_id")] Guid? UserId,
[property: JsonPropertyName("file_id")] string? FileId,
[property: JsonPropertyName("object_key")] string? ObjectKey,
[property: JsonPropertyName("method")] string? Method,
[property: JsonPropertyName("expires_at")] DateTimeOffset? ExpiresAt);
}

View File

@ -0,0 +1,265 @@
using Amazon;
using Amazon.Runtime;
using Amazon.S3;
using Amazon.S3.Model;
using FileAccessAgent.Application.Abstractions;
using FileAccessAgent.Application.Models;
using FileAccessAgent.Infrastructure.Configuration;
using Microsoft.Extensions.Options;
namespace FileAccessAgent.Infrastructure.Services;
public sealed class MinioFileStorage : IFileStorage
{
private readonly MinioOptions _options;
private readonly IAmazonS3 _s3;
private readonly SemaphoreSlim _bucketLock = new(1, 1);
private bool _bucketEnsured;
public MinioFileStorage(IOptions<MinioOptions> options)
{
_options = options.Value;
if (string.IsNullOrWhiteSpace(_options.Endpoint))
{
throw new InvalidOperationException("Minio:Endpoint is required.");
}
if (string.IsNullOrWhiteSpace(_options.AccessKey) || string.IsNullOrWhiteSpace(_options.SecretKey))
{
throw new InvalidOperationException("Minio:AccessKey and Minio:SecretKey are required.");
}
if (string.IsNullOrWhiteSpace(_options.BucketName))
{
throw new InvalidOperationException("Minio:BucketName is required.");
}
var config = new AmazonS3Config
{
ServiceURL = BuildServiceUrl(_options.Endpoint, _options.UseSsl),
ForcePathStyle = true,
SignatureVersion = "4"
};
if (!string.IsNullOrWhiteSpace(_options.Region))
{
config.RegionEndpoint = RegionEndpoint.GetBySystemName(_options.Region);
}
_s3 = new AmazonS3Client(new BasicAWSCredentials(_options.AccessKey, _options.SecretKey), config);
}
public async Task<FileObjectResult> UploadAsync(
Guid tenantId,
string objectKey,
Stream content,
long? contentLength,
string? contentType,
CancellationToken cancellationToken)
{
var normalizedKey = ObjectKeyHelper.Normalize(objectKey);
await EnsureBucketAsync(cancellationToken);
var request = new PutObjectRequest
{
BucketName = _options.BucketName,
Key = normalizedKey,
InputStream = content,
AutoCloseStream = false,
ContentType = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType
};
if (contentLength.HasValue && contentLength.Value >= 0)
{
request.Headers.ContentLength = contentLength.Value;
}
else if (content.CanSeek)
{
request.Headers.ContentLength = content.Length - content.Position;
}
else
{
throw new InvalidOperationException("Content-Length is required for MinIO upload.");
}
var put = await _s3.PutObjectAsync(request, cancellationToken);
var metadata = await GetMetadataAsync(tenantId, normalizedKey, cancellationToken);
if (metadata is null)
{
throw new InvalidOperationException("Object was uploaded but metadata was not retrievable.");
}
return metadata with { ETag = put.ETag };
}
public async Task<FileObjectResult?> GetMetadataAsync(Guid tenantId, string objectKey, CancellationToken cancellationToken)
{
var normalizedKey = ObjectKeyHelper.Normalize(objectKey);
await EnsureBucketAsync(cancellationToken);
try
{
var response = await _s3.GetObjectMetadataAsync(new GetObjectMetadataRequest
{
BucketName = _options.BucketName,
Key = normalizedKey
}, cancellationToken);
return new FileObjectResult(
tenantId,
normalizedKey,
response.Headers.ContentLength,
string.IsNullOrWhiteSpace(response.Headers.ContentType) ? "application/octet-stream" : response.Headers.ContentType,
response.LastModified.ToUniversalTime(),
ETag: response.ETag);
}
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound || ex.ErrorCode == "NoSuchKey")
{
return null;
}
}
public async Task<FileDownloadResult?> OpenReadAsync(Guid tenantId, string objectKey, CancellationToken cancellationToken)
{
var normalizedKey = ObjectKeyHelper.Normalize(objectKey);
await EnsureBucketAsync(cancellationToken);
try
{
var response = await _s3.GetObjectAsync(new GetObjectRequest
{
BucketName = _options.BucketName,
Key = normalizedKey
}, cancellationToken);
return new FileDownloadResult(
new ResponseStream(response),
string.IsNullOrWhiteSpace(response.Headers.ContentType) ? "application/octet-stream" : response.Headers.ContentType,
response.Headers.ContentLength,
response.LastModified.ToUniversalTime(),
response.ETag);
}
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound || ex.ErrorCode == "NoSuchKey")
{
return null;
}
}
public async Task<bool> DeleteAsync(Guid tenantId, string objectKey, CancellationToken cancellationToken)
{
var normalizedKey = ObjectKeyHelper.Normalize(objectKey);
await EnsureBucketAsync(cancellationToken);
var metadata = await GetMetadataAsync(tenantId, normalizedKey, cancellationToken);
if (metadata is null)
{
return false;
}
await _s3.DeleteObjectAsync(new DeleteObjectRequest
{
BucketName = _options.BucketName,
Key = normalizedKey
}, cancellationToken);
return true;
}
private async Task EnsureBucketAsync(CancellationToken cancellationToken)
{
if (_bucketEnsured)
{
return;
}
await _bucketLock.WaitAsync(cancellationToken);
try
{
if (_bucketEnsured)
{
return;
}
var exists = await BucketExistsAsync(cancellationToken);
if (!exists)
{
await _s3.PutBucketAsync(new PutBucketRequest { BucketName = _options.BucketName }, cancellationToken);
}
_bucketEnsured = true;
}
finally
{
_bucketLock.Release();
}
}
private static string BuildServiceUrl(string endpoint, bool useSsl)
{
if (endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|| endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
return endpoint.TrimEnd('/');
}
return $"{(useSsl ? "https" : "http")}://{endpoint.TrimEnd('/')}";
}
private async Task<bool> BucketExistsAsync(CancellationToken cancellationToken)
{
try
{
await _s3.GetBucketLocationAsync(new GetBucketLocationRequest
{
BucketName = _options.BucketName
}, cancellationToken);
return true;
}
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound || ex.ErrorCode == "NoSuchBucket")
{
return false;
}
}
private sealed class ResponseStream : Stream
{
private readonly GetObjectResponse _response;
private readonly Stream _inner;
public ResponseStream(GetObjectResponse response)
{
_response = response;
_inner = response.ResponseStream;
}
public override bool CanRead => _inner.CanRead;
public override bool CanSeek => _inner.CanSeek;
public override bool CanWrite => _inner.CanWrite;
public override long Length => _inner.Length;
public override long Position { get => _inner.Position; set => _inner.Position = value; }
public override void Flush() => _inner.Flush();
public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count);
public override long Seek(long offset, SeekOrigin origin) => _inner.Seek(offset, origin);
public override void SetLength(long value) => _inner.SetLength(value);
public override void Write(byte[] buffer, int offset, int count) => _inner.Write(buffer, offset, count);
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default) => _inner.ReadAsync(buffer, cancellationToken);
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => _inner.WriteAsync(buffer, offset, count, cancellationToken);
protected override void Dispose(bool disposing)
{
if (disposing)
{
_response.Dispose();
}
base.Dispose(disposing);
}
public override async ValueTask DisposeAsync()
{
_response.Dispose();
await base.DisposeAsync();
}
}
}

View File

@ -0,0 +1,25 @@
namespace FileAccessAgent.Infrastructure.Services;
internal static class ObjectKeyHelper
{
public static string Normalize(string objectKey)
{
if (string.IsNullOrWhiteSpace(objectKey))
{
throw new InvalidOperationException("objectKey is required.");
}
var normalized = objectKey.Trim().Replace('\\', '/');
if (normalized.StartsWith('/'))
{
normalized = normalized.TrimStart('/');
}
if (normalized.Contains("..", StringComparison.Ordinal))
{
throw new InvalidOperationException("objectKey cannot contain path traversal.");
}
return normalized;
}
}

View File

@ -0,0 +1,641 @@
using System.Diagnostics;
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using FileAccessAgent.TestSite.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace FileAccessAgent.TestSite.Controllers;
public class HomeController : Controller
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly FileAccessAgentTestOptions _fileAccessAgentOptions;
private readonly TestSiteAuthOptions _authOptions;
public HomeController(
IHttpClientFactory httpClientFactory,
IOptions<FileAccessAgentTestOptions> fileAccessAgentOptions,
IOptions<TestSiteAuthOptions> authOptions)
{
_httpClientFactory = httpClientFactory;
_fileAccessAgentOptions = fileAccessAgentOptions.Value;
_authOptions = authOptions.Value;
}
[HttpGet]
public async Task<IActionResult> Index()
{
var claims = User.Claims
.Select(claim => new KeyValuePair<string, string>(claim.Type, claim.Value))
.OrderBy(item => item.Key, StringComparer.Ordinal)
.ToList();
var accessToken = await HttpContext.GetTokenAsync("access_token");
var idToken = await HttpContext.GetTokenAsync("id_token");
var model = new AuthDebugViewModel
{
IsAuthenticated = User.Identity?.IsAuthenticated == true,
Name = User.Identity?.Name,
Subject = User.FindFirst("sub")?.Value,
AccessToken = accessToken,
IdToken = idToken,
Claims = claims,
AccessTokenPayload = DecodeJwtPayload(accessToken),
LastOperationName = TempData["LastOperationName"]?.ToString(),
LastOperationStatusCode = TempData["LastOperationStatusCode"]?.ToString(),
LastOperationResponseBody = TempData["LastOperationResponseBody"]?.ToString(),
CurrentObjectKey = TempData.Peek("CurrentObjectKey")?.ToString()
};
return View(model);
}
[HttpGet("auth/login")]
public IActionResult Login([FromQuery] string? returnUrl = "/")
{
if (User.Identity?.IsAuthenticated == true)
{
return LocalRedirect(SafeReturnUrl(returnUrl));
}
var properties = new AuthenticationProperties
{
RedirectUri = SafeReturnUrl(returnUrl)
};
return Challenge(properties, OpenIdConnectDefaults.AuthenticationScheme);
}
[HttpPost("auth/logout")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Logout([FromForm] string? returnUrl = "/")
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
var callbackUrl = Url.ActionLink(nameof(LogoutCallback), "Home");
if (string.IsNullOrWhiteSpace(callbackUrl))
{
SaveOperationResult("Logout", (null, "Unable to build logout callback URL."));
return RedirectToAction(nameof(Index));
}
var logoutUrl = $"{TrimSlash(_authOptions.WebBaseUrl)}/account/logout?returnUrl={Uri.EscapeDataString(callbackUrl)}";
return Redirect(logoutUrl);
}
[HttpGet("auth/logout-callback")]
public async Task<IActionResult> LogoutCallback()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return RedirectToAction(nameof(Index));
}
[HttpPost("test/upload")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UploadFile([FromForm] string? objectKey, [FromForm] IFormFile? file)
{
if (file is null || file.Length <= 0)
{
SaveOperationResult("Upload", (null, "Please select a file."));
return RedirectToAction(nameof(Index));
}
var resolvedObjectKey = string.IsNullOrWhiteSpace(objectKey)
? BuildDefaultObjectKey(file.FileName)
: objectKey.Trim();
var result = await SendAgentRequestAsync(
"Upload",
resolvedObjectKey,
async (client, encodedObjectKey, cancellationToken) =>
{
await using var stream = file.OpenReadStream();
using var body = new StreamContent(stream);
body.Headers.ContentLength = file.Length;
var mediaType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType;
body.Headers.ContentType = new MediaTypeHeaderValue(mediaType);
using var request = new HttpRequestMessage(HttpMethod.Put, $"/files/{encodedObjectKey}")
{
Content = body
};
return await client.SendAsync(request, cancellationToken);
});
SetCurrentObjectKey(TryExtractObjectKeyFromResponse(result.Body) ?? resolvedObjectKey);
SaveOperationResult("Upload", result);
return RedirectToAction(nameof(Index));
}
[HttpPost("test/metadata")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> GetMetadata([FromForm] string objectKey)
{
SetCurrentObjectKey(objectKey);
var result = await SendAgentRequestAsync(
"Metadata",
objectKey,
(client, encodedObjectKey, cancellationToken) => client.GetAsync($"/files/metadata/{encodedObjectKey}", cancellationToken));
SaveOperationResult("Metadata", result);
return RedirectToAction(nameof(Index));
}
[HttpPost("test/delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteFile([FromForm] string objectKey)
{
SetCurrentObjectKey(objectKey);
var result = await SendAgentRequestAsync(
"Delete",
objectKey,
(client, encodedObjectKey, cancellationToken) => client.DeleteAsync($"/files/{encodedObjectKey}", cancellationToken));
SaveOperationResult("Delete", result);
return RedirectToAction(nameof(Index));
}
[HttpPost("test/head")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> HeadFile([FromForm] string objectKey)
{
SetCurrentObjectKey(objectKey);
var result = await SendAgentRequestAsync(
"Head",
objectKey,
async (client, encodedObjectKey, cancellationToken) =>
{
using var request = new HttpRequestMessage(HttpMethod.Head, $"/files/{encodedObjectKey}");
return await client.SendAsync(request, cancellationToken);
});
SaveOperationResult("Head", result);
return RedirectToAction(nameof(Index));
}
[HttpPost("test/health")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Health()
{
if (!Uri.TryCreate(_fileAccessAgentOptions.BaseUrl, UriKind.Absolute, out var baseUri))
{
SaveOperationResult("Health", (null, "FileAccessAgent:BaseUrl is invalid."));
return RedirectToAction(nameof(Index));
}
var client = _httpClientFactory.CreateClient();
client.BaseAddress = baseUri;
try
{
using var response = await client.GetAsync("/health", HttpContext.RequestAborted);
var body = await response.Content.ReadAsStringAsync(HttpContext.RequestAborted);
SaveOperationResult("Health", ((int)response.StatusCode, body));
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
SaveOperationResult("Health", (null, $"Health request failed: {ex.Message}"));
return RedirectToAction(nameof(Index));
}
}
[HttpPost("test/download")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DownloadFile([FromForm] string objectKey)
{
if (string.IsNullOrWhiteSpace(objectKey))
{
SaveOperationResult("Download", (null, "objectKey is required."));
return RedirectToAction(nameof(Index));
}
SetCurrentObjectKey(objectKey);
var issue = await IssueDelegatedDownloadTokenAsync(objectKey);
if (!issue.Success || string.IsNullOrWhiteSpace(issue.Token))
{
SaveOperationResult("Download", (issue.StatusCode, issue.Body));
return RedirectToAction(nameof(Index));
}
if (!Uri.TryCreate(_fileAccessAgentOptions.BaseUrl, UriKind.Absolute, out var baseUri))
{
SaveOperationResult("Download", (null, "FileAccessAgent:BaseUrl is invalid."));
return RedirectToAction(nameof(Index));
}
var encodedObjectKey = EncodeObjectKey(objectKey);
var agentClient = _httpClientFactory.CreateClient();
agentClient.BaseAddress = baseUri;
agentClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", issue.Token);
try
{
using var response = await agentClient.GetAsync($"/files/{encodedObjectKey}", HttpContext.RequestAborted);
var bodyPreview = await ReadBodyPreviewAsync(response);
var summary = $"issue_status={issue.StatusCode ?? 0}\nissue_response={issue.Body}\n\nagent_status={(int)response.StatusCode}\nagent_response_preview={bodyPreview}";
SaveOperationResult("Download", ((int)response.StatusCode, summary));
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
SaveOperationResult("Download", (null, $"Download request failed: {ex.Message}"));
return RedirectToAction(nameof(Index));
}
}
[HttpPost("test/download-file")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DownloadFileDirect([FromForm] string objectKey)
{
if (string.IsNullOrWhiteSpace(objectKey))
{
SaveOperationResult("DownloadFile", (null, "objectKey is required."));
return RedirectToAction(nameof(Index));
}
SetCurrentObjectKey(objectKey);
var issue = await IssueDelegatedDownloadTokenAsync(objectKey);
if (!issue.Success || string.IsNullOrWhiteSpace(issue.Token))
{
SaveOperationResult("DownloadFile", (issue.StatusCode, issue.Body));
return RedirectToAction(nameof(Index));
}
if (!Uri.TryCreate(_fileAccessAgentOptions.BaseUrl, UriKind.Absolute, out var baseUri))
{
SaveOperationResult("DownloadFile", (null, "FileAccessAgent:BaseUrl is invalid."));
return RedirectToAction(nameof(Index));
}
var encodedObjectKey = EncodeObjectKey(objectKey);
var directUrl = new Uri(baseUri, $"/files/{encodedObjectKey}?access_token={Uri.EscapeDataString(issue.Token!)}").ToString();
return Redirect(directUrl);
}
[HttpPost("test/download-invalid-token")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DownloadWithInvalidToken([FromForm] string objectKey, [FromForm] string? token)
{
if (string.IsNullOrWhiteSpace(objectKey))
{
SaveOperationResult("DownloadInvalidToken", (null, "objectKey is required."));
return RedirectToAction(nameof(Index));
}
SetCurrentObjectKey(objectKey);
if (!Uri.TryCreate(_fileAccessAgentOptions.BaseUrl, UriKind.Absolute, out var baseUri))
{
SaveOperationResult("DownloadInvalidToken", (null, "FileAccessAgent:BaseUrl is invalid."));
return RedirectToAction(nameof(Index));
}
var badToken = string.IsNullOrWhiteSpace(token)
? $"invalid-{Guid.NewGuid():N}"
: token.Trim();
var encodedObjectKey = EncodeObjectKey(objectKey);
var agentClient = _httpClientFactory.CreateClient();
agentClient.BaseAddress = baseUri;
agentClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", badToken);
try
{
using var response = await agentClient.GetAsync($"/files/{encodedObjectKey}", HttpContext.RequestAborted);
var bodyPreview = await ReadBodyPreviewAsync(response);
var summary = $"token={badToken}\nagent_status={(int)response.StatusCode}\nagent_response_preview={bodyPreview}";
SaveOperationResult("DownloadInvalidToken", ((int)response.StatusCode, summary));
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
SaveOperationResult("DownloadInvalidToken", (null, $"Download invalid token request failed: {ex.Message}"));
return RedirectToAction(nameof(Index));
}
}
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult Error()
{
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
}
private static IReadOnlyList<KeyValuePair<string, string>> DecodeJwtPayload(string? token)
{
if (string.IsNullOrWhiteSpace(token))
{
return [];
}
var handler = new JwtSecurityTokenHandler();
if (!handler.CanReadToken(token))
{
return [];
}
var jwt = handler.ReadJwtToken(token);
return jwt.Claims
.Select(claim => new KeyValuePair<string, string>(claim.Type, claim.Value))
.OrderBy(item => item.Key, StringComparer.Ordinal)
.ToList();
}
private static string SafeReturnUrl(string? returnUrl)
{
if (string.IsNullOrWhiteSpace(returnUrl) || !returnUrl.StartsWith('/'))
{
return "/";
}
return returnUrl;
}
private static string TrimSlash(string value)
{
return string.IsNullOrWhiteSpace(value) ? string.Empty : value.TrimEnd('/');
}
private async Task<(int? StatusCode, string Body)> SendAgentRequestAsync(
string operation,
string objectKey,
Func<HttpClient, string, CancellationToken, Task<HttpResponseMessage>> sendAsync)
{
if (string.IsNullOrWhiteSpace(objectKey))
{
return (null, "objectKey is required.");
}
var serviceTokenResult = await GetServiceAccessTokenAsync();
if (!serviceTokenResult.Success || string.IsNullOrWhiteSpace(serviceTokenResult.Token))
{
return (serviceTokenResult.StatusCode, serviceTokenResult.Body);
}
if (!Uri.TryCreate(_fileAccessAgentOptions.BaseUrl, UriKind.Absolute, out var baseUri))
{
return (null, "FileAccessAgent:BaseUrl is invalid.");
}
var client = _httpClientFactory.CreateClient();
client.BaseAddress = baseUri;
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", serviceTokenResult.Token);
var encodedObjectKey = EncodeObjectKey(objectKey);
try
{
using var response = await sendAsync(client, encodedObjectKey, HttpContext.RequestAborted);
var body = await response.Content.ReadAsStringAsync(HttpContext.RequestAborted);
return ((int)response.StatusCode, body);
}
catch (Exception ex)
{
return (null, $"{operation} request failed: {ex.Message}");
}
}
private async Task<(bool Success, string? Token, int? StatusCode, string Body)> IssueDelegatedDownloadTokenAsync(string objectKey)
{
var serviceTokenResult = await GetServiceAccessTokenAsync();
if (!serviceTokenResult.Success || string.IsNullOrWhiteSpace(serviceTokenResult.Token))
{
return (false, null, serviceTokenResult.StatusCode, serviceTokenResult.Body);
}
if (!Uri.TryCreate(_fileAccessAgentOptions.BaseUrl, UriKind.Absolute, out _))
{
return (false, null, null, "FileAccessAgent:BaseUrl is invalid.");
}
var userIdRaw = User.FindFirst("sub")?.Value;
if (_fileAccessAgentOptions.TenantId == Guid.Empty)
{
return (false, null, null, "FileAccessAgent:TenantId is required.");
}
if (!Guid.TryParse(userIdRaw, out var userId))
{
return (false, null, null, "Current token sub claim is not a GUID user id.");
}
if (!Uri.TryCreate(_authOptions.Authority, UriKind.Absolute, out var memberCenterBase))
{
return (false, null, null, "MemberCenter:Authority is invalid.");
}
var payload = new
{
tenant_id = _fileAccessAgentOptions.TenantId,
user_id = userId,
object_key = objectKey,
method = "GET",
expires_in_seconds = 300
};
var client = _httpClientFactory.CreateClient();
client.BaseAddress = memberCenterBase;
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", serviceTokenResult.Token);
try
{
using var response = await client.PostAsJsonAsync("/file-access/download-tokens", payload, HttpContext.RequestAborted);
var body = await response.Content.ReadAsStringAsync(HttpContext.RequestAborted);
if (!response.IsSuccessStatusCode)
{
return (false, null, (int)response.StatusCode, body);
}
var token = ExtractTokenFromIssueResponse(body);
if (string.IsNullOrWhiteSpace(token))
{
return (false, null, (int)response.StatusCode, $"Cannot find token in response: {body}");
}
return (true, token, (int)response.StatusCode, body);
}
catch (Exception ex)
{
return (false, null, null, $"Issue delegated token failed: {ex.Message}");
}
}
private async Task<(bool Success, string? Token, int? StatusCode, string Body)> GetServiceAccessTokenAsync()
{
if (string.IsNullOrWhiteSpace(_authOptions.ServiceClientId) || string.IsNullOrWhiteSpace(_authOptions.ServiceClientSecret))
{
return (false, null, null, "MemberCenter:ServiceClientId and ServiceClientSecret are required.");
}
if (!Uri.TryCreate(_authOptions.Authority, UriKind.Absolute, out var memberCenterBase))
{
return (false, null, null, "MemberCenter:Authority is invalid.");
}
var form = new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = _authOptions.ServiceClientId,
["client_secret"] = _authOptions.ServiceClientSecret,
["scope"] = _authOptions.ServiceScopes
};
var client = _httpClientFactory.CreateClient();
client.BaseAddress = memberCenterBase;
try
{
using var response = await client.PostAsync(
_authOptions.ServiceTokenEndpointPath,
new FormUrlEncodedContent(form),
HttpContext.RequestAborted);
var body = await response.Content.ReadAsStringAsync(HttpContext.RequestAborted);
if (!response.IsSuccessStatusCode)
{
return (false, null, (int)response.StatusCode, body);
}
var token = ExtractTokenFromIssueResponse(body);
if (string.IsNullOrWhiteSpace(token))
{
return (false, null, (int)response.StatusCode, $"Cannot find access_token in response: {body}");
}
return (true, token, (int)response.StatusCode, body);
}
catch (Exception ex)
{
return (false, null, null, $"Service token request failed: {ex.Message}");
}
}
private void SaveOperationResult(string operationName, (int? StatusCode, string Body) result)
{
TempData["LastOperationName"] = operationName;
TempData["LastOperationStatusCode"] = result.StatusCode?.ToString() ?? "N/A";
TempData["LastOperationResponseBody"] = string.IsNullOrWhiteSpace(result.Body) ? "<empty>" : result.Body;
}
private void SetCurrentObjectKey(string? objectKey)
{
if (!string.IsNullOrWhiteSpace(objectKey))
{
TempData["CurrentObjectKey"] = objectKey.Trim();
}
}
private static string EncodeObjectKey(string objectKey)
{
var segments = objectKey
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(Uri.EscapeDataString);
return string.Join('/', segments);
}
private static string? ExtractTokenFromIssueResponse(string json)
{
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
return null;
}
if (root.TryGetProperty("token", out var token) && token.ValueKind == JsonValueKind.String)
{
return token.GetString();
}
if (root.TryGetProperty("access_token", out var accessToken) && accessToken.ValueKind == JsonValueKind.String)
{
return accessToken.GetString();
}
return null;
}
catch
{
return null;
}
}
private static string? TryExtractObjectKeyFromResponse(string json)
{
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
return null;
}
if (root.TryGetProperty("object_key", out var objectKey) && objectKey.ValueKind == JsonValueKind.String)
{
return objectKey.GetString();
}
return null;
}
catch
{
return null;
}
}
private static string BuildDefaultObjectKey(string fileName)
{
var safeName = SanitizeFileName(fileName);
return $"demo/uploads/{DateTime.UtcNow:yyyyMMddHHmmss}-{safeName}";
}
private static string SanitizeFileName(string fileName)
{
var name = Path.GetFileName(string.IsNullOrWhiteSpace(fileName) ? "upload.bin" : fileName);
foreach (var invalid in Path.GetInvalidFileNameChars())
{
name = name.Replace(invalid, '_');
}
return name;
}
private static async Task<string> ReadBodyPreviewAsync(HttpResponseMessage response)
{
var bytes = await response.Content.ReadAsByteArrayAsync();
if (bytes.Length == 0)
{
return "<empty>";
}
var contentType = response.Content.Headers.ContentType?.MediaType ?? string.Empty;
if (contentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase)
|| contentType.Equals("application/json", StringComparison.OrdinalIgnoreCase))
{
return Encoding.UTF8.GetString(bytes);
}
var preview = Convert.ToBase64String(bytes.Length > 128 ? bytes[..128] : bytes);
return $"binary({bytes.Length} bytes), base64_preview={preview}";
}
}

View File

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.12" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,17 @@
namespace FileAccessAgent.TestSite.Models;
public sealed class AuthDebugViewModel
{
public bool IsAuthenticated { get; init; }
public string? Name { get; init; }
public string? Subject { get; init; }
public string? AccessToken { get; init; }
public string? IdToken { get; init; }
public IReadOnlyList<KeyValuePair<string, string>> Claims { get; init; } = [];
public IReadOnlyList<KeyValuePair<string, string>> AccessTokenPayload { get; init; } = [];
public string? ReturnUrl { get; init; }
public string? LastOperationName { get; init; }
public string? LastOperationStatusCode { get; init; }
public string? LastOperationResponseBody { get; init; }
public string? CurrentObjectKey { get; init; }
}

View File

@ -0,0 +1,8 @@
namespace FileAccessAgent.TestSite.Models;
public class ErrorViewModel
{
public string? RequestId { get; set; }
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
}

View File

@ -0,0 +1,9 @@
namespace FileAccessAgent.TestSite.Models;
public sealed class FileAccessAgentTestOptions
{
public const string SectionName = "FileAccessAgent";
public string BaseUrl { get; set; } = "http://localhost:5081";
public Guid TenantId { get; set; } = Guid.Empty;
}

View File

@ -0,0 +1,21 @@
namespace FileAccessAgent.TestSite.Models;
public sealed class TestSiteAuthOptions
{
public const string SectionName = "MemberCenter";
public string Authority { get; set; } = "http://localhost:7850/";
public string WebBaseUrl { get; set; } = "http://localhost:5080/";
public string? MetadataAddress { get; set; }
public string ClientId { get; set; } = string.Empty; // web_login client
public string? ClientSecret { get; set; } // web_login client secret (optional for public client)
public string CallbackPath { get; set; } = "/auth/callback";
public string SignedOutCallbackPath { get; set; } = "/signout-callback-oidc";
public string Scopes { get; set; } = "openid email profile"; // web_login scopes
public bool RequireHttpsMetadata { get; set; } = false;
public string ServiceClientId { get; set; } = string.Empty; // file_api client
public string ServiceClientSecret { get; set; } = string.Empty;
public string ServiceScopes { get; set; } = "files:upload.write files:metadata.read files:delete files:download.delegate";
public string ServiceTokenEndpointPath { get; set; } = "/oauth/token";
}

View File

@ -0,0 +1,72 @@
using System.IdentityModel.Tokens.Jwt;
using FileAccessAgent.TestSite.Models;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.AddHttpClient();
builder.Services.Configure<TestSiteAuthOptions>(builder.Configuration.GetSection(TestSiteAuthOptions.SectionName));
builder.Services.Configure<FileAccessAgentTestOptions>(builder.Configuration.GetSection(FileAccessAgentTestOptions.SectionName));
var authOptions = builder.Configuration.GetSection(TestSiteAuthOptions.SectionName).Get<TestSiteAuthOptions>()
?? new TestSiteAuthOptions();
if (string.IsNullOrWhiteSpace(authOptions.ClientId))
{
throw new InvalidOperationException("MemberCenter:ClientId is required.");
}
JwtSecurityTokenHandler.DefaultMapInboundClaims = false;
builder.Services
.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
options.Cookie.Name = ".FileAccessAgent.TestSite.Auth";
})
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
options.Authority = authOptions.Authority;
options.MetadataAddress = string.IsNullOrWhiteSpace(authOptions.MetadataAddress) ? null : authOptions.MetadataAddress;
options.ClientId = authOptions.ClientId;
options.ClientSecret = string.IsNullOrWhiteSpace(authOptions.ClientSecret) ? null : authOptions.ClientSecret;
options.CallbackPath = authOptions.CallbackPath;
options.SignedOutCallbackPath = authOptions.SignedOutCallbackPath;
options.ResponseType = "code";
options.UsePkce = true;
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.RequireHttpsMetadata = authOptions.RequireHttpsMetadata;
options.MapInboundClaims = false;
options.Scope.Clear();
foreach (var scope in authOptions.Scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
options.Scope.Add(scope);
}
});
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.Run();

View File

@ -0,0 +1,38 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:0",
"sslPort": 0
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:0",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:0;http://localhost:0",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -0,0 +1,186 @@
@model FileAccessAgent.TestSite.Models.AuthDebugViewModel
@{
ViewData["Title"] = "Redirect Login Test";
}
<div class="d-flex align-items-center justify-content-between mb-3">
<h1 class="h3 mb-0">Redirect Login Test</h1>
@if (!Model.IsAuthenticated)
{
<a class="btn btn-primary" href="/auth/login?returnUrl=/">Login via Member Center</a>
}
else
{
<form method="post" action="/auth/logout">
@Html.AntiForgeryToken()
<input type="hidden" name="returnUrl" value="/" />
<button type="submit" class="btn btn-outline-danger">Logout</button>
</form>
}
</div>
<div class="alert @(Model.IsAuthenticated ? "alert-success" : "alert-warning")">
<strong>Status:</strong> @(Model.IsAuthenticated ? "Authenticated" : "Anonymous")
@if (Model.IsAuthenticated)
{
<span> | <strong>Name:</strong> @Model.Name</span>
<span> | <strong>sub:</strong> @Model.Subject</span>
}
</div>
@if (Model.IsAuthenticated)
{
<div class="card mb-3">
<div class="card-header">Access Token (raw)</div>
<div class="card-body">
<textarea class="form-control" rows="6" readonly>@Model.AccessToken</textarea>
</div>
</div>
<div class="card mb-3">
<div class="card-header">ID Token (raw)</div>
<div class="card-body">
<textarea class="form-control" rows="6" readonly>@Model.IdToken</textarea>
</div>
</div>
<div class="card mb-3">
<div class="card-header">Access Token Payload Claims</div>
<div class="table-responsive">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th style="width:30%">Type</th>
<th>Value</th>
</tr>
</thead>
<tbody>
@foreach (var claim in Model.AccessTokenPayload)
{
<tr>
<td><code>@claim.Key</code></td>
<td><code>@claim.Value</code></td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<div class="card">
<div class="card-header">User Claims</div>
<div class="table-responsive">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th style="width:30%">Type</th>
<th>Value</th>
</tr>
</thead>
<tbody>
@if (Model.Claims.Count == 0)
{
<tr>
<td colspan="2" class="text-muted">No claims.</td>
</tr>
}
else
{
@foreach (var claim in Model.Claims)
{
<tr>
<td><code>@claim.Key</code></td>
<td><code>@claim.Value</code></td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
@if (Model.IsAuthenticated)
{
var currentObjectKey = string.IsNullOrWhiteSpace(Model.CurrentObjectKey) ? "demo/uploads/sample.txt" : Model.CurrentObjectKey;
<div class="card mt-3">
<div class="card-header">Agent Functional Test</div>
<div class="card-body">
<form method="post" action="/test/upload" class="mb-3" enctype="multipart/form-data">
@Html.AntiForgeryToken()
<div class="mb-2">
<label class="form-label">File</label>
<input class="form-control" type="file" name="file" />
</div>
<div class="mb-2">
<label class="form-label">Object Key (optional)</label>
<input class="form-control" type="text" name="objectKey" value="@currentObjectKey" />
</div>
<button class="btn btn-primary" type="submit">Upload</button>
</form>
<div class="d-flex gap-2 mb-3">
<form method="post" action="/test/metadata" class="d-flex gap-2">
@Html.AntiForgeryToken()
<input class="form-control" type="text" name="objectKey" value="@currentObjectKey" />
<button class="btn btn-outline-secondary" type="submit">Get Metadata</button>
</form>
<form method="post" action="/test/head" class="d-flex gap-2">
@Html.AntiForgeryToken()
<input class="form-control" type="text" name="objectKey" value="@currentObjectKey" />
<button class="btn btn-outline-secondary" type="submit">Head</button>
</form>
<form method="post" action="/test/download" class="d-flex gap-2">
@Html.AntiForgeryToken()
<input class="form-control" type="text" name="objectKey" value="@currentObjectKey" />
<button class="btn btn-outline-success" type="submit">Download</button>
</form>
<form method="post" action="/test/download-file" class="d-flex gap-2">
@Html.AntiForgeryToken()
<input class="form-control" type="text" name="objectKey" value="@currentObjectKey" />
<button class="btn btn-success" type="submit">Download File</button>
</form>
<form method="post" action="/test/delete" class="d-flex gap-2">
@Html.AntiForgeryToken()
<input class="form-control" type="text" name="objectKey" value="@currentObjectKey" />
<button class="btn btn-outline-danger" type="submit">Delete</button>
</form>
</div>
<form method="post" action="/test/download-invalid-token" class="d-flex gap-2 mb-3">
@Html.AntiForgeryToken()
<input class="form-control" type="text" name="objectKey" value="@currentObjectKey" />
<input class="form-control" type="text" name="token" value="invalid-token" />
<button class="btn btn-outline-warning" type="submit">Download (Invalid Token)</button>
</form>
<form method="post" action="/test/health" class="mb-3">
@Html.AntiForgeryToken()
<button class="btn btn-outline-dark" type="submit">Health</button>
</form>
<div class="small text-muted mb-3">
Covered APIs: <code>PUT /files/{objectKey}</code>,
<code>GET /files/{objectKey}</code>,
<code>HEAD /files/{objectKey}</code>,
<code>GET /files/metadata/{objectKey}</code>,
<code>DELETE /files/{objectKey}</code>,
<code>GET /health</code>
</div>
@if (!string.IsNullOrWhiteSpace(Model.LastOperationName))
{
<div class="alert alert-info mb-0">
<div><strong>Last Operation:</strong> @Model.LastOperationName</div>
<div><strong>Status:</strong> @Model.LastOperationStatusCode</div>
<div><strong>Response:</strong></div>
<pre class="mb-0"><code>@Model.LastOperationResponseBody</code></pre>
</div>
}
</div>
</div>
}

View File

@ -0,0 +1,6 @@
@{
ViewData["Title"] = "Privacy Policy";
}
<h1>@ViewData["Title"]</h1>
<p>Use this page to detail your site's privacy policy.</p>

View File

@ -0,0 +1,25 @@
@model ErrorViewModel
@{
ViewData["Title"] = "Error";
}
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (Model.ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@Model.RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>

View File

@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - FileAccessAgent.TestSite</title>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
<link rel="stylesheet" href="~/FileAccessAgent.TestSite.styles.css" asp-append-version="true" />
</head>
<body>
<header>
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
<div class="container-fluid">
<a class="navbar-brand" asp-area="" asp-controller="Home" asp-action="Index">FileAccessAgent.TestSite</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
<ul class="navbar-nav flex-grow-1">
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
</li>
</ul>
<ul class="navbar-nav">
@if (User.Identity?.IsAuthenticated == true)
{
<li class="nav-item d-flex align-items-center me-2 text-muted small">
@User.Identity?.Name
</li>
<li class="nav-item">
<form method="post" action="/auth/logout" class="d-inline">
@Html.AntiForgeryToken()
<input type="hidden" name="returnUrl" value="/" />
<button type="submit" class="btn btn-sm btn-outline-danger">Logout</button>
</form>
</li>
}
else
{
<li class="nav-item">
<a class="btn btn-sm btn-primary" href="/auth/login?returnUrl=/">Login</a>
</li>
}
</ul>
</div>
</div>
</nav>
</header>
<div class="container">
<main role="main" class="pb-3">
@RenderBody()
</main>
</div>
<footer class="border-top footer text-muted">
<div class="container">
&copy; 2026 - FileAccessAgent.TestSite
</div>
</footer>
<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>

View File

@ -0,0 +1,48 @@
/* Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification
for details on configuring this project to bundle and minify static web assets. */
a.navbar-brand {
white-space: normal;
text-align: center;
word-break: break-all;
}
a {
color: #0077cc;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.nav-pills .nav-link.active, .nav-pills .show > .nav-link {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.border-top {
border-top: 1px solid #e5e5e5;
}
.border-bottom {
border-bottom: 1px solid #e5e5e5;
}
.box-shadow {
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .05);
}
button.accept-policy {
font-size: 1rem;
line-height: inherit;
}
.footer {
position: absolute;
bottom: 0;
width: 100%;
white-space: nowrap;
line-height: 60px;
}

View File

@ -0,0 +1,2 @@
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>

View File

@ -0,0 +1,3 @@
@using FileAccessAgent.TestSite
@using FileAccessAgent.TestSite.Models
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}

View File

@ -0,0 +1,11 @@
{
"MemberCenter": {
"RequireHttpsMetadata": false
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -0,0 +1,28 @@
{
"MemberCenter": {
"Authority": "http://localhost:7850/",
"WebBaseUrl": "http://localhost:5080/",
"MetadataAddress": "",
"ClientId": "0a20ce0f72964f859f3814b72d875b07",
"ClientSecret": "",
"CallbackPath": "/auth/callback",
"SignedOutCallbackPath": "/signout-callback-oidc",
"Scopes": "openid email profile",
"RequireHttpsMetadata": false,
"ServiceClientId": "56734dc009614462bef40440d8d20e07",
"ServiceClientSecret": "momrq8kyXtCuruLiDPDxBPt5uVXlw5xBFE85tHIDqV8=",
"ServiceScopes": "files:upload.write files:metadata.read files:delete files:download.delegate",
"ServiceTokenEndpointPath": "/oauth/token"
},
"FileAccessAgent": {
"BaseUrl": "http://localhost:5081",
"TenantId": "d8107508-e6b9-4630-b982-c7bbb0cb228f"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@ -0,0 +1,22 @@
html {
font-size: 14px;
}
@media (min-width: 768px) {
html {
font-size: 16px;
}
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
html {
position: relative;
min-height: 100%;
}
body {
margin-bottom: 60px;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -0,0 +1,4 @@
// Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification
// for details on configuring this project to bundle and minify static web assets.
// Write your JavaScript code.

View File

@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2011-2021 Twitter, Inc.
Copyright (c) 2011-2021 The Bootstrap Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,427 @@
/*!
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
background-color: currentColor;
border: 0;
opacity: 0.25;
}
hr:not([size]) {
height: 1px;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title],
abbr[data-bs-original-title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-left: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.2em;
background-color: #fcf8e3;
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: #0d6efd;
text-decoration: underline;
}
a:hover {
color: #0a58ca;
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 1em;
direction: ltr /* rtl:ignore */;
unicode-bidi: bidi-override;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: #d63384;
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.2rem 0.4rem;
font-size: 0.875em;
color: #fff;
background-color: #212529;
border-radius: 0.2rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
font-weight: 700;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: #6c757d;
text-align: left;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]::-webkit-calendar-picker-indicator {
display: none;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: left;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: left;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
outline-offset: -2px;
-webkit-appearance: textfield;
}
/* rtl:raw:
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::file-selector-button {
font: inherit;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,8 @@
/*!
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){h1{font-size:2.5rem}}h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){h2{font-size:2rem}}h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){h3{font-size:1.75rem}}h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){h4{font-size:1.5rem}}h5{font-size:1.25rem}h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:.875em}mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}
/*# sourceMappingURL=bootstrap-reboot.min.css.map */

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,424 @@
/*!
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
background-color: currentColor;
border: 0;
opacity: 0.25;
}
hr:not([size]) {
height: 1px;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title],
abbr[data-bs-original-title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-right: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-right: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.2em;
background-color: #fcf8e3;
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: #0d6efd;
text-decoration: underline;
}
a:hover {
color: #0a58ca;
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 1em;
direction: ltr ;
unicode-bidi: bidi-override;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: #d63384;
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.2rem 0.4rem;
font-size: 0.875em;
color: #fff;
background-color: #212529;
border-radius: 0.2rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
font-weight: 700;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: #6c757d;
text-align: right;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]::-webkit-calendar-picker-indicator {
display: none;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: right;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: right;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
outline-offset: -2px;
-webkit-appearance: textfield;
}
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::file-selector-button {
font: inherit;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,8 @@
/*!
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){h1{font-size:2.5rem}}h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){h2{font-size:2rem}}h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){h3{font-size:1.75rem}}h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){h4{font-size:1.5rem}}h5{font-size:1.25rem}h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-right:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-right:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:.875em}mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:right}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:right;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:right}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}[type=email],[type=number],[type=tel],[type=url]{direction:ltr}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}
/*# sourceMappingURL=bootstrap-reboot.rtl.min.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,23 @@
The MIT License (MIT)
Copyright (c) .NET Foundation and Contributors
All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,435 @@
/**
* @license
* Unobtrusive validation support library for jQuery and jQuery Validate
* Copyright (c) .NET Foundation. All rights reserved.
* Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
* @version v4.0.0
*/
/*jslint white: true, browser: true, onevar: true, undef: true, nomen: true, eqeqeq: true, plusplus: true, bitwise: true, regexp: true, newcap: true, immed: true, strict: false */
/*global document: false, jQuery: false */
(function (factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define("jquery.validate.unobtrusive", ['jquery-validation'], factory);
} else if (typeof module === 'object' && module.exports) {
// CommonJS-like environments that support module.exports
module.exports = factory(require('jquery-validation'));
} else {
// Browser global
jQuery.validator.unobtrusive = factory(jQuery);
}
}(function ($) {
var $jQval = $.validator,
adapters,
data_validation = "unobtrusiveValidation";
function setValidationValues(options, ruleName, value) {
options.rules[ruleName] = value;
if (options.message) {
options.messages[ruleName] = options.message;
}
}
function splitAndTrim(value) {
return value.replace(/^\s+|\s+$/g, "").split(/\s*,\s*/g);
}
function escapeAttributeValue(value) {
// As mentioned on http://api.jquery.com/category/selectors/
return value.replace(/([!"#$%&'()*+,./:;<=>?@\[\\\]^`{|}~])/g, "\\$1");
}
function getModelPrefix(fieldName) {
return fieldName.substr(0, fieldName.lastIndexOf(".") + 1);
}
function appendModelPrefix(value, prefix) {
if (value.indexOf("*.") === 0) {
value = value.replace("*.", prefix);
}
return value;
}
function onError(error, inputElement) { // 'this' is the form element
var container = $(this).find("[data-valmsg-for='" + escapeAttributeValue(inputElement[0].name) + "']"),
replaceAttrValue = container.attr("data-valmsg-replace"),
replace = replaceAttrValue ? $.parseJSON(replaceAttrValue) !== false : null;
container.removeClass("field-validation-valid").addClass("field-validation-error");
error.data("unobtrusiveContainer", container);
if (replace) {
container.empty();
error.removeClass("input-validation-error").appendTo(container);
}
else {
error.hide();
}
}
function onErrors(event, validator) { // 'this' is the form element
var container = $(this).find("[data-valmsg-summary=true]"),
list = container.find("ul");
if (list && list.length && validator.errorList.length) {
list.empty();
container.addClass("validation-summary-errors").removeClass("validation-summary-valid");
$.each(validator.errorList, function () {
$("<li />").html(this.message).appendTo(list);
});
}
}
function onSuccess(error) { // 'this' is the form element
var container = error.data("unobtrusiveContainer");
if (container) {
var replaceAttrValue = container.attr("data-valmsg-replace"),
replace = replaceAttrValue ? $.parseJSON(replaceAttrValue) : null;
container.addClass("field-validation-valid").removeClass("field-validation-error");
error.removeData("unobtrusiveContainer");
if (replace) {
container.empty();
}
}
}
function onReset(event) { // 'this' is the form element
var $form = $(this),
key = '__jquery_unobtrusive_validation_form_reset';
if ($form.data(key)) {
return;
}
// Set a flag that indicates we're currently resetting the form.
$form.data(key, true);
try {
$form.data("validator").resetForm();
} finally {
$form.removeData(key);
}
$form.find(".validation-summary-errors")
.addClass("validation-summary-valid")
.removeClass("validation-summary-errors");
$form.find(".field-validation-error")
.addClass("field-validation-valid")
.removeClass("field-validation-error")
.removeData("unobtrusiveContainer")
.find(">*") // If we were using valmsg-replace, get the underlying error
.removeData("unobtrusiveContainer");
}
function validationInfo(form) {
var $form = $(form),
result = $form.data(data_validation),
onResetProxy = $.proxy(onReset, form),
defaultOptions = $jQval.unobtrusive.options || {},
execInContext = function (name, args) {
var func = defaultOptions[name];
func && $.isFunction(func) && func.apply(form, args);
};
if (!result) {
result = {
options: { // options structure passed to jQuery Validate's validate() method
errorClass: defaultOptions.errorClass || "input-validation-error",
errorElement: defaultOptions.errorElement || "span",
errorPlacement: function () {
onError.apply(form, arguments);
execInContext("errorPlacement", arguments);
},
invalidHandler: function () {
onErrors.apply(form, arguments);
execInContext("invalidHandler", arguments);
},
messages: {},
rules: {},
success: function () {
onSuccess.apply(form, arguments);
execInContext("success", arguments);
}
},
attachValidation: function () {
$form
.off("reset." + data_validation, onResetProxy)
.on("reset." + data_validation, onResetProxy)
.validate(this.options);
},
validate: function () { // a validation function that is called by unobtrusive Ajax
$form.validate();
return $form.valid();
}
};
$form.data(data_validation, result);
}
return result;
}
$jQval.unobtrusive = {
adapters: [],
parseElement: function (element, skipAttach) {
/// <summary>
/// Parses a single HTML element for unobtrusive validation attributes.
/// </summary>
/// <param name="element" domElement="true">The HTML element to be parsed.</param>
/// <param name="skipAttach" type="Boolean">[Optional] true to skip attaching the
/// validation to the form. If parsing just this single element, you should specify true.
/// If parsing several elements, you should specify false, and manually attach the validation
/// to the form when you are finished. The default is false.</param>
var $element = $(element),
form = $element.parents("form")[0],
valInfo, rules, messages;
if (!form) { // Cannot do client-side validation without a form
return;
}
valInfo = validationInfo(form);
valInfo.options.rules[element.name] = rules = {};
valInfo.options.messages[element.name] = messages = {};
$.each(this.adapters, function () {
var prefix = "data-val-" + this.name,
message = $element.attr(prefix),
paramValues = {};
if (message !== undefined) { // Compare against undefined, because an empty message is legal (and falsy)
prefix += "-";
$.each(this.params, function () {
paramValues[this] = $element.attr(prefix + this);
});
this.adapt({
element: element,
form: form,
message: message,
params: paramValues,
rules: rules,
messages: messages
});
}
});
$.extend(rules, { "__dummy__": true });
if (!skipAttach) {
valInfo.attachValidation();
}
},
parse: function (selector) {
/// <summary>
/// Parses all the HTML elements in the specified selector. It looks for input elements decorated
/// with the [data-val=true] attribute value and enables validation according to the data-val-*
/// attribute values.
/// </summary>
/// <param name="selector" type="String">Any valid jQuery selector.</param>
// $forms includes all forms in selector's DOM hierarchy (parent, children and self) that have at least one
// element with data-val=true
var $selector = $(selector),
$forms = $selector.parents()
.addBack()
.filter("form")
.add($selector.find("form"))
.has("[data-val=true]");
$selector.find("[data-val=true]").each(function () {
$jQval.unobtrusive.parseElement(this, true);
});
$forms.each(function () {
var info = validationInfo(this);
if (info) {
info.attachValidation();
}
});
}
};
adapters = $jQval.unobtrusive.adapters;
adapters.add = function (adapterName, params, fn) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
/// <param name="params" type="Array" optional="true">[Optional] An array of parameter names (strings) that will
/// be extracted from the data-val-nnnn-mmmm HTML attributes (where nnnn is the adapter name, and
/// mmmm is the parameter name).</param>
/// <param name="fn" type="Function">The function to call, which adapts the values from the HTML
/// attributes into jQuery Validate rules and/or messages.</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
if (!fn) { // Called with no params, just a function
fn = params;
params = [];
}
this.push({ name: adapterName, params: params, adapt: fn });
return this;
};
adapters.addBool = function (adapterName, ruleName) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
/// the jQuery Validate validation rule has no parameter values.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
/// <param name="ruleName" type="String" optional="true">[Optional] The name of the jQuery Validate rule. If not provided, the value
/// of adapterName will be used instead.</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
return this.add(adapterName, function (options) {
setValidationValues(options, ruleName || adapterName, true);
});
};
adapters.addMinMax = function (adapterName, minRuleName, maxRuleName, minMaxRuleName, minAttribute, maxAttribute) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
/// the jQuery Validate validation has three potential rules (one for min-only, one for max-only, and
/// one for min-and-max). The HTML parameters are expected to be named -min and -max.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
/// <param name="minRuleName" type="String">The name of the jQuery Validate rule to be used when you only
/// have a minimum value.</param>
/// <param name="maxRuleName" type="String">The name of the jQuery Validate rule to be used when you only
/// have a maximum value.</param>
/// <param name="minMaxRuleName" type="String">The name of the jQuery Validate rule to be used when you
/// have both a minimum and maximum value.</param>
/// <param name="minAttribute" type="String" optional="true">[Optional] The name of the HTML attribute that
/// contains the minimum value. The default is "min".</param>
/// <param name="maxAttribute" type="String" optional="true">[Optional] The name of the HTML attribute that
/// contains the maximum value. The default is "max".</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
return this.add(adapterName, [minAttribute || "min", maxAttribute || "max"], function (options) {
var min = options.params.min,
max = options.params.max;
if (min && max) {
setValidationValues(options, minMaxRuleName, [min, max]);
}
else if (min) {
setValidationValues(options, minRuleName, min);
}
else if (max) {
setValidationValues(options, maxRuleName, max);
}
});
};
adapters.addSingleVal = function (adapterName, attribute, ruleName) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
/// the jQuery Validate validation rule has a single value.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute(where nnnn is the adapter name).</param>
/// <param name="attribute" type="String">[Optional] The name of the HTML attribute that contains the value.
/// The default is "val".</param>
/// <param name="ruleName" type="String" optional="true">[Optional] The name of the jQuery Validate rule. If not provided, the value
/// of adapterName will be used instead.</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
return this.add(adapterName, [attribute || "val"], function (options) {
setValidationValues(options, ruleName || adapterName, options.params[attribute]);
});
};
$jQval.addMethod("__dummy__", function (value, element, params) {
return true;
});
$jQval.addMethod("regex", function (value, element, params) {
var match;
if (this.optional(element)) {
return true;
}
match = new RegExp(params).exec(value);
return (match && (match.index === 0) && (match[0].length === value.length));
});
$jQval.addMethod("nonalphamin", function (value, element, nonalphamin) {
var match;
if (nonalphamin) {
match = value.match(/\W/g);
match = match && match.length >= nonalphamin;
}
return match;
});
if ($jQval.methods.extension) {
adapters.addSingleVal("accept", "mimtype");
adapters.addSingleVal("extension", "extension");
} else {
// for backward compatibility, when the 'extension' validation method does not exist, such as with versions
// of JQuery Validation plugin prior to 1.10, we should use the 'accept' method for
// validating the extension, and ignore mime-type validations as they are not supported.
adapters.addSingleVal("extension", "extension", "accept");
}
adapters.addSingleVal("regex", "pattern");
adapters.addBool("creditcard").addBool("date").addBool("digits").addBool("email").addBool("number").addBool("url");
adapters.addMinMax("length", "minlength", "maxlength", "rangelength").addMinMax("range", "min", "max", "range");
adapters.addMinMax("minlength", "minlength").addMinMax("maxlength", "minlength", "maxlength");
adapters.add("equalto", ["other"], function (options) {
var prefix = getModelPrefix(options.element.name),
other = options.params.other,
fullOtherName = appendModelPrefix(other, prefix),
element = $(options.form).find(":input").filter("[name='" + escapeAttributeValue(fullOtherName) + "']")[0];
setValidationValues(options, "equalTo", element);
});
adapters.add("required", function (options) {
// jQuery Validate equates "required" with "mandatory" for checkbox elements
if (options.element.tagName.toUpperCase() !== "INPUT" || options.element.type.toUpperCase() !== "CHECKBOX") {
setValidationValues(options, "required", true);
}
});
adapters.add("remote", ["url", "type", "additionalfields"], function (options) {
var value = {
url: options.params.url,
type: options.params.type || "GET",
data: {}
},
prefix = getModelPrefix(options.element.name);
$.each(splitAndTrim(options.params.additionalfields || options.element.name), function (i, fieldName) {
var paramName = appendModelPrefix(fieldName, prefix);
value.data[paramName] = function () {
var field = $(options.form).find(":input").filter("[name='" + escapeAttributeValue(paramName) + "']");
// For checkboxes and radio buttons, only pick up values from checked fields.
if (field.is(":checkbox")) {
return field.filter(":checked").val() || field.filter(":hidden").val() || '';
}
else if (field.is(":radio")) {
return field.filter(":checked").val() || '';
}
return field.val();
};
});
setValidationValues(options, "remote", value);
});
adapters.add("password", ["min", "nonalphamin", "regex"], function (options) {
if (options.params.min) {
setValidationValues(options, "minlength", options.params.min);
}
if (options.params.nonalphamin) {
setValidationValues(options, "nonalphamin", options.params.nonalphamin);
}
if (options.params.regex) {
setValidationValues(options, "regex", options.params.regex);
}
});
adapters.add("fileextensions", ["extensions"], function (options) {
setValidationValues(options, "extension", options.params.extensions);
});
$(function () {
$jQval.unobtrusive.parse(document);
});
return $jQval.unobtrusive;
}));

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More