Przeglądaj źródła

images downloader

Denys Bashkatov 2 tygodni temu
rodzic
commit
2f3e0e4bae

+ 5 - 0
Dockerfile

@@ -20,6 +20,11 @@ RUN apt-get update && apt-get install -y \
     tzdata \
     && rm -rf /var/lib/apt/lists/*
 
+# Install skopeo for container image operations
+RUN apt-get update && apt-get install -y \
+    skopeo \
+    && rm -rf /var/lib/apt/lists/*
+
 # Install apt-mirror from PyPI (using --break-system-packages for Ubuntu 24.04)
 RUN pip3 install --break-system-packages apt-mirror uvloop
 

+ 2 - 0
README.md

@@ -10,6 +10,7 @@ A containerized APT mirror solution with a web interface. This project provides
   - `mirror.intra` - DEB packages repository
   - `admin.mirror.intra` - Admin panel with authentication
   - `files.mirror.intra` - File hosting service
+- **Advanced File Manager**: File upload/download, directory management, and container image downloads from Docker Hub and GCR
 - **Multi-Architecture Support**: Builds for both AMD64 and ARM64
 - **Easy Deployment**: Simple scripts for building and deployment
 - **Configurable**: Custom domains, sync frequency, and admin passwords
@@ -182,3 +183,4 @@ This project is licensed under the MIT License - see the LICENSE file for detail
 
 - [apt-mirror2](https://gitlab.com/apt-mirror2/apt-mirror2) - The Python/asyncio APT mirroring tool from PyPI
 - [nginx](https://nginx.org/) - Web server
+- [skopeo](https://github.com/containers/skopeo) - For container image management

+ 102 - 0
admin/app/components/file-manager/download-image-modal.tsx

@@ -0,0 +1,102 @@
+import React, { useState } from 'react';
+import Modal from '~/components/shared/modal/modal';
+import FormInput from '~/components/shared/form/form-input';
+import FormButton from '~/components/shared/form/form-button';
+import { useSubmit } from 'react-router';
+
+interface DownloadImageModalProps {
+  isOpen: boolean;
+  onClose: () => void;
+  currentPath: string;
+}
+
+export default function DownloadImageModal({ isOpen, onClose, currentPath }: DownloadImageModalProps) {
+  const [imageUrl, setImageUrl] = useState('');
+  const [imageTag, setImageTag] = useState('latest');
+  const [isSubmitting, setIsSubmitting] = useState(false);
+  const submit = useSubmit();
+
+  const handleSubmit = async () => {
+    if (!imageUrl.trim()) return;
+
+    setIsSubmitting(true);
+    
+    try {
+      await submit(
+        { 
+          intent: 'downloadImage', 
+          imageUrl: imageUrl.trim(), 
+          imageTag: imageTag.trim() || 'latest',
+          currentPath: currentPath 
+        },
+        { action: '', method: 'post' }
+      );
+      
+      // Reset form and close modal
+      setImageUrl('');
+      setImageTag('latest');
+      onClose();
+    } catch (error) {
+      console.error('Failed to download image:', error);
+    } finally {
+      setIsSubmitting(false);
+    }
+  };
+
+  const handleCancel = () => {
+    setImageUrl('');
+    setImageTag('latest');
+    onClose();
+  };
+
+  return (
+    <Modal
+      isOpen={isOpen}
+      onClose={handleCancel}
+      title="Download Container Image"
+    >
+      <div className="space-y-4">
+        <div>
+          <label className="block text-sm font-medium text-gray-700 mb-1">
+            Image URL
+          </label>
+          <FormInput
+            value={imageUrl}
+            onChange={setImageUrl}
+            placeholder="e.g., nginx, repo/image, docker.io/repo/image, gcr.io/project/image"
+          />
+          <p className="text-xs text-gray-500 mt-1">
+            Supports Docker Hub and Google Container Registry (GCR). Single words (e.g., "nginx") will use docker.io/library/. Uses skopeo for downloading images.
+          </p>
+        </div>
+        
+        <div>
+          <label className="block text-sm font-medium text-gray-700 mb-1">
+            Tag
+          </label>
+          <FormInput
+            value={imageTag}
+            onChange={setImageTag}
+            placeholder="latest"
+          />
+        </div>
+        
+        <div className="flex justify-end gap-2 pt-4">
+          <FormButton
+            type="secondary"
+            onClick={handleCancel}
+            disabled={isSubmitting}
+          >
+            Cancel
+          </FormButton>
+          <FormButton
+            onClick={handleSubmit}
+            disabled={!imageUrl.trim() || isSubmitting}
+          >
+            {isSubmitting ? 'Downloading...' : 'Download'}
+          </FormButton>
+        </div>
+      </div>
+    </Modal>
+  );
+} 

+ 21 - 0
admin/app/components/shared/dropdown/dropdown-item.tsx

@@ -0,0 +1,21 @@
+import React from 'react';
+
+interface DropdownItemProps {
+  children: React.ReactNode;
+  onClick?: () => void;
+  disabled?: boolean;
+}
+
+export default function DropdownItem({ children, onClick, disabled = false }: DropdownItemProps) {
+  return (
+    <button
+      onClick={onClick}
+      disabled={disabled}
+      className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer ${
+        disabled ? 'text-gray-400' : 'text-gray-700'
+      }`}
+    >
+      {children}
+    </button>
+  );
+} 

+ 49 - 0
admin/app/components/shared/dropdown/dropdown.tsx

@@ -0,0 +1,49 @@
+import React, { useState, useRef, useEffect } from 'react';
+
+interface DropdownProps {
+  trigger: React.ReactNode;
+  children: React.ReactNode;
+  disabled?: boolean;
+}
+
+export default function Dropdown({ trigger, children, disabled = false }: DropdownProps) {
+  const [isOpen, setIsOpen] = useState(false);
+  const dropdownRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    const handleClickOutside = (event: MouseEvent) => {
+      if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
+        setIsOpen(false);
+      }
+    };
+
+    document.addEventListener('mousedown', handleClickOutside);
+    return () => {
+      document.removeEventListener('mousedown', handleClickOutside);
+    };
+  }, []);
+
+  const handleTriggerClick = () => {
+    if (!disabled) {
+      setIsOpen(!isOpen);
+    }
+  };
+
+  return (
+    <div className="relative" ref={dropdownRef}>
+      <div 
+        onClick={handleTriggerClick}
+        className={disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
+      >
+        {trigger}
+      </div>
+      {isOpen && !disabled && (
+        <div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg border border-gray-200 z-50">
+          <div className="py-1">
+            {children}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+} 

+ 102 - 0
admin/app/routes/file-manager/action.tsx

@@ -5,6 +5,11 @@ import fsSync from "fs";
 import https from "https";
 import http from "http";
 import { URL } from "url";
+import { exec } from "child_process";
+import { promisify } from "util";
+import zlib from "zlib";
+
+const execAsync = promisify(exec);
 
 // Disk-based chunk storage using destination directory
 const chunkStorage = new Map<string, { tempDir: string; totalChunks: number; fileName: string }>();
@@ -247,6 +252,83 @@ async function handleChunkUpload(formData: FormData): Promise<{ success: boolean
   }
 }
 
+async function downloadImage(imageUrl: string, imageTag: string, destPath: string): Promise<boolean> {
+  try {
+    // Ensure destination directory exists
+    await fs.mkdir(destPath, { recursive: true });
+    
+    // Create filename from image URL and tag
+    const imageName = imageUrl.replace(/[^a-zA-Z0-9.-]/g, '_');
+    const fileName = `${imageName}_${imageTag}.tar`;
+    const fullPath = path.join(destPath, fileName);
+    
+    // Parse registry and image details
+    const registryInfo = parseImageUrl(imageUrl);
+    if (!registryInfo) {
+      throw new Error('Invalid image URL format. Please use format: project/image or gcr.io/project/image');
+    }
+    
+    // Use skopeo to copy image to tar format
+    const sourceImage = `${registryInfo.registry}/${registryInfo.repository}:${imageTag}`;
+    const skopeoCommand = `skopeo copy docker://${sourceImage} docker-archive:${fullPath}`;
+    
+    await execAsync(skopeoCommand);
+    
+    return true;
+  } catch (error) {
+    console.error('Failed to download image:', error);
+    return false;
+  }
+}
+
+interface RegistryInfo {
+  registry: string;
+  repository: string;
+}
+
+function parseImageUrl(imageUrl: string): RegistryInfo | null {
+  // Handle Google Container Registry
+  if (imageUrl.startsWith('gcr.io/')) {
+    return {
+      registry: 'gcr.io',
+      repository: imageUrl.substring(8) // Remove 'gcr.io/'
+    };
+  }
+  
+  // Handle regional GCR formats (us.gcr.io, eu.gcr.io, etc.)
+  if (imageUrl.includes('.gcr.io/')) {
+    const parts = imageUrl.split('/');
+    if (parts.length >= 2) {
+      return {
+        registry: parts[0],
+        repository: parts.slice(1).join('/')
+      };
+    }
+  }
+  
+  // Handle Docker Hub registry
+  if (imageUrl.startsWith('docker.io/')) {
+    return {
+      registry: 'docker.io',
+      repository: imageUrl.substring(11) // Remove 'docker.io/'
+    };
+  }
+  
+  // Handle single-word image names (e.g., "nginx" -> "library/nginx")
+  if (!imageUrl.includes('/')) {
+    return {
+      registry: 'docker.io',
+      repository: `library/${imageUrl}`
+    };
+  }
+  
+  // Auto-prepend docker.io for images without explicit registry
+  return {
+    registry: 'docker.io',
+    repository: imageUrl
+  };
+}
+
 export async function action({ request }: Route.ActionArgs) {
   try {
     const formData = await request.formData();
@@ -343,6 +425,26 @@ export async function action({ request }: Route.ActionArgs) {
       } else {
         return { success: false, error: "Failed to download file" };
       }
+    } else if (intent === 'downloadImage') {
+      const imageUrl = formData.get('imageUrl') as string;
+      const imageTag = formData.get('imageTag') as string;
+      const currentPath = formData.get('currentPath') as string;
+      
+      if (!imageUrl || !imageUrl.trim()) {
+        return { success: false, error: "Image URL is required" };
+      }
+      
+      if (!imageTag || !imageTag.trim()) {
+        return { success: false, error: "Image tag is required" };
+      }
+      
+      const success = await downloadImage(imageUrl.trim(), imageTag.trim(), currentPath);
+      
+      if (success) {
+        return { success: true };
+      } else {
+        return { success: false, error: "Failed to download container image" };
+      }
     }
 
     return { success: false, error: "Invalid action" };

+ 28 - 0
admin/app/routes/file-manager/file-manager.tsx

@@ -8,6 +8,9 @@ import FormInput from "~/components/shared/form/form-input";
 import Modal from "~/components/shared/modal/modal";
 import RenameForm from "~/components/file-manager/rename-form";
 import Ellipsis from "~/components/shared/ellipsis/ellipsis";
+import Dropdown from "~/components/shared/dropdown/dropdown";
+import DropdownItem from "~/components/shared/dropdown/dropdown-item";
+import DownloadImageModal from "~/components/file-manager/download-image-modal";
 import { useActionData, useLoaderData, useSubmit, useRevalidator, type SubmitTarget } from "react-router";
 import appConfig from "~/config/config.json";
 import { loader } from "./loader";
@@ -62,6 +65,7 @@ export default function FileManager() {
   const revalidator = useRevalidator();
   const [isUploading, setIsUploading] = useState(false);
   const [isDownloading, setIsDownloading] = useState(false);
+  const [isDownloadImageModalOpen, setIsDownloadImageModalOpen] = useState(false);
   
   const [itemToRename, setItemToRename] = useState<{ path: string; name: string } | null>(null);
   
@@ -269,6 +273,24 @@ export default function FileManager() {
                     currentPath={currentPath}
                   />
                 )}
+                {!isUploading && !isDownloading && (
+                  <Dropdown
+                    disabled={isOperationInProgress}
+                    trigger={
+                      <FormButton
+                        type="secondary"
+                        disabled={isOperationInProgress}
+                        onClick={() => {}} // Empty handler to satisfy FormButton requirements
+                      >
+                        ⋮
+                      </FormButton>
+                    }
+                  >
+                    <DropdownItem onClick={() => setIsDownloadImageModalOpen(true)}>
+                      Download Container Image
+                    </DropdownItem>
+                  </Dropdown>
+                )}
               </>
             )}
           </div>
@@ -366,6 +388,12 @@ export default function FileManager() {
           />
         </Modal>
       )}
+      
+      <DownloadImageModal
+        isOpen={isDownloadImageModalOpen}
+        onClose={() => setIsDownloadImageModalOpen(false)}
+        currentPath={currentPath}
+      />
     </PageLayoutFull>
   );
 }