diff --git a/apps/security-advisory-patches-portal/README.md b/apps/security-advisory-patches-portal/README.md new file mode 100644 index 000000000..8cd80376b --- /dev/null +++ b/apps/security-advisory-patches-portal/README.md @@ -0,0 +1,197 @@ +# Security Advisory Patches Portal — User & Developer Guide + +This guide explains how the **Security Advisory Patches Portal** fits together: advisory **PDFs** live in an **Azure Files** share, a **Ballerina** backend streams bytes by path, and a minimal **React** app signs users in with **Asgardeo**. Any URL whose path **ends with `.pdf`** loads that file; otherwise you see **404**. + +--- + +## 1. What the portal does + +| Role | What you do | +|------|-------------| +| **Content publisher** | Upload or update PDFs (and folders) in the configured **Azure File Share** (Azure Portal, Storage Explorer, AzCopy, automation). | +| **End user (e.g. customer)** | Sign in with **Asgardeo** and open the **PDF link** you were sent (path ends with **`.pdf`**). | +| **Developer / operator** | Configure Azure credentials and share name, run or deploy the backend and webapp, tune `config.js`. | + +The webapp does **not** upload files to Azure. Publishing is always done **outside** via Azure Portal. + +--- + +## 2. Architecture (high level) + +```text +┌─────────────────┐ HTTPS + Bearer token ┌──────────────────┐ +│ React webapp │ ─────────────────────────────► │ Ballerina API │ +│ (Asgardeo OIDC)│ ◄───────────────────────────── │ (port 9090) │ +└────────┬────────┘ PDF blob └────────┬─────────┘ + │ │ + │ window.config (config.js) │ account key or SAS + │ BACKEND_BASE_URL, Asgardeo URLs ▼ + │ ┌──────────────────┐ + └────────────────────────────────────────► │ Azure File Share│ + └──────────────────┘ +``` + +- **Frontend**: Reads runtime settings from `public/config.js`. After sign-in, paths ending in **`.pdf`** map to `GET /file?path=…` (share-relative path); the PDF is shown in-page. Other paths show **404** unless you are on **`/`** (short help text). +- **Backend**: Uses `ballerinax/azure_storage_service.files` to **download file bytes only**. It performs a **health check** against the file share at startup. + +--- + +## 3. Guide for end users + +### 3.1 Sign-in and opening the site + +1. Use the portal hostname your organization gave you (for example `https://patches.example.com/`). +2. You are redirected to **Asgardeo** to sign in. +3. If you land on **/** after login without having opened a PDF link first, you see a short message: open the **full URL** you received whose path ends with **`.pdf`**, or sign out and open the link from your email again. +4. If you started from such a link before signing in, that URL is restored after login when the identity provider sends you back to the site root. + +### 3.2 PDF viewing only + +- There is **no** folder browser—only the PDF viewer when the link is valid and the file exists. +- **Hyphen encoding** (legacy links): doubled `--` for literal hyphens and single `-` for spaces in a segment; segments without `--` keep `-` as-is. Prefer **`%20`** for spaces when building links. +- **Folder slugs vs Azure**: Path segments for **directories** that look like lowercase kebab-case (e.g. `security-patches`, `january`) are turned into Azure-style names (`Security Patches`, `January`) before calling the API. The **PDF file name** segment is never changed—use the exact name in the share (e.g. `WSO2-2025-3857_CVE-2025-0326.pdf`). You can also put real folder names in the URL with **`%20`** for spaces; those segments are left as-is. +- If the first URL segment is **`patches`**, it is stripped when resolving the Azure path (existing **`/patches/…`** links keep working). + +### 3.3 Invalid links and missing files + +- If the path does not end with **`.pdf`**, you get a **404** page (except **`/`**, which shows the help text). +- If the path ends with **`.pdf`** but the backend cannot return the file, you also see **404**. + +### 3.4 Sign out + +Use **Sign out** in the header to leave the app (behavior depends on your IdP). + +--- + +## 4. Guide for content publishers (Azure File Share) + +### 4.1 Where files go + +PDFs are served from **one** Azure file share (storage account name, share name, access key or SAS in Ballerina config). + +### 4.2 Upload or update content + +Use Portal, Storage Explorer, AzCopy, or automation—same as any Azure Files workflow. + +### 4.3 Naming and path rules + +The backend validates `path` using the same segment rules as the file-storage module (no `..`, no control characters, sane length) before calling Azure. + +### 4.4 When updates appear + +The next successful fetch loads the **current** bytes from Azure (reload the page if you replaced the file). + +--- + +## 5. Guide for developers + +### 5.1 Repository layout + +| Path | Purpose | +|------|---------| +| `backend/` | Ballerina package `security_advisories_fileshare` — `modules/authorization` (JWT / roles), `modules/file_storage`, `GET /health`, `GET /file` | +| `webapp/` | React SPA — Asgardeo, Redux (auth only), PDF viewer | + +### 5.2 Prerequisites + +- **Ballerina** `2201.12.9` (`backend/Ballerina.toml`) +- **Node.js** compatible with `react-scripts` 5 / TypeScript 4.9 + +### 5.3 Backend configuration + +`Config.toml` / `Config.toml.local` follow the same layout as [`webapps/backend-template`](../../webapps/backend-template): **Azure file share** settings plus **authorization** (Asgardeo group → API access). + +**Authorization** (required for `GET /file`; see `backend/modules/authorization/`): + +```toml +[security_advisories_fileshare.authorization.authorizedRoles] +securityPatchesUserRole = "" +``` + +- Set `securityPatchesUserRole` to the **exact** string that appears in the ID token **`groups`** array. +- For `/file`, the SPA sends the Asgardeo **ID token** on **`x-jwt-assertion`**. OIDC scopes are set in `webapp/src/config/config.ts` to **`openid`**, **`email`**, and **`groups`** so the ID token includes **`email`** and **`groups`** for `CustomJwtPayload`. +- **`GET /health`** does **not** require a JWT (liveness / probes). **`OPTIONS`** preflight is also allowed without JWT. + +**File share** (same `security_advisories_fileshare.file_storage` tables as before). Then run: + +```bash +cd backend && bal run +``` + +Listener **9090** by default; startup fails fast if the share is unreachable. + +### 5.4 Backend HTTP API + +| Method | Path | Query | Description | +|--------|------|--------|-------------| +| `GET` | `/health` | — | Liveness: file share reachable (**no** `x-jwt-assertion`) | +| `GET` | `/file` | `path` required | PDF bytes; requires **`x-jwt-assertion`** (ID token) and membership in the **`securityPatchesUserRole`** Asgardeo group; `Content-Disposition: inline` | + +The server runs **`url:decode`** on the `path` query value (`UTF-8`) so `%2F` becomes `/` when needed. + +Missing/invalid JWT or wrong groups → **403** / **500** as returned by the JWT interceptor; missing `path` context after auth → **400**. Invalid `path` → **400**; path valid but missing on the share (Azure **404**) → **404**; other download failures → **500**. + +The `path` query must be the **share-relative** path Azure expects (folder names, spaces, casing). The SPA maps lowercase **kebab-case** directory segments in the URL to that shape before calling `/file`; you can also send the literal path with **`%20`** for spaces (e.g. `Security%20Patches/...`). + +### 5.5 Webapp configuration (`config.js`) + +The app loads `public/config.js` before the bundle. Define `window.config` with at least: + +| Key | Purpose | +|-----|---------| +| `APP_NAME` | Title context | +| `ASGARDEO_BASE_URL` | Asgardeo server URL | +| `ASGARDEO_CLIENT_ID` | Asgardeo client ID | +| `AUTH_SIGN_IN_REDIRECT_URL` | Post-login redirect URI (typically site root `/`) | +| `AUTH_SIGN_OUT_REDIRECT_URL` | Post-logout redirect | +| `BACKEND_BASE_URL` | API origin for `/health` and `/file` | + +Example: + +```javascript +window.config = { + APP_NAME: 'Security Advisory Patches Portal', + ASGARDEO_BASE_URL: 'https://api.asgardeo.io/t/', + ASGARDEO_CLIENT_ID: '', + AUTH_SIGN_IN_REDIRECT_URL: 'http://localhost:3000/', + AUTH_SIGN_OUT_REDIRECT_URL: 'http://localhost:3000/', + BACKEND_BASE_URL: 'http://localhost:9090', +}; +``` + +From `webapp/`: + +```bash +npm install +npm start +``` + +Default dev server **`http://localhost:3000`**. Register that origin in your Asgardeo SPA app (redirect URLs and allowed origins as required), for example `http://localhost:3000/` with a trailing slash so it matches `AUTH_SIGN_IN_REDIRECT_URL` / `AUTH_SIGN_OUT_REDIRECT_URL` in `public/config.js`. You can also add **`http://127.0.0.1:3000/`** if you open the app via `127.0.0.1`. This repo assumes local dev uses **localhost only**, not a custom hostname in `/etc/hosts`. + +### 5.6 Production authentication note + +The backend reads **`x-jwt-assertion`** (JWT decode) and checks Asgardeo **`groups`** against `authorizedRoles` (see `backend/modules/authorization/authorization.bal`). + +### 5.7 Troubleshooting + +| Symptom | Things to check | +|--------|------------------| +| Backend won’t start | Share name, credentials, firewall | +| 400 on `/file` | Path fails segment validation (e.g. `..`, empty segment); or missing user context after auth | +| 403 on `/file` | User not in `securityPatchesUserRole` group; ID token missing `groups` | +| 400 "User information header not found!" | Request reached `/file` without expected auth context; verify JWT interceptor wiring and that SPA sends `x-jwt-assertion` (see `apiService.ts`) | +| 500 "Missing invoker info header" | SPA not sending `x-jwt-assertion` (see `apiService.ts`) | +| 500 "Malformed Invoker info object!" | Decode the ID token: it must include **`email`** and **`groups`**. Use scopes **`openid`**, **`email`**, **`groups`** in `webapp/src/config/config.ts`; in Asgardeo enable those attributes on the app and assign the user to a group matching `securityPatchesUserRole` | +| Blank PDF | Wrong path mapping vs Azure layout; browser blocking blob iframe | +| Auth loops | Redirect URIs match `config.js` | +| Deep link → home after login | Keep sign-in redirect at site root; the app restores **`.pdf`** links from session | + +--- + +## 6. Summary + +- **Publishers** put PDFs in **Azure Files** and distribute links whose path ends with **`.pdf`**. +- **Users** sign in with **Asgardeo** and open PDF links (path ends with **`.pdf`**). Invalid or missing files show **404**. +- **Developers** run **`bal`** + **`npm`**, configure **`config.js`**, gateway CORS, and network boundaries. + +Code paths: `backend/service.bal`, `backend/modules/file_storage/`, `webapp/src/view/PatchesPdf/PatchesPdfPage.tsx`, `webapp/src/view/NotFound/NotFoundPage.tsx`, `webapp/src/app/AppHandler.tsx`. diff --git a/apps/security-advisory-patches-portal/backend/Ballerina.toml b/apps/security-advisory-patches-portal/backend/Ballerina.toml new file mode 100644 index 000000000..5c43ed593 --- /dev/null +++ b/apps/security-advisory-patches-portal/backend/Ballerina.toml @@ -0,0 +1,8 @@ +[package] +org = "wso2" +name = "security_advisories_fileshare" +version = "1.0.0" +distribution = "2201.12.9" + +[build-options] +observabilityIncluded = true diff --git a/apps/security-advisory-patches-portal/backend/Config.toml.local b/apps/security-advisory-patches-portal/backend/Config.toml.local new file mode 100644 index 000000000..f533a4759 --- /dev/null +++ b/apps/security-advisory-patches-portal/backend/Config.toml.local @@ -0,0 +1,12 @@ +[security_advisories_fileshare.authorization.authorizedRoles] +# Must exactly match a value in the ID token `groups` array (see README section 5.3). +securityPatchesUserRole = "app-security-patches-admin-stg" + +# Azure File Share Configuration +[security_advisories_fileshare.file_storage] +fileShareName = "" + +[security_advisories_fileshare.file_storage.fileStorageConfig] +accountName = "" +accessKeyOrSAS = "" +authorizationMethod = "" diff --git a/apps/security-advisory-patches-portal/backend/Dependencies.toml b/apps/security-advisory-patches-portal/backend/Dependencies.toml new file mode 100644 index 000000000..90f8e7289 --- /dev/null +++ b/apps/security-advisory-patches-portal/backend/Dependencies.toml @@ -0,0 +1,393 @@ +# AUTO-GENERATED FILE. DO NOT MODIFY. + +# This file is auto-generated by Ballerina for managing dependency versions. +# It should not be modified by hand. + +[ballerina] +dependencies-toml-version = "2" +distribution-version = "2201.12.9" + +[[package]] +org = "ballerina" +name = "auth" +version = "2.14.0" +dependencies = [ + {org = "ballerina", name = "crypto"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.array"}, + {org = "ballerina", name = "lang.string"}, + {org = "ballerina", name = "log"} +] + +[[package]] +org = "ballerina" +name = "cache" +version = "3.10.0" +dependencies = [ + {org = "ballerina", name = "constraint"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "task"}, + {org = "ballerina", name = "time"} +] + +[[package]] +org = "ballerina" +name = "constraint" +version = "1.7.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"} +] + +[[package]] +org = "ballerina" +name = "crypto" +version = "2.9.3" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "time"} +] + +[[package]] +org = "ballerina" +name = "data.jsondata" +version = "1.1.3" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.object"} +] + +[[package]] +org = "ballerina" +name = "file" +version = "1.12.0" +dependencies = [ + {org = "ballerina", name = "io"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "os"}, + {org = "ballerina", name = "time"} +] + +[[package]] +org = "ballerina" +name = "http" +version = "2.14.11" +dependencies = [ + {org = "ballerina", name = "auth"}, + {org = "ballerina", name = "cache"}, + {org = "ballerina", name = "constraint"}, + {org = "ballerina", name = "crypto"}, + {org = "ballerina", name = "data.jsondata"}, + {org = "ballerina", name = "file"}, + {org = "ballerina", name = "io"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "jwt"}, + {org = "ballerina", name = "lang.array"}, + {org = "ballerina", name = "lang.decimal"}, + {org = "ballerina", name = "lang.int"}, + {org = "ballerina", name = "lang.regexp"}, + {org = "ballerina", name = "lang.runtime"}, + {org = "ballerina", name = "lang.string"}, + {org = "ballerina", name = "lang.value"}, + {org = "ballerina", name = "log"}, + {org = "ballerina", name = "mime"}, + {org = "ballerina", name = "oauth2"}, + {org = "ballerina", name = "observe"}, + {org = "ballerina", name = "time"}, + {org = "ballerina", name = "url"} +] +modules = [ + {org = "ballerina", packageName = "http", moduleName = "http"}, + {org = "ballerina", packageName = "http", moduleName = "http.httpscerr"} +] + +[[package]] +org = "ballerina" +name = "io" +version = "1.8.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.value"} +] + +[[package]] +org = "ballerina" +name = "jballerina.java" +version = "0.0.0" + +[[package]] +org = "ballerina" +name = "jwt" +version = "2.15.1" +dependencies = [ + {org = "ballerina", name = "cache"}, + {org = "ballerina", name = "crypto"}, + {org = "ballerina", name = "io"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.int"}, + {org = "ballerina", name = "lang.string"}, + {org = "ballerina", name = "log"}, + {org = "ballerina", name = "time"} +] +modules = [ + {org = "ballerina", packageName = "jwt", moduleName = "jwt"} +] + +[[package]] +org = "ballerina" +name = "lang.__internal" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.object"} +] + +[[package]] +org = "ballerina" +name = "lang.array" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.__internal"} +] + +[[package]] +org = "ballerina" +name = "lang.decimal" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"} +] + +[[package]] +org = "ballerina" +name = "lang.int" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.__internal"}, + {org = "ballerina", name = "lang.object"} +] + +[[package]] +org = "ballerina" +name = "lang.object" +version = "0.0.0" + +[[package]] +org = "ballerina" +name = "lang.regexp" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"} +] + +[[package]] +org = "ballerina" +name = "lang.runtime" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"} +] + +[[package]] +org = "ballerina" +name = "lang.string" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.regexp"} +] + +[[package]] +org = "ballerina" +name = "lang.value" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"} +] + +[[package]] +org = "ballerina" +name = "lang.xml" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"} +] + +[[package]] +org = "ballerina" +name = "log" +version = "2.14.0" +dependencies = [ + {org = "ballerina", name = "io"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.value"}, + {org = "ballerina", name = "observe"} +] +modules = [ + {org = "ballerina", packageName = "log", moduleName = "log"} +] + +[[package]] +org = "ballerina" +name = "mime" +version = "2.12.1" +dependencies = [ + {org = "ballerina", name = "io"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.int"}, + {org = "ballerina", name = "log"} +] + +[[package]] +org = "ballerina" +name = "oauth2" +version = "2.14.1" +dependencies = [ + {org = "ballerina", name = "cache"}, + {org = "ballerina", name = "crypto"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "log"}, + {org = "ballerina", name = "time"}, + {org = "ballerina", name = "url"} +] + +[[package]] +org = "ballerina" +name = "observe" +version = "1.6.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"} +] + +[[package]] +org = "ballerina" +name = "os" +version = "1.10.1" +dependencies = [ + {org = "ballerina", name = "io"}, + {org = "ballerina", name = "jballerina.java"} +] + +[[package]] +org = "ballerina" +name = "regex" +version = "1.3.2" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.string"} +] + +[[package]] +org = "ballerina" +name = "task" +version = "2.11.1" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "time"}, + {org = "ballerina", name = "uuid"} +] + +[[package]] +org = "ballerina" +name = "time" +version = "2.8.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"} +] + +[[package]] +org = "ballerina" +name = "url" +version = "2.6.1" +dependencies = [ + {org = "ballerina", name = "jballerina.java"} +] +modules = [ + {org = "ballerina", packageName = "url", moduleName = "url"} +] + +[[package]] +org = "ballerina" +name = "uuid" +version = "1.10.0" +dependencies = [ + {org = "ballerina", name = "crypto"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.int"}, + {org = "ballerina", name = "time"} +] + +[[package]] +org = "ballerina" +name = "xmldata" +version = "2.9.1" +dependencies = [ + {org = "ballerina", name = "jballerina.java"} +] + +[[package]] +org = "ballerinai" +name = "observe" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "observe"} +] +modules = [ + {org = "ballerinai", packageName = "observe", moduleName = "observe"} +] + +[[package]] +org = "ballerinax" +name = "azure_storage_service" +version = "4.3.4" +dependencies = [ + {org = "ballerina", name = "crypto"}, + {org = "ballerina", name = "file"}, + {org = "ballerina", name = "http"}, + {org = "ballerina", name = "io"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.array"}, + {org = "ballerina", name = "lang.xml"}, + {org = "ballerina", name = "log"}, + {org = "ballerina", name = "os"}, + {org = "ballerina", name = "regex"}, + {org = "ballerina", name = "time"}, + {org = "ballerina", name = "xmldata"}, + {org = "ballerinax", name = "client.config"} +] +modules = [ + {org = "ballerinax", packageName = "azure_storage_service", moduleName = "azure_storage_service"}, + {org = "ballerinax", packageName = "azure_storage_service", moduleName = "azure_storage_service.blobs"}, + {org = "ballerinax", packageName = "azure_storage_service", moduleName = "azure_storage_service.files"}, + {org = "ballerinax", packageName = "azure_storage_service", moduleName = "azure_storage_service.utils"} +] + +[[package]] +org = "ballerinax" +name = "client.config" +version = "1.0.1" +dependencies = [ + {org = "ballerina", name = "http"}, + {org = "ballerina", name = "oauth2"} +] + +[[package]] +org = "wso2" +name = "security_advisories_fileshare" +version = "1.0.0" +dependencies = [ + {org = "ballerina", name = "http"}, + {org = "ballerina", name = "jwt"}, + {org = "ballerina", name = "log"}, + {org = "ballerina", name = "url"}, + {org = "ballerinai", name = "observe"}, + {org = "ballerinax", name = "azure_storage_service"} +] +modules = [ + {org = "wso2", packageName = "security_advisories_fileshare", moduleName = "security_advisories_fileshare"}, + {org = "wso2", packageName = "security_advisories_fileshare", moduleName = "security_advisories_fileshare.authorization"}, + {org = "wso2", packageName = "security_advisories_fileshare", moduleName = "security_advisories_fileshare.file_storage"} +] + diff --git a/apps/security-advisory-patches-portal/backend/constants.bal b/apps/security-advisory-patches-portal/backend/constants.bal new file mode 100644 index 000000000..dcd40a32f --- /dev/null +++ b/apps/security-advisory-patches-portal/backend/constants.bal @@ -0,0 +1,27 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +# Message returned in `GET /health` when the file share dependency is healthy. +const MSG_SERVICE_HEALTHY = "File Share backend service is running"; + +# Generic error message for `GET /file` when download fails for reasons other than Azure 404. +const ERR_MSG_DOWNLOAD_SECURITY_ADVISORY = "Error occurred while downloading security advisory"; + +# Message for `GET /file` when Azure reports the path as missing (`NotFoundError`). +const ERR_MSG_FILE_NOT_FOUND = "File or directory path not found in file share"; + +# Message for `GET /file` when the `path` query is missing, malformed, or fails percent-decoding. +const ERR_MSG_INVALID_PATH = "Invalid path format"; diff --git a/apps/security-advisory-patches-portal/backend/modules/authorization/authorization.bal b/apps/security-advisory-patches-portal/backend/modules/authorization/authorization.bal new file mode 100644 index 000000000..5f5cc16bb --- /dev/null +++ b/apps/security-advisory-patches-portal/backend/modules/authorization/authorization.bal @@ -0,0 +1,73 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +import ballerina/http; +import ballerina/jwt; +import ballerina/log; + +public configurable PatchesPortalRoles authorizedRoles = ?; + +public isolated service class JwtInterceptor { + + *http:RequestInterceptor; + isolated resource function default [string... path](http:RequestContext ctx, http:Request req) + returns http:NextService|http:Forbidden|http:InternalServerError|error? { + + if req.method == "OPTIONS" { + return ctx.next(); + } + + if path.length() == 1 && path[0] == "health" { + return ctx.next(); + } + + string|error idToken = req.getHeader(JWT_ASSERTION_HEADER); + if idToken is error { + string errorMsg = "Missing invoker info header!"; + log:printError(errorMsg, idToken); + return { + body: { + message: errorMsg + } + }; + } + + [jwt:Header, jwt:Payload]|jwt:Error result = jwt:decode(idToken); + if result is jwt:Error { + string errorMsg = "Error while reading the Invoker info!"; + log:printError(errorMsg, result); + return {body: {message: errorMsg}}; + } + + CustomJwtPayload|error userInfo = result[1].cloneWithType(CustomJwtPayload); + if userInfo is error { + string errorMsg = "Malformed Invoker info object!"; + log:printError(errorMsg, userInfo); + return {body: {message: errorMsg}}; + } + + foreach anydata role in authorizedRoles.toArray() { + if userInfo.groups.some(r => r === role) { + ctx.set(HEADER_USER_INFO, userInfo); + return ctx.next(); + } + } + + log:printError("Forbidden: caller lacks required group for advisory file download"); + + return {body: {message: "Insufficient privileges!"}}; + } +} diff --git a/apps/security-advisory-patches-portal/backend/modules/authorization/constants.bal b/apps/security-advisory-patches-portal/backend/modules/authorization/constants.bal new file mode 100644 index 000000000..5d8672eb2 --- /dev/null +++ b/apps/security-advisory-patches-portal/backend/modules/authorization/constants.bal @@ -0,0 +1,22 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +# HTTP header carrying the Asgardeo ID token. +public const JWT_ASSERTION_HEADER = "x-jwt-assertion"; + +# `http:RequestContext` attribute key for the decoded JWT payload after the interceptor succeeds. +public const HEADER_USER_INFO = "user-info"; diff --git a/apps/security-advisory-patches-portal/backend/modules/authorization/types.bal b/apps/security-advisory-patches-portal/backend/modules/authorization/types.bal new file mode 100644 index 000000000..fa9b4bd40 --- /dev/null +++ b/apps/security-advisory-patches-portal/backend/modules/authorization/types.bal @@ -0,0 +1,30 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +# User info custom type for Asgardeo token. +public type CustomJwtPayload record { + # User email + string email; + # User groups + string[] groups; +}; + +# Application specific role mapping. +public type PatchesPortalRoles record {| + # Asgardeo group name that may download advisory PDFs via `GET /file`. + string securityPatchesUserRole; +|}; diff --git a/apps/security-advisory-patches-portal/backend/modules/authorization/utils.bal b/apps/security-advisory-patches-portal/backend/modules/authorization/utils.bal new file mode 100644 index 000000000..fdd4a5f11 --- /dev/null +++ b/apps/security-advisory-patches-portal/backend/modules/authorization/utils.bal @@ -0,0 +1,30 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +# Returns true when every `requiredRoles` value appears in `userRoles`. +# +# + requiredRoles - Roles required for the operation +# + userRoles - Roles present on the signed-in user +# + return - Whether access is allowed +public isolated function checkPermissions(string[] requiredRoles, string[] userRoles) returns boolean { + if userRoles.length() == 0 && requiredRoles.length() > 0 { + return false; + } + + final string[] & readonly userRolesReadOnly = userRoles.cloneReadOnly(); + return requiredRoles.every(role => userRolesReadOnly.indexOf(role) !is ()); +} diff --git a/apps/security-advisory-patches-portal/backend/modules/file_storage/client.bal b/apps/security-advisory-patches-portal/backend/modules/file_storage/client.bal new file mode 100644 index 000000000..d6e36b985 --- /dev/null +++ b/apps/security-advisory-patches-portal/backend/modules/file_storage/client.bal @@ -0,0 +1,31 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import ballerinax/azure_storage_service.files; + +# Credentials and account settings for `ballerinax/azure_storage_service.files`. +configurable AzureFileStorageConfig fileStorageConfig = ?; + +# Name of the Azure Files share. +configurable string fileShareName = ?; + +# Shared `FileClient` used for all share operations in this package. +isolated final files:FileClient fileClient = check new ({ + accountName: fileStorageConfig.accountName, + accessKeyOrSAS: fileStorageConfig.accessKeyOrSAS, + authorizationMethod: fileStorageConfig.authorizationMethod +}); diff --git a/apps/security-advisory-patches-portal/backend/modules/file_storage/constants.bal b/apps/security-advisory-patches-portal/backend/modules/file_storage/constants.bal new file mode 100644 index 000000000..67c31e6a9 --- /dev/null +++ b/apps/security-advisory-patches-portal/backend/modules/file_storage/constants.bal @@ -0,0 +1,28 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +# Error returned from `validatePath` when the path is syntactically invalid for this module. +const ERR_MSG_INVALID_PATH = "Invalid path parameter"; + +# Error returned when a share-relative path exceeds the configured maximum length. +const ERR_MSG_PATH_TOO_LONG = "Path exceeds maximum allowed length"; + +# Path delimiter between share directory segments and the file name. +const FOLDER_DELIMITER = "/"; + +# Maximum allowed length for a share-relative path string. +const MAX_PATH_LENGTH = 2048; + diff --git a/apps/security-advisory-patches-portal/backend/modules/file_storage/file_storage.bal b/apps/security-advisory-patches-portal/backend/modules/file_storage/file_storage.bal new file mode 100644 index 000000000..db9cd3215 --- /dev/null +++ b/apps/security-advisory-patches-portal/backend/modules/file_storage/file_storage.bal @@ -0,0 +1,43 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +# Verify that the configured file share is reachable by listing the share root. +# +# + return - `()` on success, or an error if the share cannot be accessed +public isolated function healthCheck() returns error? { + _ = check fileClient->getDirectoryList(fileShareName); + return; +} + +# Download a file from the share using a share-relative path (`dir/file.pdf` or `file.pdf` at root). +# +# + filePath - Full path to the file within the share +# + return - File bytes, or an error if validation or Azure access fails +public isolated function downloadFile(string filePath) returns byte[]|error { + check validatePath(filePath); + + string dirPath = getDirectoryPath(filePath); + string fileName = getFileName(filePath); + string encodedName = check encodePathSegments(fileName); + + if dirPath == "" { + return check fileClient->getFileAsByteArray(fileShareName, encodedName); + } + + string normalizedDir = check normalizePath(dirPath, forSdkCall = true); + return check fileClient->getFileAsByteArray(fileShareName, encodedName, normalizedDir); +} diff --git a/apps/security-advisory-patches-portal/backend/modules/file_storage/types.bal b/apps/security-advisory-patches-portal/backend/modules/file_storage/types.bal new file mode 100644 index 000000000..512056aec --- /dev/null +++ b/apps/security-advisory-patches-portal/backend/modules/file_storage/types.bal @@ -0,0 +1,27 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +import ballerinax/azure_storage_service.files; + +# Connection parameters for the Azure Storage Files client. +public type AzureFileStorageConfig record {| + # Storage account name + string accountName; + # Account access key or SAS token string + string accessKeyOrSAS; + # Whether `accessKeyOrSAS` is a shared key or SAS + files:AuthorizationMethod authorizationMethod; +|}; diff --git a/apps/security-advisory-patches-portal/backend/modules/file_storage/utils.bal b/apps/security-advisory-patches-portal/backend/modules/file_storage/utils.bal new file mode 100644 index 000000000..01235366e --- /dev/null +++ b/apps/security-advisory-patches-portal/backend/modules/file_storage/utils.bal @@ -0,0 +1,164 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +import ballerina/url; + +# Validate share-relative path syntax and length before Azure SDK calls. +# +# + path - Path to validate +# + return - `()` if valid, or an error if the path is empty, too long, or malformed +public isolated function validatePath(string path) returns error? { + if path == "" { + return error(ERR_MSG_INVALID_PATH); + } + + if path.length() > MAX_PATH_LENGTH { + return error(ERR_MSG_PATH_TOO_LONG); + } + + if path.includes("\\") || path.includes("\u{0000}") { + return error(ERR_MSG_INVALID_PATH); + } + + if path.startsWith(FOLDER_DELIMITER) || path.endsWith(FOLDER_DELIMITER) || path.includes("//") { + return error(ERR_MSG_INVALID_PATH); + } + + string[] segments = re `${FOLDER_DELIMITER}`.split(path); + foreach string segment in segments { + if segment == "" || segment == "." || segment == ".." { + return error(ERR_MSG_INVALID_PATH); + } + } + + return; +} + +# Normalize a directory path for Azure SDK calls or internal use. +# +# + path - Directory path (may include leading `/`) +# + forSdkCall - When `true`, strip trailing slash and URL-encode each segment for the Azure Files REST API +# + return - Normalized path, or an error from encoding +public isolated function normalizePath(string path, boolean forSdkCall = false) returns string|error { + if path == "" { + return ""; + } + + string normalized = path.startsWith(FOLDER_DELIMITER) ? path.substring(1) : path; + + if !normalized.endsWith(FOLDER_DELIMITER) { + normalized = normalized + FOLDER_DELIMITER; + } + + if forSdkCall { + if normalized.endsWith(FOLDER_DELIMITER) { + normalized = normalized.substring(0, normalized.length() - 1); + } + normalized = check encodePathSegments(normalized); + } + + return normalized; +} + +# Return the last path segment (file name), ignoring a trailing `/`. +# +# + filePath - Full share-relative path +# + return - File name portion +public isolated function getFileName(string filePath) returns string { + if filePath == "" { + return ""; + } + string fp = filePath.endsWith(FOLDER_DELIMITER) ? filePath.substring(0, filePath.length() - 1) : filePath; + int? slash = fp.lastIndexOf(FOLDER_DELIMITER); + if slash is int && slash < fp.length() - 1 { + return fp.substring(slash + 1, fp.length()); + } + return fp; +} + +# Return the directory prefix of a full path, including trailing `/`, or `""` for root-level files. +# +# + fullPath - Full share-relative path +# + return - Directory path prefix +public isolated function getDirectoryPath(string fullPath) returns string { + if fullPath == "" { + return ""; + } + string normalized = fullPath.endsWith(FOLDER_DELIMITER) ? fullPath.substring(0, fullPath.length() - 1) : fullPath; + int? idx = normalized.lastIndexOf(FOLDER_DELIMITER); + if idx is int { + return idx > 0 ? normalized.substring(0, idx + 1) : ""; + } + return ""; +} + +# Extension to MIME map used by `getContentType`. +const map CONTENT_TYPE_MAP = { + "pdf": "application/pdf", + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "gif": "image/gif", + "webp": "image/webp", + "svg": "image/svg+xml", + "txt": "text/plain", + "json": "application/json", + "xml": "application/xml", + "html": "text/html", + "htm": "text/html", + "zip": "application/zip", + "xls": "application/vnd.ms-excel", + "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "doc": "application/msword", + "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document" +}; + +# Resolve a MIME type from the file extension (case-insensitive); unknown extensions default to `application/octet-stream`. +# +# + fileName - File name or path ending with a name that includes an extension +# + return - Content type string +public isolated function getContentType(string fileName) returns string { + string lowerFileName = fileName.toLowerAscii(); + + int? lastDotIndex = lowerFileName.lastIndexOf("."); + if lastDotIndex is int && lastDotIndex < lowerFileName.length() - 1 { + string extension = lowerFileName.substring(lastDotIndex + 1); + string? contentType = CONTENT_TYPE_MAP[extension]; + if contentType is string { + return contentType; + } + } + + return "application/octet-stream"; +} + +# URL-encode each `/`-separated segment for Azure File Share REST paths (skips empty segments). +# +# + path - Unencoded path using `/` as the delimiter +# + return - Encoded path, or an error from `url:encode` +public isolated function encodePathSegments(string path) returns string|error { + string[] parts = re `${FOLDER_DELIMITER}`.split(path); + string[] encoded = []; + + foreach string p in parts { + if p.length() == 0 { + continue; + } + encoded.push(check url:encode(p, "UTF-8")); + } + + return string:'join(FOLDER_DELIMITER, ...encoded); +} diff --git a/apps/security-advisory-patches-portal/backend/service.bal b/apps/security-advisory-patches-portal/backend/service.bal new file mode 100644 index 000000000..f7afa89d5 --- /dev/null +++ b/apps/security-advisory-patches-portal/backend/service.bal @@ -0,0 +1,155 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +import security_advisories_fileshare.authorization; +import security_advisories_fileshare.file_storage; + +import ballerina/http; +import ballerina/log; +import ballerina/url; +import ballerinax/azure_storage_service.files as azure_files; + +public isolated service class ErrorInterceptor { + *http:ResponseErrorInterceptor; + + isolated remote function interceptResponseError(error err, http:RequestContext ctx) returns http:BadRequest|error { + if err is http:PayloadBindingError { + string customError = "Payload binding failed!"; + log:printError(customError, err); + return { + body: { + message: customError + } + }; + } + return err; + } +} + +@display { + label: "Security Advisories File Share Backend", + id: "security-advisories/files-backend" +} + +service http:InterceptableService / on new http:Listener(9090) { + + # Request interceptors. + # + # + return - Interceptor chain executed for every request + public function createInterceptors() returns http:Interceptor[] { + return [new authorization:JwtInterceptor(), new ErrorInterceptor()]; + } + + # Fail fast at startup if the file share is not reachable. + function init() { + error? fsHealth = file_storage:healthCheck(); + if fsHealth is error { + log:printError("Startup failed: File storage health check failed", fsHealth); + panic fsHealth; + } + } + + # Liveness: returns whether the Azure file share is reachable. + # + # + return - `200` with status payload, or `503` if the share check fails + resource function get health() returns http:Ok|http:ServiceUnavailable { + error? fileStorageHealth = file_storage:healthCheck(); + if fileStorageHealth is error { + string customError = "Health check failed: File storage unavailable"; + log:printError(customError, fileStorageHealth); + return { + body: { + status: "unhealthy", + message: customError + } + }; + } + + return { + body: { + status: "healthy", + message: MSG_SERVICE_HEALTHY, + dependencies: { + fileStorage: "healthy" + } + } + }; + } + + # Stream file bytes for `path` (share-relative). Requires Asgardeo `x-jwt-assertion` and the reader group. + # + # + ctx - Request context (populated by `JwtInterceptor` with `HEADER_USER_INFO`) + # + path - Share-relative file path (query parameter) + # + return - Raw bytes with `Content-Type` and `Content-Disposition`, or `400` / `403` / `404` / `500` with JSON body + resource function get file(http:RequestContext ctx, string path) returns http:Response|http:BadRequest|http:Forbidden| + http:NotFound|http:InternalServerError { + + authorization:CustomJwtPayload|error userInfo = ctx.getWithType(authorization:HEADER_USER_INFO); + if userInfo is error { + return { + body: { + message: "User information header not found!" + } + }; + } + + if !authorization:checkPermissions([authorization:authorizedRoles.securityPatchesUserRole], userInfo.groups) { + return { + body: { + message: "Insufficient privileges!" + } + }; + } + + string|error decodedPath = url:decode(path, "UTF-8"); + if decodedPath is error { + log:printError(string `Invalid path query encoding: ${path}`, decodedPath); + return {body: {message: ERR_MSG_INVALID_PATH}}; + } + string filePath = decodedPath; + + error? pathValidation = file_storage:validatePath(filePath); + if pathValidation is error { + log:printError(string `Invalid path format: ${filePath}`, pathValidation); + return { + body: {message: ERR_MSG_INVALID_PATH} + }; + } + + byte[]|error fileBytes = file_storage:downloadFile(filePath); + if fileBytes is azure_files:NotFoundError { + log:printWarn(string `Advisory path not found in Azure Files (404): ${filePath}`, fileBytes); + return {body: {message: ERR_MSG_FILE_NOT_FOUND}}; + } + if fileBytes is error { + log:printError(string `Failed to download file: ${filePath}`, fileBytes); + return {body: {message: ERR_MSG_DOWNLOAD_SECURITY_ADVISORY}}; + } + + string contentType = file_storage:getContentType(filePath); + string fileName = file_storage:getFileName(filePath); + string|error encodedFileName = url:encode(fileName, "UTF-8"); + if encodedFileName is error { + log:printError("Failed to encode filename for Content-Disposition", encodedFileName); + return {body: {message: ERR_MSG_DOWNLOAD_SECURITY_ADVISORY}}; + } + http:Response response = new; + response.setPayload(fileBytes); + response.setHeader("Content-Type", contentType); + response.setHeader("Content-Disposition", string `inline; filename*=UTF-8''${encodedFileName}`); + return response; + } +} diff --git a/apps/security-advisory-patches-portal/webapp/.eslintrc.js b/apps/security-advisory-patches-portal/webapp/.eslintrc.js new file mode 100644 index 000000000..102489ebf --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/.eslintrc.js @@ -0,0 +1,10 @@ +'use strict'; + +// Single resolve root so IDE ESLint never loads a second copy of +// eslint-plugin-react from another node_modules tree (e.g. global react-app-rewired). +module.exports = { + root: true, + extends: [ + require.resolve('eslint-config-react-app', { paths: [__dirname] }), + ], +}; diff --git a/apps/security-advisory-patches-portal/webapp/.gitignore b/apps/security-advisory-patches-portal/webapp/.gitignore new file mode 100644 index 000000000..4bdd9fc38 --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +public/config.js +package-lock.json +build/ diff --git a/apps/security-advisory-patches-portal/webapp/config-overrides.js b/apps/security-advisory-patches-portal/webapp/config-overrides.js new file mode 100644 index 000000000..66af14c3c --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/config-overrides.js @@ -0,0 +1,48 @@ +const path = require('path'); +const paths = require('react-scripts/config/paths'); + +function hasJsxRuntime() { + if (process.env.DISABLE_NEW_JSX_TRANSFORM === 'true') { + return false; + } + try { + require.resolve('react/jsx-runtime', { paths: [paths.appPath] }); + return true; + } catch { + return false; + } +} + +module.exports = function override(config, env) { + config.resolve = { + ...config.resolve, + alias: { + ...config.resolve.alias, + '@src': path.resolve(__dirname, 'src'), + }, + }; + + const eslintPlugin = config.plugins.find( + (p) => p.constructor && p.constructor.name === 'ESLintWebpackPlugin' + ); + if (eslintPlugin) { + // CRA merges webpack baseConfig with package.json eslintConfig; if those + // resolve eslint-config-react-app from different installs, ESLint 8 reports + // Plugin "react" was conflicted between ... + eslintPlugin.options.useEslintrc = false; + eslintPlugin.options.resolvePluginsRelativeTo = paths.appPath; + eslintPlugin.options.baseConfig = { + extends: [ + require.resolve('eslint-config-react-app', { paths: [paths.appPath] }), + ], + rules: { + ...(!hasJsxRuntime() && { + 'react/react-in-jsx-scope': 'error', + }), + }, + }; + } + + return config; +}; + diff --git a/apps/security-advisory-patches-portal/webapp/index.html b/apps/security-advisory-patches-portal/webapp/index.html new file mode 100644 index 000000000..3b9f5a3f3 --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/index.html @@ -0,0 +1,22 @@ + + + + + + + + + WSO2 Security Advisory Patches Portal + + +
+ + + diff --git a/apps/security-advisory-patches-portal/webapp/package.json b/apps/security-advisory-patches-portal/webapp/package.json new file mode 100644 index 000000000..051f942a5 --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/package.json @@ -0,0 +1,54 @@ +{ + "name": "security-advisories-portal", + "version": "1.0.0", + "private": true, + "dependencies": { + "@asgardeo/auth-react": "5.2.3", + "@emotion/react": "^11.10.5", + "@emotion/styled": "^11.10.5", + "@mui/icons-material": "^5.10.16", + "@mui/material": "^5.10.17", + "@reduxjs/toolkit": "^1.9.1", + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^13.4.0", + "@testing-library/user-event": "^13.5.0", + "@types/jest": "^27.5.2", + "@types/node": "^16.18.6", + "@types/react": "^18.0.26", + "@types/react-dom": "^18.0.9", + "axios": "^1.6.0", + "dayjs": "^1.11.7", + "formik": "^2.2.9", + "notistack": "^2.0.8", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-redux": "^8.0.5", + "react-router-dom": "^6.4.5", + "react-scripts": "5.0.1", + "retry-axios": "3.0.0", + "sass": "^1.59.3", + "typescript": "^4.9.3", + "web-vitals": "^2.1.4" + }, + "scripts": { + "start": "react-app-rewired start", + "build": "react-app-rewired build", + "test": "react-app-rewired test", + "eject": "react-app-rewired eject" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "react-app-rewired": "^2.2.1" + } +} diff --git a/apps/security-advisory-patches-portal/webapp/public/index.html b/apps/security-advisory-patches-portal/webapp/public/index.html new file mode 100644 index 000000000..8fcbe1707 --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/public/index.html @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + WSO2 Security Advisory Patches Portal + + + +
+ + + diff --git a/apps/security-advisory-patches-portal/webapp/public/manifest.json b/apps/security-advisory-patches-portal/webapp/public/manifest.json new file mode 100644 index 000000000..e5febced8 --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/public/manifest.json @@ -0,0 +1,16 @@ +{ + "short_name": "Patches Portal", + "name": "WSO2 Security Advisory Patches Portal", + "icons": [ + { + "src": "wso2-logo.svg", + "type": "image/svg+xml", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} + diff --git a/apps/security-advisory-patches-portal/webapp/public/robots.txt b/apps/security-advisory-patches-portal/webapp/public/robots.txt new file mode 100644 index 000000000..9450c3efc --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/public/robots.txt @@ -0,0 +1,4 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: + diff --git a/apps/security-advisory-patches-portal/webapp/public/wso2-logo.svg b/apps/security-advisory-patches-portal/webapp/public/wso2-logo.svg new file mode 100644 index 000000000..a85f74c35 --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/public/wso2-logo.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/apps/security-advisory-patches-portal/webapp/src/App.tsx b/apps/security-advisory-patches-portal/webapp/src/App.tsx new file mode 100644 index 000000000..d281e7199 --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/src/App.tsx @@ -0,0 +1,52 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { useEffect } from 'react'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { ThemeProvider, CssBaseline } from '@mui/material'; +import { AuthProvider } from '@asgardeo/auth-react'; +import { SnackbarProvider } from 'notistack'; +import { store } from './slices/store'; +import { theme } from './theme'; +import { AsgardeoConfig, APP_NAME } from './config/config'; +import AppAuthProvider from './context/AuthContext'; +import AppHandler from './app/AppHandler'; + +function App() { + useEffect(() => { + document.title = APP_NAME; + }, []); + + return ( + + + + + + + + + + + + + + + ); +} + +export default App; diff --git a/apps/security-advisory-patches-portal/webapp/src/app/AppHandler.tsx b/apps/security-advisory-patches-portal/webapp/src/app/AppHandler.tsx new file mode 100644 index 000000000..e8ebecc4b --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/src/app/AppHandler.tsx @@ -0,0 +1,146 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import React, { useLayoutEffect } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { Box, CircularProgress } from '@mui/material'; +import { useAppAuth } from '@src/context/AuthContext'; +import { useAppSelector } from '@src/slices/store'; +import PatchesPdfPage from '@src/view/PatchesPdf/PatchesPdfPage'; +import RootLandingPage from '@src/view/RootLanding/RootLandingPage'; +import NotFoundPage from '@src/view/NotFound/NotFoundPage'; +import { SEC_ADV_REDIRECT_PATH_KEY, pathnameEndsWithPdf } from '@src/constants/constants'; + +const AppHandler: React.FC = () => { + const { appSignOut } = useAppAuth(); + const { isAuthenticated, isLoading, user } = useAppSelector((state) => state.auth); + const location = useLocation(); + const navigate = useNavigate(); + + useLayoutEffect(() => { + if (!isAuthenticated || isLoading) { + return; + } + try { + const redirectPath = sessionStorage.getItem(SEC_ADV_REDIRECT_PATH_KEY); + const redirectPathOnly = redirectPath?.split('?')[0] ?? ''; + if (!pathnameEndsWithPdf(redirectPathOnly)) { + return; + } + const pathOnly = location.pathname; + const onOAuthLanding = pathOnly === '/' || pathOnly === ''; + if (onOAuthLanding) { + navigate(redirectPath!, { replace: true }); + } + } catch (e) { + console.warn('Failed to restore redirect path:', e); + } + }, [isAuthenticated, isLoading, location.pathname, navigate]); + + useLayoutEffect(() => { + try { + const redirectPath = sessionStorage.getItem(SEC_ADV_REDIRECT_PATH_KEY); + if (!redirectPath) { + return; + } + const current = location.pathname + location.search; + if (current === redirectPath) { + sessionStorage.removeItem(SEC_ADV_REDIRECT_PATH_KEY); + } + } catch { + // ignore + } + }, [location.pathname, location.search]); + + if (isLoading || !isAuthenticated) { + return ( + + + + + Getting things ready... + + + + ); + } + + const pathOnly = location.pathname; + const oauthPdfResume = + typeof window !== 'undefined' && + (pathOnly === '/' || pathOnly === '') && + !!sessionStorage.getItem(SEC_ADV_REDIRECT_PATH_KEY) && + pathnameEndsWithPdf(sessionStorage.getItem(SEC_ADV_REDIRECT_PATH_KEY)!.split('?')[0]); + + if (oauthPdfResume) { + return ( + + + + + Opening your link… + + + + ); + } + + if (pathOnly === '/' || pathOnly === '') { + return ; + } + + if (pathnameEndsWithPdf(pathOnly)) { + return ; + } + + return ; +}; + +export default AppHandler; diff --git a/apps/security-advisory-patches-portal/webapp/src/config/config.ts b/apps/security-advisory-patches-portal/webapp/src/config/config.ts new file mode 100644 index 000000000..8a79059b8 --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/src/config/config.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { BaseURLAuthClientConfig } from '@asgardeo/auth-react'; + +/** Keys read from `public/config.js` as `window.config`. */ +declare global { + interface Window { + /** Runtime configuration injected before the React bundle loads. */ + config: { + /** Application title shown in the browser tab. */ + APP_NAME: string; + /** Asgardeo organization URL (e.g. `https://api.asgardeo.io/t/{org}`). */ + ASGARDEO_BASE_URL: string; + /** OIDC client ID registered in Asgardeo for this SPA. */ + ASGARDEO_CLIENT_ID: string; + /** Post-login redirect URI (must match Asgardeo app registration; typically site root). */ + AUTH_SIGN_IN_REDIRECT_URL: string; + /** Post-logout redirect URI. */ + AUTH_SIGN_OUT_REDIRECT_URL: string; + /** Origin of the Ballerina backend (e.g. `http://localhost:9090`). */ + BACKEND_BASE_URL: string; + }; + } +} + +/** Asgardeo Auth React provider configuration built from `window.config`. */ +export const AsgardeoConfig: BaseURLAuthClientConfig = { + signInRedirectURL: window.config?.AUTH_SIGN_IN_REDIRECT_URL ?? '', + signOutRedirectURL: window.config?.AUTH_SIGN_OUT_REDIRECT_URL ?? '', + clientID: window.config?.ASGARDEO_CLIENT_ID ?? '', + baseUrl: window.config?.ASGARDEO_BASE_URL ?? '', + // Match webapps/webapp-template: `email` and `groups` must appear on the ID token for the Ballerina `CustomJwtPayload`. + scope: ['openid', 'email', 'groups'], +}; + +const serviceBaseUrl = window.config?.BACKEND_BASE_URL ?? ''; + +/** Default app title if `APP_NAME` is not set in `config.js`. */ +export const APP_NAME = window.config?.APP_NAME ?? 'Security Advisory Patches Portal'; + +/** Resolved API URLs for the SPA. */ +export const AppConfig = { + /** `GET /file` on the Ballerina backend (append `?path=…`). */ + downloadFileUrl: `${serviceBaseUrl}/file`, +}; diff --git a/apps/security-advisory-patches-portal/webapp/src/constants/constants.ts b/apps/security-advisory-patches-portal/webapp/src/constants/constants.ts new file mode 100644 index 000000000..e60ed70f4 --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/src/constants/constants.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** `sessionStorage` key for restoring a `.pdf` URL after Asgardeo redirects to `/`. */ +export const SEC_ADV_REDIRECT_PATH_KEY = 'sec_adv_redirect_path'; + +/** `sessionStorage` flag used to avoid duplicate `signIn()` loops while unauthenticated. */ +export const SEC_ADV_SIGN_IN_INIT_KEY = 'sec_adv_sign_in_initiated'; + +/** + * @param pathname - Path only (e.g. `/a/b.pdf`); query string is not considered. + * @returns Whether the last path segment ends with `.pdf` (case-insensitive). + */ +export function pathnameEndsWithPdf(pathname: string): boolean { + const trimmed = pathname.replace(/\/+$/, ''); + const lastSegment = trimmed.split('/').pop() ?? ''; + return /\.pdf$/i.test(lastSegment); +} diff --git a/apps/security-advisory-patches-portal/webapp/src/context/AuthContext.tsx b/apps/security-advisory-patches-portal/webapp/src/context/AuthContext.tsx new file mode 100644 index 000000000..d94086c20 --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/src/context/AuthContext.tsx @@ -0,0 +1,151 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import React, { createContext, useContext, useEffect } from 'react'; +import { SecureApp, useAuthContext } from '@asgardeo/auth-react'; +import { Box, CircularProgress } from '@mui/material'; +import { useAppDispatch } from '@src/slices/store'; +import { setAuthenticated, setUser, setLoading } from '@src/slices/authSlice/auth'; +import { APIService } from '@src/utils/apiService'; +import { UserInfo } from '@src/types/types'; +import { SEC_ADV_REDIRECT_PATH_KEY, SEC_ADV_SIGN_IN_INIT_KEY, pathnameEndsWithPdf } from '@src/constants/constants'; + +interface AppAuthContextType { + appSignIn: () => void; + appSignOut: () => void; +} + +const AppAuthContext = createContext(undefined); + +export const AppAuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const dispatch = useAppDispatch(); + const { state, signIn, signOut, getBasicUserInfo, getIDToken } = useAuthContext(); + + useEffect(() => { + const isSignInInitiated = sessionStorage.getItem(SEC_ADV_SIGN_IN_INIT_KEY) === 'true'; + + if (state.isAuthenticated) { + getBasicUserInfo().then( + async (basicUserInfo) => { + const userInfo: UserInfo = { + username: basicUserInfo?.username || basicUserInfo?.email || 'User', + email: basicUserInfo?.email || '', + sub: basicUserInfo?.sub || '', + }; + + dispatch(setUser(userInfo)); + dispatch(setAuthenticated(true)); + dispatch(setLoading(false)); + + if (typeof window !== 'undefined' && pathnameEndsWithPdf(window.location.pathname)) { + sessionStorage.removeItem(SEC_ADV_REDIRECT_PATH_KEY); + } + + new APIService(async () => { + const token = await getIDToken(); + return { idToken: token || '' }; + }); + + sessionStorage.setItem(SEC_ADV_SIGN_IN_INIT_KEY, 'false'); + } + ).catch((error) => { + console.error('Auth initialization error:', error); + sessionStorage.setItem(SEC_ADV_SIGN_IN_INIT_KEY, 'false'); + dispatch(setAuthenticated(false)); + dispatch(setLoading(false)); + }); + } else if (!isSignInInitiated) { + sessionStorage.setItem(SEC_ADV_SIGN_IN_INIT_KEY, 'true'); + const path = window.location.pathname + window.location.search; + if (pathnameEndsWithPdf(window.location.pathname)) { + sessionStorage.setItem(SEC_ADV_REDIRECT_PATH_KEY, path); + } + signIn(); + } else if (typeof window !== 'undefined' && pathnameEndsWithPdf(window.location.pathname)) { + sessionStorage.setItem(SEC_ADV_REDIRECT_PATH_KEY, window.location.pathname + window.location.search); + signIn(); + } + }, [state.isAuthenticated, dispatch, getBasicUserInfo, getIDToken, signIn]); + + const appSignOut = async () => { + try { + dispatch(setLoading(true)); + await signOut(); + dispatch(setAuthenticated(false)); + dispatch(setUser(null)); + sessionStorage.setItem(SEC_ADV_SIGN_IN_INIT_KEY, 'false'); + } catch (error) { + console.error('Sign out error:', error); + } finally { + dispatch(setLoading(false)); + } + }; + + const appSignIn = () => { + signIn(); + }; + + const authContext: AppAuthContextType = { + appSignIn, + appSignOut, + }; + + return ( + + + + + + Getting things ready... + + + + } + > + {children} + + + ); +}; + +export const useAppAuth = (): AppAuthContextType => { + const context = useContext(AppAuthContext); + if (!context) { + throw new Error('useAppAuth must be used within AppAuthProvider'); + } + return context; +}; + +export default AppAuthProvider; diff --git a/apps/security-advisory-patches-portal/webapp/src/index.css b/apps/security-advisory-patches-portal/webapp/src/index.css new file mode 100644 index 000000000..92a70afb7 --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/src/index.css @@ -0,0 +1,50 @@ +/* +Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). All Rights Reserved. +This software is the property of WSO2 LLC. and its suppliers, if any. +Dissemination of any information or reproduction of any material contained +herein in any form is strictly forbidden, unless permitted by WSO2 expressly. +You may not alter or remove any copyright or other notice from copies of this content. +*/ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + margin: 0; + font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} + +#root { + height: 100vh; + overflow: hidden; +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; +} + +::-webkit-scrollbar-thumb { + background: #888; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #555; +} diff --git a/apps/security-advisory-patches-portal/webapp/src/index.tsx b/apps/security-advisory-patches-portal/webapp/src/index.tsx new file mode 100644 index 000000000..04c17e072 --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/src/index.tsx @@ -0,0 +1,40 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import './index.css'; +import App from './App'; +import { SEC_ADV_REDIRECT_PATH_KEY, SEC_ADV_SIGN_IN_INIT_KEY } from './constants/constants'; + +try { + const path = window.location.pathname; + if (path.startsWith('/patches')) { + sessionStorage.setItem(SEC_ADV_REDIRECT_PATH_KEY, path + window.location.search); + sessionStorage.removeItem(SEC_ADV_SIGN_IN_INIT_KEY); + } +} catch { + /* ignore */ +} + +const rootElement = document.getElementById('root'); + +if (!rootElement) { + throw new Error('Root element not found'); +} + +const root = ReactDOM.createRoot(rootElement); +root.render(); diff --git a/apps/security-advisory-patches-portal/webapp/src/layout/Header.tsx b/apps/security-advisory-patches-portal/webapp/src/layout/Header.tsx new file mode 100644 index 000000000..b35bad0c9 --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/src/layout/Header.tsx @@ -0,0 +1,177 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + AppBar, + Toolbar, + Typography, + Box, + Avatar, + Menu, + MenuItem, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + ListItemIcon, + ListItemText, +} from '@mui/material'; +import { + AccountCircle as UserIcon, + Logout as LogOutIcon, + KeyboardArrowDown as ArrowDownIcon, +} from '@mui/icons-material'; + +interface HeaderProps { + username?: string; + onLogout: () => void; +} + +const Header: React.FC = ({ + username = 'User', + onLogout, +}) => { + const navigate = useNavigate(); + const [anchorEl, setAnchorEl] = useState(null); + const [logoutDialogOpen, setLogoutDialogOpen] = useState(false); + const menuOpen = Boolean(anchorEl); + + const handleMenuClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleMenuClose = () => { + setAnchorEl(null); + }; + + const handleLogoutClick = () => { + handleMenuClose(); + setLogoutDialogOpen(true); + }; + + const handleLogoutConfirm = () => { + setLogoutDialogOpen(false); + onLogout(); + }; + + const handleLogoutCancel = () => { + setLogoutDialogOpen(false); + }; + + return ( + <> + + + + + WSO2 Logo navigate('/')} + /> + + Security Advisory Patches Portal + + + + + + + + + + {username} + + + + + + + + + + + Logout + + + + + Confirm Logout + + Are you sure you want to logout? + + + + + + + + ); +}; + +export default Header; diff --git a/apps/security-advisory-patches-portal/webapp/src/slices/authSlice/auth.ts b/apps/security-advisory-patches-portal/webapp/src/slices/authSlice/auth.ts new file mode 100644 index 000000000..128844b89 --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/src/slices/authSlice/auth.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { UserInfo } from '@src/types/types'; + +/** Redux state for OIDC session and user display fields used by the header. */ +interface AuthState { + isAuthenticated: boolean; + user: UserInfo | null; + isLoading: boolean; +} + +const initialState: AuthState = { + isAuthenticated: false, + user: null, + isLoading: true, +}; + +const authSlice = createSlice({ + name: 'auth', + initialState, + reducers: { + setAuthenticated: (state, action: PayloadAction) => { + state.isAuthenticated = action.payload; + state.isLoading = false; + }, + setUser: (state, action: PayloadAction) => { + state.user = action.payload; + }, + setLoading: (state, action: PayloadAction) => { + state.isLoading = action.payload; + }, + logout: (state) => { + state.isAuthenticated = false; + state.user = null; + }, + }, +}); + +/** Action creators: `setAuthenticated`, `setUser`, `setLoading`, `logout`. */ +export const { setAuthenticated, setUser, setLoading, logout } = authSlice.actions; + +export default authSlice.reducer; diff --git a/apps/security-advisory-patches-portal/webapp/src/slices/store.ts b/apps/security-advisory-patches-portal/webapp/src/slices/store.ts new file mode 100644 index 000000000..28182e278 --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/src/slices/store.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { configureStore } from '@reduxjs/toolkit'; +import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; +import authReducer from './authSlice/auth'; + +export const store = configureStore({ + reducer: { + auth: authReducer, + }, +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; + +export const useAppDispatch: () => AppDispatch = useDispatch; +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/apps/security-advisory-patches-portal/webapp/src/theme.ts b/apps/security-advisory-patches-portal/webapp/src/theme.ts new file mode 100644 index 000000000..3d1235116 --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/src/theme.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { createTheme } from '@mui/material/styles'; + +// Create a theme with WSO2 branding +export const theme = createTheme({ + palette: { + primary: { + main: '#FF7300', // WSO2 orange + }, + secondary: { + main: '#0e1624', // WSO2 dark blue/black + }, + background: { + default: '#f7f7f7', + paper: '#ffffff', + }, + }, + typography: { + fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif', + button: { + textTransform: 'none', + }, + }, + components: { + MuiAppBar: { + defaultProps: { + color: 'secondary', + }, + }, + MuiButton: { + styleOverrides: { + root: { + borderRadius: 4, + }, + }, + }, + MuiCard: { + styleOverrides: { + root: { + borderRadius: 8, + }, + }, + }, + }, +}); diff --git a/apps/security-advisory-patches-portal/webapp/src/types/images.d.ts b/apps/security-advisory-patches-portal/webapp/src/types/images.d.ts new file mode 100644 index 000000000..c684d7928 --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/src/types/images.d.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +declare module '*.svg' { + const content: string; + export default content; +} + +declare module '*.png' { + const content: string; + export default content; +} + +declare module '*.jpg' { + const content: string; + export default content; +} + +declare module '*.jpeg' { + const content: string; + export default content; +} diff --git a/apps/security-advisory-patches-portal/webapp/src/types/types.ts b/apps/security-advisory-patches-portal/webapp/src/types/types.ts new file mode 100644 index 000000000..2cabf5932 --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/src/types/types.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** Signed-in user fields stored in Redux after Asgardeo `getBasicUserInfo()`. */ +export interface UserInfo { + /** Display or login name. */ + username: string; + /** Email when provided by the IdP. */ + email: string; + /** OIDC subject identifier. */ + sub: string; +} diff --git a/apps/security-advisory-patches-portal/webapp/src/utils/apiService.ts b/apps/security-advisory-patches-portal/webapp/src/utils/apiService.ts new file mode 100644 index 000000000..d8e61d768 --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/src/utils/apiService.ts @@ -0,0 +1,103 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import axios, { AxiosInstance } from 'axios'; +import { attach, RaxConfig } from 'retry-axios'; + +/** + * Singleton Axios instance with Asgardeo ID token on `x-jwt-assertion` and 401 retries (`retry-axios`), + * aligned with `webapps/webapp-template` and the Ballerina `JwtInterceptor`. + */ +export class APIService { + private static _instance: AxiosInstance; + private static _callback: () => Promise<{ idToken: string }>; + private static _initialized = false; + private static _authInterceptorId: number | null = null; + + /** + * @param callback - Returns a fresh Asgardeo ID token for `x-jwt-assertion` on each request (and on retry), + * matching `webapps/webapp-template` / backend JWT interceptor expectations. + */ + constructor(callback: () => Promise<{ idToken: string }>) { + if (!APIService._instance) { + APIService._instance = axios.create({ + withCredentials: true, + }); + } + + attach(APIService._instance); + + APIService._callback = callback; + APIService._initialized = true; + APIService.updateRequestInterceptor(); + + (APIService._instance.defaults as unknown as RaxConfig).raxConfig = { + retry: 3, + instance: APIService._instance, + httpMethodsToRetry: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE'], + statusCodesToRetry: [[401, 401]], + retryDelay: 100, + + onRetryAttempt: async () => { + await callback(); + if (APIService._authInterceptorId !== null) { + APIService._instance.interceptors.request.eject(APIService._authInterceptorId); + } + APIService.updateRequestInterceptor(); + }, + }; + } + + /** Shared Axios instance; creates a bare instance if `APIService` was never constructed with auth. */ + public static getInstance(): AxiosInstance { + if (!APIService._instance) { + APIService._instance = axios.create({ + withCredentials: true, + }); + } + + if (!APIService._initialized) { + attach(APIService._instance); + APIService._initialized = true; + } + + return APIService._instance; + } + + /** Re-registers the request interceptor after ejecting the previous one (used on 401 retry). */ + private static updateRequestInterceptor() { + if (APIService._authInterceptorId !== null) { + APIService._instance.interceptors.request.eject(APIService._authInterceptorId); + } + + APIService._authInterceptorId = APIService._instance.interceptors.request.use( + async (config) => { + if (APIService._callback && APIService._initialized) { + try { + const res = await APIService._callback(); + config.headers.set('x-jwt-assertion', res.idToken); + } catch (error) { + console.error('Failed to get token:', error); + } + } + + return config; + }, + (error) => Promise.reject(error) + ); + } +} diff --git a/apps/security-advisory-patches-portal/webapp/src/utils/fileService.ts b/apps/security-advisory-patches-portal/webapp/src/utils/fileService.ts new file mode 100644 index 000000000..996508f54 --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/src/utils/fileService.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { APIService } from './apiService'; +import { AppConfig } from '@src/config/config'; + +/** + * Download advisory bytes from `GET /file?path=…`. The shared `APIService` must attach **`x-jwt-assertion`** + * (ID token) on each request; see `webapps/webapp-template` for the same pattern. + */ +export const downloadSecurityAdvisory = async (path: string): Promise => { + const response = await APIService.getInstance().get(AppConfig.downloadFileUrl, { + params: { path }, + responseType: 'blob', + }); + return response.data; +}; + +/** Last segment of a `/`-delimited path (used for UI labels and `Content-Disposition` filename hints). */ +export const getFileName = (path: string): string => { + const parts = path.split('/'); + return parts[parts.length - 1] || path; +}; diff --git a/apps/security-advisory-patches-portal/webapp/src/utils/utils.ts b/apps/security-advisory-patches-portal/webapp/src/utils/utils.ts new file mode 100644 index 000000000..91e3909e3 --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/src/utils/utils.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +const DASH_LITERAL_HOLD = '\uE000'; + +/** Decode legacy doubled-hyphen encoding inside a path segment. */ +function unescapeDashSegment(raw: string): string { + return raw + .split('--') + .map((chunk) => chunk.replace(/-/g, ' ')) + .join(DASH_LITERAL_HOLD) + .replace(new RegExp(DASH_LITERAL_HOLD, 'g'), '-'); +} + +/** Decode one URL path segment: `decodeURIComponent`, plus optional legacy `--` / `-` space rules. */ +function decodePathSegment(segment: string): string { + if (segment.includes('%20')) { + return decodeURIComponent(segment); + } + const raw = decodeURIComponent(segment); + if (!raw.includes('--')) { + return raw; + } + return unescapeDashSegment(raw); +} + +const SLUG_FOLDER_SEGMENT = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/; + +/** Map `security-patches` → `Security Patches`; leaves non-slug segments unchanged. */ +function slugFolderSegmentToAzureTitle(segment: string): string { + if (!SLUG_FOLDER_SEGMENT.test(segment)) { + return segment; + } + return segment + .split('-') + .filter((w) => w.length > 0) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .join(' '); +} + +/** + * Map the browser pathname to the share-relative path sent to `/file`. + * - Strips optional leading `patches` segment. + * - Requires the last segment to end in `.pdf`. + * - Rewrites lowercase kebab directory segments to Azure-style titled folder names; does not alter the PDF file name segment. + * + * @param pathname - `location.pathname` (no origin or query) + * @returns Share-relative path, or `null` if the URL is not a valid PDF document path + */ +export function pathnameToPdfStoragePath(pathname: string): string | null { + const trimmed = pathname.trim().replace(/\/+$/, ''); + const rawSegments = trimmed.split('/').filter((s) => s.length > 0); + if (rawSegments.length === 0) { + return null; + } + const decoded = rawSegments.map((s) => decodePathSegment(s)); + const last = decoded[decoded.length - 1]; + if (!/\.pdf$/i.test(last)) { + return null; + } + let rest = decoded; + if (rest[0] === 'patches') { + rest = rest.slice(1); + } + if (rest.length === 0) { + return null; + } + const fileSeg = rest[rest.length - 1]; + const dirSegs = rest.slice(0, -1).map((d) => slugFolderSegmentToAzureTitle(d)); + return [...dirSegs, fileSeg].join('/'); +} diff --git a/apps/security-advisory-patches-portal/webapp/src/view/NotFound/NotFoundPage.tsx b/apps/security-advisory-patches-portal/webapp/src/view/NotFound/NotFoundPage.tsx new file mode 100644 index 000000000..2721d6965 --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/src/view/NotFound/NotFoundPage.tsx @@ -0,0 +1,55 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import React from 'react'; +import { Box, Paper, Typography } from '@mui/material'; +import Header from '@src/layout/Header'; + +interface NotFoundPageProps { + username?: string; + onLogout: () => void; +} + +const NotFoundPage: React.FC = ({ username, onLogout }) => { + return ( + +
+ + + + 404 + + + Page not found + + + Open a valid link that ends with .pdf, or the file may be missing from storage. + + + + + ); +}; + +export default NotFoundPage; diff --git a/apps/security-advisory-patches-portal/webapp/src/view/PatchesPdf/PatchesPdfPage.tsx b/apps/security-advisory-patches-portal/webapp/src/view/PatchesPdf/PatchesPdfPage.tsx new file mode 100644 index 000000000..280867eb5 --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/src/view/PatchesPdf/PatchesPdfPage.tsx @@ -0,0 +1,131 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import React, { useEffect, useMemo, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import { Box, CircularProgress, Typography } from '@mui/material'; +import Header from '@src/layout/Header'; +import { pathnameToPdfStoragePath } from '@src/utils/utils'; +import { downloadSecurityAdvisory, getFileName } from '@src/utils/fileService'; +import NotFoundPage from '@src/view/NotFound/NotFoundPage'; + +interface PatchesPdfPageProps { + username?: string; + onLogout: () => void; +} + +const PatchesPdfPage: React.FC = ({ username, onLogout }) => { + const location = useLocation(); + const storagePath = useMemo(() => pathnameToPdfStoragePath(location.pathname), [location.pathname]); + + const [blobUrl, setBlobUrl] = useState(null); + const [notFound, setNotFound] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!storagePath) { + setNotFound(true); + setLoading(false); + return; + } + + let cancelled = false; + let objectUrl = ''; + + const run = async () => { + setLoading(true); + setNotFound(false); + setBlobUrl(null); + + try { + const blob = await downloadSecurityAdvisory(storagePath); + if (cancelled) { + return; + } + objectUrl = URL.createObjectURL(blob); + setBlobUrl(objectUrl); + } catch { + if (!cancelled) { + setNotFound(true); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + run(); + + return () => { + cancelled = true; + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + } + }; + }, [storagePath]); + + if (notFound) { + return ; + } + + const displayName = storagePath ? getFileName(storagePath) : ''; + + return ( + +
+ + {displayName ? ( + + {displayName} + + ) : null} + + {loading && ( + + + + )} + + {!loading && blobUrl && ( + + )} + + + ); +}; + +export default PatchesPdfPage; diff --git a/apps/security-advisory-patches-portal/webapp/src/view/RootLanding/RootLandingPage.tsx b/apps/security-advisory-patches-portal/webapp/src/view/RootLanding/RootLandingPage.tsx new file mode 100644 index 000000000..447690af9 --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/src/view/RootLanding/RootLandingPage.tsx @@ -0,0 +1,63 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import React from 'react'; +import { Box, Paper, Typography } from '@mui/material'; +import Header from '@src/layout/Header'; + +interface RootLandingPageProps { + username?: string; + onLogout: () => void; +} + +/** + * Shown at site origin (`/`) after sign-in when the user opened the portal without a PDF advisory link. + */ +const RootLandingPage: React.FC = ({ username, onLogout }) => { + return ( + +
+ + + + Use your advisory PDF link + + + This site only opens a PDF when you use the link you were sent—the address path must end with{' '} + .pdf (for example /patches/your-doc.pdf or another path your team uses). + + + Paste that full URL into the address bar (for example{' '} + patches.wso2.com/patches/your-doc.pdf), or sign out below and open the link from your email again. + + + Signing out and opening your link again is the most reliable way to reach the right document. + + + + + ); +}; + +export default RootLandingPage; diff --git a/apps/security-advisory-patches-portal/webapp/tsconfig.json b/apps/security-advisory-patches-portal/webapp/tsconfig.json new file mode 100644 index 000000000..1b15aef5d --- /dev/null +++ b/apps/security-advisory-patches-portal/webapp/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "baseUrl": "src", + "paths": { + "@src/*": ["*"] + } + }, + "include": ["src"] +}