refactor(release-cleanup): 將清理邏輯由 bash 改寫為 Node.js #5

Closed
jiantw83 wants to merge 43 commits from refactor/nodejs-rewrite-20260626-103443 into develop
5 changed files with 96 additions and 12 deletions
Showing only changes of commit 154ab032bf - Show all commits
+9 -1
View File
@@ -1,7 +1,13 @@
// 讀取並驗證環境變數,組出後續流程所需的設定物件。 // 讀取並驗證環境變數,組出後續流程所需的設定物件。
// 對應原本 entrypoint.sh 的「參數檢查」區段。 // 對應原本 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' import { section, info, warn } from './logger.js'
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Maya
問題:loadConfig 函式雖有呼叫驗證邏輯,但缺乏針對環境變數異常情境(如必填欄位缺失、KEEP_COUNT 非整數)的單元測試,無法確保配置載入流程的穩定性。
建議:補齊 app/test/config.test.js,測試當 process.env 缺少必要參數或 KEEP_COUNT 為無效數字時,loadConfig 是否會正確拋出錯誤。

**嚴重等級**:🟡 警告 **審查員**:Maya **問題**:loadConfig 函式雖有呼叫驗證邏輯,但缺乏針對環境變數異常情境(如必填欄位缺失、KEEP_COUNT 非整數)的單元測試,無法確保配置載入流程的穩定性。 **建議**:補齊 app/test/config.test.js,測試當 process.env 缺少必要參數或 KEEP_COUNT 為無效數字時,loadConfig 是否會正確拋出錯誤。
/** /**
@@ -27,9 +33,11 @@ export function loadConfig(env = process.env) {
Ghost marked this conversation as resolved
Review

嚴重等級🔵 建議
審查員:Leo
問題:直接將 env 預設為 process.env,全域相依性可能導致難以追蹤的副作用。
建議:建議在複雜系統中,將環境變數讀取抽離為獨立的 Provider 或 ConfigFactory。

**嚴重等級**:🔵 建議 **審查員**:Leo **問題**:直接將 env 預設為 process.env,全域相依性可能導致難以追蹤的副作用。 **建議**:建議在複雜系統中,將環境變數讀取抽離為獨立的 Provider 或 ConfigFactory。
Review

嚴重等級🔴 嚴重
審查員:Mage
問題:在 loadConfig 函式中,對 GITEA_SERVER_URL 的 replace(//+$/, '') 操作假設了輸入一定是 string。雖然隨後有 requireValue 和 requireUrl 的驗證,但如果 env.GITEA_SERVER_URL 是非字串(例如數字、陣列、物件),這裡的 replace 可能會拋出 TypeError。
建議:應在 replace 之前確保其型別為 string,或使用 optional chaining 以及更嚴格的類型防護。例如: typeof rawServerUrl === 'string' ? rawServerUrl.replace(//+$/, '') : rawServerUrl 已經做了檢查,但後續的 requireValue 檢查順序應確保傳入的是處理後的字串。

**嚴重等級**:🔴 嚴重 **審查員**:Mage **問題**:在 loadConfig 函式中,對 GITEA_SERVER_URL 的 replace(//+$/, '') 操作假設了輸入一定是 string。雖然隨後有 requireValue 和 requireUrl 的驗證,但如果 env.GITEA_SERVER_URL 是非字串(例如數字、陣列、物件),這裡的 replace 可能會拋出 TypeError。 **建議**:應在 replace 之前確保其型別為 string,或使用 optional chaining 以及更嚴格的類型防護。例如: `typeof rawServerUrl === 'string' ? rawServerUrl.replace(//+$/, '') : rawServerUrl` 已經做了檢查,但後續的 `requireValue` 檢查順序應確保傳入的是處理後的字串。
info(`GITEA_SERVER_URL=${serverUrl}`) info(`GITEA_SERVER_URL=${serverUrl}`)
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Assassin
問題:將環境變數值直接輸出至日誌,若 GITEA_SERVER_URL 等變數內容被注入惡意字元或過長,可能造成 Log Injection 或日誌系統資源耗盡。
建議:在輸出前進行 sanitization,移除控制字元並限制長度,類似於 gitea-client.js 中的 sanitizeBody

**嚴重等級**:🟡 警告 **審查員**:Assassin **問題**:將環境變數值直接輸出至日誌,若 `GITEA_SERVER_URL` 等變數內容被注入惡意字元或過長,可能造成 Log Injection 或日誌系統資源耗盡。 **建議**:在輸出前進行 sanitization,移除控制字元並限制長度,類似於 `gitea-client.js` 中的 `sanitizeBody`。
Review

嚴重等級🔵 建議
審查員:Maya
問題:雖然有 requireUrl 驗證,但在 loadConfig 中,對於 GITEA_SERVER_URL 的解析假設其為完整路徑。若環境變數提供的網址不含 api/v1 或路徑有變化,整合後的 releaseApiUrl 可能無效。
建議:建議在 loadConfig 中增加對 serverUrl 的處理,確保結尾沒有多餘的 /,避免拼接 API 路徑時產生類似 //api 的錯誤路徑。

**嚴重等級**:🔵 建議 **審查員**:Maya **問題**:雖然有 requireUrl 驗證,但在 loadConfig 中,對於 GITEA_SERVER_URL 的解析假設其為完整路徑。若環境變數提供的網址不含 api/v1 或路徑有變化,整合後的 releaseApiUrl 可能無效。 **建議**:建議在 loadConfig 中增加對 serverUrl 的處理,確保結尾沒有多餘的 /,避免拼接 API 路徑時產生類似 //api 的錯誤路徑。
requireValue('GITEA_SERVER_URL', serverUrl) requireValue('GITEA_SERVER_URL', serverUrl)
Ghost marked this conversation as resolved
Review

嚴重等級🔴 嚴重
審查員:Assassin
問題:僅驗證是否為 URL 格式,未檢查傳入的 GITEA_SERVER_URL 是否指向內部網路敏感資源(如 localhost, 169.254.169.254 等),這在容器化環境中可能導致 SSRF(伺服器端請求偽造)。
建議:在 validate.jsrequireUrl 中加入黑名單機制,禁止解析為內部 IP 位址或 loopback 位址。

**嚴重等級**:🔴 嚴重 **審查員**:Assassin **問題**:僅驗證是否為 URL 格式,未檢查傳入的 `GITEA_SERVER_URL` 是否指向內部網路敏感資源(如 localhost, 169.254.169.254 等),這在容器化環境中可能導致 SSRF(伺服器端請求偽造)。 **建議**:在 `validate.js` 的 `requireUrl` 中加入黑名單機制,禁止解析為內部 IP 位址或 loopback 位址。
requireUrl('GITEA_SERVER_URL', serverUrl)
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Maya
問題:缺乏對 GITEA_TOKEN 長度或格式的邊界測試,以及對 KEEP_COUNT 格式異常的檢查。
建議:在測試檔中增加針對 Token 與 KEEP_COUNT 的邊界測試,並在 loadConfig 內加強格式轉換檢查。

**嚴重等級**:🟡 警告 **審查員**:Maya **問題**:缺乏對 GITEA_TOKEN 長度或格式的邊界測試,以及對 KEEP_COUNT 格式異常的檢查。 **建議**:在測試檔中增加針對 Token 與 KEEP_COUNT 的邊界測試,並在 loadConfig 內加強格式轉換檢查。
info(`GITEA_REPOSITORY=${repository}`) info(`GITEA_REPOSITORY=${repository}`)
requireValue('GITEA_REPOSITORY', repository) requireValue('GITEA_REPOSITORY', repository)
requireRepository('GITEA_REPOSITORY', repository)
info(`KEEP_COUNT=${keepCountRaw}`) info(`KEEP_COUNT=${keepCountRaw}`)
requireValue('KEEP_COUNT', keepCountRaw) requireValue('KEEP_COUNT', keepCountRaw)
+25 -3
View File
@@ -1,6 +1,9 @@
// 與 Gitea API 溝通的 HTTP 客戶端,封裝認證標頭、分頁讀取與刪除請求。 // 與 Gitea API 溝通的 HTTP 客戶端,封裝認證標頭、分頁讀取與刪除請求。
// 對應原本 entrypoint.sh 的 fetch_all_pages 與 curl DELETE 呼叫。 // 對應原本 entrypoint.sh 的 fetch_all_pages 與 curl DELETE 呼叫。
// 單一 HTTP 請求的逾時(毫秒)。避免 API 緩慢或掛起時容器永久卡死。
const REQUEST_TIMEOUT_MS = 30000
/** /**
* 與 Gitea REST API 溝通的輕量 HTTP 客戶端,負責帶上認證標頭、分頁讀取清單與發出刪除請求。 * 與 Gitea REST API 溝通的輕量 HTTP 客戶端,負責帶上認證標頭、分頁讀取清單與發出刪除請求。
* 取代原 bash 版本以 `curl`/`jq` 進行的 API 操作。 * 取代原 bash 版本以 `curl`/`jq` 進行的 API 操作。
7
@@ -34,10 +37,25 @@ export class GiteaClient {
while (true) { while (true) {
const url = `${baseUrl}?page=${page}` 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) { 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(() => '')
Ghost marked this conversation as resolved
Review

嚴重等級🔴 嚴重
審查員:Mage
問題:await res.json() 若遇到 API 回傳格式不符的內容(即使 content-type 為 application/json),會直接拋出 SyntaxError,且因為此處未以 try-catch 包裹,會導致程式崩潰並遺失錯誤發生的 URL 上下文。
建議:將 await res.json() 包裹在 try-catch 區塊中,解析失敗時捕捉錯誤並拋出包含當前請求 URL 的明確錯誤訊息。

**嚴重等級**:🔴 嚴重 **審查員**:Mage **問題**:await res.json() 若遇到 API 回傳格式不符的內容(即使 content-type 為 application/json),會直接拋出 SyntaxError,且因為此處未以 try-catch 包裹,會導致程式崩潰並遺失錯誤發生的 URL 上下文。 **建議**:將 await res.json() 包裹在 try-catch 區塊中,解析失敗時捕捉錯誤並拋出包含當前請求 URL 的明確錯誤訊息。
throw new Error(
`GET ${url} returned non-JSON content-type "${contentType}": ${body.slice(0, 200)}`,
)
} }
Ghost marked this conversation as resolved
Review

嚴重等級🔴 嚴重
審查員:Bard
問題:fetchAllPages 採展開運算子處理分頁資料,面對龐大項目數量可能導致 Maximum call stack size exceeded 錯誤,並伴隨無窮迴圈風險 (缺少 MAX_PAGES 限制)。
建議:改用簡單的 for...of 迴圈逐一 push 項目以規避堆疊風險,並務必加入 MAX_PAGES 常數作為安全斷點,防止 API 異常導致無限迴圈與資源耗盡。

**嚴重等級**:🔴 嚴重 **審查員**:Bard **問題**:fetchAllPages 採展開運算子處理分頁資料,面對龐大項目數量可能導致 Maximum call stack size exceeded 錯誤,並伴隨無窮迴圈風險 (缺少 MAX_PAGES 限制)。 **建議**:改用簡單的 for...of 迴圈逐一 push 項目以規避堆疊風險,並務必加入 MAX_PAGES 常數作為安全斷點,防止 API 異常導致無限迴圈與資源耗盡。
Review

嚴重等級🔴 嚴重
審查員:Bard
問題:fetchAllPages 採展開運算子處理分頁資料,面對龐大項目數量可能導致 Maximum call stack size exceeded 錯誤,並伴隨無窮迴圈風險 (缺少 MAX_PAGES 限制)。
建議:改用簡單的 for...of 迴圈逐一 push 項目以規避堆疊風險,並務必加入 MAX_PAGES 常數作為安全斷點,防止 API 異常導致無限迴圈與資源耗盡。

**嚴重等級**:🔴 嚴重 **審查員**:Bard **問題**:fetchAllPages 採展開運算子處理分頁資料,面對龐大項目數量可能導致 Maximum call stack size exceeded 錯誤,並伴隨無窮迴圈風險 (缺少 MAX_PAGES 限制)。 **建議**:改用簡單的 for...of 迴圈逐一 push 項目以規避堆疊風險,並務必加入 MAX_PAGES 常數作為安全斷點,防止 API 異常導致無限迴圈與資源耗盡。
const items = await res.json() const items = await res.json()
3
@@ -58,7 +76,11 @@ export class GiteaClient {
* @returns {Promise<number>} 回應的 HTTP 狀態碼(成功刪除通常為 204) * @returns {Promise<number>} 回應的 HTTP 狀態碼(成功刪除通常為 204)
*/ */
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Assassin
問題:攻擊者可透過惡意伺服器回傳極大的 JSON 資料,利用此處未限制大小的 res.json() 直接解析整個回應,導致記憶體耗盡 (DoS)。
建議:應對 API 回應設定明確的大小上限,超過時拒絕解析並拋出異常。

