diff --git a/app/config.js b/app/config.js index bf57afe..6b5de7f 100644 --- a/app/config.js +++ b/app/config.js @@ -42,7 +42,19 @@ function normalizeBetaFlag(value) { return value === 'true'; } -// 驗證字串為合法的 http/https URL,否則拋出錯誤(避免指向非預期協定或格式錯誤的位址) +/** + * 驗證字串為合法的 http/https URL,否則拋出錯誤(避免指向非預期協定或格式錯誤的位址)。 + * + * 先以 `new URL(value)` 解析,無法解析時視為格式錯誤;解析成功後再檢查協定, + * 僅允許 `http:` 與 `https:`,其餘協定(如 ftp)一律拒絕。 + * + * @param {string} name - 設定項名稱,用於組出錯誤訊息(例如 `GITEA_SERVER_URL`)。 + * @param {string} value - 待驗證的 URL 字串。 + * @returns {void} 驗證通過時不回傳值;不通過時改以拋出例外表示。 + * @throws {Error} 當 value 無法解析為合法 URL 時拋出 `${name} 格式錯誤,必須為合法的 URL`。 + * @throws {Error} 當 value 協定非 http/https 時拋出 `${name} 必須使用 http 或 https 協定`。 + * @remarks 由 loadConfig 於驗證 GITEA_SERVER_URL 時呼叫,確保後續組出的 release API URL 指向合法位址。 + */ function assertHttpUrl(name, value) { let parsed; try { @@ -55,20 +67,42 @@ function assertHttpUrl(name, value) { } } +/** + * 驗證字串為合法的 `owner/repo` 形式,否則拋出錯誤。 + * + * 此值會直接拼接於 release API URL(`.../repos/{repository}/releases`),故須限制為單一斜線 + * 分隔的合法 repo 字元,並排除含 `..` 等可能造成路徑穿越或非預期端點存取的輸入。 + * + * @param {string} name - 設定項名稱,用於組出錯誤訊息(例如 `GITEA_REPOSITORY`)。 + * @param {string} value - 待驗證的 `owner/repo` 字串。 + * @returns {void} 驗證通過時不回傳值;不通過時改以拋出例外表示。 + * @throws {Error} 當 value 非 `owner/repo` 形式、含非法字元或含 `..` 路徑穿越片段時拋出。 + * @remarks 由 loadConfig 於驗證 GITEA_REPOSITORY 時呼叫;允許 repo 名稱含 `.`(如 `org/my.repo`), + * 但拒絕任何 `..` 片段與多餘斜線,避免拼接出非預期的 API 路徑。 + */ +function assertRepository(name, value) { + if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(value) || value.includes('..')) { + throw new Error(`${name} 格式錯誤,必須為 owner/repo 形式`); + } +} + /** * 從環境變數載入並驗證執行所需的設定。 * * GITEA_SERVER_URL 與 GITEA_REPOSITORY 為必填,未設定時會拋出錯誤;GITEA_SERVER_URL - * 另需為合法的 http/https URL;GITEA_TOKEN 為非必填,未設定時為 null;IS_BETA 會被正規化為布林值。 + * 另需為合法的 http/https URL;GITEA_REPOSITORY 另需為合法的 `owner/repo` 形式(排除路徑穿越); + * GITEA_TOKEN 為非必填,未設定時為 null;IS_BETA 會被正規化為布林值。 * * @param {Object} [env=process.env] - 環境變數來源物件,預設為 process.env。 * @returns {{ serverUrl: string, repository: string, token: (string|null), isBeta: boolean }} 已驗證的設定物件。 - * @throws {Error} 當 GITEA_SERVER_URL 或 GITEA_REPOSITORY 未設定,或 GITEA_SERVER_URL 非合法 http/https URL 時拋出。 + * @throws {Error} 當 GITEA_SERVER_URL 或 GITEA_REPOSITORY 未設定、GITEA_SERVER_URL 非合法 http/https URL, + * 或 GITEA_REPOSITORY 非合法 owner/repo 形式時拋出。 */ function loadConfig(env = process.env) { const serverUrl = requireEnv('GITEA_SERVER_URL', env.GITEA_SERVER_URL); assertHttpUrl('GITEA_SERVER_URL', serverUrl); const repository = requireEnv('GITEA_REPOSITORY', env.GITEA_REPOSITORY); + assertRepository('GITEA_REPOSITORY', repository); const token = isUnset(env.GITEA_TOKEN) ? null : env.GITEA_TOKEN; const isBeta = normalizeBetaFlag(env.IS_BETA); diff --git a/app/releases.js b/app/releases.js index e2b049d..db64c1b 100644 --- a/app/releases.js +++ b/app/releases.js @@ -15,9 +15,11 @@ const RELEASES_PER_PAGE = 10; * 進行授權請求,否則以匿名方式請求。 * @param {{ info: (message: string) => void }} [options.logger] - 選用的記錄器, * 需提供 info() 方法以輸出進度訊息。 - * @returns {Promise} 解析為所有 release 物件合併後的陣列。 - * @throws {Error} 當網路請求失敗、response 非 2xx、回傳資料無法解析, + * @returns {Promise} 解析為所有 release 物件合併後的陣列;首頁即無資料時回傳空陣列。 + * @throws {Error} 當網路請求失敗、response 非 2xx、回應本文讀取失敗、回傳資料無法解析, * 或回傳非陣列資料時拋出。 + * @remarks 依賴 Node.js 18+ 的全域 fetch;baseUrl 不含 query string,函式自行附加 + * `?limit=&page=` 逐頁請求,直到某頁空字串/`null`/不足一頁為止。 */ async function fetchReleases(baseUrl, options = {}) { const { token, logger } = options; @@ -63,7 +65,9 @@ async function fetchReleases(baseUrl, options = {}) { try { pageJson = JSON.parse(text); } catch { - throw new Error(`release API 回傳資料無法解析 (page=${page})`); + // 附上截斷的回傳內容(至多 200 字元)以利定位回傳格式異常的確切原因 + const snippet = text.length > 200 ? `${text.slice(0, 200)}…` : text; + throw new Error(`release API 回傳資料無法解析 (page=${page}):${snippet}`); } if (pageJson === null) {