mirror of
https://github.com/Molunerfinn/PicGo.git
synced 2026-10-20 08:16:42 +00:00
336 lines
10 KiB
JavaScript
336 lines
10 KiB
JavaScript
const fs = require('fs')
|
|
const http = require('http')
|
|
const https = require('https')
|
|
const path = require('path')
|
|
|
|
const API_URL = process.env.ATOM_API_URL || 'https://api.atomgit.com/api/v5'
|
|
const ATOM_PAT = process.env.ATOM_PAT
|
|
const ATOM_OWNER = process.env.ATOM_OWNER
|
|
const ATOM_REPO = process.env.ATOM_REPO
|
|
const RELEASE_TAG = process.env.RELEASE_TAG
|
|
const TARGET_COMMITISH = process.env.ATOM_TARGET_COMMITISH
|
|
const DIST_DIR = path.resolve(process.env.DIST_DIR || 'dist')
|
|
const RELEASE_METADATA_PATH = path.resolve(process.env.GITHUB_RELEASE_JSON || 'github-release.json')
|
|
const MAX_ATTEMPTS = 3
|
|
const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504])
|
|
const UPLOAD_TIMEOUT_MINUTES = Number.parseInt(process.env.ATOM_UPLOAD_TIMEOUT_MINUTES || '70', 10)
|
|
|
|
function requireEnvironmentVariable(name, value) {
|
|
if (!value) {
|
|
throw new Error(`Missing required environment variable: ${name}`)
|
|
}
|
|
}
|
|
|
|
function encodePathSegment(value) {
|
|
return encodeURIComponent(value)
|
|
}
|
|
|
|
function wait(milliseconds) {
|
|
return new Promise(resolve => setTimeout(resolve, milliseconds))
|
|
}
|
|
|
|
function formatFileSize(bytes) {
|
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
}
|
|
|
|
function formatDuration(milliseconds) {
|
|
return `${Math.round(milliseconds / 1000)}s`
|
|
}
|
|
|
|
async function requestWithRetry(url, createOptions, label) {
|
|
let lastError
|
|
|
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
|
try {
|
|
const response = await fetch(url, createOptions())
|
|
if (!RETRYABLE_STATUS_CODES.has(response.status) || attempt === MAX_ATTEMPTS) {
|
|
return response
|
|
}
|
|
|
|
await response.text()
|
|
console.warn(`[AtomGit] ${label} returned ${response.status}; retrying (${attempt}/${MAX_ATTEMPTS})`)
|
|
} catch (error) {
|
|
lastError = error
|
|
if (attempt === MAX_ATTEMPTS) {
|
|
throw error
|
|
}
|
|
|
|
console.warn(`[AtomGit] ${label} failed; retrying (${attempt}/${MAX_ATTEMPTS})`)
|
|
}
|
|
|
|
await wait(1000 * attempt)
|
|
}
|
|
|
|
throw lastError || new Error(`${label} failed after ${MAX_ATTEMPTS} attempts`)
|
|
}
|
|
|
|
function atomGitApiUrl(relativePath) {
|
|
return `${API_URL}/repos/${encodePathSegment(ATOM_OWNER)}/${encodePathSegment(ATOM_REPO)}${relativePath}`
|
|
}
|
|
|
|
function atomGitHeaders(additionalHeaders = {}) {
|
|
return {
|
|
Authorization: `Bearer ${ATOM_PAT}`,
|
|
Accept: 'application/json',
|
|
...additionalHeaders
|
|
}
|
|
}
|
|
|
|
function parseResponseText(text) {
|
|
if (!text) {
|
|
return null
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(text)
|
|
} catch {
|
|
return text
|
|
}
|
|
}
|
|
|
|
async function readResponseBody(response) {
|
|
return parseResponseText(await response.text())
|
|
}
|
|
|
|
function formatResponseBody(body) {
|
|
return typeof body === 'string' ? body : JSON.stringify(body)
|
|
}
|
|
|
|
async function assertSuccessfulResponse(response, label) {
|
|
const body = await readResponseBody(response)
|
|
if (!response.ok) {
|
|
throw new Error(`${label} failed with HTTP ${response.status}: ${formatResponseBody(body)}`)
|
|
}
|
|
|
|
return body
|
|
}
|
|
|
|
async function verifyCredentials() {
|
|
const response = await requestWithRetry(
|
|
`${API_URL}/user`,
|
|
() => ({ headers: atomGitHeaders() }),
|
|
'credential verification'
|
|
)
|
|
await assertSuccessfulResponse(response, 'AtomGit credential verification')
|
|
}
|
|
|
|
async function getRelease() {
|
|
const response = await requestWithRetry(
|
|
atomGitApiUrl(`/releases/tags/${encodePathSegment(RELEASE_TAG)}`),
|
|
() => ({ headers: atomGitHeaders() }),
|
|
`get release ${RELEASE_TAG}`
|
|
)
|
|
|
|
if (response.status === 404) {
|
|
await response.text()
|
|
return null
|
|
}
|
|
|
|
return assertSuccessfulResponse(response, `Get AtomGit release ${RELEASE_TAG}`)
|
|
}
|
|
|
|
async function createRelease(metadata) {
|
|
const name = metadata.name || RELEASE_TAG
|
|
const payload = {
|
|
tag_name: RELEASE_TAG,
|
|
name,
|
|
body: metadata.body || `Release ${name}`,
|
|
release_status: metadata.isPrerelease ? 'pre' : 'latest'
|
|
}
|
|
const targetCommitish = TARGET_COMMITISH || metadata.targetCommitish
|
|
|
|
if (targetCommitish) {
|
|
payload.target_commitish = targetCommitish
|
|
}
|
|
|
|
const response = await requestWithRetry(
|
|
atomGitApiUrl('/releases'),
|
|
() => ({
|
|
method: 'POST',
|
|
headers: atomGitHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify(payload)
|
|
}),
|
|
`create release ${RELEASE_TAG}`
|
|
)
|
|
|
|
if (response.status === 409) {
|
|
await response.text()
|
|
return getRelease()
|
|
}
|
|
|
|
return assertSuccessfulResponse(response, `Create AtomGit release ${RELEASE_TAG}`)
|
|
}
|
|
|
|
async function getOrCreateRelease(metadata) {
|
|
const existingRelease = await getRelease()
|
|
if (existingRelease) {
|
|
console.log(`[AtomGit] Release ${RELEASE_TAG} already exists`)
|
|
return existingRelease
|
|
}
|
|
|
|
console.log(`[AtomGit] Creating release ${RELEASE_TAG}`)
|
|
const createdRelease = await createRelease(metadata)
|
|
if (!createdRelease) {
|
|
throw new Error(`AtomGit release ${RELEASE_TAG} was not available after creation`)
|
|
}
|
|
|
|
return createdRelease
|
|
}
|
|
|
|
async function getUploadRequest(fileName) {
|
|
const url = new URL(atomGitApiUrl(`/releases/${encodePathSegment(RELEASE_TAG)}/upload_url`))
|
|
url.searchParams.set('file_name', fileName)
|
|
|
|
const response = await requestWithRetry(
|
|
url,
|
|
() => ({ headers: atomGitHeaders() }),
|
|
`request upload URL for ${fileName}`
|
|
)
|
|
const body = await assertSuccessfulResponse(response, `Get upload URL for ${fileName}`)
|
|
|
|
if (!body || typeof body !== 'object' || typeof body.url !== 'string' || !body.headers || typeof body.headers !== 'object') {
|
|
throw new Error(`AtomGit returned an invalid upload request for ${fileName}`)
|
|
}
|
|
|
|
return body
|
|
}
|
|
|
|
function putAssetBuffer(url, headers, fileBuffer) {
|
|
return new Promise((resolve, reject) => {
|
|
const targetUrl = new URL(url)
|
|
const requestClient = targetUrl.protocol === 'http:' ? http : https
|
|
const request = requestClient.request(targetUrl, {
|
|
method: 'PUT',
|
|
headers: Object.fromEntries(headers.entries())
|
|
}, response => {
|
|
const chunks = []
|
|
|
|
response.on('data', chunk => {
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
|
})
|
|
response.on('end', () => {
|
|
resolve({
|
|
status: response.statusCode || 0,
|
|
body: Buffer.concat(chunks).toString('utf8')
|
|
})
|
|
})
|
|
response.on('error', reject)
|
|
})
|
|
|
|
request.setTimeout(UPLOAD_TIMEOUT_MINUTES * 60 * 1000, () => {
|
|
request.destroy(new Error(`Upload request timed out after ${UPLOAD_TIMEOUT_MINUTES} minutes`))
|
|
})
|
|
request.on('error', reject)
|
|
request.end(fileBuffer)
|
|
})
|
|
}
|
|
|
|
async function uploadAsset(filePath) {
|
|
const fileName = path.basename(filePath)
|
|
const fileBuffer = await fs.promises.readFile(filePath)
|
|
const fileSize = fileBuffer.byteLength
|
|
const startedAt = Date.now()
|
|
|
|
console.log(`[AtomGit] Uploading ${fileName} (${formatFileSize(fileSize)})`)
|
|
|
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
|
const uploadRequest = await getUploadRequest(fileName)
|
|
const uploadHeaders = new Headers(uploadRequest.headers)
|
|
uploadHeaders.set('Content-Length', String(fileSize))
|
|
let response
|
|
|
|
try {
|
|
response = await putAssetBuffer(uploadRequest.url, uploadHeaders, fileBuffer)
|
|
} catch (error) {
|
|
if (attempt === MAX_ATTEMPTS) {
|
|
throw error
|
|
}
|
|
|
|
console.warn(`[AtomGit] Upload ${fileName} failed; requesting a new upload URL (${attempt}/${MAX_ATTEMPTS})`)
|
|
await wait(1000 * attempt)
|
|
continue
|
|
}
|
|
|
|
if (response.status >= 200 && response.status < 300) {
|
|
console.log(`[AtomGit] Uploaded ${fileName} in ${formatDuration(Date.now() - startedAt)}`)
|
|
return
|
|
}
|
|
|
|
const responseBody = parseResponseText(response.body)
|
|
if (!RETRYABLE_STATUS_CODES.has(response.status) || attempt === MAX_ATTEMPTS) {
|
|
throw new Error(`Upload ${fileName} failed with HTTP ${response.status}: ${formatResponseBody(responseBody)}`)
|
|
}
|
|
|
|
console.warn(`[AtomGit] Upload ${fileName} returned ${response.status}; requesting a new upload URL (${attempt}/${MAX_ATTEMPTS})`)
|
|
await wait(1000 * attempt)
|
|
}
|
|
}
|
|
|
|
function readReleaseMetadata() {
|
|
const metadata = JSON.parse(fs.readFileSync(RELEASE_METADATA_PATH, 'utf8'))
|
|
if (metadata.isDraft) {
|
|
throw new Error(`GitHub release ${RELEASE_TAG} is still a draft and will not be mirrored`)
|
|
}
|
|
|
|
if (metadata.tagName !== RELEASE_TAG) {
|
|
throw new Error(`GitHub release tag ${metadata.tagName} does not match requested tag ${RELEASE_TAG}`)
|
|
}
|
|
|
|
return metadata
|
|
}
|
|
|
|
function findAssets() {
|
|
return fs.readdirSync(DIST_DIR, { withFileTypes: true })
|
|
.filter(entry => entry.isFile())
|
|
.map(entry => path.join(DIST_DIR, entry.name))
|
|
.sort((left, right) => left.localeCompare(right))
|
|
}
|
|
|
|
function getExistingAssetNames(release) {
|
|
if (!Array.isArray(release.assets)) {
|
|
return new Set()
|
|
}
|
|
|
|
return new Set(release.assets
|
|
.map(asset => asset && asset.name)
|
|
.filter(name => typeof name === 'string'))
|
|
}
|
|
|
|
async function publishRelease() {
|
|
requireEnvironmentVariable('ATOM_PAT', ATOM_PAT)
|
|
requireEnvironmentVariable('ATOM_OWNER', ATOM_OWNER)
|
|
requireEnvironmentVariable('ATOM_REPO', ATOM_REPO)
|
|
requireEnvironmentVariable('RELEASE_TAG', RELEASE_TAG)
|
|
if (!Number.isInteger(UPLOAD_TIMEOUT_MINUTES) || UPLOAD_TIMEOUT_MINUTES < 1 || UPLOAD_TIMEOUT_MINUTES > 180) {
|
|
throw new Error('ATOM_UPLOAD_TIMEOUT_MINUTES must be an integer between 1 and 180')
|
|
}
|
|
|
|
const metadata = readReleaseMetadata()
|
|
const assetPaths = findAssets()
|
|
if (assetPaths.length === 0) {
|
|
throw new Error(`No GitHub release assets found in ${DIST_DIR}`)
|
|
}
|
|
|
|
console.log(`[AtomGit] Mirroring ${assetPaths.length} assets to ${ATOM_OWNER}/${ATOM_REPO}@${RELEASE_TAG}`)
|
|
await verifyCredentials()
|
|
const release = await getOrCreateRelease(metadata)
|
|
const existingAssetNames = getExistingAssetNames(release)
|
|
|
|
for (const assetPath of assetPaths) {
|
|
const fileName = path.basename(assetPath)
|
|
if (existingAssetNames.has(fileName)) {
|
|
console.log(`[AtomGit] Skipping existing asset ${fileName}`)
|
|
continue
|
|
}
|
|
|
|
await uploadAsset(assetPath)
|
|
}
|
|
|
|
console.log(`[AtomGit] Release ${RELEASE_TAG} mirror completed`)
|
|
}
|
|
|
|
publishRelease().catch(error => {
|
|
console.error('[AtomGit] Publish failed:', error)
|
|
process.exitCode = 1
|
|
})
|