@mostafamohamed18/docker-image-downloader 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -0
- package/package.json +37 -0
- package/public/index.html +468 -0
- package/server.js +528 -0
package/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Docker Image Downloader
|
|
2
|
+
|
|
3
|
+
A modern web application to download Docker images directly from Docker Hub without needing Docker installed. The app downloads the image as a `.tar` file compatible with `docker load`.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- Node.js (v14 or higher)
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
1. Open a terminal in this folder.
|
|
12
|
+
2. Install dependencies:
|
|
13
|
+
```bash
|
|
14
|
+
npm install
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
1. Start the server:
|
|
20
|
+
```bash
|
|
21
|
+
npm start
|
|
22
|
+
```
|
|
23
|
+
2. Open your browser and go to:
|
|
24
|
+
[http://localhost:3000](http://localhost:3000)
|
|
25
|
+
|
|
26
|
+
3. Choose a registry (**Docker Hub** or **Chainguard (cgr.dev)**).
|
|
27
|
+
4. Enter an image name and click **Check Tags**.
|
|
28
|
+
- Docker Hub examples: `nginx`, `redis`, `library/ubuntu`
|
|
29
|
+
- cgr.dev examples: `chainguard/tomcat` or `cgr.dev/chainguard/tomcat`
|
|
30
|
+
5. Select a version from the dropdown.
|
|
31
|
+
6. Click **Download Image**.
|
|
32
|
+
7. Once downloaded, you can load the image on any machine with Docker:
|
|
33
|
+
```bash
|
|
34
|
+
docker load -i <filename>.tar
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Troubleshooting
|
|
38
|
+
|
|
39
|
+
- **Large Images**: Downloading very large images (>2GB) might be slow or hit timeout limits depending on your connection.
|
|
40
|
+
- **Rate Limits**: Docker Hub has rate limits for anonymous pulls. If you encounter errors, retry later.
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mostafamohamed18/docker-image-downloader",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A modern web application to download Docker images directly from Docker Hub and Chainguard registries without needing Docker installed.",
|
|
5
|
+
"main": "server.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"start": "node server.js",
|
|
8
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"docker",
|
|
12
|
+
"image",
|
|
13
|
+
"downloader",
|
|
14
|
+
"download",
|
|
15
|
+
"container",
|
|
16
|
+
"registry",
|
|
17
|
+
"dockerhub",
|
|
18
|
+
"chainguard"
|
|
19
|
+
],
|
|
20
|
+
"author": "Mostafa Mohamed Ali <mostafamhmdali9@gmail.com>",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/mostafamohamed18/docker-image-downloader.git"
|
|
25
|
+
},
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/mostafamohamed18/docker-image-downloader/issues"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/mostafamohamed18/docker-image-downloader#readme",
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=14.0.0"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Docker Image Downloader</title>
|
|
7
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
8
|
+
<style>
|
|
9
|
+
body {
|
|
10
|
+
background: linear-gradient(135deg, #1b1134, #0f0a1f);
|
|
11
|
+
color: #ede9fe;
|
|
12
|
+
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
|
13
|
+
min-height: 100vh;
|
|
14
|
+
display: flex;
|
|
15
|
+
align-items: center;
|
|
16
|
+
justify-content: center;
|
|
17
|
+
}
|
|
18
|
+
.glass-card {
|
|
19
|
+
background: rgba(139, 92, 246, 0.08);
|
|
20
|
+
backdrop-filter: blur(10px);
|
|
21
|
+
border: 1px solid rgba(167, 139, 250, 0.24);
|
|
22
|
+
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
|
23
|
+
}
|
|
24
|
+
/* Custom scrollbar for tag list */
|
|
25
|
+
select::-webkit-scrollbar {
|
|
26
|
+
width: 8px;
|
|
27
|
+
}
|
|
28
|
+
select::-webkit-scrollbar-track {
|
|
29
|
+
background: #1f1638;
|
|
30
|
+
}
|
|
31
|
+
select::-webkit-scrollbar-thumb {
|
|
32
|
+
background: #7c3aed;
|
|
33
|
+
border-radius: 4px;
|
|
34
|
+
}
|
|
35
|
+
select::-webkit-scrollbar-thumb:hover {
|
|
36
|
+
background: #8b5cf6;
|
|
37
|
+
}
|
|
38
|
+
</style>
|
|
39
|
+
</head>
|
|
40
|
+
<body>
|
|
41
|
+
|
|
42
|
+
<div class="glass-card p-8 rounded-2xl w-full max-w-md space-y-6">
|
|
43
|
+
<div class="text-center space-y-2">
|
|
44
|
+
<div class="bg-violet-500/20 p-3 rounded-full w-16 h-16 mx-auto flex items-center justify-center mb-4">
|
|
45
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-violet-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
46
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
|
47
|
+
</svg>
|
|
48
|
+
</div>
|
|
49
|
+
<h1 class="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-violet-300 to-fuchsia-300">Docker Image Downloader</h1>
|
|
50
|
+
<p class="text-violet-200/80 text-sm">Download images without Docker installed.</p>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div class="space-y-4">
|
|
54
|
+
<!-- Registry Select -->
|
|
55
|
+
<div class="space-y-2">
|
|
56
|
+
<label class="text-sm font-medium text-violet-200 ml-1">Registry</label>
|
|
57
|
+
<select id="registrySelect" class="w-full bg-violet-950/40 border border-violet-400/30 rounded-lg px-4 py-3 text-violet-100 focus:outline-none focus:ring-2 focus:ring-violet-500/60 transition-all appearance-none cursor-pointer">
|
|
58
|
+
<option value="dockerhub" selected>Docker Hub</option>
|
|
59
|
+
<option value="cgr">Chainguard (cgr.dev)</option>
|
|
60
|
+
</select>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<!-- Image Input -->
|
|
64
|
+
<div class="space-y-2">
|
|
65
|
+
<label class="text-sm font-medium text-violet-200 ml-1">Image Name</label>
|
|
66
|
+
<div class="relative">
|
|
67
|
+
<input type="text" id="imageInput" placeholder="e.g. nginx, ubuntu, library/redis"
|
|
68
|
+
class="w-full bg-violet-950/40 border border-violet-400/30 rounded-lg px-4 py-3 text-violet-100 focus:outline-none focus:ring-2 focus:ring-violet-500/60 focus:border-violet-400 transition-all placeholder:text-violet-300/45">
|
|
69
|
+
<button id="checkTagsBtn" class="absolute right-2 top-2 bottom-2 px-3 bg-violet-700/70 hover:bg-violet-600/80 rounded text-xs font-medium transition-colors text-violet-100">
|
|
70
|
+
Check Tags
|
|
71
|
+
</button>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<!-- Tags Select -->
|
|
76
|
+
<div class="space-y-2 transition-all duration-300 transform origin-top hidden" id="tagsContainer">
|
|
77
|
+
<label class="text-sm font-medium text-violet-200 ml-1">Select Tag</label>
|
|
78
|
+
<select id="tagSelect" class="w-full bg-violet-950/40 border border-violet-400/30 rounded-lg px-4 py-3 text-violet-100 focus:outline-none focus:ring-2 focus:ring-violet-500/60 transition-all appearance-none cursor-pointer">
|
|
79
|
+
<option value="" disabled selected>Choose a version...</option>
|
|
80
|
+
</select>
|
|
81
|
+
<div class="relative w-full">
|
|
82
|
+
<div class="absolute right-4 top-[-2.2rem] pointer-events-none text-violet-300/60">
|
|
83
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
84
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
|
85
|
+
</svg>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<!-- Download Button -->
|
|
91
|
+
<button id="downloadBtn" disabled
|
|
92
|
+
class="w-full bg-gradient-to-r from-violet-700 to-fuchsia-700 hover:from-violet-600 hover:to-fuchsia-600 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold py-3.5 rounded-lg shadow-lg shadow-violet-900/40 transition-all transform active:scale-[0.98] flex items-center justify-center gap-2 group">
|
|
93
|
+
<span>Download Image</span>
|
|
94
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 group-hover:translate-y-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
95
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
|
96
|
+
</svg>
|
|
97
|
+
</button>
|
|
98
|
+
|
|
99
|
+
<!-- Status/Progress -->
|
|
100
|
+
<div id="status" class="hidden text-center text-sm p-3 rounded-lg bg-violet-950/35 border border-violet-400/20 text-violet-200/80">
|
|
101
|
+
Ready to start.
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<div id="debugPanel" class="fixed bottom-4 left-4 w-[min(92vw,34rem)] max-h-56 overflow-auto text-xs bg-black/50 border border-violet-400/30 rounded-lg p-3 text-violet-100/90 backdrop-blur-sm hidden">
|
|
107
|
+
<div class="flex items-center justify-between mb-2">
|
|
108
|
+
<span class="font-semibold">Debug Log</span>
|
|
109
|
+
<div class="flex gap-3">
|
|
110
|
+
<button id="testBackendBtn" class="text-violet-200 hover:text-white text-xs">Test Backend</button>
|
|
111
|
+
<button id="clearDebugBtn" class="text-violet-200 hover:text-white text-xs">Clear</button>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
<pre id="debugLog" class="whitespace-pre-wrap break-words"></pre>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<a href="https://github.com/mostafamohamed18" target="_blank" rel="noopener noreferrer" class="fixed bottom-4 right-4 text-sm text-violet-200/85 hover:text-violet-100 bg-violet-900/35 border border-violet-400/25 rounded-lg px-3 py-2 backdrop-blur-sm transition-colors">
|
|
118
|
+
github.com/mostafamohamed18
|
|
119
|
+
</a>
|
|
120
|
+
|
|
121
|
+
<script>
|
|
122
|
+
const registrySelect = document.getElementById('registrySelect');
|
|
123
|
+
const imageInput = document.getElementById('imageInput');
|
|
124
|
+
const checkTagsBtn = document.getElementById('checkTagsBtn');
|
|
125
|
+
const tagsContainer = document.getElementById('tagsContainer');
|
|
126
|
+
const tagSelect = document.getElementById('tagSelect');
|
|
127
|
+
const downloadBtn = document.getElementById('downloadBtn');
|
|
128
|
+
const statusDiv = document.getElementById('status');
|
|
129
|
+
const debugPanel = document.getElementById('debugPanel');
|
|
130
|
+
const debugLog = document.getElementById('debugLog');
|
|
131
|
+
const clearDebugBtn = document.getElementById('clearDebugBtn');
|
|
132
|
+
const testBackendBtn = document.getElementById('testBackendBtn');
|
|
133
|
+
|
|
134
|
+
let lastApiAttemptUrls = [];
|
|
135
|
+
let resolvedImageName = null;
|
|
136
|
+
|
|
137
|
+
function appendDebug(message, data) {
|
|
138
|
+
debugPanel.classList.remove('hidden');
|
|
139
|
+
const time = new Date().toISOString();
|
|
140
|
+
const serialized = data ? ` ${JSON.stringify(data)}` : '';
|
|
141
|
+
debugLog.textContent += `[${time}] ${message}${serialized}\n`;
|
|
142
|
+
debugPanel.scrollTop = debugPanel.scrollHeight;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
clearDebugBtn.addEventListener('click', () => {
|
|
146
|
+
debugLog.textContent = '';
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
async function runBackendDiagnostics() {
|
|
150
|
+
appendDebug('diag.start', {
|
|
151
|
+
pageOrigin: window.location.origin,
|
|
152
|
+
href: window.location.href,
|
|
153
|
+
online: navigator.onLine,
|
|
154
|
+
userAgent: navigator.userAgent
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const endpoints = [
|
|
158
|
+
'/api/health',
|
|
159
|
+
'/api/debug/ping?source=ui',
|
|
160
|
+
'/api/tags?registry=cgr&image=cgr.dev/chainguard/tomcat:latest'
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
for (const endpoint of endpoints) {
|
|
164
|
+
const candidates = getApiCandidates(endpoint);
|
|
165
|
+
for (const apiUrl of candidates) {
|
|
166
|
+
try {
|
|
167
|
+
appendDebug('diag.try', { endpoint, apiUrl });
|
|
168
|
+
const response = await fetch(apiUrl);
|
|
169
|
+
const text = await response.text();
|
|
170
|
+
appendDebug('diag.result', {
|
|
171
|
+
endpoint,
|
|
172
|
+
apiUrl,
|
|
173
|
+
status: response.status,
|
|
174
|
+
bodySnippet: text.substring(0, 300)
|
|
175
|
+
});
|
|
176
|
+
} catch (err) {
|
|
177
|
+
appendDebug('diag.error', {
|
|
178
|
+
endpoint,
|
|
179
|
+
apiUrl,
|
|
180
|
+
name: err?.name,
|
|
181
|
+
message: err?.message
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
appendDebug('diag.done');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
testBackendBtn.addEventListener('click', async () => {
|
|
191
|
+
testBackendBtn.disabled = true;
|
|
192
|
+
testBackendBtn.textContent = 'Testing...';
|
|
193
|
+
try {
|
|
194
|
+
await runBackendDiagnostics();
|
|
195
|
+
} finally {
|
|
196
|
+
testBackendBtn.disabled = false;
|
|
197
|
+
testBackendBtn.textContent = 'Test Backend';
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
appendDebug('debug.init', {
|
|
202
|
+
pageOrigin: window.location.origin,
|
|
203
|
+
pageHref: window.location.href,
|
|
204
|
+
online: navigator.onLine
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
function getApiCandidates(pathWithQuery) {
|
|
208
|
+
const { protocol, hostname, origin } = window.location;
|
|
209
|
+
const isHttpPage = protocol === 'http:' || protocol === 'https:';
|
|
210
|
+
|
|
211
|
+
const candidates = [];
|
|
212
|
+
if (isHttpPage) {
|
|
213
|
+
candidates.push(`${origin}${pathWithQuery}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const localhostUrl = `http://localhost:3000${pathWithQuery}`;
|
|
217
|
+
const sameAsLocalhost3000 = isHttpPage && (origin === 'http://localhost:3000' || origin === 'http://127.0.0.1:3000');
|
|
218
|
+
if (!sameAsLocalhost3000) {
|
|
219
|
+
candidates.push(localhostUrl);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const uniqueCandidates = [...new Set(candidates)];
|
|
223
|
+
appendDebug('api.candidates', { pathWithQuery, uniqueCandidates, origin: window.location.origin });
|
|
224
|
+
return uniqueCandidates;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function fetchFromApi(pathWithQuery) {
|
|
228
|
+
const candidates = getApiCandidates(pathWithQuery);
|
|
229
|
+
let lastError = null;
|
|
230
|
+
lastApiAttemptUrls = candidates;
|
|
231
|
+
|
|
232
|
+
for (const apiUrl of candidates) {
|
|
233
|
+
try {
|
|
234
|
+
appendDebug('api.try', { apiUrl });
|
|
235
|
+
const res = await fetch(apiUrl);
|
|
236
|
+
appendDebug('api.response', { apiUrl, status: res.status, ok: res.ok });
|
|
237
|
+
return res;
|
|
238
|
+
} catch (err) {
|
|
239
|
+
lastError = err;
|
|
240
|
+
appendDebug('api.fetch.error', {
|
|
241
|
+
apiUrl,
|
|
242
|
+
name: err?.name,
|
|
243
|
+
message: err?.message
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
throw lastError || new Error('Unable to reach API');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function stripTagOrDigest(imageName) {
|
|
252
|
+
if (!imageName) return imageName;
|
|
253
|
+
let normalized = imageName.split('@')[0];
|
|
254
|
+
const lastSlash = normalized.lastIndexOf('/');
|
|
255
|
+
const lastColon = normalized.lastIndexOf(':');
|
|
256
|
+
if (lastColon > lastSlash) {
|
|
257
|
+
normalized = normalized.substring(0, lastColon);
|
|
258
|
+
}
|
|
259
|
+
return normalized;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function sanitizeImageInput(imageName) {
|
|
263
|
+
if (!imageName) return imageName;
|
|
264
|
+
let sanitized = imageName.trim();
|
|
265
|
+
sanitized = sanitized.replace(/^(docker|podman)\s+pull\s+/i, '');
|
|
266
|
+
sanitized = sanitized.replace(/^https?:\/\//i, '');
|
|
267
|
+
sanitized = sanitized.replace(/^['\"]+|['\"]+$/g, '');
|
|
268
|
+
sanitized = sanitized.split(/\s+/)[0];
|
|
269
|
+
sanitized = sanitized.replace(/^['\"]+|['\"]+$/g, '');
|
|
270
|
+
return sanitized;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function getNormalizedImageName(image, registry) {
|
|
274
|
+
const cleanImage = sanitizeImageInput(image);
|
|
275
|
+
if (registry === 'cgr') {
|
|
276
|
+
return cleanImage.replace(/^cgr\.dev\//i, '');
|
|
277
|
+
}
|
|
278
|
+
return cleanImage.replace(/^docker\.io\//i, '');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function getRepositoryName(image, registry) {
|
|
282
|
+
const normalized = getNormalizedImageName(image, registry);
|
|
283
|
+
return stripTagOrDigest(normalized);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function getImageReferenceForDownload(image, registry) {
|
|
287
|
+
const normalized = getRepositoryName(image, registry);
|
|
288
|
+
if (registry === 'cgr') {
|
|
289
|
+
return `cgr.dev/${normalized}`;
|
|
290
|
+
}
|
|
291
|
+
return normalized;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function updateImagePlaceholder() {
|
|
295
|
+
if (registrySelect.value === 'cgr') {
|
|
296
|
+
imageInput.placeholder = 'e.g. tomcat, chainguard/tomcat, or cgr.dev/chainguard/tomcat';
|
|
297
|
+
} else {
|
|
298
|
+
imageInput.placeholder = 'e.g. nginx, ubuntu, library/redis';
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
imageInput.addEventListener('input', () => {
|
|
303
|
+
const value = sanitizeImageInput(imageInput.value).toLowerCase();
|
|
304
|
+
if (value.startsWith('cgr.dev/')) {
|
|
305
|
+
registrySelect.value = 'cgr';
|
|
306
|
+
updateImagePlaceholder();
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
registrySelect.addEventListener('change', () => {
|
|
311
|
+
updateImagePlaceholder();
|
|
312
|
+
tagsContainer.classList.add('hidden');
|
|
313
|
+
tagSelect.innerHTML = '<option value="" disabled selected>Choose a version...</option>';
|
|
314
|
+
downloadBtn.disabled = true;
|
|
315
|
+
resolvedImageName = null;
|
|
316
|
+
statusDiv.classList.add('hidden');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
updateImagePlaceholder();
|
|
320
|
+
|
|
321
|
+
// Handle Check Tags
|
|
322
|
+
checkTagsBtn.addEventListener('click', async () => {
|
|
323
|
+
const image = sanitizeImageInput(imageInput.value);
|
|
324
|
+
const registry = registrySelect.value;
|
|
325
|
+
if (!image) {
|
|
326
|
+
showStatus('Please enter an image name first.', 'error');
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const normalizedImage = getRepositoryName(image, registry);
|
|
331
|
+
if (!normalizedImage) {
|
|
332
|
+
showStatus('Please enter a valid image name.', 'error');
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
checkTagsBtn.textContent = '...';
|
|
337
|
+
checkTagsBtn.disabled = true;
|
|
338
|
+
statusDiv.classList.add('hidden');
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
const res = await fetchFromApi(`/api/tags?registry=${encodeURIComponent(registry)}&image=${encodeURIComponent(normalizedImage)}`);
|
|
342
|
+
if (!res.ok) {
|
|
343
|
+
const bodyText = await res.text();
|
|
344
|
+
appendDebug('api.http.notOk', { status: res.status, bodyText: bodyText.substring(0, 500) });
|
|
345
|
+
throw new Error(`HTTP ${res.status}`);
|
|
346
|
+
}
|
|
347
|
+
const data = await res.json();
|
|
348
|
+
appendDebug('api.json', {
|
|
349
|
+
hasError: Boolean(data.error),
|
|
350
|
+
tagCount: Array.isArray(data.tags) ? data.tags.length : 0,
|
|
351
|
+
resolvedImage: data.resolvedImage || null,
|
|
352
|
+
autoCorrected: Boolean(data.autoCorrected)
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
if (data.error) throw new Error(data.error);
|
|
356
|
+
|
|
357
|
+
resolvedImageName = data.resolvedImage || normalizedImage;
|
|
358
|
+
|
|
359
|
+
// Populate tags
|
|
360
|
+
tagSelect.innerHTML = '<option value="" disabled selected>Choose a version...</option>';
|
|
361
|
+
// Sort tags? Maybe latest first.
|
|
362
|
+
// Simple sort logic: prioritize 'latest', then semantic versions descending
|
|
363
|
+
const tags = data.tags.sort((a,b) => {
|
|
364
|
+
if (a === 'latest') return -1;
|
|
365
|
+
if (b === 'latest') return 1;
|
|
366
|
+
return b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' });
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
tags.forEach(tag => {
|
|
370
|
+
const option = document.createElement('option');
|
|
371
|
+
option.value = tag;
|
|
372
|
+
option.textContent = tag;
|
|
373
|
+
tagSelect.appendChild(option);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
tagsContainer.classList.remove('hidden');
|
|
377
|
+
tagsContainer.classList.remove('origin-top');
|
|
378
|
+
tagsContainer.classList.add('animate-fade-in-down'); // if using animation classes
|
|
379
|
+
|
|
380
|
+
if (data.autoCorrected && data.resolvedImage) {
|
|
381
|
+
showStatus(`Found ${tags.length} tags. Using closest match: ${data.resolvedImage}.`, 'success');
|
|
382
|
+
} else if (data.resolvedImage && data.resolvedImage !== normalizedImage) {
|
|
383
|
+
showStatus(`Found ${tags.length} tags. Resolved image: ${data.resolvedImage}.`, 'success');
|
|
384
|
+
} else {
|
|
385
|
+
showStatus(`Found ${tags.length} tags.`, 'success');
|
|
386
|
+
}
|
|
387
|
+
checkTagsBtn.textContent = 'Updated';
|
|
388
|
+
setTimeout(() => { checkTagsBtn.textContent = 'Check Tags'; checkTagsBtn.disabled = false; }, 2000);
|
|
389
|
+
|
|
390
|
+
} catch (err) {
|
|
391
|
+
console.error(err);
|
|
392
|
+
appendDebug('checkTags.catch', {
|
|
393
|
+
name: err?.name,
|
|
394
|
+
message: err?.message,
|
|
395
|
+
attemptedUrls: lastApiAttemptUrls
|
|
396
|
+
});
|
|
397
|
+
if (err instanceof TypeError) {
|
|
398
|
+
showStatus('Error fetching tags: Backend is unreachable. Start server and open the app at http://localhost:3000 (not another port).', 'error');
|
|
399
|
+
} else {
|
|
400
|
+
showStatus('Error fetching tags: ' + err.message, 'error');
|
|
401
|
+
}
|
|
402
|
+
checkTagsBtn.textContent = 'Check Tags';
|
|
403
|
+
checkTagsBtn.disabled = false;
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// Handle Tag Selection
|
|
408
|
+
tagSelect.addEventListener('change', () => {
|
|
409
|
+
if (tagSelect.value) {
|
|
410
|
+
downloadBtn.disabled = false;
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// Handle Download
|
|
415
|
+
downloadBtn.addEventListener('click', async () => {
|
|
416
|
+
const image = sanitizeImageInput(imageInput.value);
|
|
417
|
+
const registry = registrySelect.value;
|
|
418
|
+
const tag = tagSelect.value;
|
|
419
|
+
|
|
420
|
+
if (!image || !tag) return;
|
|
421
|
+
|
|
422
|
+
const imageRef = getImageReferenceForDownload(resolvedImageName || image, registry);
|
|
423
|
+
|
|
424
|
+
downloadBtn.disabled = true;
|
|
425
|
+
downloadBtn.innerHTML = `<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> Downloading...`;
|
|
426
|
+
|
|
427
|
+
showStatus('Starting download... This may take a while depending on image size.', 'info');
|
|
428
|
+
|
|
429
|
+
try {
|
|
430
|
+
// Redirect user to the external downloader service
|
|
431
|
+
// Using dockerimagesave.akiel.dev as requested
|
|
432
|
+
const downloadUrl = `https://dockerimagesave.akiel.dev/image?name=${encodeURIComponent(imageRef)}:${encodeURIComponent(tag)}`;
|
|
433
|
+
|
|
434
|
+
showStatus('Redirecting to download service...', 'info');
|
|
435
|
+
|
|
436
|
+
// Open in new tab or same window? New tab is safer for downloads usually.
|
|
437
|
+
window.location.href = downloadUrl;
|
|
438
|
+
|
|
439
|
+
// Reset button after a delay
|
|
440
|
+
setTimeout(() => {
|
|
441
|
+
downloadBtn.disabled = false;
|
|
442
|
+
downloadBtn.innerHTML = `<span>Download Again</span> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>`;
|
|
443
|
+
showStatus('Download started! Check your browser downloads.', 'success');
|
|
444
|
+
}, 3000);
|
|
445
|
+
|
|
446
|
+
} catch (err) {
|
|
447
|
+
showStatus('Error: ' + err.message, 'error');
|
|
448
|
+
downloadBtn.disabled = false;
|
|
449
|
+
downloadBtn.textContent = 'Download Image';
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
function showStatus(msg, type) {
|
|
454
|
+
statusDiv.textContent = msg;
|
|
455
|
+
statusDiv.classList.remove('hidden');
|
|
456
|
+
statusDiv.className = 'text-center text-sm p-3 rounded-lg border'; // reset
|
|
457
|
+
|
|
458
|
+
if (type === 'error') {
|
|
459
|
+
statusDiv.classList.add('bg-red-500/10', 'border-red-500/20', 'text-red-400');
|
|
460
|
+
} else if (type === 'success') {
|
|
461
|
+
statusDiv.classList.add('bg-green-500/10', 'border-green-500/20', 'text-green-400');
|
|
462
|
+
} else {
|
|
463
|
+
statusDiv.classList.add('bg-violet-500/10', 'border-violet-500/25', 'text-violet-300');
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
</script>
|
|
467
|
+
</body>
|
|
468
|
+
</html>
|
package/server.js
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
const http = require('http');
|
|
2
|
+
const https = require('https');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const url = require('url');
|
|
6
|
+
|
|
7
|
+
const PORT = 3000;
|
|
8
|
+
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
9
|
+
const SERVER_STARTED_AT = new Date().toISOString();
|
|
10
|
+
const CGR_KNOWN_REPOS = [
|
|
11
|
+
'chainguard/tomcat',
|
|
12
|
+
'chainguard/nginx',
|
|
13
|
+
'chainguard/redis',
|
|
14
|
+
'chainguard/postgres',
|
|
15
|
+
'chainguard/mysql',
|
|
16
|
+
'chainguard/mariadb',
|
|
17
|
+
'chainguard/node',
|
|
18
|
+
'chainguard/python',
|
|
19
|
+
'chainguard/java',
|
|
20
|
+
'chainguard/jre',
|
|
21
|
+
'chainguard/jdk',
|
|
22
|
+
'chainguard/go',
|
|
23
|
+
'chainguard/ruby',
|
|
24
|
+
'chainguard/php',
|
|
25
|
+
'chainguard/busybox',
|
|
26
|
+
'chainguard/curl',
|
|
27
|
+
'chainguard/git',
|
|
28
|
+
'chainguard/kubectl',
|
|
29
|
+
'chainguard/maven',
|
|
30
|
+
'chainguard/gradle'
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
// --- Helper Functions ---
|
|
34
|
+
const mimeTypes = {
|
|
35
|
+
'.html': 'text/html',
|
|
36
|
+
'.css': 'text/css',
|
|
37
|
+
'.js': 'text/javascript',
|
|
38
|
+
'.json': 'application/json',
|
|
39
|
+
'.png': 'image/png',
|
|
40
|
+
'.svg': 'image/svg+xml'
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function sendJson(res, status, data) {
|
|
44
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
45
|
+
res.end(JSON.stringify(data));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function logDebug(context, details = {}) {
|
|
49
|
+
const ts = new Date().toISOString();
|
|
50
|
+
console.log(`[${ts}] [${context}] ${JSON.stringify(details)}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function httpsGetResponse(urlStr, headers = {}) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
logDebug('https.request.start', {
|
|
56
|
+
url: urlStr,
|
|
57
|
+
hasAuthorization: Boolean(headers.Authorization)
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const urlObj = url.parse(urlStr);
|
|
61
|
+
const options = {
|
|
62
|
+
hostname: urlObj.hostname,
|
|
63
|
+
path: urlObj.path,
|
|
64
|
+
method: 'GET',
|
|
65
|
+
headers: headers,
|
|
66
|
+
rejectUnauthorized: false // Bypass SSL errors (self-signed certs)
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const req = https.request(options, (res) => {
|
|
70
|
+
let data = '';
|
|
71
|
+
|
|
72
|
+
res.on('data', chunk => data += chunk);
|
|
73
|
+
|
|
74
|
+
res.on('end', () => {
|
|
75
|
+
logDebug('https.request.end', {
|
|
76
|
+
url: urlStr,
|
|
77
|
+
statusCode: res.statusCode,
|
|
78
|
+
bodySnippet: (data || '').substring(0, 180)
|
|
79
|
+
});
|
|
80
|
+
resolve({
|
|
81
|
+
statusCode: res.statusCode,
|
|
82
|
+
headers: res.headers,
|
|
83
|
+
body: data
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
req.on('error', e => {
|
|
89
|
+
logDebug('https.request.error', {
|
|
90
|
+
url: urlStr,
|
|
91
|
+
error: e.message
|
|
92
|
+
});
|
|
93
|
+
reject(e);
|
|
94
|
+
});
|
|
95
|
+
req.end();
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function httpsGet(urlStr, headers = {}) {
|
|
100
|
+
return httpsGetResponse(urlStr, headers)
|
|
101
|
+
.then(response => {
|
|
102
|
+
if (response.statusCode >= 400) {
|
|
103
|
+
// Try to parse error message if JSON
|
|
104
|
+
try {
|
|
105
|
+
const json = JSON.parse(response.body);
|
|
106
|
+
throw new Error(json.message || json.error || `HTTP ${response.statusCode}`);
|
|
107
|
+
} catch (e) {
|
|
108
|
+
if (e instanceof Error && !e.message.startsWith('Unexpected token')) {
|
|
109
|
+
throw e;
|
|
110
|
+
}
|
|
111
|
+
throw new Error(`HTTP ${response.statusCode}: ${response.body.substring(0, 100)}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return response.body;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseWwwAuthenticateBearer(headerValue) {
|
|
119
|
+
if (!headerValue) return null;
|
|
120
|
+
const match = headerValue.match(/^Bearer\s+(.+)$/i);
|
|
121
|
+
if (!match) return null;
|
|
122
|
+
|
|
123
|
+
const params = {};
|
|
124
|
+
const regex = /(\w+)="([^"]*)"/g;
|
|
125
|
+
let pair;
|
|
126
|
+
while ((pair = regex.exec(match[1])) !== null) {
|
|
127
|
+
params[pair[1]] = pair[2];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
realm: params.realm,
|
|
132
|
+
service: params.service,
|
|
133
|
+
scope: params.scope
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function stripTagOrDigest(imageName) {
|
|
138
|
+
if (!imageName) return imageName;
|
|
139
|
+
let normalized = imageName.split('@')[0];
|
|
140
|
+
const lastSlash = normalized.lastIndexOf('/');
|
|
141
|
+
const lastColon = normalized.lastIndexOf(':');
|
|
142
|
+
if (lastColon > lastSlash) {
|
|
143
|
+
normalized = normalized.substring(0, lastColon);
|
|
144
|
+
}
|
|
145
|
+
return normalized;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function sanitizeImageInput(imageName) {
|
|
149
|
+
if (!imageName) return imageName;
|
|
150
|
+
let sanitized = imageName.trim();
|
|
151
|
+
sanitized = sanitized.replace(/^(docker|podman)\s+pull\s+/i, '');
|
|
152
|
+
sanitized = sanitized.replace(/^https?:\/\//i, '');
|
|
153
|
+
sanitized = sanitized.replace(/^['\"]+|['\"]+$/g, '');
|
|
154
|
+
sanitized = sanitized.split(/\s+/)[0];
|
|
155
|
+
sanitized = sanitized.replace(/^['\"]+|['\"]+$/g, '');
|
|
156
|
+
return sanitized;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isValidRepoName(repoName, registry) {
|
|
160
|
+
if (!repoName) return false;
|
|
161
|
+
|
|
162
|
+
const cgrPattern = /^[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)+$/i;
|
|
163
|
+
const dockerPattern = /^[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*$/i;
|
|
164
|
+
|
|
165
|
+
if (registry === 'cgr') {
|
|
166
|
+
return cgrPattern.test(repoName);
|
|
167
|
+
}
|
|
168
|
+
return dockerPattern.test(repoName);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function levenshteinDistance(a, b) {
|
|
172
|
+
const source = (a || '').toLowerCase();
|
|
173
|
+
const target = (b || '').toLowerCase();
|
|
174
|
+
const matrix = Array.from({ length: source.length + 1 }, () => Array(target.length + 1).fill(0));
|
|
175
|
+
|
|
176
|
+
for (let i = 0; i <= source.length; i += 1) matrix[i][0] = i;
|
|
177
|
+
for (let j = 0; j <= target.length; j += 1) matrix[0][j] = j;
|
|
178
|
+
|
|
179
|
+
for (let i = 1; i <= source.length; i += 1) {
|
|
180
|
+
for (let j = 1; j <= target.length; j += 1) {
|
|
181
|
+
const cost = source[i - 1] === target[j - 1] ? 0 : 1;
|
|
182
|
+
matrix[i][j] = Math.min(
|
|
183
|
+
matrix[i - 1][j] + 1,
|
|
184
|
+
matrix[i][j - 1] + 1,
|
|
185
|
+
matrix[i - 1][j - 1] + cost
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return matrix[source.length][target.length];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function findBestCgrRepoMatch(inputRepo) {
|
|
194
|
+
const normalized = (inputRepo || '').toLowerCase();
|
|
195
|
+
const inputLeaf = normalized.split('/').pop() || normalized;
|
|
196
|
+
|
|
197
|
+
let best = null;
|
|
198
|
+
for (const repo of CGR_KNOWN_REPOS) {
|
|
199
|
+
const repoLower = repo.toLowerCase();
|
|
200
|
+
const repoLeaf = repoLower.split('/').pop();
|
|
201
|
+
const fullDistance = levenshteinDistance(normalized, repoLower);
|
|
202
|
+
const leafDistance = levenshteinDistance(inputLeaf, repoLeaf);
|
|
203
|
+
const score = Math.min(fullDistance, leafDistance);
|
|
204
|
+
|
|
205
|
+
if (!best || score < best.score) {
|
|
206
|
+
best = { repo, score };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!best) return null;
|
|
211
|
+
if (best.score <= 2) return best.repo;
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function buildCgrCandidates(imageName) {
|
|
216
|
+
const rawRepo = stripTagOrDigest(imageName.replace(/^cgr\.dev\//i, ''));
|
|
217
|
+
const candidates = [];
|
|
218
|
+
|
|
219
|
+
if (rawRepo.includes('/')) {
|
|
220
|
+
candidates.push(rawRepo);
|
|
221
|
+
} else {
|
|
222
|
+
candidates.push(`chainguard/${rawRepo}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const bestMatch = findBestCgrRepoMatch(rawRepo);
|
|
226
|
+
if (bestMatch && !candidates.includes(bestMatch)) {
|
|
227
|
+
candidates.push(bestMatch);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
rawRepo,
|
|
232
|
+
candidates: [...new Set(candidates)]
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function resolveRegistry(imageName, requestedRegistry) {
|
|
237
|
+
const normalized = sanitizeImageInput(imageName).toLowerCase();
|
|
238
|
+
if (normalized.startsWith('cgr.dev/')) return 'cgr';
|
|
239
|
+
if (normalized.startsWith('docker.io/') || normalized.startsWith('registry-1.docker.io/')) return 'dockerhub';
|
|
240
|
+
return requestedRegistry;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function fetchDockerHubTags(imageName) {
|
|
244
|
+
let repo = stripTagOrDigest(imageName);
|
|
245
|
+
repo = repo.replace(/^docker\.io\//i, '').replace(/^registry-1\.docker\.io\//i, '');
|
|
246
|
+
if (!repo.includes('/')) repo = `library/${repo}`;
|
|
247
|
+
|
|
248
|
+
if (!isValidRepoName(repo, 'dockerhub')) {
|
|
249
|
+
throw new Error('Invalid Docker Hub image name format');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
logDebug('dockerhub.tags.start', { input: imageName, repo });
|
|
253
|
+
|
|
254
|
+
// 1. Get Auth Token
|
|
255
|
+
const authUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull`;
|
|
256
|
+
|
|
257
|
+
return httpsGet(authUrl)
|
|
258
|
+
.then(authData => {
|
|
259
|
+
try {
|
|
260
|
+
const token = JSON.parse(authData).token;
|
|
261
|
+
if (!token) throw new Error('No token in auth response');
|
|
262
|
+
return token;
|
|
263
|
+
} catch (e) {
|
|
264
|
+
throw new Error('Auth parse failed: ' + e.message);
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
.then(token => {
|
|
268
|
+
// 2. Get Tags
|
|
269
|
+
const tagsUrl = `https://registry-1.docker.io/v2/${repo}/tags/list`;
|
|
270
|
+
logDebug('dockerhub.tags.fetch', { repo, tagsUrl });
|
|
271
|
+
return httpsGet(tagsUrl, { 'Authorization': `Bearer ${token}` });
|
|
272
|
+
})
|
|
273
|
+
.then(tagsData => {
|
|
274
|
+
try {
|
|
275
|
+
const json = JSON.parse(tagsData);
|
|
276
|
+
return { tags: json.tags || [], resolvedImage: repo };
|
|
277
|
+
} catch (e) {
|
|
278
|
+
throw new Error('Tags parse failed');
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function fetchCgrTagsForRepo(repo) {
|
|
284
|
+
const tagsUrl = `https://cgr.dev/v2/${repo}/tags/list`;
|
|
285
|
+
return httpsGetResponse(tagsUrl)
|
|
286
|
+
.then(response => {
|
|
287
|
+
if (response.statusCode < 400) {
|
|
288
|
+
return response.body;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (response.statusCode !== 401) {
|
|
292
|
+
logDebug('cgr.tags.unexpectedStatus', {
|
|
293
|
+
repo,
|
|
294
|
+
statusCode: response.statusCode,
|
|
295
|
+
bodySnippet: (response.body || '').substring(0, 180)
|
|
296
|
+
});
|
|
297
|
+
throw new Error(`HTTP ${response.statusCode}: ${response.body.substring(0, 100)}`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const challenge = parseWwwAuthenticateBearer(response.headers['www-authenticate']);
|
|
301
|
+
if (!challenge || !challenge.realm) {
|
|
302
|
+
throw new Error('cgr.dev auth challenge missing or invalid');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const authUrlObj = url.parse(challenge.realm, true);
|
|
306
|
+
authUrlObj.query = authUrlObj.query || {};
|
|
307
|
+
if (challenge.service) authUrlObj.query.service = challenge.service;
|
|
308
|
+
if (challenge.scope) {
|
|
309
|
+
authUrlObj.query.scope = challenge.scope;
|
|
310
|
+
} else {
|
|
311
|
+
authUrlObj.query.scope = `repository:${repo}:pull`;
|
|
312
|
+
}
|
|
313
|
+
delete authUrlObj.search;
|
|
314
|
+
const authUrl = url.format(authUrlObj);
|
|
315
|
+
logDebug('cgr.auth.challenge', {
|
|
316
|
+
repo,
|
|
317
|
+
realm: challenge.realm,
|
|
318
|
+
service: challenge.service,
|
|
319
|
+
scope: authUrlObj.query.scope
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
return httpsGet(authUrl)
|
|
323
|
+
.then(authData => {
|
|
324
|
+
let token;
|
|
325
|
+
try {
|
|
326
|
+
const authJson = JSON.parse(authData);
|
|
327
|
+
token = authJson.token || authJson.access_token;
|
|
328
|
+
} catch (e) {
|
|
329
|
+
throw new Error('Auth parse failed');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (!token) {
|
|
333
|
+
throw new Error('No token in cgr.dev auth response');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
logDebug('cgr.tags.fetch', { repo, tagsUrl, hasToken: true });
|
|
337
|
+
return httpsGet(tagsUrl, { 'Authorization': `Bearer ${token}` });
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function fetchCgrTags(imageName) {
|
|
343
|
+
const { rawRepo, candidates } = buildCgrCandidates(imageName);
|
|
344
|
+
if (!rawRepo) {
|
|
345
|
+
throw new Error('Invalid cgr.dev image name');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
logDebug('cgr.tags.start', { input: imageName, rawRepo, candidates });
|
|
349
|
+
|
|
350
|
+
let sequence = Promise.reject(new Error('No cgr candidates to try'));
|
|
351
|
+
|
|
352
|
+
candidates.forEach((candidateRepo) => {
|
|
353
|
+
sequence = sequence.catch(() => {
|
|
354
|
+
if (!isValidRepoName(candidateRepo, 'cgr')) {
|
|
355
|
+
throw new Error(`Invalid cgr.dev image name format: ${candidateRepo}`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return fetchCgrTagsForRepo(candidateRepo)
|
|
359
|
+
.then(tagsData => {
|
|
360
|
+
const json = JSON.parse(tagsData);
|
|
361
|
+
return {
|
|
362
|
+
tags: json.tags || [],
|
|
363
|
+
resolvedImage: candidateRepo,
|
|
364
|
+
autoCorrected: candidateRepo.toLowerCase() !== rawRepo.toLowerCase()
|
|
365
|
+
};
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
return sequence.catch((err) => {
|
|
371
|
+
throw new Error(err.message || 'Failed to fetch cgr.dev tags');
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function fetchTags(imageName, registry = 'dockerhub') {
|
|
376
|
+
const sanitizedImageName = sanitizeImageInput(imageName);
|
|
377
|
+
logDebug('tags.dispatch', { imageName, sanitizedImageName, registry });
|
|
378
|
+
if (registry === 'cgr') {
|
|
379
|
+
return fetchCgrTags(sanitizedImageName);
|
|
380
|
+
}
|
|
381
|
+
return fetchDockerHubTags(sanitizedImageName);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// --- HTTP Server ---
|
|
385
|
+
const server = http.createServer((req, res) => {
|
|
386
|
+
// Set CORS headers
|
|
387
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
388
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
389
|
+
|
|
390
|
+
if (req.method === 'OPTIONS') {
|
|
391
|
+
res.writeHead(204);
|
|
392
|
+
res.end();
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const parsedUrl = url.parse(req.url, true);
|
|
397
|
+
let pathname = parsedUrl.pathname;
|
|
398
|
+
|
|
399
|
+
if (pathname === '/api/health') {
|
|
400
|
+
sendJson(res, 200, {
|
|
401
|
+
ok: true,
|
|
402
|
+
service: 'docker-image-downloader',
|
|
403
|
+
startedAt: SERVER_STARTED_AT,
|
|
404
|
+
now: new Date().toISOString(),
|
|
405
|
+
uptimeSeconds: Math.round(process.uptime())
|
|
406
|
+
});
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (pathname === '/api/debug/ping') {
|
|
411
|
+
sendJson(res, 200, {
|
|
412
|
+
ok: true,
|
|
413
|
+
method: req.method,
|
|
414
|
+
url: req.url,
|
|
415
|
+
query: parsedUrl.query,
|
|
416
|
+
headers: {
|
|
417
|
+
host: req.headers.host,
|
|
418
|
+
origin: req.headers.origin || null,
|
|
419
|
+
referer: req.headers.referer || null,
|
|
420
|
+
userAgent: req.headers['user-agent'] || null
|
|
421
|
+
},
|
|
422
|
+
now: new Date().toISOString()
|
|
423
|
+
});
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// API: /api/tags
|
|
428
|
+
if (pathname === '/api/tags') {
|
|
429
|
+
const image = parsedUrl.query.image;
|
|
430
|
+
const sanitizedImage = sanitizeImageInput(image);
|
|
431
|
+
const requestedRegistry = (parsedUrl.query.registry || 'dockerhub').toLowerCase();
|
|
432
|
+
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
433
|
+
|
|
434
|
+
logDebug('api.tags.request', {
|
|
435
|
+
requestId,
|
|
436
|
+
image,
|
|
437
|
+
sanitizedImage,
|
|
438
|
+
requestedRegistry,
|
|
439
|
+
rawQuery: parsedUrl.query
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
if (!sanitizedImage) {
|
|
443
|
+
sendJson(res, 400, { error: 'Image name required' });
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (!['dockerhub', 'cgr'].includes(requestedRegistry)) {
|
|
448
|
+
sendJson(res, 400, { error: 'Unsupported registry' });
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const registry = resolveRegistry(sanitizedImage, requestedRegistry);
|
|
453
|
+
logDebug('api.tags.resolved', { requestId, registry, requestedRegistry, image, sanitizedImage });
|
|
454
|
+
|
|
455
|
+
fetchTags(sanitizedImage, registry)
|
|
456
|
+
.then(data => {
|
|
457
|
+
logDebug('api.tags.success', {
|
|
458
|
+
requestId,
|
|
459
|
+
registry,
|
|
460
|
+
tagCount: Array.isArray(data.tags) ? data.tags.length : 0
|
|
461
|
+
});
|
|
462
|
+
sendJson(res, 200, data);
|
|
463
|
+
})
|
|
464
|
+
.catch(err => {
|
|
465
|
+
logDebug('api.tags.error', {
|
|
466
|
+
requestId,
|
|
467
|
+
registry,
|
|
468
|
+
error: err.message
|
|
469
|
+
});
|
|
470
|
+
// Return empty list on error so UI can still function manually
|
|
471
|
+
sendJson(res, 200, { tags: [], error: err.message });
|
|
472
|
+
});
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Serve Static Files
|
|
477
|
+
if (pathname === '/') pathname = '/index.html';
|
|
478
|
+
|
|
479
|
+
// Prevent directory traversal
|
|
480
|
+
const safePath = path.normalize(pathname).replace(/^(\.\.[\/\\])+/, '');
|
|
481
|
+
const filePath = path.join(PUBLIC_DIR, safePath);
|
|
482
|
+
|
|
483
|
+
fs.readFile(filePath, (err, content) => {
|
|
484
|
+
if (err) {
|
|
485
|
+
if (err.code === 'ENOENT') {
|
|
486
|
+
res.writeHead(404);
|
|
487
|
+
res.end('<h1>404 Not Found</h1>');
|
|
488
|
+
} else {
|
|
489
|
+
res.writeHead(500);
|
|
490
|
+
res.end(`Server Error: ${err.code}`);
|
|
491
|
+
}
|
|
492
|
+
} else {
|
|
493
|
+
const ext = path.extname(filePath);
|
|
494
|
+
const contentType = mimeTypes[ext] || 'application/octet-stream';
|
|
495
|
+
res.writeHead(200, { 'Content-Type': contentType });
|
|
496
|
+
res.end(content);
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// Start Server
|
|
503
|
+
server.listen(PORT, () => {
|
|
504
|
+
console.log(`Server running at http://localhost:${PORT}`);
|
|
505
|
+
console.log('Mode: Zero-Dependency (using built-in http/https)');
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
server.on('error', (err) => {
|
|
509
|
+
logDebug('server.error', {
|
|
510
|
+
code: err.code,
|
|
511
|
+
message: err.message,
|
|
512
|
+
port: PORT
|
|
513
|
+
});
|
|
514
|
+
console.error('Server failed to start:', err.message);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
process.on('uncaughtException', (err) => {
|
|
518
|
+
logDebug('process.uncaughtException', {
|
|
519
|
+
message: err.message,
|
|
520
|
+
stack: err.stack
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
process.on('unhandledRejection', (reason) => {
|
|
525
|
+
const message = reason instanceof Error ? reason.message : String(reason);
|
|
526
|
+
const stack = reason instanceof Error ? reason.stack : null;
|
|
527
|
+
logDebug('process.unhandledRejection', { message, stack });
|
|
528
|
+
});
|