**嚴重等級**:🟡 警告 **審查員**:Assassin **問題**:攻擊者可透過惡意伺服器回傳極大的 JSON 資料,利用此處未限制大小的 res.json() 直接解析整個回應,導致記憶體耗盡 (DoS)。 **建議**:應對 API 回應設定明確的大小上限,超過時拒絕解析並拋出異常。
async deleteResource(url) { 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 return res.status
} }
} }
+21 -2
View File
@@ -14,7 +14,7 @@ import { separator, fail } from './logger.js'
* *
* @returns {Promise<void>} 流程完成時 resolve;設定驗證或讀取 API 失敗時 reject。 * @returns {Promise<void>} 流程完成時 resolve;設定驗證或讀取 API 失敗時 reject。
*/ */
async function main() { export async function main() {
const config = loadConfig() const config = loadConfig()
const client = new GiteaClient({ token: config.token }) const client = new GiteaClient({ token: config.token })
1
@@ -24,7 +24,26 @@ async function main() {
separator() separator()
} }
Ghost marked this conversation as resolved
Review

嚴重等級🔴 嚴重
審查員:Mage
問題:當 main().catch(...) 捕捉到錯誤並執行 process.exit(1) 時,僅輸出 error.message。若錯誤是由 fetch 網路層拋出(例如 DNS 解析失敗或 connection refused),Node.js 的 Error 物件可能不包含足夠的上下文訊息,導致使用者難以區分是 API 回傳錯誤還是程式碼執行期錯誤。
建議:建議在 catch 區塊中,若錯誤是 Error 物件,輸出 error.stack 或至少記錄錯誤類型,並區分不同層級的例外(例如:驗證錯誤 vs. API 請求錯誤)。

**嚴重等級**:🔴 嚴重 **審查員**:Mage **問題**:當 `main().catch(...)` 捕捉到錯誤並執行 `process.exit(1)` 時,僅輸出 `error.message`。若錯誤是由 `fetch` 網路層拋出(例如 DNS 解析失敗或 connection refused),Node.js 的 `Error` 物件可能不包含足夠的上下文訊息,導致使用者難以區分是 API 回傳錯誤還是程式碼執行期錯誤。 **建議**:建議在 `catch` 區塊中,若錯誤是 `Error` 物件,輸出 `error.stack` 或至少記錄錯誤類型,並區分不同層級的例外(例如:驗證錯誤 vs. API 請求錯誤)。
/**
* 將錯誤輸出至 stderr,並盡量保留可供除錯的上下文(錯誤類型與堆疊),
* 以便區分設定驗證錯誤與網路/API 請求錯誤。
* @param {unknown} error 捕捉到的錯誤
*/
Ghost marked this conversation as resolved
Review

嚴重等級🔴 嚴重
審查員:Maya
問題:main 函式執行失敗時會觸發 process.exit(1),確保 CI/CD 流程能正確偵測錯誤。目前的測試僅驗證了 Promise 被 reject,但並未驗證程式是否真的正確以非零狀態碼結束。
建議:請在 app/test/main.test.js 中,模擬 main 拋出錯誤的情境,並透過 mock process.exit 來驗證當 main 執行失敗時,程式碼確實執行了 process.exit(1)。

**嚴重等級**:🔴 嚴重 **審查員**:Maya **問題**:main 函式執行失敗時會觸發 process.exit(1),確保 CI/CD 流程能正確偵測錯誤。目前的測試僅驗證了 Promise 被 reject,但並未驗證程式是否真的正確以非零狀態碼結束。 **建議**:請在 app/test/main.test.js 中,模擬 main 拋出錯誤的情境,並透過 mock process.exit 來驗證當 main 執行失敗時,程式碼確實執行了 process.exit(1)。
function reportFatal(error) {
if (error instanceof Error) {
fail(`${error.name}: ${error.message}`)
Ghost marked this conversation as resolved
Review

嚴重等級🔵 建議
審查員:Leo
問題reportFatal 直接操作 process.stderr 並在 main().catch 中手動調用,這與 logger.js 中定義的 fail 函式職責重疊,這會在未來增加日誌格式變更的維護成本。
建議:建議直接呼叫 fail 函式,並在 logger.js 中根據錯誤類型(是否為 Error 物件)自動處理堆疊追蹤資訊,將「如何輸出錯誤」的邏輯全部收斂至 logger.js

**嚴重等級**:🔵 建議 **審查員**:Leo **問題**:`reportFatal` 直接操作 `process.stderr` 並在 `main().catch` 中手動調用,這與 `logger.js` 中定義的 `fail` 函式職責重疊,這會在未來增加日誌格式變更的維護成本。 **建議**:建議直接呼叫 `fail` 函式,並在 `logger.js` 中根據錯誤類型(是否為 Error 物件)自動處理堆疊追蹤資訊,將「如何輸出錯誤」的邏輯全部收斂至 `logger.js`。
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) => { main().catch((error) => {
fail(error.message) reportFatal(error)
Ghost marked this conversation as resolved
Review

嚴重等級🔴 嚴重
審查員:Maya
問題:專案雖然新增了 app/test/ 資料夾與多個測試檔案,但目前沒看到任何整合測試,且 app/index.jsmain() 邏輯未被驗證過,這意味著最核心的清理流程尚未受到測試保護。
建議:請在 app/test/ 中新增整合測試,模擬 loadConfig 回傳假設定後,確認 cleanupReleasescleanupOrphanTags 有被正確呼叫(例如透過 mock GiteaClient),確保流程串接無誤。

**嚴重等級**:🔴 嚴重 **審查員**:Maya **問題**:專案雖然新增了 `app/test/` 資料夾與多個測試檔案,但目前沒看到任何整合測試,且 `app/index.js` 的 `main()` 邏輯未被驗證過,這意味著最核心的清理流程尚未受到測試保護。 **建議**:請在 `app/test/` 中新增整合測試,模擬 `loadConfig` 回傳假設定後,確認 `cleanupReleases` 與 `cleanupOrphanTags` 有被正確呼叫(例如透過 mock GiteaClient),確保流程串接無誤。
process.exit(1) process.exit(1)
}) })
}
+6 -3
View File
@@ -12,9 +12,12 @@ import { isEmptyOrNull } from './validate.js'
* @returns {any[]} 需要刪除的成品陣列(較舊者),依新到舊排序 * @returns {any[]} 需要刪除的成品陣列(較舊者),依新到舊排序
*/ */
export function selectReleasesToDelete(releases, keepCount) { export function selectReleasesToDelete(releases, keepCount) {
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Leo
問題:使用 new Date(release.created_at) 進行排序時,若 created_at 格式非預期或為空,會導致 NaN 並造成排序異常,可能無法正確刪除舊版本。
建議:建議在排序邏輯中增加對 created_at 的有效性檢查,若無效則給予預設值(例如:new Date(0)),確保排序結果可預期。

**嚴重等級**:🟡 警告 **審查員**:Leo **問題**:使用 `new Date(release.created_at)` 進行排序時,若 `created_at` 格式非預期或為空,會導致 `NaN` 並造成排序異常,可能無法正確刪除舊版本。 **建議**:建議在排序邏輯中增加對 `created_at` 的有效性檢查,若無效則給予預設值(例如:`new Date(0)`),確保排序結果可預期。
const sorted = [...releases].sort( // 解析 created_at;格式無效或缺漏時視為最舊(0),避免 NaN 造成排序結果不可預期。
(a, b) => new Date(b.created_at) - new Date(a.created_at), const createdTime = (release) => {
) const time = Date.parse(release?.created_at)
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Rogue
問題:selectReleasesToDelete 針對每一筆 release 重複執行 Date.parse,浪費 CPU 週期。
建議:應在排序前先執行一次 map 轉換,預先計算時間戳記。

**嚴重等級**:🟡 警告 **審查員**:Rogue **問題**:selectReleasesToDelete 針對每一筆 release 重複執行 Date.parse,浪費 CPU 週期。 **建議**:應在排序前先執行一次 map 轉換,預先計算時間戳記。
return Number.isNaN(time) ? 0 : time
}
const sorted = [...releases].sort((a, b) => createdTime(b) - createdTime(a))
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Leo
問題:在 selectReleasesToDelete 函式中,對於 Date.parse 無法解析的日期直接視為 0,這在資料清理邏輯中是一個隱晦的行為,未來維護者可能不清楚為什麼無效日期會優先被刪除。
建議:應明確記錄無效日期的處理方式(例如記錄 warning),或在 Date.parse 失敗時,應考慮給予一個明確的邏輯(例如拋出錯誤或放到特定排序位置),以減少不可預期的副作用。

**嚴重等級**:🟡 警告 **審查員**:Leo **問題**:在 `selectReleasesToDelete` 函式中,對於 `Date.parse` 無法解析的日期直接視為 `0`,這在資料清理邏輯中是一個隱晦的行為,未來維護者可能不清楚為什麼無效日期會優先被刪除。 **建議**:應明確記錄無效日期的處理方式(例如記錄 warning),或在 `Date.parse` 失敗時,應考慮給予一個明確的邏輯(例如拋出錯誤或放到特定排序位置),以減少不可預期的副作用。
return sorted.slice(keepCount) return sorted.slice(keepCount)
} }
Ghost marked this conversation as resolved
Review

