解決 AI review 的安全性與健壯性問題: - 驗證 GITEA_SERVER_URL 為合法 http/https URL(降低 SSRF 風險) - 驗證 GITEA_REPOSITORY 為 owner/repo 形式並拒絕路徑穿越 - 為所有 fetch 請求加上 30 秒逾時(AbortSignal.timeout),避免卡死 - fetchAllPages 解析前檢查 content-type,非 JSON 時拋出明確錯誤 - selectReleasesToDelete 對無效 created_at 防呆,避免 NaN 排序 - index.js 匯出 main 並加上直接執行守衛,錯誤輸出含類型與堆疊 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
87 lines
3.0 KiB
JavaScript
87 lines
3.0 KiB
JavaScript
// 與 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 操作。
|
|
*/
|
|
export class GiteaClient {
|
|
/**
|
|
* 建立客戶端;有 token 時於後續所有請求帶上 `Authorization: token <token>` 標頭,
|
|
* 無 token(或為 `null`)時以匿名方式呼叫。
|
|
* @param {{ token?: string | null }} [options] 認證設定
|
|
*/
|
|
constructor({ token = null } = {}) {
|
|
this.headers = {}
|
|
if (token) {
|
|
this.headers.Authorization = `token ${token}`
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 逐頁讀取分頁式清單 API(每頁以 `?page=N` 由 1 遞增),直到某頁回傳空陣列(或非陣列)為止,
|
|
* 將所有頁面項目合併成單一陣列回傳。
|
|
*
|
|
* 注意:終止條件依賴「空陣列代表最後一頁」的假設;若 API 不以空陣列結尾則迴圈不會自然停止。
|
|
*
|
|
* @param {string} baseUrl 不含 query string 的 API 位址(本方法會自行附加 `?page=N`)
|
|
* @returns {Promise<any[]>} 所有頁面合併後的項目陣列;無資料時為空陣列
|
|
* @throws {Error} 任一頁回應 HTTP 非 2xx(`!res.ok`)時拋出,訊息含 URL 與狀態碼
|
|
*/
|
|
async fetchAllPages(baseUrl) {
|
|
const all = []
|
|
let page = 1
|
|
|
|
while (true) {
|
|
const url = `${baseUrl}?page=${page}`
|
|
const res = await fetch(url, {
|
|
headers: this.headers,
|
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
})
|
|
|
|
if (!res.ok) {
|
|
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()
|
|
if (!Array.isArray(items) || items.length === 0) {
|
|
break
|
|
}
|
|
|
|
all.push(...items)
|
|
page += 1
|
|
}
|
|
|
|
return all
|
|
}
|
|
|
|
/**
|
|
* 對指定資源發出 DELETE 請求,僅回傳 HTTP 狀態碼供呼叫端判斷成敗(不檢查 ok、不解析回應內容)。
|
|
* @param {string} url 目標資源位址
|
|
* @returns {Promise<number>} 回應的 HTTP 狀態碼(成功刪除通常為 204)
|
|
*/
|
|
async deleteResource(url) {
|
|
const res = await fetch(url, {
|
|
method: 'DELETE',
|
|
headers: this.headers,
|
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
})
|
|
return res.status
|
|
}
|
|
}
|