Files
release-cleanup/app/gitea-client.js
T
JefferyandClaude Opus 4.8 aa4f6364e2 fix(release-cleanup): 加入分頁上限並編碼刪除 URL 路徑片段
- fetchAllPages 加入 MAX_PAGES(1000)安全斷點,避免 API 異常時無限迴圈
- 刪除 release/tag 時對 id 與 tag 名稱做 encodeURIComponent,
  防止特殊字元造成路徑穿越(正常數值/版本字串編碼後不變)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:21:56 +08:00

121 lines
4.4 KiB
JavaScript

// 與 Gitea API 溝通的 HTTP 客戶端,封裝認證標頭、分頁讀取與刪除請求。
// 對應原本 entrypoint.sh 的 fetch_all_pages 與 curl DELETE 呼叫。
// 單一 HTTP 請求的逾時(毫秒)。避免 API 緩慢或掛起時容器永久卡死。
const REQUEST_TIMEOUT_MS = 30000
// 分頁讀取的最大頁數上限,作為安全斷點:即使 API 異常未以空陣列結尾,也不致無限迴圈耗盡資源。
const MAX_PAGES = 1000
/**
* 將回應內容整理成可安全寫入錯誤訊息的片段:移除控制字元(避免換行等造成的 log 注入)並限制長度。
* @param {string} text 原始回應文字
* @returns {string} 清理後、最長 200 字元的片段
*/
function sanitizeBody(text) {
return (text || '')
.replace(/[\u0000-\u001F\u007F]+/g, ' ')
.trim()
.slice(0, 200)
}
/**
* 與 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 遞增),直到某頁回傳空陣列為止,
* 將所有頁面項目合併成單一陣列回傳。
*
* 在 `res.ok` 為真的前提下嚴格要求回應為 JSON 陣列:非 JSON、JSON 解析失敗或非陣列內容
* 都會拋出帶 URL 上下文的錯誤,而非被誤判為最後一頁而靜默結束。
*
* @param {string} baseUrl 不含 query string 的 API 位址(本方法會自行附加 `?page=N`)
* @returns {Promise<any[]>} 所有頁面合併後的項目陣列;無資料時為空陣列
* @throws {Error} HTTP 非 2xx、回應非 JSON、JSON 無法解析或非陣列、或請求逾時(AbortError)時拋出
*/
async fetchAllPages(baseUrl) {
const all = []
let page = 1
while (true) {
if (page > MAX_PAGES) {
throw new Error(`GET ${baseUrl} exceeded MAX_PAGES (${MAX_PAGES}); aborting to avoid an unbounded loop`)
}
const url = `${baseUrl}?page=${page}`
const res = await fetch(url, {
headers: this.headers,
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
if (!res.ok) {
const body = sanitizeBody(await res.text().catch(() => ''))
throw new Error(
`GET ${url} failed: HTTP ${res.status}${body ? ` - ${body}` : ''}`,
)
}
// 確認回應確實是 JSON,避免 API 回傳 HTML 錯誤頁時 res.json() 拋出難以理解的 SyntaxError。
const contentType = res.headers.get('content-type') || ''
if (!contentType.includes('application/json')) {
const body = sanitizeBody(await res.text().catch(() => ''))
throw new Error(
`GET ${url} returned non-JSON content-type "${contentType}"${body ? `: ${body}` : ''}`,
)
}
// 即使 content-type 正確,內容仍可能格式錯誤;明確攔截 SyntaxError 並補上 URL 上下文。
let items
try {
items = await res.json()
} catch (error) {
throw new Error(`GET ${url} returned invalid JSON: ${error.message}`)
}
// res.ok 為真時嚴格要求陣列:非陣列代表非預期回應(如錯誤物件),應報錯而非視為最後一頁。
if (!Array.isArray(items)) {
throw new Error(`GET ${url} returned a non-array JSON payload`)
}
if (items.length === 0) {
break
}
// 逐一加入(不使用展開運算子),避免大量項目時觸發呼叫堆疊上限。
for (const item of items) {
all.push(item)
}
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
}
}