Skip to content
This repository was archived by the owner on Jul 27, 2026. It is now read-only.

友链:𝑰𝑲𝑼𝑵 #274

友链:𝑰𝑲𝑼𝑵

友链:𝑰𝑲𝑼𝑵 #274

Workflow file for this run

name: auto-pr
on:
pull_request_target:
types: [opened, synchronize, reopened]
issue_comment:
types: [created]
permissions:
contents: write
pull-requests: write
issues: write
env:
PAT: ${{ secrets.PAT }}
concurrency:
group: auto-pr-${{ github.event.pull_request.number || github.event.issue.number }}
cancel-in-progress: true
jobs:
triage:
if: ${{ github.event_name == 'pull_request_target' }}
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Label PR by changed paths
uses: actions/github-script@v7
with:
github-token: ${{ secrets.PAT }}
script: |
const owner = context.repo.owner
const repo = context.repo.repo
const pull_number = context.payload.pull_request.number
const LABEL_FRIEND = '友链'
const LABEL_SPONSOR = '赞助'
const LABEL_AVATAR_OK = '头像可达'
const LABEL_AVATAR_BAD = '头像不可达'
const LABEL_SITE_OK = '网站可达'
const LABEL_SITE_BAD = '网站不可达'
const LABEL_ALL_OK = 'URL全部可达'
const LABEL_BIDIR = '双向链接验证'
const LABEL_BIDIR_OK = '双向链接验证通过'
const MSG = {
PR_ONE_FILE: 'PR 必须仅包含一个文件变更。',
PR_PATH_ERROR: '只允许更改 src/data/friends/ 或 src/data/sponsors/ 下的文件。',
PR_ACTION_ERROR: '只允许新增、编辑或删除文件。',
JSON_EXT_ERROR: '检测到友链/赞助目录下存在非 .json 文件,已停止处理:',
JSON_PARSE_ERROR: ': JSON 解析失败',
JSON_OBJECT_ERROR: ': JSON 必须是对象',
VALIDATE_FAIL: '检测到数据文件校验失败:',
VIP_DETECTED: '检测到 JSON 存在 vip 字段,已终止自动流程。\n\n请从以下文件移除 vip 字段后再 push 更新(移除后才会继续自动校验/自动合并):',
URL_SELF_ERROR: '检测到友链 JSON 的 url 指向本站,请填写你自己的网站 URL。',
BACKLINK_SELF_ERROR: '检测到友链 JSON 的 backlink 指向本站,请填写你自己网站的友链页 URL。',
SITE_CONFIG_MISSING: '无法读取本站 site 配置,无法进行双向链接验证。',
BIDIR_INSTRUCTIONS: (siteBase) => [
'基础校验已通过,需要进行双向链接验证:',
'',
`1) 请在你的友链页面添加本站友链(必须为绝对链接):${siteBase}`,
'2) 然后更新本 PR 的友链 JSON,增加字段 backlink(填写你的友链页面 URL,必须是 http/https 绝对链接)',
'',
'示例:',
'',
'```json',
'{',
' "name": "...",',
' "avatar": "...",',
' "url": "...",',
' "backlink": "https://example.com/friends/"',
'}',
'```',
'',
`3) 请确保你的 backlink 页面中包含指向本站的链接 href=${siteBase} (必须完全一致的绝对链接)`,
'4) push 更新后 Action 会自动重新校验并在通过后自动合并,无需额外评论',
].join('\n'),
BIDIR_BACKLINK_INVALID: '双向链接验证失败:backlink 不是合法的 URL',
BIDIR_BACKLINK_NOT_HTTP: '双向链接验证失败:backlink 必须以 http/https 开头',
BIDIR_SITE_INVALID: '双向链接验证失败:友链 JSON 中 url 无效,无法比较主域名。',
BIDIR_HOST_MISMATCH: '双向链接验证失败:backlink 的主域名必须与 url 的主域名一致。',
BIDIR_FETCH_ERROR: '双向链接验证失败:访问 backlink 出错。',
BIDIR_ACCESS_FAIL: '双向链接验证失败:无法访问 backlink',
BIDIR_NOT_FOUND: (siteBase, backlinkUrl) => [
'双向链接验证未通过:在 backlink 页面未检测到本站友链。',
'',
`需要添加的绝对链接:${siteBase}`,
`backlink 页面:${backlinkUrl}`,
'',
].join('\n'),
BIDIR_VERIFY_OK: '双向链接验证通过。',
MERGE_FAIL: '自动合并失败:',
DNS_VERIFY_FAIL: '域名所有权验证失败。',
DNS_VERIFY_INSTRUCTIONS: (isDelete, hostname, expected) => [
`检测到你正在${isDelete ? '删除' : '修改'}现有的友链/赞助数据。为了防止误操作,请完成域名所有权验证:`,
'',
`1. 请在域名 ${hostname} 或 _fuwari-verification.${hostname} 下添加 DNS TXT 记录。`,
`2. 记录内容:${expected}`,
'3. 添加完成后,请 push 更新(或关闭并重新打开 PR)以触发重新校验。',
].join('\n'),
}
async function ensureLabel(name) {
try {
await github.rest.issues.getLabel({ owner, repo, name })
} catch (e) {
if (e.status !== 404) throw e
await github.rest.issues.createLabel({
owner,
repo,
name,
color: 'ededed',
})
}
}
async function listAllFiles() {
const files = []
for await (const res of github.paginate.iterator(
github.rest.pulls.listFiles,
{ owner, repo, pull_number, per_page: 100 }
)) {
for (const f of res.data) files.push(f)
}
return files
}
const files = await listAllFiles()
if (files.length !== 1) {
await createComment('PR 必须仅包含一个文件变更。')
return
}
const file = files[0]
if (!file.filename.startsWith('src/data/friends/') && !file.filename.startsWith('src/data/sponsors/')) {
await createComment('只允许更改 src/data/friends/ 或 src/data/sponsors/ 下的文件。')
return
}
if (file.status !== 'added' && file.status !== 'modified' && file.status !== 'removed') {
await createComment('只允许新增、编辑或删除文件。')
return
}
const paths = files.map((f) => f.filename)
const touchedFriends = file.filename.startsWith('src/data/friends/')
const touchedSponsors = file.filename.startsWith('src/data/sponsors/')
for (const name of [
LABEL_FRIEND,
LABEL_SPONSOR,
LABEL_AVATAR_OK,
LABEL_AVATAR_BAD,
LABEL_SITE_OK,
LABEL_SITE_BAD,
LABEL_ALL_OK,
LABEL_BIDIR,
LABEL_BIDIR_OK,
]) {
await ensureLabel(name)
}
const add = []
const remove = []
if (touchedFriends) add.push(LABEL_FRIEND)
if (touchedSponsors) add.push(LABEL_SPONSOR)
if (touchedFriends && !touchedSponsors) remove.push(LABEL_SPONSOR)
if (touchedSponsors && !touchedFriends) remove.push(LABEL_FRIEND)
if (add.length) {
await github.rest.issues.addLabels({ owner, repo, issue_number: pull_number, labels: add })
}
for (const name of remove) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: pull_number, name })
} catch (e) {
if (e.status !== 404) throw e
}
}
async function createComment(body) {
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
const footer = `\n\n---\n该评论由 Action 自动化发送,无需回复。\n\n🔗 [查看 Action](${runUrl})`
const fullBody = body + footer
core.info(`createComment len=${String(fullBody || '').length}`)
try {
await github.rest.issues.createComment({ owner, repo, issue_number: pull_number, body: fullBody })
core.info('createComment ok')
} catch (e) {
core.error(`createComment failed: status=${e?.status || ''}`)
core.error(`createComment failed: message=${e?.message || e}`)
try {
core.error(`createComment failed: response=${JSON.stringify(e?.response?.data || null)}`)
} catch {}
throw e
}
}
function isNonEmptyString(v) {
return typeof v === 'string' && v.trim().length > 0
}
function parseJsonSafe(text) {
try {
return { ok: true, value: JSON.parse(text) }
} catch (e) {
return { ok: false, error: e?.message || String(e) }
}
}
async function getTextFileFromRepo(path, ref) {
const res = await github.rest.repos.getContent({ owner, repo, path, ref })
if (Array.isArray(res.data) || !res.data?.content) throw new Error(`Invalid content response for ${path}`)
const raw = Buffer.from(res.data.content, res.data.encoding || 'base64').toString('utf8')
return raw
}
async function getSiteBase() {
try {
const raw = await getTextFileFromRepo('src/config.ts', context.payload.repository.default_branch)
const m = raw.match(/customDomain\s*=\s*["']([^"']+)["']/)
if (!m) return null
return `https://${m[1]}`
} catch (e) {
core.warning(`Failed to read src/config.ts: ${e?.message || e}`)
return null
}
}
const siteBase = await getSiteBase()
// Reusable verification logic from link-utils.js (simplified for github-script)
async function checkUrlReachability(url) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 10000)
try {
const res = await fetch(url, {
method: 'GET',
redirect: 'follow',
headers: { 'user-agent': 'fuwari-auto-pr/1.0 (+github actions)', accept: 'text/html,*/*;q=0.8' },
signal: controller.signal,
})
const body = res.ok ? await res.text() : ''
return { ok: res.ok, status: res.status, body, url: res.url }
} catch (e) {
return { ok: false, error: e?.message || String(e) }
} finally {
clearTimeout(timer)
}
}
function verifyBacklink(html, expected) {
if (!html || !expected) return false
const target = expected.replace(/\/$/, '')
const re = /href\s*=\s*["']([^"']+)["']/gi
let m
while ((m = re.exec(html)) !== null) {
const href = (m[1] || '').trim()
if (!href.startsWith('http')) continue
const normalized = href.replace(/\/$/, '')
if (normalized === target) return true
}
return false
}
function normalizeUrl(rawUrl) {
if (!isNonEmptyString(rawUrl)) return null
const u = rawUrl.trim()
if (u.startsWith('/')) {
if (!siteBase) return null
return new URL(u, siteBase).toString()
}
try {
return new URL(u).toString()
} catch {
return null
}
}
async function checkUrl(url, { timeoutMs }) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
// SSRF Protection
try {
const u = new URL(url)
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('Invalid protocol')
const h = u.hostname
if (h === 'localhost' || h === '127.0.0.1' || h === '::1') throw new Error('Localhost not allowed')
if (/^10\./.test(h)) throw new Error('Private IP not allowed')
if (/^192\.168\./.test(h)) throw new Error('Private IP not allowed')
if (/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(h)) throw new Error('Private IP not allowed')
} catch (e) {
return { ok: false, error: e.message }
}
const res = await fetch(url, {
method: 'GET',
redirect: 'follow',
headers: {
'user-agent': 'fuwari-auto-pr/1.0 (+github actions)',
accept: 'text/html,application/json,image/*,*/*;q=0.8',
},
signal: controller.signal,
})
return { ok: res.status >= 200 && res.status < 400, status: res.status }
} catch (e) {
return { ok: false, error: e?.message || String(e) }
} finally {
clearTimeout(timer)
}
}
async function setLabels({ addLabels, removeLabels, skipIfSame }) {
const addSet = new Set(addLabels || [])
const removeSet = new Set(removeLabels || [])
for (const x of addSet) removeSet.delete(x)
const current = await github.rest.issues.get({ owner, repo, issue_number: pull_number })
const currentLabels = new Set((current.data.labels || []).map((l) => l.name))
if (skipIfSame) {
for (const name of addSet) {
if (currentLabels.has(name)) {
core.info(`Label ${name} already set, skip label operations`)
return
}
}
}
const finalRemove = Array.from(removeSet).filter((name) => currentLabels.has(name))
if (finalRemove.length) {
for (const name of finalRemove) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: pull_number, name })
} catch (e) {
if (e.status !== 404) throw e
}
}
}
const finalAdd = Array.from(addSet).filter((name) => !currentLabels.has(name))
if (finalAdd.length) {
await github.rest.issues.addLabels({ owner, repo, issue_number: pull_number, labels: finalAdd })
}
}
const relevant = files.filter((f) => {
if (f.filename.startsWith('src/data/friends/')) return true
if (f.filename.startsWith('src/data/sponsors/')) return true
return false
})
// Verify domain ownership for removed or modified files
if (relevant.length === 1 && (relevant[0].status === 'removed' || relevant[0].status === 'modified')) {
const f = relevant[0]
const isDelete = f.status === 'removed'
try {
const raw = await getTextFileFromRepo(f.filename, context.payload.pull_request.base.sha)
const parsed = parseJsonSafe(raw)
if (parsed.ok && parsed.value && parsed.value.url) {
let hostname
try {
hostname = new URL(parsed.value.url).hostname
} catch {}
if (hostname) {
const dns = require('dns').promises
const expected = `fuwari-verification=${pull_number}`
const domains = [hostname, `_fuwari-verification.${hostname}`]
let verified = false
for (const d of domains) {
try {
const records = await dns.resolveTxt(d)
const flat = records.flat()
if (flat.some(t => t.includes(expected))) {
verified = true
break
}
} catch {}
}
if (!verified) {
await createComment(MSG.DNS_VERIFY_FAIL + '\n\n' + MSG.DNS_VERIFY_INSTRUCTIONS(isDelete, hostname, expected))
return
}
if (isDelete) {
await github.rest.pulls.merge({ owner, repo, pull_number, merge_method: 'squash' })
return
}
}
}
} catch (e) {
core.warning(`Ownership check failed: ${e}`)
}
}
const invalidSuffix = relevant.filter((f) => !f.filename.endsWith('.json')).map((f) => f.filename)
if (invalidSuffix.length) {
await createComment(MSG.JSON_EXT_ERROR + '\n\n' + invalidSuffix.map((p) => `- ${p}`).join('\n'))
return
}
// Skip further checks if only file removed (already handled or invalid)
if (relevant.length === 1 && relevant[0].status === 'removed') return
const headSha = context.payload.pull_request.head.sha
const parsedEntries = []
const errors = []
for (const f of relevant) {
const raw = await getTextFileFromRepo(f.filename, headSha)
const parsed = parseJsonSafe(raw)
if (!parsed.ok) {
errors.push(`${f.filename}${MSG.JSON_PARSE_ERROR}(${parsed.error})`)
continue
}
if (!parsed.value || typeof parsed.value !== 'object' || Array.isArray(parsed.value)) {
errors.push(`${f.filename}${MSG.JSON_OBJECT_ERROR}`)
continue
}
const kind = f.filename.startsWith('src/data/friends/') ? 'friend' : 'sponsor'
parsedEntries.push({ path: f.filename, kind, data: parsed.value })
}
function validateEntry({ kind, data, path }) {
const entryErrors = []
if (!isNonEmptyString(data.name)) entryErrors.push('name 必填')
if (!isNonEmptyString(data.avatar)) entryErrors.push('avatar 必填')
if (kind === 'friend') {
if (!isNonEmptyString(data.url)) entryErrors.push('url 必填')
}
if (kind === 'sponsor') {
if (!isNonEmptyString(data.date)) entryErrors.push('date 必填')
if (!isNonEmptyString(data.amount)) entryErrors.push('amount 必填')
}
if (entryErrors.length) return `${path}: ${entryErrors.join('、')}`
return null
}
for (const e of parsedEntries) {
const err = validateEntry(e)
if (err) errors.push(err)
}
if (errors.length) {
await createComment([MSG.VALIDATE_FAIL, '', ...errors.map((x) => `- ${x}`)].join('\n'))
return
}
const vipPaths = parsedEntries
.filter((e) => Object.prototype.hasOwnProperty.call(e.data, 'vip'))
.map((e) => e.path)
if (vipPaths.length) {
await createComment(
[
MSG.VIP_DETECTED,
'',
...vipPaths.map((p) => `- ${p}`),
].join('\n')
)
return
}
const primaryKind = touchedSponsors ? 'sponsor' : 'friend'
const primary = parsedEntries.find((e) => e.kind === primaryKind) || parsedEntries[0]
await github.rest.pulls.update({
owner,
repo,
pull_number,
title: `${primary.kind === 'friend' ? '友链' : '赞助'}:${primary.data.name}`,
})
const avatarUrl = normalizeUrl(primary.data.avatar)
const siteUrl = normalizeUrl(primary.data.url)
const selfSite = siteBase ? siteBase.replace(/\/$/, '') : null
const normalizedSiteUrl = siteUrl ? siteUrl.replace(/\/$/, '') : null
const normalizedBacklinkRaw = isNonEmptyString(primary.data.backlink) ? primary.data.backlink.trim() : null
const normalizedBacklinkUrl = normalizedBacklinkRaw
? (() => {
try {
return new URL(normalizedBacklinkRaw).toString().replace(/\/$/, '')
} catch {
return null
}
})()
: null
if (primary.kind === 'friend' && selfSite) {
if (normalizedSiteUrl === selfSite) {
await createComment(MSG.URL_SELF_ERROR)
return
}
if (normalizedBacklinkUrl === selfSite) {
await createComment(MSG.BACKLINK_SELF_ERROR)
return
}
}
const avatarCheck = avatarUrl ? await checkUrlReachability(avatarUrl) : { ok: false, error: 'Invalid avatar URL' }
const siteCheck =
primary.kind === 'friend'
? siteUrl
? await checkUrlReachability(siteUrl)
: { ok: false, error: 'Invalid site URL' }
: siteUrl
? await checkUrlReachability(siteUrl)
: null
const addReach = []
const removeReach = [LABEL_AVATAR_OK, LABEL_AVATAR_BAD, LABEL_SITE_OK, LABEL_SITE_BAD, LABEL_ALL_OK]
addReach.push(avatarCheck.ok ? LABEL_AVATAR_OK : LABEL_AVATAR_BAD)
if (siteCheck) addReach.push(siteCheck.ok ? LABEL_SITE_OK : LABEL_SITE_BAD)
const allOk = avatarCheck.ok && (!siteCheck || siteCheck.ok)
if (allOk && siteCheck) addReach.push(LABEL_ALL_OK)
await setLabels({ addLabels: addReach, removeLabels: removeReach, skipIfSame: true })
// Auto merge if it is an edit (modified) and all checks pass
if (relevant.length === 1 && relevant[0].status === 'modified' && allOk) {
try {
await github.rest.pulls.merge({ owner, repo, pull_number, merge_method: 'squash' })
return
} catch {}
}
const existingLabelNames = new Set((context.payload.pull_request.labels || []).map((l) => l.name))
core.info(`bidir: event.action=${context.payload.action || ''}`)
core.info(`bidir: siteBase=${siteBase || ''}`)
core.info(`bidir: existingLabels=${Array.from(existingLabelNames).join(',')}`)
if (primary.kind === 'friend') {
if (!siteBase) {
core.info('bidir: missing siteBase -> comment and return')
await createComment(MSG.SITE_CONFIG_MISSING)
return
}
if (!existingLabelNames.has(LABEL_BIDIR) && !existingLabelNames.has(LABEL_BIDIR_OK)) {
core.info('bidir: add LABEL_BIDIR')
await github.rest.issues.addLabels({ owner, repo, issue_number: pull_number, labels: [LABEL_BIDIR] })
}
const expected = siteBase.replace(/\/$/, '')
const backlink = primary.data.backlink
core.info(`bidir: url=${String(siteUrl || '')}`)
core.info(`bidir: backlink=${String(backlink || '')}`)
if (!isNonEmptyString(backlink)) {
core.info('bidir: missing backlink -> setLabels + comment')
await setLabels({ addLabels: [LABEL_BIDIR], removeLabels: [LABEL_BIDIR_OK] })
await createComment(MSG.BIDIR_INSTRUCTIONS(siteBase))
return
}
let backlinkUrl
try {
backlinkUrl = new URL(backlink).toString()
} catch {
core.info('bidir: backlink invalid url -> setLabels + comment')
await setLabels({ addLabels: [LABEL_BIDIR], removeLabels: [LABEL_BIDIR_OK] })
await createComment(`${MSG.BIDIR_BACKLINK_INVALID}(${backlink})`)
return
}
if (!backlinkUrl.startsWith('http://') && !backlinkUrl.startsWith('https://')) {
core.info('bidir: backlink not http(s) -> setLabels + comment')
await setLabels({ addLabels: [LABEL_BIDIR], removeLabels: [LABEL_BIDIR_OK] })
await createComment(`${MSG.BIDIR_BACKLINK_NOT_HTTP}(${backlink})`)
return
}
let siteUrlObj
try {
siteUrlObj = new URL(siteUrl)
} catch {
core.info('bidir: siteUrl invalid -> setLabels + comment')
await setLabels({ addLabels: [LABEL_BIDIR], removeLabels: [LABEL_BIDIR_OK] })
await createComment(MSG.BIDIR_SITE_INVALID)
return
}
const backlinkHost = new URL(backlinkUrl).host.toLowerCase()
const siteHost = siteUrlObj.host.toLowerCase()
core.info(`bidir: host url=${siteHost} backlink=${backlinkHost}`)
if (backlinkHost !== siteHost) {
core.info('bidir: host mismatch -> setLabels + comment')
await setLabels({ addLabels: [LABEL_BIDIR], removeLabels: [LABEL_BIDIR_OK] })
await createComment(
[
MSG.BIDIR_HOST_MISMATCH,
'',
`url:${siteUrlObj.toString()}`,
`backlink:${backlinkUrl}`,
].join('\n')
)
return
}
function extractTitle(html) {
if (!isNonEmptyString(html)) return null
const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)
if (!m) return null
const t = String(m[1] || '').replace(/\s+/g, ' ').trim()
return t.slice(0, 200)
}
function formatBacklinkDebug(d) {
const lines = ['调试信息:']
if (isNonEmptyString(d?.finalUrl)) lines.push(`- 最终 URL:${d.finalUrl}`)
if (typeof d?.status === 'number') lines.push(`- 状态码:${d.status}`)
if (isNonEmptyString(d?.contentType)) lines.push(`- Content-Type:${d.contentType}`)
if (isNonEmptyString(d?.title)) lines.push(`- 标题:${d.title}`)
return lines.join('\n')
}
const fetched = await checkUrlReachability(backlinkUrl)
if (!fetched.ok) {
core.info(`bidir: fetch backlink not ok status=${fetched.status}`)
await setLabels({ addLabels: [LABEL_BIDIR], removeLabels: [LABEL_BIDIR_OK] })
await createComment(
[
`${MSG.BIDIR_ACCESS_FAIL}(${fetched.status || fetched.error})。`,
'',
`backlink:${backlinkUrl}`,
formatBacklinkDebug({
finalUrl: fetched.url,
status: fetched.status,
}),
].join('\n')
)
return
}
core.info('bidir: fetch backlink ok, read text')
const html = fetched.body || ''
core.info(`bidir: html length=${html.length}`)
const found = verifyBacklink(html, expected)
core.info(`bidir: expected=${expected} found=${found}`)
if (!found) {
core.info('bidir: not found -> setLabels + comment')
await setLabels({ addLabels: [LABEL_BIDIR], removeLabels: [LABEL_BIDIR_OK] })
const debugInfo = {
finalUrl: fetched.url,
status: fetched.status,
title: extractTitle(html),
}
await createComment(MSG.BIDIR_NOT_FOUND(siteBase, backlinkUrl) + formatBacklinkDebug(debugInfo) + '\n\n' + '请添加后 push 更新(无需评论)。')
return
}
await setLabels({ addLabels: [LABEL_BIDIR_OK], removeLabels: [LABEL_BIDIR] })
core.info('bidir: verified ok -> merge')
try {
await github.rest.pulls.merge({ owner, repo, pull_number, merge_method: 'squash' })
} catch (e) {
await createComment(`${MSG.MERGE_FAIL}${e?.message || e}`)
return
}
}
verify_and_merge:
if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request && github.event.comment.body == '准备完毕' }}
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Auto-merge
uses: actions/github-script@v7
with:
github-token: ${{ env.PAT }}
script: |
const owner = context.repo.owner
const repo = context.repo.repo
const pull_number = context.payload.issue.number
const LABEL_FRIEND = '友链'
const LABEL_SPONSOR = '赞助'
const LABEL_AVATAR_OK = '头像可达'
const LABEL_AVATAR_BAD = '头像不可达'
const LABEL_SITE_OK = '网站可达'
const LABEL_SITE_BAD = '网站不可达'
const LABEL_ALL_OK = 'URL全部可达'
const LABEL_BIDIR = '双向链接验证'
const LABEL_BIDIR_OK = '双向链接验证通过'
const MSG = {
PR_ONE_FILE: 'PR 必须仅包含一个文件变更。',
PR_PATH_ERROR: '只允许更改 src/data/friends/ 或 src/data/sponsors/ 下的文件。',
PR_ACTION_ERROR: '只允许新增、编辑或删除文件。',
JSON_EXT_ERROR: '检测到友链/赞助目录下存在非 .json 文件,已停止处理:',
JSON_PARSE_ERROR: ': JSON 解析失败',
JSON_OBJECT_ERROR: ': JSON 必须是对象',
VALIDATE_FAIL: '检测到数据文件校验失败:',
VIP_DETECTED: '检测到 JSON 存在 vip 字段,已终止自动流程。\n\n请从以下文件移除 vip 字段后再 push 更新,然后再次回复“准备完毕”:',
URL_SELF_ERROR: '检测到友链 JSON 的 url 指向本站,请填写你自己的网站 URL。',
BACKLINK_SELF_ERROR: '检测到友链 JSON 的 backlink 指向本站,请填写你自己网站的友链页 URL。',
SITE_CONFIG_MISSING: '无法读取本站 site 配置,无法进行双向链接验证。',
BIDIR_INSTRUCTIONS: '该 PR 走双向链接验证流程:请在友链 JSON 中填写 backlink 字段并 push 更新,无需回复“准备完毕”。',
MERGE_FAIL: '自动合并失败。',
DNS_VERIFY_FAIL: '域名所有权验证失败。',
DNS_VERIFY_INSTRUCTIONS: (isDelete, hostname, expected) => [
`检测到你正在${isDelete ? '删除' : '修改'}现有的友链/赞助数据。为了防止误操作,请完成域名所有权验证:`,
'',
`1. 请在域名 ${hostname} 或 _fuwari-verification.${hostname} 下添加 DNS TXT 记录。`,
`2. 记录内容:${expected}`,
'3. 添加完成后,请回复“准备完毕”以触发重新校验。',
].join('\n'),
LINK_UNREACHABLE: '检测到链接不可达,请修复后再次回复“准备完毕”。',
}
function isNonEmptyString(v) {
return typeof v === 'string' && v.trim().length > 0
}
async function createComment(body) {
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
const footer = `\n\n---\n该评论由 Action 自动化发送,无需回复。\n\n🔗 [查看 Action](${runUrl})`
const fullBody = body + footer
await github.rest.issues.createComment({ owner, repo, issue_number: pull_number, body: fullBody })
}
function parseJsonSafe(text) {
try {
return { ok: true, value: JSON.parse(text) }
} catch (e) {
return { ok: false, error: e?.message || String(e) }
}
}
async function getTextFileFromRepo(path, ref) {
const res = await github.rest.repos.getContent({ owner, repo, path, ref })
if (Array.isArray(res.data) || !res.data?.content) throw new Error(`Invalid content response for ${path}`)
const raw = Buffer.from(res.data.content, res.data.encoding || 'base64').toString('utf8')
return raw
}
async function getSiteBase(defaultBranch) {
try {
const raw = await getTextFileFromRepo('src/config.ts', defaultBranch)
const m = raw.match(/customDomain\s*=\s*["']([^"']+)["']/)
if (!m) return null
return `https://${m[1]}`
} catch {
return null
}
}
function normalizeUrl(rawUrl, siteBase) {
if (!isNonEmptyString(rawUrl)) return null
const u = rawUrl.trim()
if (u.startsWith('/')) {
if (!siteBase) return null
return new URL(u, siteBase).toString()
}
try {
return new URL(u).toString()
} catch {
return null
}
}
async function checkUrl(url, { timeoutMs }) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
// SSRF Protection
try {
const u = new URL(url)
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('Invalid protocol')
const h = u.hostname
if (h === 'localhost' || h === '127.0.0.1' || h === '::1') throw new Error('Localhost not allowed')
if (/^10\./.test(h)) throw new Error('Private IP not allowed')
if (/^192\.168\./.test(h)) throw new Error('Private IP not allowed')
if (/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(h)) throw new Error('Private IP not allowed')
} catch (e) {
return { ok: false, error: e.message }
}
const res = await fetch(url, {
method: 'GET',
redirect: 'follow',
headers: {
'user-agent': 'fuwari-auto-pr/1.0 (+github actions)',
accept: 'text/html,application/json,image/*,*/*;q=0.8',
},
signal: controller.signal,
})
return { ok: res.status >= 200 && res.status < 400, status: res.status }
} catch (e) {
return { ok: false, error: e?.message || String(e) }
} finally {
clearTimeout(timer)
}
}
async function setLabels({ addLabels, removeLabels, skipIfSame }) {
const addSet = new Set(addLabels || [])
const removeSet = new Set(removeLabels || [])
for (const x of addSet) removeSet.delete(x)
const current = await github.rest.issues.get({ owner, repo, issue_number: pull_number })
const currentLabels = new Set((current.data.labels || []).map((l) => l.name))
if (skipIfSame) {
for (const name of addSet) {
if (currentLabels.has(name)) {
core.info(`Label ${name} already set, skip label operations`)
return
}
}
}
const finalRemove = Array.from(removeSet).filter((name) => currentLabels.has(name))
if (finalRemove.length) {
for (const name of finalRemove) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: pull_number, name })
} catch (e) {
if (e.status !== 404) throw e
}
}
}
const finalAdd = Array.from(addSet).filter((name) => !currentLabels.has(name))
if (finalAdd.length) {
await github.rest.issues.addLabels({ owner, repo, issue_number: pull_number, labels: finalAdd })
}
}
async function listAllFiles() {
const files = []
for await (const res of github.paginate.iterator(
github.rest.pulls.listFiles,
{ owner, repo, pull_number, per_page: 100 }
)) {
for (const f of res.data) files.push(f)
}
return files
}
const pr = await github.rest.pulls.get({ owner, repo, pull_number })
core.info(`event_name=${context.eventName}`)
core.info(`actor=${context.actor}`)
core.info(`issue_comment_author=${context.payload.comment?.user?.login || ''}`)
core.info(`pr.number=${pr.data.number}`)
core.info(`pr.state=${pr.data.state}, pr.draft=${pr.data.draft}`)
core.info(`pr.user=${pr.data.user?.login || ''}`)
core.info(`pr.head.ref=${pr.data.head?.ref || ''}`)
core.info(`pr.head.sha=${pr.data.head?.sha || ''}`)
core.info(`pr.head.repo.fork=${pr.data.head?.repo?.fork}`)
core.info(`pr.base.ref=${pr.data.base?.ref || ''}`)
core.info(`pr.base.repo.private=${pr.data.base?.repo?.private}`)
core.info(`pr.maintainer_can_modify=${pr.data.maintainer_can_modify}`)
core.info(`pr.mergeable=${pr.data.mergeable}`)
core.info(`pr.mergeable_state=${pr.data.mergeable_state}`)
core.info(`pr.rebaseable=${pr.data.rebaseable}`)
core.info(`pr.auto_merge=${pr.data.auto_merge ? 'enabled' : 'disabled'}`)
core.info(`pr.labels=${(pr.data.labels || []).map((l) => l.name).join(',')}`)
try {
const auth = await github.rest.apps.getAuthenticated()
core.info(`authenticated_app=${auth.data?.name || ''}`)
} catch (e) {
core.info(`getAuthenticated failed: ${e?.status || ''} ${e?.message || e}`)
}
const prLabels = new Set((pr.data.labels || []).map((l) => l.name))
if (!prLabels.has(LABEL_FRIEND) && !prLabels.has(LABEL_SPONSOR)) {
core.info('PR is not labeled as 友链/赞助. Skip.')
return
}
if (prLabels.has(LABEL_BIDIR) || prLabels.has(LABEL_BIDIR_OK)) {
await createComment('该 PR 走双向链接验证流程:请在友链 JSON 中填写 backlink 字段并 push 更新,无需回复“准备完毕”。')
return
}
const files = await listAllFiles()
if (files.length !== 1) {
await createComment(MSG.PR_ONE_FILE)
return
}
const file = files[0]
if (!file.filename.startsWith('src/data/friends/') && !file.filename.startsWith('src/data/sponsors/')) {
await createComment(MSG.PR_PATH_ERROR)
return
}
if (file.status !== 'added' && file.status !== 'modified' && file.status !== 'removed') {
await createComment(MSG.PR_ACTION_ERROR)
return
}
const relevant = files
// Verify domain ownership for removed or modified files
if (relevant.length === 1 && (relevant[0].status === 'removed' || relevant[0].status === 'modified')) {
const f = relevant[0]
const isDelete = f.status === 'removed'
try {
const raw = await getTextFileFromRepo(f.filename, pr.data.base.sha)
const parsed = parseJsonSafe(raw)
if (parsed.ok && parsed.value && parsed.value.url) {
let hostname
try {
hostname = new URL(parsed.value.url).hostname
} catch {}
if (hostname) {
const dns = require('dns').promises
const expected = `fuwari-verification=${pull_number}`
const domains = [hostname, `_fuwari-verification.${hostname}`]
let verified = false
for (const d of domains) {
try {
const records = await dns.resolveTxt(d)
const flat = records.flat()
if (flat.some(t => t.includes(expected))) {
verified = true
break
}
} catch {}
}
if (!verified) {
await createComment(MSG.DNS_VERIFY_FAIL + '\n\n' + MSG.DNS_VERIFY_INSTRUCTIONS(isDelete, hostname, expected))
return
}
if (isDelete) {
await github.rest.pulls.merge({ owner, repo, pull_number, merge_method: 'squash' })
return
}
}
}
} catch (e) {
core.warning(`Ownership check failed: ${e}`)
}
}
const invalidSuffix = relevant.filter((f) => !f.filename.endsWith('.json')).map((f) => f.filename)
if (invalidSuffix.length) {
await createComment(MSG.JSON_EXT_ERROR + '\n\n' + invalidSuffix.map((p) => `- ${p}`).join('\n'))
return
}
// Skip further checks if only file removed (already handled or invalid)
if (relevant.length === 1 && relevant[0].status === 'removed') return
const parsedEntries = []
const errors = []
for (const f of relevant) {
const raw = await getTextFileFromRepo(f.filename, pr.data.head.sha)
const parsed = parseJsonSafe(raw)
if (!parsed.ok) {
errors.push(`${f.filename}${MSG.JSON_PARSE_ERROR}(${parsed.error})`)
continue
}
if (!parsed.value || typeof parsed.value !== 'object' || Array.isArray(parsed.value)) {
errors.push(`${f.filename}${MSG.JSON_OBJECT_ERROR}`)
continue
}
const kind = f.filename.startsWith('src/data/friends/') ? 'friend' : 'sponsor'
parsedEntries.push({ path: f.filename, kind, data: parsed.value })
}
function validateEntry({ kind, data, path }) {
const entryErrors = []
if (!isNonEmptyString(data.name)) entryErrors.push('name 必填')
if (!isNonEmptyString(data.avatar)) entryErrors.push('avatar 必填')
if (kind === 'friend') {
if (!isNonEmptyString(data.url)) entryErrors.push('url 必填')
}
if (kind === 'sponsor') {
if (!isNonEmptyString(data.date)) entryErrors.push('date 必填')
if (!isNonEmptyString(data.amount)) entryErrors.push('amount 必填')
}
if (entryErrors.length) return `${path}: ${entryErrors.join('、')}`
return null
}
for (const e of parsedEntries) {
const err = validateEntry(e)
if (err) errors.push(err)
}
if (errors.length) {
await createComment([MSG.VALIDATE_FAIL, '', ...errors.map((x) => `- ${x}`)].join('\n'))
return
}
const vipPaths = parsedEntries
.filter((e) => Object.prototype.hasOwnProperty.call(e.data, 'vip'))
.map((e) => e.path)
if (vipPaths.length) {
await createComment(
[
MSG.VIP_DETECTED,
'',
...vipPaths.map((p) => `- ${p}`),
].join('\n')
)
return
}
const primaryKind = prLabels.has(LABEL_SPONSOR) ? 'sponsor' : 'friend'
const primary = parsedEntries.find((e) => e.kind === primaryKind) || parsedEntries[0]
await github.rest.pulls.update({
owner,
repo,
pull_number,
title: `${primary.kind === 'friend' ? '友链' : '赞助'}:${primary.data.name}`,
})
const siteBase = await getSiteBase(pr.data.base.repo.default_branch)
async function checkUrlReachability(url) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 10000)
try {
const res = await fetch(url, {
method: 'GET',
redirect: 'follow',
headers: { 'user-agent': 'fuwari-auto-pr/1.0 (+github actions)', accept: 'text/html,*/*;q=0.8' },
signal: controller.signal,
})
const body = res.ok ? await res.text() : ''
return { ok: res.ok, status: res.status, body, url: res.url }
} catch (e) {
return { ok: false, error: e?.message || String(e) }
} finally {
clearTimeout(timer)
}
}
const avatarUrl = normalizeUrl(primary.data.avatar, siteBase)
const siteUrl = normalizeUrl(primary.data.url, siteBase)
const selfSite = siteBase ? siteBase.replace(/\/$/, '') : null
const normalizedSiteUrl = siteUrl ? siteUrl.replace(/\/$/, '') : null
if (primary.kind === 'friend' && selfSite && normalizedSiteUrl === selfSite) {
await createComment(MSG.URL_SELF_ERROR)
return
}
const avatarCheck = avatarUrl ? await checkUrlReachability(avatarUrl) : { ok: false, error: 'Invalid avatar URL' }
const siteCheck =
primary.kind === 'friend'
? siteUrl
? await checkUrlReachability(siteUrl)
: { ok: false, error: 'Invalid site URL' }
: siteUrl
? await checkUrlReachability(siteUrl)
: null
const addReach = []
const removeReach = [LABEL_AVATAR_OK, LABEL_AVATAR_BAD, LABEL_SITE_OK, LABEL_SITE_BAD, LABEL_ALL_OK]
addReach.push(avatarCheck.ok ? LABEL_AVATAR_OK : LABEL_AVATAR_BAD)
if (siteCheck) addReach.push(siteCheck.ok ? LABEL_SITE_OK : LABEL_SITE_BAD)
const allOk = avatarCheck.ok && (!siteCheck || siteCheck.ok)
if (allOk && siteCheck) addReach.push(LABEL_ALL_OK)
await setLabels({ addLabels: addReach, removeLabels: removeReach, skipIfSame: true })
// Auto merge if it is an edit (modified) and all checks pass
if (relevant.length === 1 && relevant[0].status === 'modified' && allOk) {
try {
await github.rest.pulls.merge({ owner, repo, pull_number, merge_method: 'squash' })
return
} catch {}
}
if (!avatarCheck.ok || (siteCheck && !siteCheck.ok)) {
await createComment('检测到链接不可达,请修复后再次回复“准备完毕”。')
return
}
try {
await github.rest.pulls.merge({ owner, repo, pull_number, merge_method: 'squash' })
} catch (e) {
core.error(`merge failed: status=${e?.status || ''}`)
core.error(`merge failed: message=${e?.message || e}`)
try {
core.error(`merge failed: response=${JSON.stringify(e?.response?.data || null)}`)
} catch {}
await createComment(
[
'自动合并失败。',
'',
`错误信息:${e?.message || e}`,
`HTTP 状态:${e?.status || 'unknown'}`,
`响应:${(() => { try { return JSON.stringify(e?.response?.data || null) } catch { return 'unavailable' } })()}`,
].join('\n')
)
return
}