嚴重等級🔵 建議
審查員:Rogue
問題:為了排序而進行了多次 map 操作,先將陣列 map 為物件陣列,排序後又 map 回原始物件陣列。這在大數據集下會造成多次 O(n) 的陣列分配與垃圾回收壓力,浪費 CPU 與記憶體週期。
建議:排序時應嘗試減少陣列中間狀態的產生,例如考慮使用原地排序(若不介意原陣列變更)或簡化 mapping 的邏輯。

**嚴重等級**:🔵 建議 **審查員**:Rogue **問題**:為了排序而進行了多次 map 操作,先將陣列 map 為物件陣列,排序後又 map 回原始物件陣列。這在大數據集下會造成多次 O(n) 的陣列分配與垃圾回收壓力,浪費 CPU 與記憶體週期。 **建議**:排序時應嘗試減少陣列中間狀態的產生,例如考慮使用原地排序(若不介意原陣列變更)或簡化 mapping 的邏輯。
Review

嚴重等級🟡 警告
審查員:Mage
問題:在 selectReleasesToDelete 的排序邏輯中,若多個成品具有完全相同的 created_at 時間戳,目前的排序行為依賴於 JavaScript 引擎對 sort() 的實作(在某些情況下可能不穩定),導致保留與刪除的成品選擇具有不確定性。
建議:建議在排序邏輯中加入次要的排序鍵值(如 idtag_name)作為比較依據(例如:若時間相同,則比較 ID 大小),以確保排序結果在時間相同時仍具有決定性。

