diff --git a/app/Data/Node/Storage/IsoData.php b/app/Data/Node/Storage/IsoData.php new file mode 100644 index 00000000000..79c32d4dd7a --- /dev/null +++ b/app/Data/Node/Storage/IsoData.php @@ -0,0 +1,18 @@ +checksum_algorithum ? ChecksumData::from([ - 'algorithm' => ChecksumAlgorithm::from($request->checksum_algorithum), - 'checksum' => $request->checksum, - ]) : null; + $shouldDownload = $request->boolean('should_download'); - $iso = $this->isoService->create($node, $request->name, $request->file_name, $request->link, $checksumData, $request->hidden); + if($shouldDownload) { + $checksumData = (bool) $request->checksum_algorithum ? ChecksumData::from([ + 'algorithm' => ChecksumAlgorithm::from($request->checksum_algorithum), + 'checksum' => $request->checksum, + ]) : null; + + $iso = $this->isoService->download($node, $request->name, $request->file_name, $request->link, $checksumData, $request->hidden); + } else { + $isoFromProxmox = $this->isoService->getIso($node, $request->file_name); + + $iso = $node->isos()->create([ + 'is_successful' => true, + 'name' => $request->name, + 'file_name' => $request->file_name, + 'size' => $isoFromProxmox->size, + 'hidden' => $request->boolean('hidden'), + 'completed_at' => now(), + ]); + } return fractal($iso, new IsoTransformer)->respond(); } diff --git a/app/Http/Controllers/Admin/Nodes/NodeController.php b/app/Http/Controllers/Admin/Nodes/NodeController.php index ab68b0a0a56..62fcb811d1a 100644 --- a/app/Http/Controllers/Admin/Nodes/NodeController.php +++ b/app/Http/Controllers/Admin/Nodes/NodeController.php @@ -66,6 +66,8 @@ public function updateCoterm(UpdateCotermRequest $request, Node $node) $payload['coterm_token'] = $creds['token']; } + + $node->update($payload); return new JsonResponse([ diff --git a/app/Http/Requests/Admin/Nodes/Isos/StoreIsoRequest.php b/app/Http/Requests/Admin/Nodes/Isos/StoreIsoRequest.php index db16796dad7..fff6d1ca79e 100644 --- a/app/Http/Requests/Admin/Nodes/Isos/StoreIsoRequest.php +++ b/app/Http/Requests/Admin/Nodes/Isos/StoreIsoRequest.php @@ -2,9 +2,12 @@ namespace Convoy\Http\Requests\Admin\Nodes\Isos; +use Convoy\Models\Node; +use Illuminate\Validation\Validator; +use Convoy\Http\Requests\FormRequest; +use Convoy\Services\Nodes\Isos\IsoService; use Convoy\Enums\Helpers\ChecksumAlgorithm; use Convoy\Models\ISO; -use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rules\Enum; class StoreIsoRequest extends FormRequest @@ -14,14 +17,31 @@ public function rules(): array $isoRules = ISO::getRules(); $rules = [ + 'should_download' => 'required|boolean', 'name' => $isoRules['name'], 'file_name' => $isoRules['file_name'], 'hidden' => $isoRules['hidden'], - 'link' => 'required|url|max:191', - 'checksum_algorithm' => ['sometimes', new Enum(ChecksumAlgorithm::class)], - 'checksum' => 'required_with:checksum_algorithm|string|max:191', + 'link' => 'required_if:should_download,1|url|max:191|exclude_if:should_download,0', + 'checksum_algorithm' => ['sometimes', new Enum(ChecksumAlgorithm::class), 'exclude_if:should_download,0'], + 'checksum' => 'required_with:checksum_algorithm|string|max:191|exclude_if:should_download,0', ]; return $rules; } + + public function withValidator(Validator $validator) + { + + if (!$this->boolean('should_download')) { + $validator->after(function (Validator $validator) { + $node = $this->parameter('node', Node::class); + + $iso = app(IsoService::class)->getIso($node, $this->input('file_name')); + + if (is_null($iso)) { + $validator->errors()->add('file_name', 'This ISO doesn\'t exist.'); + } + }); + } + } } diff --git a/app/Http/Requests/Admin/Nodes/UpdateNodeRequest.php b/app/Http/Requests/Admin/Nodes/UpdateNodeRequest.php index 339cfa54ca7..1896a24b701 100644 --- a/app/Http/Requests/Admin/Nodes/UpdateNodeRequest.php +++ b/app/Http/Requests/Admin/Nodes/UpdateNodeRequest.php @@ -9,19 +9,6 @@ class UpdateNodeRequest extends FormRequest { - /** - * Determine if the user is authorized to make this request. - */ - public function authorize(): bool - { - return true; - } - - /** - * Get the validation rules that apply to the request. - * - * @return array - */ public function rules(): array { $rules = Node::getRulesForUpdate($this->parameter('node', Node::class)); diff --git a/app/Models/ISO.php b/app/Models/ISO.php index 2f98094518a..0a0ece7a39a 100644 --- a/app/Models/ISO.php +++ b/app/Models/ISO.php @@ -2,6 +2,8 @@ namespace Convoy\Models; +use Illuminate\Support\Str; +use Convoy\Casts\MebibytesToAndFromBytes; use Illuminate\Database\Eloquent\Factories\HasFactory; class ISO extends Model @@ -14,11 +16,11 @@ class ISO extends Model protected $casts = [ 'is_successful' => 'boolean', + 'size' => MebibytesToAndFromBytes::class, 'hidden' => 'boolean', ]; public static $validationRules = [ - 'uuid' => 'required|uuid', 'node_id' => 'required|integer|exists:nodes,id', 'is_successful' => 'sometimes|boolean', 'name' => 'required|string|min:1|max:40', @@ -32,4 +34,13 @@ public function node() { return $this->belongsTo(Node::class); } + + protected static function boot() + { + parent::boot(); + + static::creating(function (ISO $user) { + $user->uuid = Str::uuid()->toString(); + }); + } } diff --git a/app/Repositories/Proxmox/Node/ProxmoxStorageRepository.php b/app/Repositories/Proxmox/Node/ProxmoxStorageRepository.php index c04fe78a247..75dfb7113d3 100644 --- a/app/Repositories/Proxmox/Node/ProxmoxStorageRepository.php +++ b/app/Repositories/Proxmox/Node/ProxmoxStorageRepository.php @@ -2,14 +2,17 @@ namespace Convoy\Repositories\Proxmox\Node; +use Convoy\Models\Node; +use Carbon\CarbonImmutable; +use Illuminate\Support\Arr; +use Webmozart\Assert\Assert; use Convoy\Data\Helpers\ChecksumData; +use Convoy\Data\Node\Storage\IsoData; +use Spatie\LaravelData\DataCollection; use Convoy\Data\Node\Storage\FileMetaData; use Convoy\Enums\Node\Storage\ContentType; -use Convoy\Exceptions\Service\Node\IsoLibrary\InvalidIsoLinkException; -use Convoy\Models\Node; use Convoy\Repositories\Proxmox\ProxmoxRepository; -use Illuminate\Support\Arr; -use Webmozart\Assert\Assert; +use Convoy\Exceptions\Service\Node\IsoLibrary\InvalidIsoLinkException; class ProxmoxStorageRepository extends ProxmoxRepository { @@ -51,13 +54,40 @@ public function deleteFile(ContentType $contentType, string $fileName) 'storage' => $this->node->iso_storage, 'file' => "{$this->node->iso_storage}:$contentType->value/$fileName", ]) - ->delete('/api2/json/nodes/{node}/storage/{storage}/content//{file}') + ->delete('/api2/json/nodes/{node}/storage/{storage}/content/{file}') ->json(); return $this->getData($response); } - public function getFileMetadata(string $link, ?bool $verifyCertificates = true) + public function getIsos() + { + Assert::isInstanceOf($this->node, Node::class); + + $response = $this->getHttpClient() + ->withUrlParameters([ + 'node' => $this->node->cluster, + 'storage' => $this->node->iso_storage, + ]) + ->get('/api2/json/nodes/{node}/storage/{storage}/content?content=iso') + ->json(); + + $response = $this->getData($response); + + $isos = []; + + foreach ($response as $iso) { + $isos[] = IsoData::from([ + 'file_name' => explode('/', $iso['volid'])[1], + 'size' => $iso['size'], + 'created_at' => CarbonImmutable::createFromTimestamp($iso['ctime']), + ]); + } + + return IsoData::collection($isos); + } + + public function getFileMetadata(string $link, ?bool $verifyCertificates = true): FileMetaData { Assert::isInstanceOf($this->node, Node::class); Assert::regex($link, '/^(http|https):\/\//'); diff --git a/app/Services/Nodes/Isos/IsoService.php b/app/Services/Nodes/Isos/IsoService.php index 39d8ba9a055..865aa2dafd1 100644 --- a/app/Services/Nodes/Isos/IsoService.php +++ b/app/Services/Nodes/Isos/IsoService.php @@ -2,14 +2,14 @@ namespace Convoy\Services\Nodes\Isos; +use Convoy\Models\ISO; +use Convoy\Models\Node; use Convoy\Data\Helpers\ChecksumData; +use Convoy\Data\Node\Storage\IsoData; use Convoy\Enums\Node\Storage\ContentType; use Convoy\Jobs\Node\MonitorIsoDownloadJob; -use Convoy\Models\ISO; -use Convoy\Models\Node; -use Convoy\Repositories\Proxmox\Node\ProxmoxStorageRepository; use Illuminate\Database\ConnectionInterface; -use Ramsey\Uuid\Uuid; +use Convoy\Repositories\Proxmox\Node\ProxmoxStorageRepository; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; class IsoService @@ -18,13 +18,12 @@ public function __construct(private ConnectionInterface $connection, private Pro { } - public function create(Node $node, string $name, ?string $fileName, string $link, ?ChecksumData $checksumData = null, ?bool $hidden = false) + public function download(Node $node, string $name, ?string $fileName, string $link, ?ChecksumData $checksumData = null, ?bool $hidden = false) { $queriedFileMetadata = $this->repository->setNode($node)->getFileMetadata($link); return $this->connection->transaction(function () use ($queriedFileMetadata, $node, $hidden, $fileName, $link, $name, $checksumData) { $iso = ISO::create([ - 'uuid' => Uuid::uuid4()->toString(), 'node_id' => $node->id, 'name' => $name, 'file_name' => $fileName ?? $queriedFileMetadata->file_name, @@ -40,6 +39,13 @@ public function create(Node $node, string $name, ?string $fileName, string $link }); } + public function getIso(Node $node, string $fileName): ?IsoData + { + $isos = $this->repository->setNode($node)->getIsos(); + + return $isos->where('file_name', '=', $fileName)->first(); + } + public function delete(Node $node, ISO $iso) { if (is_null($iso->completed_at)) { diff --git a/lang/en_US/strings.php b/lang/en_US/strings.php index b8a6ea1e8e9..0714b38e4db 100644 --- a/lang/en_US/strings.php +++ b/lang/en_US/strings.php @@ -99,4 +99,5 @@ 'link' => 'Link', 'file_name' => 'File Name', 'query' => 'Query', + 'import' => 'Import', ]; diff --git a/resources/scripts/api/admin/nodes/isos/createIso.ts b/resources/scripts/api/admin/nodes/isos/createIso.ts index 4e424d4fb50..70c2093cf69 100644 --- a/resources/scripts/api/admin/nodes/isos/createIso.ts +++ b/resources/scripts/api/admin/nodes/isos/createIso.ts @@ -4,25 +4,30 @@ import http from '@/api/http' export type ChecksumAlgorithm = 'md5' | 'sha1' | 'sha224' | 'sha256' | 'sha384' | 'sha512' interface CreateIsoParameters { + shouldDownload: boolean name: string - fileName?: string + fileName: string hidden: boolean - link: string + link?: string checksumAlgorithm?: ChecksumAlgorithm checksum?: string } -const createIso = async (nodeId: number, {fileName, checksumAlgorithm, checksum, ...data}: CreateIsoParameters): Promise => { +const createIso = async ( + nodeId: number, + { shouldDownload, fileName, checksumAlgorithm, checksum, ...data }: CreateIsoParameters +): Promise => { const { data: { data: responseData }, } = await http.post(`/api/admin/nodes/${nodeId}/isos`, { + should_download: shouldDownload, file_name: fileName, checksum_algorithm: checksumAlgorithm, checksum: checksumAlgorithm ? checksum : undefined, - ...data + ...data, }) return rawDataToISO(responseData) } -export default createIso \ No newline at end of file +export default createIso diff --git a/resources/scripts/components/admin/nodes/isos/CreateIsoModal.tsx b/resources/scripts/components/admin/nodes/isos/CreateIsoModal.tsx index 01e1698128c..d433a2df928 100644 --- a/resources/scripts/components/admin/nodes/isos/CreateIsoModal.tsx +++ b/resources/scripts/components/admin/nodes/isos/CreateIsoModal.tsx @@ -13,10 +13,11 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useCallback, useMemo, useState } from 'react' import TextInputForm from '@/components/elements/forms/TextInputForm' import { KeyedMutator } from 'swr' -import { IsoResponse } from '@/api/admin/nodes/isos/getIsos' +import { ISO, IsoResponse } from '@/api/admin/nodes/isos/getIsos' import CheckboxForm from '@/components/elements/forms/CheckboxForm' import { data } from 'autoprefixer' import SelectForm from '@/components/elements/forms/SelectForm' +import SegmentedControl from '@/components/elements/SegmentedControl' interface Props { open: boolean @@ -31,20 +32,23 @@ const CreateIsoModal = ({ open, onClose, mutate }: Props) => { const { t } = useTranslation('admin.nodes.isos') const schemaWithoutDownloading = z.object({ + shouldDownload: z.literal(false), name: z.string().max(40).nonempty(), fileName: z .string() - .regex(/\.iso$/, t('non_iso_file_name_error') ?? 'The file extension must end in .iso') + .regex(/\.iso$/, t('create_modal.non_iso_file_name_error') ?? 'The file extension must end in .iso') .max(191) .nonempty(), + hidden: z.boolean(), }) const schemaWithoutChecksum = z.object({ + shouldDownload: z.literal(true), name: z.string().max(40).nonempty(), - link: z.string().max(191).nonempty(), + link: z.string().url().max(191).nonempty(), fileName: z .string() - .regex(/\.iso$/, t('non_iso_file_name_error') ?? 'The file extension must end in .iso') + .regex(/\.iso$/, t('create_modal.non_iso_file_name_error') ?? 'The file extension must end in .iso') .max(191) .nonempty(), checksumAlgorithm: z.literal('none'), @@ -53,11 +57,12 @@ const CreateIsoModal = ({ open, onClose, mutate }: Props) => { }) const schemaWithChecksum = z.object({ + shouldDownload: z.literal(true), name: z.string().max(40).nonempty(), - link: z.string().max(191).nonempty(), + link: z.string().url().max(191).nonempty(), fileName: z .string() - .regex(/\.iso$/, t('non_iso_file_name_error') ?? 'The file extension must end in .iso') + .regex(/\.iso$/, t('create_modal.non_iso_file_name_error') ?? 'The file extension must end in .iso') .max(191) .nonempty(), checksumAlgorithm: z.enum(['md5', 'sha1', 'sha224', 'sha256', 'sha384', 'sha512']), @@ -65,11 +70,14 @@ const CreateIsoModal = ({ open, onClose, mutate }: Props) => { hidden: z.boolean(), }) - const schema = z.discriminatedUnion('checksumAlgorithm', [schemaWithoutChecksum, schemaWithChecksum]) + const schemaWithDownloading = z.discriminatedUnion('checksumAlgorithm', [schemaWithoutChecksum, schemaWithChecksum]) + + const schema = z.union([schemaWithoutDownloading, schemaWithoutChecksum, schemaWithChecksum]) const form = useForm({ resolver: zodResolver(schema), defaultValues: { + shouldDownload: true, name: '', link: '', fileName: '', @@ -80,35 +88,64 @@ const CreateIsoModal = ({ open, onClose, mutate }: Props) => { }) const watchChecksumAlgorithm = form.watch('checksumAlgorithm') + const watchShouldDownload: boolean = form.watch('shouldDownload') const submit = async (_data: any) => { - const { checksumAlgorithm, ...data } = _data as z.infer clearFlashes() - try { - const iso = await createIso(nodeId, { - checksumAlgorithm: checksumAlgorithm !== 'none' ? checksumAlgorithm : undefined, - ...data, - }) - - mutate(isoResponse => { - if (!isoResponse) return isoResponse - if (isoResponse.pagination.totalPages > 1) return isoResponse - - return { - ...isoResponse, - items: [...isoResponse.items, iso], - } - }, false) - - handleClose() - } catch (e) { - clearAndAddHttpError(e as Error) + const data = _data as z.infer + + if (data.shouldDownload) { + const { checksumAlgorithm, ...params } = data + + try { + const iso = await createIso(nodeId, { + checksumAlgorithm: checksumAlgorithm !== 'none' ? checksumAlgorithm : undefined, + ...params, + }) + + appendToSWR(iso) + } catch (e) { + clearAndAddHttpError(e as Error) + + return + } + } + + if (!data.shouldDownload) { + try { + const iso = await createIso(nodeId, data) + + appendToSWR(iso) + } catch (e) { + clearAndAddHttpError(e as Error) + + return + } } + + handleClose() + } + + const appendToSWR = (iso: ISO) => { + mutate(isoResponse => { + if (!isoResponse) return isoResponse + if ( + isoResponse.pagination.totalPages > 1 && + isoResponse.pagination.currentPage !== isoResponse.pagination.totalPages + ) + return isoResponse + + return { + ...isoResponse, + items: [...isoResponse.items, iso], + } + }, false) } const handleClose = () => { form.reset() + clearFlashes() onClose() } @@ -132,23 +169,42 @@ const CreateIsoModal = ({ open, onClose, mutate }: Props) => {
- - -
- - -
- - - form.setValue('shouldDownload', val === 'new')} + data={[ + { value: 'new', label: tStrings('new') }, + { value: 'import', label: tStrings('import') }, + ]} /> + + {watchShouldDownload && ( +
+ + +
+ )} + + {watchShouldDownload && ( + <> + + + + )}
diff --git a/resources/scripts/components/admin/nodes/isos/QueryFileButton.tsx b/resources/scripts/components/admin/nodes/isos/QueryFileButton.tsx index aa7c60d1639..1708e129498 100644 --- a/resources/scripts/components/admin/nodes/isos/QueryFileButton.tsx +++ b/resources/scripts/components/admin/nodes/isos/QueryFileButton.tsx @@ -20,7 +20,9 @@ const QueryFileButton = () => { setValue('fileName', metadata.fileName) } catch { - setError('link', t('create_modal.fail_to_query_remote_file_error')) + setError('link', { + message: t('create_modal.fail_to_query_remote_file_error') ?? 'Failed to query remote file.', + }) } setLoading(false)