增加镜像离线下载
This commit is contained in:
@@ -11,6 +11,12 @@ FROM alpine:3.20
|
||||
|
||||
WORKDIR /root/
|
||||
|
||||
# 安装skopeo
|
||||
RUN apk add --no-cache skopeo
|
||||
|
||||
# 创建临时目录
|
||||
RUN mkdir -p ./temp && chmod 777 ./temp
|
||||
|
||||
COPY --from=builder /app/main .
|
||||
COPY --from=builder /app/config.json .
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
@@ -2,7 +2,10 @@ module ghproxy
|
||||
|
||||
go 1.22.5
|
||||
|
||||
require github.com/gin-gonic/gin v1.10.0
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/gorilla/websocket v1.5.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
|
||||
@@ -73,6 +73,9 @@ func main() {
|
||||
// 前端访问路径,默认根路径
|
||||
router.Static("/", "./public")
|
||||
router.NoRoute(handler)
|
||||
|
||||
// 初始化Skopeo相关路由
|
||||
initSkopeoRoutes(router)
|
||||
|
||||
err := router.Run(fmt.Sprintf("%s:%d", host, port))
|
||||
if err != nil {
|
||||
|
||||
@@ -351,6 +351,28 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.download-button {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 80px;
|
||||
padding: 2px 8px;
|
||||
background-color: #f5f5f5;
|
||||
border: 0px solid #eeeeee;
|
||||
color: #333;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.download-button:hover {
|
||||
background-color: #39c5bc;
|
||||
color: white;
|
||||
transform: scale(1.05);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.github-link {
|
||||
display: inline-block;
|
||||
position: static;
|
||||
@@ -372,6 +394,7 @@
|
||||
|
||||
<body>
|
||||
<a href="https://gitee.com/if-the-wind/github-hosts/raw/main/hosts" target="_blank" class="hosts-button">hosts</a>
|
||||
<a href="/skopeo.html" class="download-button">镜像下载</a>
|
||||
<div class="container">
|
||||
<h1>Github文件加速</h1>
|
||||
<div class="form-group">
|
||||
|
||||
585
ghproxy/public/skopeo.html
Normal file
585
ghproxy/public/skopeo.html
Normal file
@@ -0,0 +1,585 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="Docker镜像批量下载工具,docker镜像打包下载">
|
||||
<meta name="keywords" content="Docker,镜像下载,skopeo,docker镜像打包">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>Docker镜像批量下载</title>
|
||||
<link rel="icon" href="./favicon.ico">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.5.3/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://font.sec.miui.com/font/css?family=MiSans:400,700:MiSans">
|
||||
<style>
|
||||
:root {
|
||||
--color: #ffffff;
|
||||
--fontcolor: #333;
|
||||
--inputcolor: #f5f5f5;
|
||||
--inputcolor-font: #333;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color: #53535338;
|
||||
--fontcolor: #b8b8b8;
|
||||
--inputcolor: #012333;
|
||||
--inputcolor-font: #969696d8;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color);
|
||||
background-image: url('./bj.svg');
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
background-attachment: fixed;
|
||||
color: var(--fontcolor);
|
||||
font-family: 'Misans', Arial, sans-serif;
|
||||
padding: 30px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
height: 10px;
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: #39c5bb;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 80%;
|
||||
text-align: center;
|
||||
min-height: 65%;
|
||||
line-height: 1.25;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: var(--fontcolor);
|
||||
font-weight: bold;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.rounded-button {
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.3s, transform 0.2s;
|
||||
padding: 10px 30px;
|
||||
background-color: #555c5c;
|
||||
color: rgb(255, 255, 255);
|
||||
border: none;
|
||||
margin-bottom: 3%;
|
||||
}
|
||||
|
||||
.rounded-button:hover {
|
||||
background-color: #39c5bcda;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
footer {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
line-height: 1.25;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #012333;
|
||||
color: #39c5bc;
|
||||
padding: 15px 20px 15px 20px;
|
||||
margin: 0px 0;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
pre::before {
|
||||
content: " ";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #bd3c35;
|
||||
border-radius: 50%;
|
||||
box-shadow: 20px 0 0 #d69f27, 40px 0 0 #39c5bb;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
max-width: 100%;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
max-width: 65%;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 3%;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
background-color: var(--inputcolor);
|
||||
color: var(--inputcolor-font);
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
background-color: var(--inputcolor);
|
||||
color: var(--inputcolor-font);
|
||||
}
|
||||
|
||||
#toast {
|
||||
position: fixed;
|
||||
top: 10%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: #39c5bcde;
|
||||
color: white;
|
||||
padding: 15px 20px;
|
||||
border-radius: 10px;
|
||||
font-size: 90%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
padding: 2px 8px;
|
||||
background-color: #f5f5f5;
|
||||
border: 0px solid #eeeeee;
|
||||
color: #333;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background-color: #39c5bc;
|
||||
color: white;
|
||||
transform: scale(1.05);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.image-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--inputcolor);
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.image-name {
|
||||
flex-grow: 1;
|
||||
text-align: left;
|
||||
padding-left: 10px;
|
||||
color: var(--inputcolor-font);
|
||||
}
|
||||
|
||||
#imageList {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
margin-top: 20px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 20px;
|
||||
background-color: #39c5bb;
|
||||
width: 0%;
|
||||
border-radius: 10px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
margin-top: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.image-progress {
|
||||
margin-top: 15px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.image-progress-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.image-progress-name {
|
||||
flex: 0 0 200px;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.image-progress-bar-container {
|
||||
flex-grow: 1;
|
||||
height: 15px;
|
||||
background-color: #ddd;
|
||||
border-radius: 5px;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.image-progress-bar {
|
||||
height: 100%;
|
||||
background-color: #39c5bb;
|
||||
border-radius: 5px;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.image-progress-text {
|
||||
flex: 0 0 50px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.download-button {
|
||||
display: none;
|
||||
margin-top: 20px;
|
||||
padding: 10px 20px;
|
||||
background-color: #39c5bb;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.download-button:hover {
|
||||
background-color: #2ea89e;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
textarea.form-control {
|
||||
min-height: 150px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--fontcolor);
|
||||
opacity: 0.8;
|
||||
margin-bottom: 15px;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<a href="/" class="back-button">返回</a>
|
||||
<div class="container">
|
||||
<h1>Docker镜像批量下载</h1>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="info-text">每行输入一个镜像名称,如:nginx:latest, redis:6 等</div>
|
||||
<textarea class="form-control" id="imageInput" placeholder="输入镜像名称,每行一个 例如: nginx:latest redis:6 mysql:8"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="info-text">镜像架构,默认为 amd64</div>
|
||||
<input type="text" class="form-control" id="platformInput" placeholder="输入架构,例如:amd64, arm64等" value="amd64">
|
||||
</div>
|
||||
|
||||
<button class="btn rounded-button" id="downloadButton">开始下载</button>
|
||||
|
||||
<div id="imageList"></div>
|
||||
|
||||
<div class="progress-container" id="progressContainer">
|
||||
<h4>整体进度</h4>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar" id="totalProgressBar"></div>
|
||||
</div>
|
||||
<div class="progress-text" id="totalProgressText">0%</div>
|
||||
|
||||
<div class="image-progress" id="imageProgressList">
|
||||
<!-- Image progress items will be added here -->
|
||||
</div>
|
||||
|
||||
<button class="download-button" id="getFileButton">下载文件</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" style="display:none;"></div>
|
||||
|
||||
<footer>
|
||||
<a href="https://github.com/sky22333/hub-proxy" target="_blank" class="github-link">
|
||||
<svg height="32" viewBox="0 0 16 16" width="32">
|
||||
<path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const imageInput = document.getElementById('imageInput');
|
||||
const platformInput = document.getElementById('platformInput');
|
||||
const imageList = document.getElementById('imageList');
|
||||
const downloadButton = document.getElementById('downloadButton');
|
||||
const progressContainer = document.getElementById('progressContainer');
|
||||
const totalProgressBar = document.getElementById('totalProgressBar');
|
||||
const totalProgressText = document.getElementById('totalProgressText');
|
||||
const imageProgressList = document.getElementById('imageProgressList');
|
||||
const getFileButton = document.getElementById('getFileButton');
|
||||
|
||||
let images = [];
|
||||
let currentTaskId = null;
|
||||
let websocket = null;
|
||||
|
||||
// 从多行文本框解析镜像列表
|
||||
function parseImageList() {
|
||||
const text = imageInput.value.trim();
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 按行分割并过滤空行
|
||||
return text.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0);
|
||||
}
|
||||
|
||||
// 渲染镜像列表
|
||||
function renderImageList(images) {
|
||||
imageList.innerHTML = '';
|
||||
imageList.style.display = 'block';
|
||||
|
||||
images.forEach(image => {
|
||||
const itemDiv = document.createElement('div');
|
||||
itemDiv.className = 'image-item';
|
||||
|
||||
const nameDiv = document.createElement('div');
|
||||
nameDiv.className = 'image-name';
|
||||
nameDiv.textContent = image;
|
||||
|
||||
itemDiv.appendChild(nameDiv);
|
||||
imageList.appendChild(itemDiv);
|
||||
});
|
||||
}
|
||||
|
||||
// 开始下载
|
||||
function startDownload() {
|
||||
images = parseImageList();
|
||||
|
||||
if (images.length === 0) {
|
||||
showToast('请至少输入一个镜像');
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取平台值
|
||||
const platform = platformInput.value.trim() || 'amd64';
|
||||
|
||||
// 显示镜像列表
|
||||
renderImageList(images);
|
||||
|
||||
// 准备请求数据
|
||||
const requestData = {
|
||||
images: images,
|
||||
platform: platform
|
||||
};
|
||||
|
||||
// 发送下载请求
|
||||
fetch('/api/download', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestData)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.taskId) {
|
||||
currentTaskId = data.taskId;
|
||||
showProgressUI();
|
||||
connectWebSocket(currentTaskId);
|
||||
} else {
|
||||
showToast('下载任务创建失败');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showToast('请求失败: ' + error.message);
|
||||
});
|
||||
}
|
||||
|
||||
// 显示进度UI
|
||||
function showProgressUI() {
|
||||
progressContainer.style.display = 'block';
|
||||
downloadButton.style.display = 'none';
|
||||
imageInput.disabled = true;
|
||||
platformInput.disabled = true;
|
||||
|
||||
// 初始化每个镜像的进度条
|
||||
imageProgressList.innerHTML = '';
|
||||
images.forEach(image => {
|
||||
addImageProgressItem(image, 0);
|
||||
});
|
||||
}
|
||||
|
||||
// 添加单个镜像进度项
|
||||
function addImageProgressItem(image, progress) {
|
||||
const itemDiv = document.createElement('div');
|
||||
itemDiv.className = 'image-progress-item';
|
||||
itemDiv.id = `progress-${image.replace(/[\/\.:]/g, '_')}`;
|
||||
|
||||
const nameDiv = document.createElement('div');
|
||||
nameDiv.className = 'image-progress-name';
|
||||
nameDiv.title = image;
|
||||
nameDiv.textContent = image;
|
||||
|
||||
const barContainerDiv = document.createElement('div');
|
||||
barContainerDiv.className = 'image-progress-bar-container';
|
||||
|
||||
const barDiv = document.createElement('div');
|
||||
barDiv.className = 'image-progress-bar';
|
||||
barDiv.style.width = `${progress}%`;
|
||||
|
||||
const textDiv = document.createElement('div');
|
||||
textDiv.className = 'image-progress-text';
|
||||
textDiv.textContent = `${Math.round(progress)}%`;
|
||||
|
||||
barContainerDiv.appendChild(barDiv);
|
||||
itemDiv.appendChild(nameDiv);
|
||||
itemDiv.appendChild(barContainerDiv);
|
||||
itemDiv.appendChild(textDiv);
|
||||
|
||||
imageProgressList.appendChild(itemDiv);
|
||||
}
|
||||
|
||||
// 更新镜像进度
|
||||
function updateImageProgress(image, progress, status) {
|
||||
const itemId = `progress-${image.replace(/[\/\.:]/g, '_')}`;
|
||||
const item = document.getElementById(itemId);
|
||||
|
||||
if (item) {
|
||||
const bar = item.querySelector('.image-progress-bar');
|
||||
const text = item.querySelector('.image-progress-text');
|
||||
|
||||
bar.style.width = `${progress}%`;
|
||||
text.textContent = `${Math.round(progress)}%`;
|
||||
|
||||
if (status === 'failed') {
|
||||
bar.style.backgroundColor = '#bd3c35';
|
||||
text.textContent = '失败';
|
||||
} else if (status === 'completed') {
|
||||
bar.style.backgroundColor = '#4CAF50';
|
||||
text.textContent = '完成';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 连接WebSocket
|
||||
function connectWebSocket(taskId) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/${taskId}`;
|
||||
|
||||
websocket = new WebSocket(wsUrl);
|
||||
|
||||
websocket.onopen = function() {
|
||||
console.log('WebSocket连接已建立');
|
||||
};
|
||||
|
||||
websocket.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
updateProgress(data);
|
||||
};
|
||||
|
||||
websocket.onerror = function(error) {
|
||||
console.error('WebSocket错误:', error);
|
||||
showToast('WebSocket连接错误');
|
||||
};
|
||||
|
||||
websocket.onclose = function() {
|
||||
console.log('WebSocket连接已关闭');
|
||||
};
|
||||
}
|
||||
|
||||
// 更新总体进度
|
||||
function updateProgress(data) {
|
||||
// 更新总进度
|
||||
totalProgressBar.style.width = `${data.totalProgress}%`;
|
||||
totalProgressText.textContent = `${Math.round(data.totalProgress)}%`;
|
||||
|
||||
// 更新各个镜像的进度
|
||||
data.images.forEach(imgData => {
|
||||
updateImageProgress(imgData.image, imgData.progress, imgData.status);
|
||||
});
|
||||
|
||||
// 如果任务完成,显示下载按钮
|
||||
if (data.status === 'completed') {
|
||||
getFileButton.style.display = 'inline-block';
|
||||
|
||||
if (websocket) {
|
||||
websocket.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
function downloadFile() {
|
||||
if (!currentTaskId) {
|
||||
showToast('没有可下载的文件');
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/api/files/${currentTaskId}_file`;
|
||||
}
|
||||
|
||||
// 显示提示消息
|
||||
function showToast(message) {
|
||||
const toast = document.getElementById('toast');
|
||||
toast.textContent = message;
|
||||
toast.style.display = 'block';
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.display = 'none';
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 事件监听
|
||||
downloadButton.addEventListener('click', startDownload);
|
||||
getFileButton.addEventListener('click', downloadFile);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
604
ghproxy/skopeo_service.go
Normal file
604
ghproxy/skopeo_service.go
Normal file
@@ -0,0 +1,604 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// 任务状态
|
||||
type TaskStatus string
|
||||
|
||||
const (
|
||||
StatusPending TaskStatus = "pending"
|
||||
StatusRunning TaskStatus = "running"
|
||||
StatusCompleted TaskStatus = "completed"
|
||||
StatusFailed TaskStatus = "failed"
|
||||
)
|
||||
|
||||
// 镜像下载任务
|
||||
type ImageTask struct {
|
||||
Image string `json:"image"`
|
||||
Progress float64 `json:"progress"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
OutputPath string `json:"-"` // 输出文件路径,不发送给客户端
|
||||
}
|
||||
|
||||
// 下载任务
|
||||
type DownloadTask struct {
|
||||
ID string `json:"id"`
|
||||
Images []*ImageTask `json:"images"`
|
||||
TotalProgress float64 `json:"totalProgress"`
|
||||
Status TaskStatus `json:"status"`
|
||||
OutputFile string `json:"-"` // 最终输出文件
|
||||
TempDir string `json:"-"` // 临时目录
|
||||
Lock sync.Mutex `json:"-"` // 锁,防止并发冲突
|
||||
}
|
||||
|
||||
// WebSocket客户端
|
||||
type Client struct {
|
||||
Conn *websocket.Conn
|
||||
TaskID string
|
||||
Send chan []byte
|
||||
CloseOnce sync.Once
|
||||
}
|
||||
|
||||
var (
|
||||
// WebSocket升级器
|
||||
upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // 允许所有源
|
||||
},
|
||||
}
|
||||
|
||||
// 活跃任务映射
|
||||
tasks = make(map[string]*DownloadTask)
|
||||
tasksLock sync.Mutex
|
||||
clients = make(map[string]*Client)
|
||||
clientLock sync.Mutex
|
||||
)
|
||||
|
||||
// 初始化Skopeo相关路由
|
||||
func initSkopeoRoutes(router *gin.Engine) {
|
||||
// 创建临时目录
|
||||
os.MkdirAll("./temp", 0755)
|
||||
|
||||
// WebSocket路由 - 用于实时获取进度
|
||||
router.GET("/ws/:taskId", handleWebSocket)
|
||||
|
||||
// 创建下载任务
|
||||
router.POST("/api/download", handleDownload)
|
||||
|
||||
// 获取任务状态
|
||||
router.GET("/api/task/:taskId", getTaskStatus)
|
||||
|
||||
// 下载文件
|
||||
router.GET("/api/files/:filename", serveFile)
|
||||
|
||||
// 启动清理过期文件的goroutine
|
||||
go cleanupTempFiles()
|
||||
}
|
||||
|
||||
// 处理WebSocket连接
|
||||
func handleWebSocket(c *gin.Context) {
|
||||
taskID := c.Param("taskId")
|
||||
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("WebSocket升级失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
Conn: conn,
|
||||
TaskID: taskID,
|
||||
Send: make(chan []byte, 256),
|
||||
}
|
||||
|
||||
// 注册客户端
|
||||
clientLock.Lock()
|
||||
clients[taskID] = client
|
||||
clientLock.Unlock()
|
||||
|
||||
// 启动goroutine处理消息发送
|
||||
go client.writePump()
|
||||
|
||||
// 如果任务已存在,立即发送当前状态
|
||||
tasksLock.Lock()
|
||||
if task, exists := tasks[taskID]; exists {
|
||||
tasksLock.Unlock()
|
||||
taskJSON, _ := json.Marshal(task)
|
||||
client.Send <- taskJSON
|
||||
} else {
|
||||
tasksLock.Unlock()
|
||||
}
|
||||
|
||||
// 处理WebSocket关闭
|
||||
conn.SetCloseHandler(func(code int, text string) error {
|
||||
client.CloseOnce.Do(func() {
|
||||
close(client.Send)
|
||||
clientLock.Lock()
|
||||
delete(clients, taskID)
|
||||
clientLock.Unlock()
|
||||
})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// 客户端消息发送loop
|
||||
func (c *Client) writePump() {
|
||||
defer func() {
|
||||
c.Conn.Close()
|
||||
}()
|
||||
|
||||
for message := range c.Send {
|
||||
err := c.Conn.WriteMessage(websocket.TextMessage, message)
|
||||
if err != nil {
|
||||
fmt.Printf("发送WS消息失败: %v\n", err)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取任务状态
|
||||
func getTaskStatus(c *gin.Context) {
|
||||
taskID := c.Param("taskId")
|
||||
|
||||
tasksLock.Lock()
|
||||
task, exists := tasks[taskID]
|
||||
tasksLock.Unlock()
|
||||
|
||||
if !exists {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "任务不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, task)
|
||||
}
|
||||
|
||||
// 生成随机任务ID
|
||||
func generateTaskID() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// 处理下载请求
|
||||
func handleDownload(c *gin.Context) {
|
||||
type DownloadRequest struct {
|
||||
Images []string `json:"images"`
|
||||
Platform string `json:"platform"` // 平台: amd64, arm64等
|
||||
}
|
||||
|
||||
var req DownloadRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Images) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供至少一个镜像"})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建新任务
|
||||
taskID := generateTaskID()
|
||||
tempDir := filepath.Join("./temp", taskID)
|
||||
os.MkdirAll(tempDir, 0755)
|
||||
|
||||
// 初始化任务
|
||||
imageTasks := make([]*ImageTask, len(req.Images))
|
||||
for i, image := range req.Images {
|
||||
imageTasks[i] = &ImageTask{
|
||||
Image: image,
|
||||
Progress: 0,
|
||||
Status: string(StatusPending),
|
||||
}
|
||||
}
|
||||
|
||||
task := &DownloadTask{
|
||||
ID: taskID,
|
||||
Images: imageTasks,
|
||||
TotalProgress: 0,
|
||||
Status: StatusPending,
|
||||
TempDir: tempDir,
|
||||
}
|
||||
|
||||
// 保存任务
|
||||
tasksLock.Lock()
|
||||
tasks[taskID] = task
|
||||
tasksLock.Unlock()
|
||||
|
||||
// 异步处理下载
|
||||
go func() {
|
||||
processDownloadTask(task, req.Platform)
|
||||
}()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"taskId": taskID,
|
||||
"status": "started",
|
||||
})
|
||||
}
|
||||
|
||||
// 处理下载任务
|
||||
func processDownloadTask(task *DownloadTask, platform string) {
|
||||
task.Lock.Lock()
|
||||
task.Status = StatusRunning
|
||||
task.Lock.Unlock()
|
||||
|
||||
// 通知客户端任务已开始
|
||||
sendTaskUpdate(task)
|
||||
|
||||
// 使用WaitGroup等待所有镜像下载完成
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(len(task.Images))
|
||||
|
||||
// 使用并发下载镜像
|
||||
for i, imgTask := range task.Images {
|
||||
go func(idx int, imgTask *ImageTask) {
|
||||
defer wg.Done()
|
||||
downloadImage(task, idx, imgTask, platform)
|
||||
}(i, imgTask)
|
||||
}
|
||||
|
||||
// 等待所有下载完成
|
||||
wg.Wait()
|
||||
|
||||
// 判断是单个tar还是需要打包
|
||||
var finalFilePath string
|
||||
var err error
|
||||
|
||||
task.Lock.Lock()
|
||||
allSuccess := true
|
||||
for _, img := range task.Images {
|
||||
if img.Status == string(StatusFailed) {
|
||||
allSuccess = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !allSuccess {
|
||||
task.Status = StatusFailed
|
||||
task.Lock.Unlock()
|
||||
sendTaskUpdate(task)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果只有一个文件,直接使用它
|
||||
if len(task.Images) == 1 && task.Images[0].Status == string(StatusCompleted) {
|
||||
finalFilePath = task.Images[0].OutputPath
|
||||
// 重命名为更友好的名称
|
||||
imageName := strings.ReplaceAll(task.Images[0].Image, "/", "_")
|
||||
imageName = strings.ReplaceAll(imageName, ":", "_")
|
||||
newPath := filepath.Join(task.TempDir, imageName+".tar")
|
||||
os.Rename(finalFilePath, newPath)
|
||||
finalFilePath = newPath
|
||||
} else {
|
||||
// 多个文件打包成zip
|
||||
finalFilePath, err = createZipArchive(task)
|
||||
if err != nil {
|
||||
task.Status = StatusFailed
|
||||
task.Lock.Unlock()
|
||||
sendTaskUpdate(task)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
task.OutputFile = finalFilePath
|
||||
task.Status = StatusCompleted
|
||||
task.TotalProgress = 100
|
||||
task.Lock.Unlock()
|
||||
|
||||
// 发送最终状态更新
|
||||
sendTaskUpdate(task)
|
||||
}
|
||||
|
||||
// 下载单个镜像
|
||||
func downloadImage(task *DownloadTask, index int, imgTask *ImageTask, platform string) {
|
||||
imgTask.Status = string(StatusRunning)
|
||||
sendImageUpdate(task, index)
|
||||
|
||||
// 创建输出文件名
|
||||
outputFileName := fmt.Sprintf("image_%d.tar", index)
|
||||
outputPath := filepath.Join(task.TempDir, outputFileName)
|
||||
imgTask.OutputPath = outputPath
|
||||
|
||||
// 创建skopeo命令
|
||||
platformArg := ""
|
||||
if platform != "" {
|
||||
// 支持手动输入完整的平台参数
|
||||
if strings.Contains(platform, "--") {
|
||||
platformArg = platform
|
||||
} else {
|
||||
// 仅指定架构名称的情况
|
||||
platformArg = fmt.Sprintf("--override-os linux --override-arch %s", platform)
|
||||
}
|
||||
}
|
||||
|
||||
// 构建命令
|
||||
cmd := fmt.Sprintf("skopeo copy %s docker://%s docker-archive:%s",
|
||||
platformArg, imgTask.Image, outputPath)
|
||||
|
||||
// 执行命令
|
||||
command := exec.Command("sh", "-c", cmd)
|
||||
|
||||
// 获取命令输出
|
||||
stderr, err := command.StderrPipe()
|
||||
if err != nil {
|
||||
imgTask.Status = string(StatusFailed)
|
||||
imgTask.Error = fmt.Sprintf("无法创建输出管道: %v", err)
|
||||
sendImageUpdate(task, index)
|
||||
return
|
||||
}
|
||||
|
||||
if err := command.Start(); err != nil {
|
||||
imgTask.Status = string(StatusFailed)
|
||||
imgTask.Error = fmt.Sprintf("启动命令失败: %v", err)
|
||||
sendImageUpdate(task, index)
|
||||
return
|
||||
}
|
||||
|
||||
// 读取stderr以获取进度信息
|
||||
go func() {
|
||||
buf := make([]byte, 1024)
|
||||
for {
|
||||
n, err := stderr.Read(buf)
|
||||
if n > 0 {
|
||||
output := string(buf[:n])
|
||||
// 解析进度信息 (这里简化处理,假设skopeo输出进度信息)
|
||||
// 实际需要根据skopeo的真实输出格式进行解析
|
||||
if strings.Contains(output, "%") {
|
||||
// 简单解析,实际使用时可能需要更复杂的解析逻辑
|
||||
parts := strings.Split(output, "%")
|
||||
if len(parts) > 0 {
|
||||
numStr := strings.TrimSpace(parts[0])
|
||||
numStr = strings.TrimLeft(numStr, "Copying blob ")
|
||||
numStr = strings.TrimLeft(numStr, "Copying config ")
|
||||
numStr = strings.TrimRight(numStr, " / ")
|
||||
numStr = strings.TrimSpace(numStr)
|
||||
// 尝试提取最后一个数字作为进度
|
||||
fields := strings.Fields(numStr)
|
||||
if len(fields) > 0 {
|
||||
lastField := fields[len(fields)-1]
|
||||
progress := 0.0
|
||||
fmt.Sscanf(lastField, "%f", &progress)
|
||||
if progress > 0 && progress <= 100 {
|
||||
imgTask.Progress = progress
|
||||
updateTaskProgress(task)
|
||||
sendImageUpdate(task, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if err := command.Wait(); err != nil {
|
||||
imgTask.Status = string(StatusFailed)
|
||||
imgTask.Error = fmt.Sprintf("命令执行失败: %v", err)
|
||||
sendImageUpdate(task, index)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查文件是否成功创建
|
||||
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
|
||||
imgTask.Status = string(StatusFailed)
|
||||
imgTask.Error = "文件未成功创建"
|
||||
sendImageUpdate(task, index)
|
||||
return
|
||||
}
|
||||
|
||||
// 更新状态为已完成
|
||||
imgTask.Status = string(StatusCompleted)
|
||||
imgTask.Progress = 100
|
||||
updateTaskProgress(task)
|
||||
sendImageUpdate(task, index)
|
||||
}
|
||||
|
||||
// 更新任务总进度
|
||||
func updateTaskProgress(task *DownloadTask) {
|
||||
task.Lock.Lock()
|
||||
defer task.Lock.Unlock()
|
||||
|
||||
totalProgress := 0.0
|
||||
for _, img := range task.Images {
|
||||
totalProgress += img.Progress
|
||||
}
|
||||
task.TotalProgress = totalProgress / float64(len(task.Images))
|
||||
}
|
||||
|
||||
// 创建ZIP归档
|
||||
func createZipArchive(task *DownloadTask) (string, error) {
|
||||
zipFilePath := filepath.Join(task.TempDir, "images.zip")
|
||||
zipFile, err := os.Create(zipFilePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer zipFile.Close()
|
||||
|
||||
zipWriter := zip.NewWriter(zipFile)
|
||||
defer zipWriter.Close()
|
||||
|
||||
for _, img := range task.Images {
|
||||
if img.Status != string(StatusCompleted) || img.OutputPath == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 创建ZIP条目
|
||||
imgFile, err := os.Open(img.OutputPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 使用镜像名作为文件名
|
||||
imageName := strings.ReplaceAll(img.Image, "/", "_")
|
||||
imageName = strings.ReplaceAll(imageName, ":", "_")
|
||||
fileName := imageName + ".tar"
|
||||
|
||||
fileInfo, err := imgFile.Stat()
|
||||
if err != nil {
|
||||
imgFile.Close()
|
||||
return "", err
|
||||
}
|
||||
|
||||
header, err := zip.FileInfoHeader(fileInfo)
|
||||
if err != nil {
|
||||
imgFile.Close()
|
||||
return "", err
|
||||
}
|
||||
|
||||
header.Name = fileName
|
||||
header.Method = zip.Deflate
|
||||
|
||||
writer, err := zipWriter.CreateHeader(header)
|
||||
if err != nil {
|
||||
imgFile.Close()
|
||||
return "", err
|
||||
}
|
||||
|
||||
_, err = io.Copy(writer, imgFile)
|
||||
imgFile.Close()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return zipFilePath, nil
|
||||
}
|
||||
|
||||
// 发送任务更新到WebSocket
|
||||
func sendTaskUpdate(task *DownloadTask) {
|
||||
taskJSON, err := json.Marshal(task)
|
||||
if err != nil {
|
||||
fmt.Printf("序列化任务失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
clientLock.Lock()
|
||||
client, exists := clients[task.ID]
|
||||
clientLock.Unlock()
|
||||
|
||||
if exists {
|
||||
select {
|
||||
case client.Send <- taskJSON:
|
||||
default:
|
||||
// 通道已满或关闭,忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 发送单个镜像更新
|
||||
func sendImageUpdate(task *DownloadTask, imageIndex int) {
|
||||
sendTaskUpdate(task)
|
||||
}
|
||||
|
||||
// 提供文件下载
|
||||
func serveFile(c *gin.Context) {
|
||||
filename := c.Param("filename")
|
||||
|
||||
// 安全检查,防止任意文件访问
|
||||
if strings.Contains(filename, "..") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无效的文件名"})
|
||||
return
|
||||
}
|
||||
|
||||
// 根据任务ID和文件名查找文件
|
||||
parts := strings.Split(filename, "_")
|
||||
if len(parts) < 2 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件名格式"})
|
||||
return
|
||||
}
|
||||
|
||||
taskID := parts[0]
|
||||
|
||||
tasksLock.Lock()
|
||||
task, exists := tasks[taskID]
|
||||
tasksLock.Unlock()
|
||||
|
||||
if !exists {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "任务不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
filePath := task.OutputFile
|
||||
if filePath == "" || !fileExists(filePath) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取文件信息
|
||||
fileInfo, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "无法获取文件信息"})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置文件名 - 提取有意义的文件名
|
||||
downloadName := filepath.Base(filePath)
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", downloadName))
|
||||
c.Header("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
|
||||
|
||||
// 返回文件
|
||||
c.File(filePath)
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return !os.IsNotExist(err)
|
||||
}
|
||||
|
||||
// 清理过期临时文件
|
||||
func cleanupTempFiles() {
|
||||
for {
|
||||
time.Sleep(1 * time.Hour)
|
||||
|
||||
// 遍历temp目录
|
||||
err := filepath.Walk("./temp", func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 跳过根目录
|
||||
if path == "./temp" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 如果文件或目录超过24小时未修改,则删除
|
||||
if time.Since(info.ModTime()) > 24*time.Hour {
|
||||
if info.IsDir() {
|
||||
os.RemoveAll(path)
|
||||
return filepath.SkipDir
|
||||
}
|
||||
os.Remove(path)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("清理临时文件失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user