**嚴重等級**:🟡 警告 **審查員**:Mage **問題**:在 `selectReleasesToDelete` 的排序邏輯中,若多個成品具有完全相同的 `created_at` 時間戳,目前的排序行為依賴於 JavaScript 引擎對 `sort()` 的實作(在某些情況下可能不穩定),導致保留與刪除的成品選擇具有不確定性。 **建議**:建議在排序邏輯中加入次要的排序鍵值(如 `id` 或 `tag_name`)作為比較依據(例如:若時間相同,則比較 ID 大小),以確保排序結果在時間相同時仍具有決定性。
20
+32
View File
1
@@ -34,3 +34,35 @@ export function requireInteger(name, value) {
throw new Error(`${name} must be a non-negative integer`) 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 {
Ghost marked this conversation as resolved
Review

嚴重等級🔵 建議
審查員:Leo
問題:雖然驗證邏輯完整,但 requireUrlrequireRepository 的規則直接寫死在函式內。如果未來有其他的 API 端點需要不同的驗證規則,會產生大量重複程式碼。
建議:考慮將驗證規則(Regex 或協定清單)提取為設定物件或共用常數,這能讓維護者一眼看出系統允許的 URL 限制,方便未來擴充。

**嚴重等級**:🔵 建議 **審查員**:Leo **問題**:雖然驗證邏輯完整,但 `requireUrl` 和 `requireRepository` 的規則直接寫死在函式內。如果未來有其他的 API 端點需要不同的驗證規則,會產生大量重複程式碼。 **建議**:考慮將驗證規則(Regex 或協定清單)提取為設定物件或共用常數,這能讓維護者一眼看出系統允許的 URL 限制,方便未來擴充。
Review

嚴重等級🔵 建議
審查員:Leo
問題:驗證規則寫死在函式內,易產生重複程式碼,且缺乏擴充性。
建議:將驗證規則提取為設定物件或共用常數,或考慮使用 Zod 等 Schema 套件進行驗證與型別轉換。

**嚴重等級**:🔵 建議 **審查員**:Leo **問題**:驗證規則寫死在函式內,易產生重複程式碼,且缺乏擴充性。 **建議**:將驗證規則提取為設定物件或共用常數,或考慮使用 Zod 等 Schema 套件進行驗證與型別轉換。
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`)
}
}