diff --git a/src/app/[locale]/(dashboard)/dashboard/templates/[storageId]/[storagePrefix]/page.tsx b/src/app/[locale]/(dashboard)/dashboard/templates/[storageId]/[storagePrefix]/page.tsx index 25d6ec5..f9023a8 100644 --- a/src/app/[locale]/(dashboard)/dashboard/templates/[storageId]/[storagePrefix]/page.tsx +++ b/src/app/[locale]/(dashboard)/dashboard/templates/[storageId]/[storagePrefix]/page.tsx @@ -14,6 +14,7 @@ import NoAccess from '@/components/static/noAccess' import { serverStorageApi } from '@/lib/server-api' import NoRecords from '@/components/static/noRecords' import Link from 'next/link' +import CreateTemplateDialog from '@/components/templates/createTemplateDialog' export default async function TemplatesPage(props) { const params = await props.params @@ -72,6 +73,9 @@ export default async function TemplatesPage(props) { return ( +
+ +
A list of your templates. diff --git a/src/app/[locale]/(dashboard)/dashboard/templates/[storageId]/page.tsx b/src/app/[locale]/(dashboard)/dashboard/templates/[storageId]/page.tsx index 576c0f7..7993c06 100644 --- a/src/app/[locale]/(dashboard)/dashboard/templates/[storageId]/page.tsx +++ b/src/app/[locale]/(dashboard)/dashboard/templates/[storageId]/page.tsx @@ -14,6 +14,7 @@ import NoAccess from '@/components/static/noAccess' import NoRecords from '@/components/static/noRecords' import Link from 'next/link' import { serverStorageApi } from '@/lib/server-api' +import CreateTemplateDialog from '@/components/templates/createTemplateDialog' export default async function ServicesPage(props) { const params = await props.params @@ -65,6 +66,9 @@ export default async function ServicesPage(props) { return ( +
+ +
A list of your templates. diff --git a/src/app/[locale]/(dashboard)/dashboard/templates/page.tsx b/src/app/[locale]/(dashboard)/dashboard/templates/page.tsx index e12e51b..fdece95 100644 --- a/src/app/[locale]/(dashboard)/dashboard/templates/page.tsx +++ b/src/app/[locale]/(dashboard)/dashboard/templates/page.tsx @@ -14,6 +14,7 @@ import NoAccess from '@/components/static/noAccess' import NoRecords from '@/components/static/noRecords' import Link from 'next/link' import { serverStorageApi } from '@/lib/server-api' +import CreateTemplateDialog from '@/components/templates/createTemplateDialog' export default async function ServicesPage() { let storages: Storages = { storages: [] } @@ -48,6 +49,9 @@ export default async function ServicesPage() { return ( +
+ +
A list of your storages. diff --git a/src/app/api/templates/[storageId]/[prefixId]/[name]/create/route.ts b/src/app/api/templates/[storageId]/[prefixId]/[name]/create/route.ts new file mode 100644 index 0000000..658196a --- /dev/null +++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/create/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from 'next/server' +import { + checkPermissions, + makeApiRequest, + createApiRoute +} from '@/lib/api-helpers' +import { safeTemplateTriple } from '@/lib/pathSafe' + +export const POST = createApiRoute(async (_req, { params }) => { + const p = await params + let storageId: string, prefixId: string, name: string + try { + ;({ storageId, prefixId, name } = safeTemplateTriple(p.storageId, p.prefixId, p.name)) + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 400 }) + } + + const requiredPermissions = [ + 'cloudnet_rest:template_write', + 'cloudnet_rest:template_create', + 'global:admin' + ] + + const permissionCheck = await checkPermissions(requiredPermissions) + if (permissionCheck) { + return NextResponse.json(permissionCheck, { + status: permissionCheck.status + }) + } + + const response = await makeApiRequest( + `/template/${storageId}/${prefixId}/${name}/create`, + 'POST' + ) + return NextResponse.json(response) +}) diff --git a/src/app/api/templates/[storageId]/[prefixId]/[name]/deploy/route.ts b/src/app/api/templates/[storageId]/[prefixId]/[name]/deploy/route.ts new file mode 100644 index 0000000..e8bbf5b --- /dev/null +++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/deploy/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from 'next/server' +import { checkPermissions, createApiRoute } from '@/lib/api-helpers' +import { getCookies } from '@/lib/server-calls' +import { safeTemplateTriple } from '@/lib/pathSafe' + +export const POST = createApiRoute(async (req, { params }) => { + const p = await params + let storageId: string, prefixId: string, name: string + try { + ;({ storageId, prefixId, name } = safeTemplateTriple(p.storageId, p.prefixId, p.name)) + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 400 }) + } + + const requiredPermissions = [ + 'cloudnet_rest:template_write', + 'cloudnet_rest:template_deploy', + 'global:admin' + ] + + const permissionCheck = await checkPermissions(requiredPermissions) + if (permissionCheck) { + return NextResponse.json(permissionCheck, { + status: permissionCheck.status + }) + } + + const cookies = await getCookies() + const accessToken = cookies['at'] + const address = cookies['add'] + + if (!accessToken || !address) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const bodyBuffer = await req.arrayBuffer() + + const upstream = await fetch( + `${decodeURIComponent(address)}/template/${storageId}/${prefixId}/${name}/deploy`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/zip', + Authorization: `Bearer ${accessToken}` + }, + body: bodyBuffer + } + ) + + const text = await upstream.text() + return new NextResponse(text || null, { + status: upstream.status, + headers: { 'Content-Type': upstream.headers.get('content-type') || 'application/json' } + }) +}) + +export const config = { + api: { bodyParser: false } +} diff --git a/src/app/api/templates/[storageId]/[prefixId]/[name]/directory/create/route.ts b/src/app/api/templates/[storageId]/[prefixId]/[name]/directory/create/route.ts new file mode 100644 index 0000000..a4ffec4 --- /dev/null +++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/directory/create/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server' +import { + checkPermissions, + makeApiRequest, + createApiRoute +} from '@/lib/api-helpers' +import { safeTemplatePath, safeTemplateTriple } from '@/lib/pathSafe' + +export const POST = createApiRoute(async (req, { params }) => { + const p = await params + let storageId: string, prefixId: string, name: string, path: string + try { + ;({ storageId, prefixId, name } = safeTemplateTriple(p.storageId, p.prefixId, p.name)) + const { searchParams } = new URL(req.url) + path = safeTemplatePath(searchParams.get('path')) + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 400 }) + } + + const requiredPermissions = [ + 'cloudnet_rest:template_write', + 'cloudnet_rest:template_create', + 'global:admin' + ] + + const permissionCheck = await checkPermissions(requiredPermissions) + if (permissionCheck) { + return NextResponse.json(permissionCheck, { + status: permissionCheck.status + }) + } + + const response = await makeApiRequest( + `/template/${storageId}/${prefixId}/${name}/directory/create?path=${encodeURIComponent(path)}`, + 'POST' + ) + return NextResponse.json(response) +}) diff --git a/src/app/api/templates/[storageId]/[prefixId]/[name]/download/route.ts b/src/app/api/templates/[storageId]/[prefixId]/[name]/download/route.ts new file mode 100644 index 0000000..c9d99b0 --- /dev/null +++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/download/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from 'next/server' +import { checkPermissions, createApiRoute } from '@/lib/api-helpers' +import { getCookies } from '@/lib/server-calls' +import { safeTemplateTriple, contentDispositionAttachment } from '@/lib/pathSafe' + +export const GET = createApiRoute(async (_req, { params }) => { + const p = await params + let storageId: string, prefixId: string, name: string + try { + ;({ storageId, prefixId, name } = safeTemplateTriple(p.storageId, p.prefixId, p.name)) + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 400 }) + } + + const requiredPermissions = [ + 'cloudnet_rest:template_read', + 'cloudnet_rest:template_download', + 'global:admin' + ] + + const permissionCheck = await checkPermissions(requiredPermissions) + if (permissionCheck) { + return NextResponse.json(permissionCheck, { + status: permissionCheck.status + }) + } + + const cookies = await getCookies() + const accessToken = cookies['at'] + const address = cookies['add'] + + if (!accessToken || !address) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const upstream = await fetch( + `${decodeURIComponent(address)}/template/${storageId}/${prefixId}/${name}/download`, + { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}` } + } + ) + + if (!upstream.ok) { + const text = await upstream.text() + return new NextResponse(text || null, { status: upstream.status }) + } + + return new NextResponse(upstream.body, { + status: 200, + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': contentDispositionAttachment(`${prefixId}-${name}.zip`) + } + }) +}) diff --git a/src/app/api/templates/[storageId]/[prefixId]/[name]/file/download/route.ts b/src/app/api/templates/[storageId]/[prefixId]/[name]/file/download/route.ts new file mode 100644 index 0000000..0543205 --- /dev/null +++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/file/download/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from 'next/server' +import { checkPermissions, createApiRoute } from '@/lib/api-helpers' +import { getCookies } from '@/lib/server-calls' +import { safeTemplatePath, safeTemplateTriple, contentDispositionAttachment } from '@/lib/pathSafe' + +export const GET = createApiRoute(async (req, { params }) => { + const p = await params + let storageId: string, prefixId: string, name: string, path: string + try { + ;({ storageId, prefixId, name } = safeTemplateTriple(p.storageId, p.prefixId, p.name)) + const { searchParams } = new URL(req.url) + path = safeTemplatePath(searchParams.get('path')) + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 400 }) + } + if (!path) { + return NextResponse.json({ error: 'path required' }, { status: 400 }) + } + + const requiredPermissions = [ + 'cloudnet_rest:template_read', + 'cloudnet_rest:template_file_get', + 'global:admin' + ] + + const permissionCheck = await checkPermissions(requiredPermissions) + if (permissionCheck) { + return NextResponse.json(permissionCheck, { + status: permissionCheck.status + }) + } + + const cookies = await getCookies() + const accessToken = cookies['at'] + const address = cookies['add'] + + if (!accessToken || !address) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const upstream = await fetch( + `${decodeURIComponent(address)}/template/${storageId}/${prefixId}/${name}/file/download?path=${encodeURIComponent(path)}`, + { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}` } + } + ) + + if (!upstream.ok) { + const text = await upstream.text() + return new NextResponse(text || null, { status: upstream.status }) + } + + const filename = path.split('/').pop() || 'file' + return new NextResponse(upstream.body, { + status: 200, + headers: { + 'Content-Type': upstream.headers.get('content-type') || 'application/octet-stream', + 'Content-Disposition': contentDispositionAttachment(filename) + } + }) +}) diff --git a/src/app/api/templates/[storageId]/[prefixId]/[name]/file/upload/route.ts b/src/app/api/templates/[storageId]/[prefixId]/[name]/file/upload/route.ts new file mode 100644 index 0000000..29ccd69 --- /dev/null +++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/file/upload/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from 'next/server' +import { checkPermissions, createApiRoute } from '@/lib/api-helpers' +import { getCookies } from '@/lib/server-calls' +import { safeTemplatePath, safeTemplateTriple } from '@/lib/pathSafe' + +export const POST = createApiRoute(async (req, { params }) => { + const p = await params + let storageId: string, prefixId: string, name: string, path: string + try { + ;({ storageId, prefixId, name } = safeTemplateTriple(p.storageId, p.prefixId, p.name)) + const { searchParams } = new URL(req.url) + path = safeTemplatePath(searchParams.get('path')) + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 400 }) + } + if (!path) { + return NextResponse.json({ error: 'path required' }, { status: 400 }) + } + + const requiredPermissions = [ + 'cloudnet_rest:template_write', + 'cloudnet_rest:template_file_append', + 'global:admin' + ] + + const permissionCheck = await checkPermissions(requiredPermissions) + if (permissionCheck) { + return NextResponse.json(permissionCheck, { + status: permissionCheck.status + }) + } + + const cookies = await getCookies() + const accessToken = cookies['at'] + const address = cookies['add'] + + if (!accessToken || !address) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const bodyBuffer = await req.arrayBuffer() + const contentType = req.headers.get('content-type') || 'application/octet-stream' + + const upstream = await fetch( + `${decodeURIComponent(address)}/template/${storageId}/${prefixId}/${name}/file/create?path=${encodeURIComponent(path)}`, + { + method: 'POST', + headers: { + 'Content-Type': contentType, + Authorization: `Bearer ${accessToken}` + }, + body: bodyBuffer + } + ) + + const responseText = await upstream.text() + return new NextResponse(responseText || null, { + status: upstream.status, + headers: { 'Content-Type': upstream.headers.get('content-type') || 'application/json' } + }) +}) + +export const config = { + api: { bodyParser: false } +} diff --git a/src/app/api/templates/[storageId]/[prefixId]/[name]/rename/route.ts b/src/app/api/templates/[storageId]/[prefixId]/[name]/rename/route.ts new file mode 100644 index 0000000..9f344a0 --- /dev/null +++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/rename/route.ts @@ -0,0 +1,127 @@ +import { NextResponse } from 'next/server' +import { checkPermissions, createApiRoute } from '@/lib/api-helpers' +import { getCookies } from '@/lib/server-calls' +import { safeTemplatePath, safeTemplateTriple } from '@/lib/pathSafe' + +// Emulates rename by download → upload with new path → delete old. +// Body: { from: string, to: string, isDirectory?: boolean } +export const POST = createApiRoute(async (req, { params }) => { + const p = await params + let storageId: string, prefixId: string, name: string + try { + ;({ storageId, prefixId, name } = safeTemplateTriple(p.storageId, p.prefixId, p.name)) + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 400 }) + } + + const requiredPermissions = [ + 'cloudnet_rest:template_write', + 'cloudnet_rest:template_file_append', + 'global:admin' + ] + + const permissionCheck = await checkPermissions(requiredPermissions) + if (permissionCheck) { + return NextResponse.json(permissionCheck, { + status: permissionCheck.status + }) + } + + const cookies = await getCookies() + const accessToken = cookies['at'] + const address = cookies['add'] + + if (!accessToken || !address) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await req.json() + const isDirectory = !!body.isDirectory + let from: string, to: string + try { + from = safeTemplatePath(body.from) + to = safeTemplatePath(body.to) + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 400 }) + } + + if (!from || !to || from === to) { + return NextResponse.json({ error: 'Invalid from/to' }, { status: 400 }) + } + + const base = `${decodeURIComponent(address)}/template/${storageId}/${prefixId}/${name}` + const authHeader = { Authorization: `Bearer ${accessToken}` } + + const listFiles = async (path: string): Promise> => { + const res = await fetch( + `${base}/directory/list?deep=true&directory=${encodeURIComponent(path)}`, + { headers: authHeader } + ) + if (!res.ok) return [] + const data = await res.json() + return Array.isArray(data) ? data : [] + } + + const copyFile = async (srcPath: string, dstPath: string) => { + const dl = await fetch( + `${base}/file/download?path=${encodeURIComponent(srcPath)}`, + { headers: authHeader } + ) + if (!dl.ok) throw new Error(`download failed: ${dl.status}`) + const buf = await dl.arrayBuffer() + const up = await fetch( + `${base}/file/create?path=${encodeURIComponent(dstPath)}`, + { + method: 'POST', + headers: { + ...authHeader, + 'Content-Type': 'application/octet-stream' + }, + body: buf + } + ) + if (!up.ok) throw new Error(`upload failed: ${up.status}`) + } + + const mkdir = async (path: string) => { + await fetch( + `${base}/directory/create?path=${encodeURIComponent(path)}`, + { method: 'POST', headers: authHeader } + ) + } + + const deleteFile = async (path: string) => { + await fetch( + `${base}/file?path=${encodeURIComponent(path)}`, + { method: 'DELETE', headers: authHeader } + ) + } + + try { + if (isDirectory) { + const items = await listFiles(from) + await mkdir(to) + for (const item of items) { + const relative = item.path.startsWith(from + '/') + ? item.path.slice(from.length + 1) + : item.path + const dstPath = `${to}/${relative}` + if (item.directory) { + await mkdir(dstPath) + } else { + await copyFile(item.path, dstPath) + } + } + for (const item of items.slice().reverse()) { + await deleteFile(item.path) + } + await deleteFile(from) + } else { + await copyFile(from, to) + await deleteFile(from) + } + return NextResponse.json({ status: 204 }, { status: 204 }) + } catch (e: any) { + return NextResponse.json({ error: e.message || 'rename failed' }, { status: 500 }) + } +}) diff --git a/src/components/header/data.tsx b/src/components/header/data.tsx index 009b1c9..114f460 100644 --- a/src/components/header/data.tsx +++ b/src/components/header/data.tsx @@ -89,8 +89,7 @@ export const Nav2 = () => { 'cloudnet_rest:service_read', 'cloudnet_rest:service_list' ] - } - /* + }, { title: navigationT('templates'), label: '', @@ -100,10 +99,9 @@ export const Nav2 = () => { permission: [ 'global:admin', 'cloudnet_rest:template_storage_read', - 'cloudnet_rest:template_storage_list', - ], - }, - */ + 'cloudnet_rest:template_storage_list' + ] + } ] } diff --git a/src/components/templates/createTemplateDialog.tsx b/src/components/templates/createTemplateDialog.tsx new file mode 100644 index 0000000..f228a94 --- /dev/null +++ b/src/components/templates/createTemplateDialog.tsx @@ -0,0 +1,113 @@ +'use client' +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@/components/ui/dialog' +import { toast } from 'sonner' +import { templateStorageApi } from '@/lib/client-api' +import { PlusIcon } from 'lucide-react' + +export default function CreateTemplateDialog({ + storage, + prefix +}: { + storage?: string + prefix?: string +}) { + const router = useRouter() + const [open, setOpen] = useState(false) + const [busy, setBusy] = useState(false) + const [s, setS] = useState(storage || 'local') + const [p, setP] = useState(prefix || '') + const [n, setN] = useState('default') + + const submit = async () => { + if (!s || !p || !n) { + toast.error('Storage, prefix and name required') + return + } + setBusy(true) + try { + const res = await templateStorageApi.createTemplate(s, p, n) + if (res.status && res.status >= 400) { + toast.error(`Failed (${res.status})`) + } else { + toast.success(`Template ${p}/${n} created`) + setOpen(false) + router.refresh() + router.push(`/dashboard/templates/${s}/${p}/${n}`) + } + } catch (e: any) { + toast.error(e.message || 'Failed') + } finally { + setBusy(false) + } + } + + return ( + + + + + + + Create template + + Creates an empty template at {s}/{p}/{n}. + + +
+
+ + setS(e.target.value)} + /> +
+
+ + setP(e.target.value)} + /> +
+
+ + setN(e.target.value)} + /> +
+
+ + + + +
+
+ ) +} diff --git a/src/components/templates/fileBrowser.tsx b/src/components/templates/fileBrowser.tsx index f435c0a..fe54166 100644 --- a/src/components/templates/fileBrowser.tsx +++ b/src/components/templates/fileBrowser.tsx @@ -8,12 +8,54 @@ import { TableRow } from '@/components/ui/table' import { Button } from '@/components/ui/button' -import { useEffect, useState } from 'react' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger +} from '@/components/ui/alert-dialog' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@/components/ui/dialog' +import { useCallback, useEffect, useRef, useState } from 'react' import { formatBytes } from '@/components/formatBytes' import { formatDate } from '@/components/formatDate' import { useRouter, usePathname } from 'next/navigation' import Link from 'next/link' import { templateStorageApi } from '@/lib/client-api' +import { toast } from 'sonner' +import { + FileIcon, + FolderIcon, + Trash2Icon, + DownloadIcon, + UploadIcon, + FolderPlusIcon, + PencilIcon, + ArchiveIcon, + FilePlusIcon +} from 'lucide-react' + +type FileType = { + name: string + path: string + directory: boolean + size: number + lastModified: number +} + export default function FileBrowser({ params }: { @@ -21,196 +63,535 @@ export default function FileBrowser({ storageId: string storagePrefix: string templateId: string - fileId: string[] + fileId?: string[] } }) { const [files, setFiles] = useState([]) + const [dragActive, setDragActive] = useState(false) + const [uploading, setUploading] = useState(false) + const [progress, setProgress] = useState<{ done: number; total: number } | null>(null) const router = useRouter() const pathname = usePathname() + const inputRef = useRef(null) + const zipInputRef = useRef(null) + + const fileId = params.fileId || [] + const currentDir = fileId.join('/') - const fetchFiles = async () => { - return await templateStorageApi.getTemplateFiles( + const load = useCallback(async () => { + const res = await templateStorageApi.getTemplateFiles( params.storageId, params.storagePrefix, params.templateId, - params.fileId + fileId ) - } - - useEffect(() => { - fetchFiles().then((fetchedFiles) => { - console.log(fetchedFiles) - // Ensure we have an array to sort - const filesArray = Array.isArray(fetchedFiles?.data) - ? fetchedFiles.data + const raw: any = res?.data + const filesArray: FileType[] = Array.isArray(raw) + ? raw + : Array.isArray(raw?.files) + ? raw.files : [] - - const sortedFiles = filesArray.sort((a, b) => { - // Put directories at the top - if (a?.directory !== b?.directory) { - return a?.directory ? -1 : 1 - } - // Sort alphabetically - return a?.name.localeCompare(b.name) - }) - - // Filter out files that are in a subdirectory deeper than the first level, but not directories themselves - const filteredFiles = sortedFiles.filter((file) => { - const pathParts = file.path.split('/') - return !(pathParts?.length > 2 && !file?.directory) - }) - - setFiles(filteredFiles) + const sorted = filesArray.sort((a, b) => { + if (a.directory !== b.directory) return a.directory ? -1 : 1 + return a.name.localeCompare(b.name) + }) + const filtered = sorted.filter((f) => { + const depth = f.path.split('/').length + const baseDepth = currentDir ? currentDir.split('/').length : 0 + return depth === baseDepth + 1 }) + setFiles(filtered) // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [params.storageId, params.storagePrefix, params.templateId, currentDir]) + + useEffect(() => { + load() + }, [load]) + + const doUpload = async (fileList: FileList | File[]) => { + const list = Array.from(fileList) + if (list.length === 0) return + setUploading(true) + setProgress({ done: 0, total: list.length }) + let success = 0 + for (let i = 0; i < list.length; i++) { + const f = list[i] + const relPath = (f as any).webkitRelativePath || f.name + const target = currentDir ? `${currentDir}/${relPath}` : relPath + try { + const res = await templateStorageApi.uploadFile( + params.storageId, + params.storagePrefix, + params.templateId, + target, + f + ) + if (res.status >= 400) throw new Error(`HTTP ${res.status}`) + success++ + } catch (e: any) { + toast.error(`Upload failed for ${f.name}: ${e.message}`) + } + setProgress({ done: i + 1, total: list.length }) + } + setUploading(false) + setProgress(null) + if (success > 0) toast.success(`Uploaded ${success}/${list.length} file(s)`) + await load() + router.refresh() + } + + const onDrop = async (e: React.DragEvent) => { + e.preventDefault() + setDragActive(false) + if (e.dataTransfer?.files) await doUpload(e.dataTransfer.files) + } + + const onDragOver = (e: React.DragEvent) => { + e.preventDefault() + setDragActive(true) + } + + const onDragLeave = (e: React.DragEvent) => { + e.preventDefault() + setDragActive(false) + } - const handleDelete = async (file: string) => { - const newFileId = [...params.fileId, file] + const handleDelete = async (name: string) => { + const filePath = [...fileId, name] await templateStorageApi.deleteFile( params.storageId, params.storagePrefix, params.templateId, - newFileId + filePath ) + toast.success(`Deleted ${name}`) + await load() router.refresh() } + const handleRename = async (item: FileType, newName: string) => { + if (!newName || newName === item.name) return + const from = item.path + const to = currentDir ? `${currentDir}/${newName}` : newName + const res = await templateStorageApi.rename( + params.storageId, + params.storagePrefix, + params.templateId, + from, + to, + item.directory + ) + if (res.status === 204) { + toast.success(`Renamed to ${newName}`) + await load() + router.refresh() + } else { + toast.error(`Rename failed`) + } + } + + const handleMkdir = async (name: string) => { + if (!name) return + const path = currentDir ? `${currentDir}/${name}` : name + const res = await templateStorageApi.createDirectory( + params.storageId, + params.storagePrefix, + params.templateId, + path + ) + if (res.status && res.status >= 400) { + toast.error(`mkdir failed (${res.status})`) + } else { + toast.success(`Created folder ${name}`) + await load() + router.refresh() + } + } + + const handleDeleteTemplate = async () => { + const res = await templateStorageApi.deleteTemplate( + params.storageId, + params.storagePrefix, + params.templateId + ) + if (res.status && res.status >= 400) { + toast.error(`Delete failed (${res.status})`) + } else { + toast.success(`Template deleted`) + router.push(`/dashboard/templates/${params.storageId}/${params.storagePrefix}`) + } + } + + const handleDeployZip = async (file: File) => { + setUploading(true) + try { + const res = await templateStorageApi.deployZip( + params.storageId, + params.storagePrefix, + params.templateId, + file + ) + if (res.status >= 400) throw new Error(`HTTP ${res.status}`) + toast.success(`Deployed zip`) + await load() + router.refresh() + } catch (e: any) { + toast.error(`Deploy failed: ${e.message}`) + } finally { + setUploading(false) + } + } + + const downloadFileUrl = (name: string) => { + const p = currentDir ? `${currentDir}/${name}` : name + return templateStorageApi.downloadFileUrl( + params.storageId, + params.storagePrefix, + params.templateId, + p + ) + } + + const downloadTemplateUrl = templateStorageApi.downloadTemplateUrl( + params.storageId, + params.storagePrefix, + params.templateId + ) + return ( -
-
-
-
-
- - - Name - Size - Modified - Actions - - - - +
+
+
+ Path: /{currentDir || ''} +
+
+ e.target.files && doUpload(e.target.files)} + /> + e.target.files?.[0] && handleDeployZip(e.target.files[0])} + /> + + + + + + + + +
+
+ + {progress && ( +
+ Uploading {progress.done}/{progress.total}… +
+ )} + +
+
+ + + Name + Size + Modified + Actions + + + + + +
+ + + .. + +
+
+ + + +
+ {files.map((file) => { + const newPath = `${pathname}/${file.name}` + return ( +
- - - .. - + {file.directory ? ( + + ) : ( + + )} + {file.directory ? ( + + {file.name} + + ) : ( + + {file.name} + + )}
- - - -
- {files.map((file) => { - // Append the file name to the current path - const newPath = `${pathname}/${file.name}` - - return ( - - -
- {file.directory ? ( - - ) : ( - - )} - {/* @ts-ignore */} - - {file.name} - -
-
- - {file.directory ? '-' : `${formatBytes(file.size)}`} - - - {formatDate(new Date(file.lastModified))} - - -
- -
-
-
- ) - })} -
-
+ + )} + + handleDelete(file.name)} + /> + + + + ) + })} + + + {dragActive && ( +
+ Drop files to upload into /{currentDir || ''}
- - + )} + ) } -function FileIcon(props) { +function NewFolderButton({ onCreate }: { onCreate: (name: string) => void }) { + const [open, setOpen] = useState(false) + const [name, setName] = useState('') + return ( + + + + + + + Create folder + +
+ + setName(e.target.value)} placeholder="plugins" /> +
+ + + + +
+
+ ) +} + +function NewFileButton({ + storageId, + prefixId, + templateId, + currentDir, + onCreated +}: { + storageId: string + prefixId: string + templateId: string + currentDir: string + onCreated: () => void +}) { + const [open, setOpen] = useState(false) + const [name, setName] = useState('') + const [busy, setBusy] = useState(false) + const router = useRouter() + return ( + + + + + + + Create empty file + +
+ + setName(e.target.value)} placeholder="config.yml" /> +
+ + + + +
+
+ ) +} + +function RenameButton({ + item, + onRename +}: { + item: FileType + onRename: (item: FileType, newName: string) => void +}) { + const [open, setOpen] = useState(false) + const [name, setName] = useState(item.name) return ( - - - - + { setOpen(o); if (o) setName(item.name) }}> + + + + + + Rename {item.directory ? 'folder' : 'file'} + +
+ + setName(e.target.value)} /> + {item.directory && ( +

+ Note: renaming a folder copies every file inside then deletes the old — may be slow for large folders. +

+ )} +
+ + + + +
+
) } -function FolderIcon(props) { +function DeleteRowButton({ + name, + isDirectory, + onConfirm +}: { + name: string + isDirectory: boolean + onConfirm: () => void +}) { return ( - - - + + + + + + + Delete {isDirectory ? 'folder' : 'file'} {name}? + + This cannot be undone. + + + + Cancel + Delete + + + ) } -function Trash2Icon(props) { +function DeleteTemplateButton({ onConfirm }: { onConfirm: () => void }) { return ( - - - - - - - + + + + + + + Delete this template? + + This deletes the whole template folder and all files. Cannot be undone. + + + + Cancel + Delete + + + ) } diff --git a/src/lib/client-api.ts b/src/lib/client-api.ts index 8179117..072b7c0 100644 --- a/src/lib/client-api.ts +++ b/src/lib/client-api.ts @@ -284,5 +284,77 @@ export const templateStorageApi = { filePath, content } - ) + ), + createTemplate: (storageId: string, prefixId: string, templateId: string) => + apiPost(`/api/templates/${storageId}/${prefixId}/${templateId}/create`, {}), + createDirectory: ( + storageId: string, + prefixId: string, + templateId: string, + path: string + ) => + apiPost( + `/api/templates/${storageId}/${prefixId}/${templateId}/directory/create`, + {}, + { path } + ), + uploadFile: async ( + storageId: string, + prefixId: string, + templateId: string, + path: string, + file: File | Blob + ) => { + const baseUrl = process.env.NEXT_PUBLIC_DOMAIN + const url = `${baseUrl}/api/templates/${storageId}/${prefixId}/${templateId}/file/upload?path=${encodeURIComponent(path)}` + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': (file as any).type || 'application/octet-stream' + }, + body: file + }) + return { status: res.status } + }, + deployZip: async ( + storageId: string, + prefixId: string, + templateId: string, + zip: File | Blob + ) => { + const baseUrl = process.env.NEXT_PUBLIC_DOMAIN + const url = `${baseUrl}/api/templates/${storageId}/${prefixId}/${templateId}/deploy` + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/zip' }, + body: zip + }) + return { status: res.status } + }, + downloadFileUrl: ( + storageId: string, + prefixId: string, + templateId: string, + path: string + ) => + `${process.env.NEXT_PUBLIC_DOMAIN}/api/templates/${storageId}/${prefixId}/${templateId}/file/download?path=${encodeURIComponent(path)}`, + downloadTemplateUrl: ( + storageId: string, + prefixId: string, + templateId: string + ) => + `${process.env.NEXT_PUBLIC_DOMAIN}/api/templates/${storageId}/${prefixId}/${templateId}/download`, + rename: ( + storageId: string, + prefixId: string, + templateId: string, + from: string, + to: string, + isDirectory: boolean + ) => + apiPost(`/api/templates/${storageId}/${prefixId}/${templateId}/rename`, { + from, + to, + isDirectory + }) } diff --git a/src/lib/pathSafe.ts b/src/lib/pathSafe.ts new file mode 100644 index 0000000..14d9905 --- /dev/null +++ b/src/lib/pathSafe.ts @@ -0,0 +1,85 @@ +// Defensive sanitizer for template file/dir paths. +// +// CloudNet REST 4.0.0-RC17 does NOT validate that the `path` query parameter +// on /template/{s}/{p}/{n}/file/create stays within the template directory — +// `path=../../../etc/passwd` writes the file to CloudNet's local/ tree. +// We reject anything containing path-escape sequences at the panel layer so +// this never reaches CloudNet. +// +// Rules: +// - reject absolute paths (leading /, C:\ …) +// - reject `..` segments and every URL-encoded variant of them +// - reject backslashes (Windows path separator) +// - reject NUL bytes and CR/LF (header/log injection) +export function safeTemplatePath(raw: string | null | undefined): string { + const s = (raw ?? '').trim() + if (s === '') return '' + + // NUL / CR / LF + if (/[\x00\r\n]/.test(s)) throw new Error('unsafe path: control chars') + + // Absolute paths + if (s.startsWith('/') || s.startsWith('\\')) throw new Error('unsafe path: absolute') + if (/^[A-Za-z]:/.test(s)) throw new Error('unsafe path: windows drive') + + // Backslash separator + if (s.includes('\\')) throw new Error('unsafe path: backslash') + + // Normalize any percent-encoding once so `..%2f..` and `%2e%2e/` also trip. + let decoded = s + try { + decoded = decodeURIComponent(s) + } catch { + // Invalid % sequence: reject rather than pass through raw. + throw new Error('unsafe path: bad percent-encoding') + } + if (decoded.includes('\\') || /[\x00\r\n]/.test(decoded)) { + throw new Error('unsafe path: control chars after decode') + } + + // Segment-by-segment `..` check on both raw and decoded forms. + for (const src of [s, decoded]) { + for (const seg of src.split('/')) { + if (seg === '..' || seg === '.') throw new Error('unsafe path: traversal') + } + } + + return s +} + +// Validate the three route params in one go, returning a JSON error response +// if any is invalid. Kept close to the routes so the guard reads locally. +export function safeTemplateTriple( + storageId: string, + prefixId: string, + name: string +): { storageId: string; prefixId: string; name: string } { + return { + storageId: safeSegment(storageId), + prefixId: safeSegment(prefixId), + name: safeSegment(name) + } +} + +// URL path segment for storage / prefix / template-name — must not contain +// separators, dot-dot, or control chars, so a request to +// `/api/templates/local/y%2F..%2Fetc/x/create` cannot escape into an +// unintended CloudNet URL. +export function safeSegment(raw: string | null | undefined): string { + const s = (raw ?? '').trim() + if (s === '') throw new Error('unsafe segment: empty') + if (s === '.' || s === '..') throw new Error('unsafe segment: dot') + if (/[\/\\\x00\r\n]/.test(s)) throw new Error('unsafe segment: separator') + if (s.length > 128) throw new Error('unsafe segment: too long') + return s +} + +// Safe Content-Disposition filename per RFC 6266 / 5987 — never let quotes +// or control chars in a user-supplied filename break out of the header. +export function contentDispositionAttachment(name: string): string { + const fallback = name + .replace(/[\x00-\x1f\x7f"\\]/g, '_') + .slice(0, 255) || 'download' + const encoded = encodeURIComponent(name).replace(/['()]/g, escape).replace(/\*/g, '%2A') + return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}` +}