diff --git a/app/config.js b/app/config.js index f0278ce..f2895cc 100644 --- a/app/config.js +++ b/app/config.js @@ -1,7 +1,13 @@ // 讀取並驗證環境變數,組出後續流程所需的設定物件。 // 對應原本 entrypoint.sh 的「參數檢查」區段。 -import { isEmptyOrNull, requireValue, requireInteger } from './validate.js' +import { + isEmptyOrNull, + requireValue, + requireInteger, + requireUrl, + requireRepository, +} from './validate.js' import { section, info, warn } from './logger.js' /** @@ -27,9 +33,11 @@ export function loadConfig(env = process.env) { info(`GITEA_SERVER_URL=${serverUrl}`) requireValue('GITEA_SERVER_URL', serverUrl) + requireUrl('GITEA_SERVER_URL', serverUrl) info(`GITEA_REPOSITORY=${repository}`) requireValue('GITEA_REPOSITORY', repository) + requireRepository('GITEA_REPOSITORY', repository) info(`KEEP_COUNT=${keepCountRaw}`) requireValue('KEEP_COUNT', keepCountRaw) diff --git a/app/gitea-client.js b/app/gitea-client.js index d5a7bf6..6f6ce8e 100644 --- a/app/gitea-client.js +++ b/app/gitea-client.js @@ -1,6 +1,9 @@ // 與 Gitea API 溝通的 HTTP 客戶端,封裝認證標頭、分頁讀取與刪除請求。 // 對應原本 entrypoint.sh 的 fetch_all_pages 與 curl DELETE 呼叫。 +// 單一 HTTP 請求的逾時(毫秒)。避免 API 緩慢或掛起時容器永久卡死。 +const REQUEST_TIMEOUT_MS = 30000 + /** * 與 Gitea REST API 溝通的輕量 HTTP 客戶端,負責帶上認證標頭、分頁讀取清單與發出刪除請求。 * 取代原 bash 版本以 `curl`/`jq` 進行的 API 操作。 @@ -34,10 +37,25 @@ export class GiteaClient { while (true) { const url = `${baseUrl}?page=${page}` - const res = await fetch(url, { headers: this.headers }) + const res = await fetch(url, { + headers: this.headers, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) if (!res.ok) { - throw new Error(`GET ${url} failed: HTTP ${res.status}`) + const body = await res.text().catch(() => '') + throw new Error( + `GET ${url} failed: HTTP ${res.status}${body ? ` - ${body.slice(0, 200)}` : ''}`, + ) + } + + // 確認回應確實是 JSON,避免 API 回傳 HTML 錯誤頁時 res.json() 拋出難以理解的 SyntaxError。 + const contentType = res.headers.get('content-type') || '' + if (!contentType.includes('application/json')) { + const body = await res.text().catch(() => '') + throw new Error( + `GET ${url} returned non-JSON content-type "${contentType}": ${body.slice(0, 200)}`, + ) } const items = await res.json() @@ -58,7 +76,11 @@ export class GiteaClient { * @returns {Promise} 回應的 HTTP 狀態碼(成功刪除通常為 204) */ async deleteResource(url) { - const res = await fetch(url, { method: 'DELETE', headers: this.headers }) + const res = await fetch(url, { + method: 'DELETE', + headers: this.headers, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) return res.status } } diff --git a/app/index.js b/app/index.js index 4cb2d44..ec5c3fb 100644 --- a/app/index.js +++ b/app/index.js @@ -14,7 +14,7 @@ import { separator, fail } from './logger.js' * * @returns {Promise} 流程完成時 resolve;設定驗證或讀取 API 失敗時 reject。 */ -async function main() { +export async function main() { const config = loadConfig() const client = new GiteaClient({ token: config.token }) @@ -24,7 +24,26 @@ async function main() { separator() } -main().catch((error) => { - fail(error.message) - process.exit(1) -}) +/** + * 將錯誤輸出至 stderr,並盡量保留可供除錯的上下文(錯誤類型與堆疊), + * 以便區分設定驗證錯誤與網路/API 請求錯誤。 + * @param {unknown} error 捕捉到的錯誤 + */ +function reportFatal(error) { + if (error instanceof Error) { + fail(`${error.name}: ${error.message}`) + if (error.stack) { + process.stderr.write(`${error.stack}\n`) + } + } else { + fail(String(error)) + } +} + +// 僅在直接以 `node index.js` 執行時啟動主流程;被測試 import 時不自動執行,方便撰寫整合測試。 +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((error) => { + reportFatal(error) + process.exit(1) + }) +} diff --git a/app/releases.js b/app/releases.js index 9ec6881..4bd40f3 100644 --- a/app/releases.js +++ b/app/releases.js @@ -12,9 +12,12 @@ import { isEmptyOrNull } from './validate.js' * @returns {any[]} 需要刪除的成品陣列(較舊者),依新到舊排序 */ export function selectReleasesToDelete(releases, keepCount) { - const sorted = [...releases].sort( - (a, b) => new Date(b.created_at) - new Date(a.created_at), - ) + // 解析 created_at;格式無效或缺漏時視為最舊(0),避免 NaN 造成排序結果不可預期。 + const createdTime = (release) => { + const time = Date.parse(release?.created_at) + return Number.isNaN(time) ? 0 : time + } + const sorted = [...releases].sort((a, b) => createdTime(b) - createdTime(a)) return sorted.slice(keepCount) } diff --git a/app/validate.js b/app/validate.js index 3115cf4..7c2ee95 100644 --- a/app/validate.js +++ b/app/validate.js @@ -34,3 +34,35 @@ export function requireInteger(name, value) { throw new Error(`${name} must be a non-negative integer`) } } + +/** + * 要求值為合法的 http/https URL,藉此避免設定來源指向格式錯誤或非預期協定的伺服器(降低 SSRF 風險)。 + * @param {string} name 欄位名稱,用於組出錯誤訊息 + * @param {string} value 待檢查的 URL 字串 + * @throws {Error} 當 value 無法解析為 URL 時丟出 `${name} must be a valid URL`; + * 協定非 http/https 時丟出 `${name} must use http or https protocol` + */ +export function requireUrl(name, value) { + let url + try { + url = new URL(value) + } catch { + throw new Error(`${name} must be a valid URL`) + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`${name} must use http or https protocol`) + } +} + +/** + * 要求值為合法的 `owner/repo` 形式:僅允許英數字與 `. _ -`、恰好一個 `/`,且不可含路徑穿越片段 `..`。 + * 用於防止 GITEA_REPOSITORY 被竄改造成 API 目標被導向其他儲存庫。 + * @param {string} name 欄位名稱,用於組出錯誤訊息 + * @param {string} value 待檢查的 repository 字串 + * @throws {Error} 格式不符或含 `..` 時丟出 `${name} must be in the form owner/repo without path traversal` + */ +export function requireRepository(name, value) { + if (String(value).includes('..') || !/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(value)) { + throw new Error(`${name} must be in the form owner/repo without path traversal`) + } +}