From f76048e22a197d3469c21764dd1b7673ca812478 Mon Sep 17 00:00:00 2001 From: Tomxba Date: Sat, 29 Aug 2026 08:33:59 +0200 Subject: [PATCH 1/6] feat(templates): full template management from the panel Adds create/upload/download/rename/mkdir/deploy/delete for templates. Panel-side, template management was previously limited to editing existing text files. This wires the missing CloudNet REST endpoints so users can do the whole lifecycle from the UI: - Create a template (dialog with storage/prefix/name) - Drag & drop file upload into the current directory - New folder / new empty file dialogs - Deploy zip (upload a template as a zip) - Download single file / download whole template as zip - Rename file (native) or folder (emulated: recursive copy+delete because CloudNet REST has no native rename) - Delete the whole template Also uncomments the Templates entry in the sidebar so the feature is discoverable. Backend additions (Next.js API proxying CloudNet REST /template/*): - POST /api/templates/[s]/[p]/[n]/create - POST /api/templates/[s]/[p]/[n]/deploy (application/zip passthrough) - POST /api/templates/[s]/[p]/[n]/directory/create?path= - GET /api/templates/[s]/[p]/[n]/download (streams zip) - GET /api/templates/[s]/[p]/[n]/file/download?path= - POST /api/templates/[s]/[p]/[n]/file/upload?path= (raw body passthrough) - POST /api/templates/[s]/[p]/[n]/rename (copy+delete emulation) Client API additions in lib/client-api.ts: - createTemplate / createDirectory / uploadFile / deployZip / rename - downloadFileUrl / downloadTemplateUrl (browser-side download) --- .../[storageId]/[storagePrefix]/page.tsx | 4 + .../dashboard/templates/[storageId]/page.tsx | 4 + .../(dashboard)/dashboard/templates/page.tsx | 4 + .../[prefixId]/[name]/create/route.ts | 29 + .../[prefixId]/[name]/deploy/route.ts | 52 ++ .../[name]/directory/create/route.ts | 31 + .../[prefixId]/[name]/download/route.ts | 49 ++ .../[prefixId]/[name]/file/download/route.ts | 52 ++ .../[prefixId]/[name]/file/upload/route.ts | 55 ++ .../[prefixId]/[name]/rename/route.ts | 112 +++ src/components/header/data.tsx | 10 +- .../templates/createTemplateDialog.tsx | 113 +++ src/components/templates/fileBrowser.tsx | 681 ++++++++++++++---- src/lib/client-api.ts | 74 +- 14 files changed, 1113 insertions(+), 157 deletions(-) create mode 100644 src/app/api/templates/[storageId]/[prefixId]/[name]/create/route.ts create mode 100644 src/app/api/templates/[storageId]/[prefixId]/[name]/deploy/route.ts create mode 100644 src/app/api/templates/[storageId]/[prefixId]/[name]/directory/create/route.ts create mode 100644 src/app/api/templates/[storageId]/[prefixId]/[name]/download/route.ts create mode 100644 src/app/api/templates/[storageId]/[prefixId]/[name]/file/download/route.ts create mode 100644 src/app/api/templates/[storageId]/[prefixId]/[name]/file/upload/route.ts create mode 100644 src/app/api/templates/[storageId]/[prefixId]/[name]/rename/route.ts create mode 100644 src/components/templates/createTemplateDialog.tsx 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..1229e2c --- /dev/null +++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/create/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server' +import { + checkPermissions, + makeApiRequest, + createApiRoute +} from '@/lib/api-helpers' + +export const POST = createApiRoute(async (_req, { params }) => { + const { storageId, prefixId, name } = await params + + 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..5aeba43 --- /dev/null +++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/deploy/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server' +import { checkPermissions, createApiRoute } from '@/lib/api-helpers' +import { getCookies } from '@/lib/server-calls' + +export const POST = createApiRoute(async (req, { params }) => { + const { storageId, prefixId, name } = await params + + 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..048720c --- /dev/null +++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/directory/create/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server' +import { + checkPermissions, + makeApiRequest, + createApiRoute +} from '@/lib/api-helpers' + +export const POST = createApiRoute(async (req, { params }) => { + const { storageId, prefixId, name } = await params + const { searchParams } = new URL(req.url) + const path = searchParams.get('path') || '' + + 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..fa33dc2 --- /dev/null +++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/download/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from 'next/server' +import { checkPermissions, createApiRoute } from '@/lib/api-helpers' +import { getCookies } from '@/lib/server-calls' + +export const GET = createApiRoute(async (_req, { params }) => { + const { storageId, prefixId, name } = await params + + 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': `attachment; filename="${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..e127f2c --- /dev/null +++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/file/download/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server' +import { checkPermissions, createApiRoute } from '@/lib/api-helpers' +import { getCookies } from '@/lib/server-calls' + +export const GET = createApiRoute(async (req, { params }) => { + const { storageId, prefixId, name } = await params + const { searchParams } = new URL(req.url) + const path = searchParams.get('path') || '' + + 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': `attachment; filename="${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..7dd55b2 --- /dev/null +++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/file/upload/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from 'next/server' +import { checkPermissions, createApiRoute } from '@/lib/api-helpers' +import { getCookies } from '@/lib/server-calls' + +export const POST = createApiRoute(async (req, { params }) => { + const { storageId, prefixId, name } = await params + const { searchParams } = new URL(req.url) + const path = searchParams.get('path') || '' + + 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..c8942a1 --- /dev/null +++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/rename/route.ts @@ -0,0 +1,112 @@ +import { NextResponse } from 'next/server' +import { checkPermissions, createApiRoute } from '@/lib/api-helpers' +import { getCookies } from '@/lib/server-calls' + +// 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 { storageId, prefixId, name } = await params + + 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 { from, to, isDirectory } = await req.json() + + 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 + }) } From 4a80d5421b473754b8250d0ffcb9361f2e14a569 Mon Sep 17 00:00:00 2001 From: Tomxba Date: Sat, 29 Aug 2026 08:45:52 +0200 Subject: [PATCH 2/6] sec(templates): defense-in-depth path sanitization + RFC 6266 Content-Disposition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds src/lib/pathSafe.ts with: - safeTemplatePath() rejects .., absolute paths, backslashes, control chars, and every URL-encoded variant of them before the path is handed to CloudNet REST - safeSegment() same rules for storage/prefix/name route params so a request to /api/templates/local/..%2Fetc/x/y cannot escape to an unintended CloudNet URL - contentDispositionAttachment() RFC 6266 / 5987 encoding so a filename like `inj".txt` cannot break out of the header (fallback quote-safe + filename* UTF-8 form) All 7 new template routes now validate their inputs before forwarding. Behavioral tests: every traversal attempt now returns 400 at the panel edge, the happy path still returns 204 / 200. Why: - CloudNet REST 4.0.0-RC17 does NOT sanitize the `path` query on /template/{s}/{p}/{n}/file/create — sending `path=../../../etc/passwd` writes the file into CloudNet's local/ tree instead of the template directory. This is an upstream bug, but there is no reason for the panel to hand it a traversal string in the first place. This commit makes the panel refuse it at the edge as defense in depth, until upstream lands a fix. - Content-Disposition previously used a bare `filename="…"`; a filename with a double quote produced malformed headers. Not fixed here (out of scope, pre-existing): - SSRF via the `add` cookie in src/lib/api-helpers.ts. The upstream base URL is taken from a cookie the browser controls, so any authenticated user can proxy requests through the panel to arbitrary HTTP endpoints. This is a broader change — the address should be sourced from server-side session state, not from a cookie — and affects existing routes too. Filing separately. --- .../[prefixId]/[name]/create/route.ts | 9 +- .../[prefixId]/[name]/deploy/route.ts | 9 +- .../[name]/directory/create/route.ts | 13 ++- .../[prefixId]/[name]/download/route.ts | 11 ++- .../[prefixId]/[name]/file/download/route.ts | 18 +++- .../[prefixId]/[name]/file/upload/route.ts | 16 +++- .../[prefixId]/[name]/rename/route.ts | 19 ++++- src/lib/pathSafe.ts | 85 +++++++++++++++++++ 8 files changed, 164 insertions(+), 16 deletions(-) create mode 100644 src/lib/pathSafe.ts diff --git a/src/app/api/templates/[storageId]/[prefixId]/[name]/create/route.ts b/src/app/api/templates/[storageId]/[prefixId]/[name]/create/route.ts index 1229e2c..658196a 100644 --- a/src/app/api/templates/[storageId]/[prefixId]/[name]/create/route.ts +++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/create/route.ts @@ -4,9 +4,16 @@ import { makeApiRequest, createApiRoute } from '@/lib/api-helpers' +import { safeTemplateTriple } from '@/lib/pathSafe' export const POST = createApiRoute(async (_req, { params }) => { - const { storageId, prefixId, name } = await 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', diff --git a/src/app/api/templates/[storageId]/[prefixId]/[name]/deploy/route.ts b/src/app/api/templates/[storageId]/[prefixId]/[name]/deploy/route.ts index 5aeba43..e8bbf5b 100644 --- a/src/app/api/templates/[storageId]/[prefixId]/[name]/deploy/route.ts +++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/deploy/route.ts @@ -1,9 +1,16 @@ 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 { storageId, prefixId, name } = await 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', 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 index 048720c..a4ffec4 100644 --- a/src/app/api/templates/[storageId]/[prefixId]/[name]/directory/create/route.ts +++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/directory/create/route.ts @@ -4,11 +4,18 @@ import { makeApiRequest, createApiRoute } from '@/lib/api-helpers' +import { safeTemplatePath, safeTemplateTriple } from '@/lib/pathSafe' export const POST = createApiRoute(async (req, { params }) => { - const { storageId, prefixId, name } = await params - const { searchParams } = new URL(req.url) - const path = searchParams.get('path') || '' + 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', diff --git a/src/app/api/templates/[storageId]/[prefixId]/[name]/download/route.ts b/src/app/api/templates/[storageId]/[prefixId]/[name]/download/route.ts index fa33dc2..c9d99b0 100644 --- a/src/app/api/templates/[storageId]/[prefixId]/[name]/download/route.ts +++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/download/route.ts @@ -1,9 +1,16 @@ 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 { storageId, prefixId, name } = await 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', @@ -43,7 +50,7 @@ export const GET = createApiRoute(async (_req, { params }) => { status: 200, headers: { 'Content-Type': 'application/zip', - 'Content-Disposition': `attachment; filename="${prefixId}-${name}.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 index e127f2c..0543205 100644 --- a/src/app/api/templates/[storageId]/[prefixId]/[name]/file/download/route.ts +++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/file/download/route.ts @@ -1,11 +1,21 @@ 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 { storageId, prefixId, name } = await params - const { searchParams } = new URL(req.url) - const path = searchParams.get('path') || '' + 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', @@ -46,7 +56,7 @@ export const GET = createApiRoute(async (req, { params }) => { status: 200, headers: { 'Content-Type': upstream.headers.get('content-type') || 'application/octet-stream', - 'Content-Disposition': `attachment; filename="${filename}"` + '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 index 7dd55b2..29ccd69 100644 --- a/src/app/api/templates/[storageId]/[prefixId]/[name]/file/upload/route.ts +++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/file/upload/route.ts @@ -1,11 +1,21 @@ 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 { storageId, prefixId, name } = await params - const { searchParams } = new URL(req.url) - const path = searchParams.get('path') || '' + 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', diff --git a/src/app/api/templates/[storageId]/[prefixId]/[name]/rename/route.ts b/src/app/api/templates/[storageId]/[prefixId]/[name]/rename/route.ts index c8942a1..9f344a0 100644 --- a/src/app/api/templates/[storageId]/[prefixId]/[name]/rename/route.ts +++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/rename/route.ts @@ -1,11 +1,18 @@ 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 { storageId, prefixId, name } = await 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', @@ -28,7 +35,15 @@ export const POST = createApiRoute(async (req, { params }) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { from, to, isDirectory } = await req.json() + 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 }) 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}` +} From f465fe2050307576412067f60e9b1489fd848e7e Mon Sep 17 00:00:00 2001 From: Tomxba Date: Sun, 30 Aug 2026 13:38:08 +0200 Subject: [PATCH 3/6] feat: blueprint wizard, service files, save-as-template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three related workflows that make the panel usable end-to-end without dropping to the CloudNet console — everything goes through existing CloudNet REST endpoints, no changes to the CloudNet node itself. Blueprint wizard (Tasks page → New task) A 3-step modal that in one submit does: 1. POST /template/{s}/{p}/{n}/create — makes the template 2. POST /serviceVersion/install — drops the jar into it 3. POST /task — upserts the task 4. (optional) starts a seed service, waits for Paper/Purpur to write out its default configs (bukkit.yml, spigot.yml, paper-global.yml, config/, …), then deployResources to the template so those defaults are visible in the Templates browser, then stops + deletes the seed. Presets: Lobby / Survival-Creative / Minigame / Proxy / Custom, each with sensible defaults for env, groups, memory, static-vs-ephemeral. Backend: src/app/api/blueprint/route.ts orchestrates the whole thing. Create service (Services page → New service) Picks an existing task from a dropdown and calls POST /service/create/taskName, then PATCH lifecycle?target=start. Backend: src/app/api/service/create/route.ts. Create group (Groups page → New group) Replaces the previous minimal CreateGroup component with one that also lets the user set targetEnvironments (needed for a group to auto-attach to services). Backend uses the existing group/update upsert route. Runtime service files browser (Files tab on a service page) New tab, feature-flagged behind CLOUDNET_SERVICES_PATH env var — only shown when the panel container has bind-mounted CloudNet's temp/services directory. Adds: GET /api/services/[id]/files/enabled GET /api/services/[id]/files/directory/list POST /api/services/[id]/files/directory/create POST /api/services/[id]/files/directory/delete GET /api/services/[id]/files/file/get (text) POST /api/services/[id]/files/file/update (text edit) POST /api/services/[id]/files/file/upload (binary) GET /api/services/[id]/files/file/download (streamed) POST /api/services/[id]/files/file/delete POST /api/services/[id]/files/rename (native fs.rename) UI is a full browser: breadcrumb nav, drag-and-drop upload, in-place text editor for common config files, download, rename, delete + protection against removing CloudNet's own wrapper files (wrapper.jar, .wrapper/, .token). Save-as-template (Files tab → Save as template) One-shot deployment target + deployResources, so the current runtime state of a service becomes a reusable template that any future service can be built from. Backend: POST /api/services/[id]/save-as-template. Client-api additions - versionApi.list() - serviceCreateApi.create() / .saveAsTemplate() - blueprintApi.create() - serviceFilesApi (list/get/update/upload/download/mkdir/rmdir/rm/rename) Also fixes a pre-existing bug in handleResponse — it was throwing ApiError on any 2xx response with an empty body (i.e. all 204 No Content). Every mutating template/service call was actually succeeding on the server but the UI treated it as an error and never refreshed. Now 204 returns { status: 204 } cleanly. Panel deployment docker-compose.yml gains a commented-out volume mount + CLOUDNET_SERVICES_PATH env var, with a note pointing to the local override for same-host setups. Safety - Every routed path goes through safeTemplatePath / safeSegment from the previous commit (defense in depth). - Service file paths resolve against the service directory then realpath'd — a symlink whose target lies outside is rejected. - Deleting the service root, wrapper.jar, .wrapper/, .token is refused server-side. --- docker-compose.yml | 10 + .../(dashboard)/dashboard/groups/page.tsx | 15 +- .../dashboard/services/[serviceId]/page.tsx | 12 + .../(dashboard)/dashboard/services/page.tsx | 13 +- .../(dashboard)/dashboard/tasks/page.tsx | 13 +- src/app/api/blueprint/route.ts | 155 ++++++ src/app/api/service/create/route.ts | 37 ++ src/app/api/serviceVersion/install/route.ts | 25 + src/app/api/serviceVersion/list/route.ts | 14 + .../[id]/files/directory/create/route.ts | 28 ++ .../[id]/files/directory/delete/route.ts | 31 ++ .../[id]/files/directory/list/route.ts | 29 ++ .../api/services/[id]/files/enabled/route.ts | 7 + .../services/[id]/files/file/delete/route.ts | 36 ++ .../[id]/files/file/download/route.ts | 49 ++ .../api/services/[id]/files/file/get/route.ts | 39 ++ .../services/[id]/files/file/update/route.ts | 32 ++ .../services/[id]/files/file/upload/route.ts | 36 ++ .../api/services/[id]/files/rename/route.ts | 38 ++ .../services/[id]/save-as-template/route.ts | 63 +++ src/components/blueprint/blueprintDialog.tsx | 308 ++++++++++++ .../blueprint/createGroupDialog.tsx | 91 ++++ .../blueprint/createServiceDialog.tsx | 98 ++++ .../blueprint/saveAsTemplateDialog.tsx | 81 ++++ .../services/serviceFileBrowser.tsx | 455 ++++++++++++++++++ src/lib/client-api.ts | 100 +++- src/lib/serviceFs.ts | 124 +++++ 27 files changed, 1923 insertions(+), 16 deletions(-) create mode 100644 src/app/api/blueprint/route.ts create mode 100644 src/app/api/service/create/route.ts create mode 100644 src/app/api/serviceVersion/install/route.ts create mode 100644 src/app/api/serviceVersion/list/route.ts create mode 100644 src/app/api/services/[id]/files/directory/create/route.ts create mode 100644 src/app/api/services/[id]/files/directory/delete/route.ts create mode 100644 src/app/api/services/[id]/files/directory/list/route.ts create mode 100644 src/app/api/services/[id]/files/enabled/route.ts create mode 100644 src/app/api/services/[id]/files/file/delete/route.ts create mode 100644 src/app/api/services/[id]/files/file/download/route.ts create mode 100644 src/app/api/services/[id]/files/file/get/route.ts create mode 100644 src/app/api/services/[id]/files/file/update/route.ts create mode 100644 src/app/api/services/[id]/files/file/upload/route.ts create mode 100644 src/app/api/services/[id]/files/rename/route.ts create mode 100644 src/app/api/services/[id]/save-as-template/route.ts create mode 100644 src/components/blueprint/blueprintDialog.tsx create mode 100644 src/components/blueprint/createGroupDialog.tsx create mode 100644 src/components/blueprint/createServiceDialog.tsx create mode 100644 src/components/blueprint/saveAsTemplateDialog.tsx create mode 100644 src/components/services/serviceFileBrowser.tsx create mode 100644 src/lib/serviceFs.ts diff --git a/docker-compose.yml b/docker-compose.yml index 649610f..5434598 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,16 @@ services: - SENTRY_PROJECT=${SENTRY_PROJECT} - SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN} - SENTRY_URL=${SENTRY_URL} + # Runtime service files browser (feature-flagged). When set, the panel + # exposes a Files tab on each service that reads/writes the files of + # the running service directly on the filesystem. Requires panel and + # CloudNet node to share the same host (bind-mount the node's + # temp/services directory into the container at this path). + - CLOUDNET_SERVICES_PATH=${CLOUDNET_SERVICES_PATH:-} + # volumes: + # # Uncomment when running the panel on the same host as the CloudNet + # # node. Target path must match CLOUDNET_SERVICES_PATH in .env. + # - /opt/netcloud/node/temp/services:/services:rw restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000/api/health", "||", "exit", "1"] diff --git a/src/app/[locale]/(dashboard)/dashboard/groups/page.tsx b/src/app/[locale]/(dashboard)/dashboard/groups/page.tsx index 9087b10..6f36247 100644 --- a/src/app/[locale]/(dashboard)/dashboard/groups/page.tsx +++ b/src/app/[locale]/(dashboard)/dashboard/groups/page.tsx @@ -12,7 +12,7 @@ import { Button } from '@/components/ui/button' import { getPermissions } from '@/utils/server-api/getPermissions' import NoAccess from '@/components/static/noAccess' import NoRecords from '@/components/static/noRecords' -import CreateGroup from '@/components/modules/groups/createGroup' +import CreateGroup from '@/components/blueprint/createGroupDialog' import Link from 'next/link' import { serverGroupApi } from '@/lib/server-api' import { getTranslations } from 'gt-next/server' @@ -48,12 +48,21 @@ export default async function GroupsPage() { } if (!groups.groups) { - return + return ( + +
+ +
+ +
+ ) } return ( - +
+ +
{groupsT('tableCaption')} diff --git a/src/app/[locale]/(dashboard)/dashboard/services/[serviceId]/page.tsx b/src/app/[locale]/(dashboard)/dashboard/services/[serviceId]/page.tsx index 6d55a1f..4d64162 100644 --- a/src/app/[locale]/(dashboard)/dashboard/services/[serviceId]/page.tsx +++ b/src/app/[locale]/(dashboard)/dashboard/services/[serviceId]/page.tsx @@ -17,6 +17,8 @@ import { getPermissions } from '@/utils/server-api/getPermissions' import { serverServiceApi } from '@/lib/server-api' import DoesNotExist from '@/components/static/doesNotExist' import { getTranslations } from 'gt-next/server' +import ServiceFileBrowser from '@/components/services/serviceFileBrowser' +import { isEnabled as serviceFilesEnabled } from '@/lib/serviceFs' export default async function UserPage(props) { const params = await props.params @@ -129,6 +131,8 @@ export default async function UserPage(props) { service?.configuration.serviceId.nameSplitter + service?.configuration.serviceId.taskServiceId || serviceT('name') + const showFilesTab = serviceFilesEnabled() && hasEditPermissions + return ( @@ -141,6 +145,9 @@ export default async function UserPage(props) { ) && ( {serviceT('console')} )} + {showFilesTab && ( + Files + )} )} + {showFilesTab && ( + + + + )} ) diff --git a/src/app/[locale]/(dashboard)/dashboard/services/page.tsx b/src/app/[locale]/(dashboard)/dashboard/services/page.tsx index 8176ca4..f6d3a8c 100644 --- a/src/app/[locale]/(dashboard)/dashboard/services/page.tsx +++ b/src/app/[locale]/(dashboard)/dashboard/services/page.tsx @@ -17,6 +17,7 @@ import AutoRefresh from '@/components/autoRefresh' import Link from 'next/link' import { serverServiceApi } from '@/lib/server-api' import { getTranslations } from 'gt-next/server' +import CreateServiceDialog from '@/components/blueprint/createServiceDialog' export default async function ServicesPage() { const servicesT = await getTranslations('Services') @@ -38,11 +39,21 @@ export default async function ServicesPage() { } if (!services.services) { - return + return ( + +
+ +
+ +
+ ) } return ( +
+ +
{servicesT('tableCaption')} diff --git a/src/app/[locale]/(dashboard)/dashboard/tasks/page.tsx b/src/app/[locale]/(dashboard)/dashboard/tasks/page.tsx index e8fe96a..9c27bee 100644 --- a/src/app/[locale]/(dashboard)/dashboard/tasks/page.tsx +++ b/src/app/[locale]/(dashboard)/dashboard/tasks/page.tsx @@ -15,6 +15,7 @@ import NoRecords from '@/components/static/noRecords' import Link from 'next/link' import { serverTaskApi } from '@/lib/server-api' import { getTranslations } from 'gt-next/server' +import BlueprintDialog from '@/components/blueprint/blueprintDialog' export default async function TasksPage() { const tasks = await serverTaskApi.list() @@ -46,11 +47,21 @@ export default async function TasksPage() { } if (!tasks?.tasks || tasks.tasks.length === 0) { - return + return ( + +
+ +
+ +
+ ) } return ( +
+ +
{taskT('tableCaption')} diff --git a/src/app/api/blueprint/route.ts b/src/app/api/blueprint/route.ts new file mode 100644 index 0000000..311c9cc --- /dev/null +++ b/src/app/api/blueprint/route.ts @@ -0,0 +1,155 @@ +import { NextResponse } from 'next/server' +import { checkPermissions, makeApiRequest, createApiRoute } from '@/lib/api-helpers' + +// Body: +// { +// taskName: string, +// preset: 'lobby'|'survival'|'minigame'|'proxy'|'custom', +// environment: string, // MINECRAFT_SERVER | VELOCITY | ... +// groups: string[], +// static: boolean, // true → autoDeleteOnStop=false + staticServices=true +// memory: number, // MB +// minServiceCount: number, +// startPort: number, +// serviceVersionType?: string,// 'purpurmc' | 'papermc' | 'velocity' | ... +// serviceVersion?: string, // '1.21.1' | '26.2' | ... +// javaCommand?: string, +// bootstrap: boolean // pre-generate config files by running the service once +// } +export const POST = createApiRoute(async (req) => { + const permissionCheck = await checkPermissions([ + 'cloudnet_rest:task_write', + 'cloudnet_rest:task_create', + 'global:admin' + ]) + if (permissionCheck) return NextResponse.json(permissionCheck, { status: permissionCheck.status }) + + const b = await req.json() + const { + taskName, environment, groups = [], memory = 512, minServiceCount = 0, + startPort = 44955, serviceVersionType, serviceVersion, javaCommand, + bootstrap = false + } = b + const isStatic: boolean = !!b.static + + if (!taskName || !/^[A-Za-z0-9_-]{1,40}$/.test(taskName)) { + return NextResponse.json({ error: 'invalid taskName' }, { status: 400 }) + } + + const storage = 'local' + const templatePrefix = taskName + const templateName = 'default' + const templateRef = { prefix: templatePrefix, name: templateName, storage, priority: 0, alwaysCopyToStaticServices: false } + + // Step 1: create the template folder (idempotent — CloudNet ignores if exists) + await makeApiRequest( + `/template/${storage}/${templatePrefix}/${templateName}/create`, + 'POST' + ) + + // Step 2: install a service version into the template (optional) + if (serviceVersionType && serviceVersion) { + const installRes = await makeApiRequest( + `/serviceVersion/install?cache=true`, + 'POST', + { + template: templateRef, + serviceVersionType, + serviceVersion, + }, + { stringifyBody: true, returnJson: false } + ) + if (installRes.status >= 400) { + return NextResponse.json({ step: 'install-version', ...installRes }, { status: installRes.status }) + } + } + + // Step 3: upsert the task + const taskConfig = { + name: taskName, + runtime: 'jvm', + hostAddress: null, + javaCommand: javaCommand || '/usr/lib/jvm/java-25-openjdk-amd64/bin/java', + nameSplitter: '-', + disableIpRewrite: false, + maintenance: false, + autoDeleteOnStop: !isStatic, + staticServices: isStatic, + groups, + associatedNodes: [], + deletedFilesAfterStop: [], + processConfiguration: { + environment, + maxHeapMemorySize: memory, + jvmOptions: [], + processParameters: [], + environmentVariables: {} + }, + startPort, + minServiceCount, + templates: [templateRef], + deployments: [], + includes: [], + properties: { requiredPermission: null } + } + const taskRes = await makeApiRequest(`/task`, 'POST', taskConfig, { + stringifyBody: true, returnJson: false + }) + if (taskRes.status >= 400) { + return NextResponse.json({ step: 'create-task', ...taskRes }, { status: taskRes.status }) + } + + // Step 4: bootstrap — start a seed service, let it generate configs, deploy back to template + if (bootstrap) { + // create service + const created = await makeApiRequest( + `/service/create/taskName`, + 'POST', + { taskName }, + { stringifyBody: true } + ) + if (created.status >= 400) { + return NextResponse.json({ step: 'bootstrap-create', ...created }, { status: created.status }) + } + const cd: any = created.data + const uuid: string | undefined = + cd?.serviceInfo?.configuration?.serviceId?.uniqueId || + cd?.creationId || + cd?.serviceInfoSnapshot?.configuration?.serviceId?.uniqueId || + cd?.uniqueId + if (!uuid) { + return NextResponse.json({ step: 'bootstrap-uuid', error: 'no uuid returned' }, { status: 500 }) + } + + // start + await makeApiRequest(`/service/${uuid}/lifecycle?target=start`, 'PATCH') + + // wait for the service to have written its config files + // (Minecraft servers take 10-20s to produce bukkit.yml/paper-global.yml/…) + const waitMs = environment === 'MINECRAFT_SERVER' ? 22000 : 8000 + await new Promise(r => setTimeout(r, waitMs)) + + // attach deployment to our template (so deployResources writes there) + await makeApiRequest( + `/service/${uuid}/add/deployment?flush=false`, + 'POST', + { + template: templateRef, + excludes: [], + includes: [], + properties: {} + }, + { stringifyBody: true, returnJson: false } + ) + + // deploy runtime → template + await makeApiRequest(`/service/${uuid}/deployResources?remove=true`, 'POST', undefined, { returnJson: false }) + + // stop + delete + await makeApiRequest(`/service/${uuid}/lifecycle?target=stop`, 'PATCH') + await new Promise(r => setTimeout(r, 1500)) + await makeApiRequest(`/service/${uuid}`, 'DELETE') + } + + return NextResponse.json({ status: 200, ok: true, taskName }) +}) diff --git a/src/app/api/service/create/route.ts b/src/app/api/service/create/route.ts new file mode 100644 index 0000000..09434f1 --- /dev/null +++ b/src/app/api/service/create/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from 'next/server' +import { checkPermissions, makeApiRequest, createApiRoute } from '@/lib/api-helpers' + +// Body: { taskName: string, start?: boolean } +// Creates a service instance from an existing task, optionally auto-starts it. +export const POST = createApiRoute(async (req) => { + const permissionCheck = await checkPermissions([ + 'cloudnet_rest:service_write', + 'cloudnet_rest:service_create_task_name', + 'global:admin' + ]) + if (permissionCheck) return NextResponse.json(permissionCheck, { status: permissionCheck.status }) + + const body = await req.json() + const taskName: string = body.taskName + const autoStart: boolean = body.start !== false + if (!taskName) return NextResponse.json({ error: 'taskName required' }, { status: 400 }) + + const created = await makeApiRequest( + `/service/create/taskName`, + 'POST', + { taskName }, + { stringifyBody: true } + ) + if (created.status >= 400) return NextResponse.json(created, { status: created.status }) + + const cd: any = created.data + const uuid: string | undefined = + cd?.serviceInfo?.configuration?.serviceId?.uniqueId || + cd?.creationId || + cd?.serviceInfoSnapshot?.configuration?.serviceId?.uniqueId || + cd?.uniqueId + if (autoStart && uuid) { + await makeApiRequest(`/service/${uuid}/lifecycle?target=start`, 'PATCH') + } + return NextResponse.json({ status: 200, data: created.data, uuid }) +}) diff --git a/src/app/api/serviceVersion/install/route.ts b/src/app/api/serviceVersion/install/route.ts new file mode 100644 index 0000000..05ce1e4 --- /dev/null +++ b/src/app/api/serviceVersion/install/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from 'next/server' +import { checkPermissions, makeApiRequest, createApiRoute } from '@/lib/api-helpers' + +// Body: { template: {prefix, name, storage}, serviceVersionType, serviceVersion } +export const POST = createApiRoute(async (req) => { + const permissionCheck = await checkPermissions([ + 'cloudnet_rest:service_version_write', + 'cloudnet_rest:service_version_install', + 'global:admin' + ]) + if (permissionCheck) return NextResponse.json(permissionCheck, { status: permissionCheck.status }) + + const body = await req.json() + const { searchParams } = new URL(req.url) + const force = searchParams.get('force') === 'true' ? '&force=true' : '' + const cache = searchParams.get('cache') !== 'false' ? '&cache=true' : '&cache=false' + + const response = await makeApiRequest( + `/serviceVersion/install?${force.slice(1)}${cache}`, + 'POST', + body, + { returnJson: false, stringifyBody: true } + ) + return NextResponse.json(response) +}) diff --git a/src/app/api/serviceVersion/list/route.ts b/src/app/api/serviceVersion/list/route.ts new file mode 100644 index 0000000..6315047 --- /dev/null +++ b/src/app/api/serviceVersion/list/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server' +import { checkPermissions, makeApiRequest, createApiRoute } from '@/lib/api-helpers' + +export const GET = createApiRoute(async () => { + const permissionCheck = await checkPermissions([ + 'cloudnet_rest:service_version_read', + 'cloudnet_rest:service_version_list', + 'global:admin' + ]) + if (permissionCheck) return NextResponse.json(permissionCheck, { status: permissionCheck.status }) + + const response = await makeApiRequest('/serviceVersion', 'GET') + return NextResponse.json(response) +}) diff --git a/src/app/api/services/[id]/files/directory/create/route.ts b/src/app/api/services/[id]/files/directory/create/route.ts new file mode 100644 index 0000000..5509ddc --- /dev/null +++ b/src/app/api/services/[id]/files/directory/create/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from 'next/server' +import { promises as fs } from 'fs' +import { checkPermissions, createApiRoute } from '@/lib/api-helpers' +import { isEnabled, resolveServiceDir, safeJoin } from '@/lib/serviceFs' + +export const POST = createApiRoute(async (req, { params }) => { + if (!isEnabled()) return NextResponse.json({ error: 'disabled' }, { status: 501 }) + + const permissionCheck = await checkPermissions([ + 'cloudnet_rest:service_write', + 'global:admin' + ]) + if (permissionCheck) return NextResponse.json(permissionCheck, { status: permissionCheck.status }) + + const { id } = await params + const { searchParams } = new URL(req.url) + const sub = searchParams.get('path') || '' + if (!sub) return NextResponse.json({ error: 'path required' }, { status: 400 }) + + try { + const base = await resolveServiceDir(id) + const target = await safeJoin(base, sub) + await fs.mkdir(target, { recursive: true }) + return new NextResponse(null, { status: 204 }) + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 400 }) + } +}) diff --git a/src/app/api/services/[id]/files/directory/delete/route.ts b/src/app/api/services/[id]/files/directory/delete/route.ts new file mode 100644 index 0000000..a9fb2ac --- /dev/null +++ b/src/app/api/services/[id]/files/directory/delete/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server' +import { promises as fs } from 'fs' +import { checkPermissions, createApiRoute } from '@/lib/api-helpers' +import { isEnabled, resolveServiceDir, safeJoin } from '@/lib/serviceFs' + +export const POST = createApiRoute(async (req, { params }) => { + if (!isEnabled()) return NextResponse.json({ error: 'disabled' }, { status: 501 }) + + const permissionCheck = await checkPermissions([ + 'cloudnet_rest:service_write', + 'global:admin' + ]) + if (permissionCheck) return NextResponse.json(permissionCheck, { status: permissionCheck.status }) + + const { id } = await params + const { searchParams } = new URL(req.url) + const sub = searchParams.get('path') || '' + if (!sub) return NextResponse.json({ error: 'path required' }, { status: 400 }) + + try { + const base = await resolveServiceDir(id) + const target = await safeJoin(base, sub) + if (target === base) { + return NextResponse.json({ error: 'refuse to delete service root' }, { status: 400 }) + } + await fs.rm(target, { recursive: true, force: true }) + return new NextResponse(null, { status: 204 }) + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 400 }) + } +}) diff --git a/src/app/api/services/[id]/files/directory/list/route.ts b/src/app/api/services/[id]/files/directory/list/route.ts new file mode 100644 index 0000000..e9a6980 --- /dev/null +++ b/src/app/api/services/[id]/files/directory/list/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server' +import { checkPermissions, createApiRoute } from '@/lib/api-helpers' +import { isEnabled, resolveServiceDir, safeJoin, listDir } from '@/lib/serviceFs' + +export const GET = createApiRoute(async (req, { params }) => { + if (!isEnabled()) { + return NextResponse.json({ error: 'service files browser disabled' }, { status: 501 }) + } + + const permissionCheck = await checkPermissions([ + 'cloudnet_rest:service_read', + 'cloudnet_rest:service_get', + 'global:admin' + ]) + if (permissionCheck) return NextResponse.json(permissionCheck, { status: permissionCheck.status }) + + const { id } = await params + const { searchParams } = new URL(req.url) + const sub = searchParams.get('directory') || '' + + try { + const base = await resolveServiceDir(id) + const dir = await safeJoin(base, sub) + const entries = await listDir(dir, base) + return NextResponse.json({ files: entries }) + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 400 }) + } +}) diff --git a/src/app/api/services/[id]/files/enabled/route.ts b/src/app/api/services/[id]/files/enabled/route.ts new file mode 100644 index 0000000..1fa7645 --- /dev/null +++ b/src/app/api/services/[id]/files/enabled/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from 'next/server' +import { createApiRoute } from '@/lib/api-helpers' +import { isEnabled } from '@/lib/serviceFs' + +export const GET = createApiRoute(async () => { + return NextResponse.json({ enabled: isEnabled() }) +}) diff --git a/src/app/api/services/[id]/files/file/delete/route.ts b/src/app/api/services/[id]/files/file/delete/route.ts new file mode 100644 index 0000000..99b5d66 --- /dev/null +++ b/src/app/api/services/[id]/files/file/delete/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from 'next/server' +import { promises as fs } from 'fs' +import { checkPermissions, createApiRoute } from '@/lib/api-helpers' +import { isEnabled, resolveServiceDir, safeJoin, isProtectedName } from '@/lib/serviceFs' + +export const POST = createApiRoute(async (req, { params }) => { + if (!isEnabled()) return NextResponse.json({ error: 'disabled' }, { status: 501 }) + + const permissionCheck = await checkPermissions([ + 'cloudnet_rest:service_write', + 'global:admin' + ]) + if (permissionCheck) return NextResponse.json(permissionCheck, { status: permissionCheck.status }) + + const { id } = await params + const { searchParams } = new URL(req.url) + const sub = searchParams.get('path') || '' + if (!sub) return NextResponse.json({ error: 'path required' }, { status: 400 }) + + try { + const base = await resolveServiceDir(id) + const target = await safeJoin(base, sub) + if (target === base) return NextResponse.json({ error: 'refuse to delete root' }, { status: 400 }) + + // Protect CloudNet wrapper metadata files at the top level. + const rel = sub.replace(/^\/+/, '') + if (!rel.includes('/') && isProtectedName(rel)) { + return NextResponse.json({ error: 'refuse to delete CloudNet wrapper file' }, { status: 400 }) + } + + await fs.rm(target, { force: true }) + return new NextResponse(null, { status: 204 }) + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 400 }) + } +}) diff --git a/src/app/api/services/[id]/files/file/download/route.ts b/src/app/api/services/[id]/files/file/download/route.ts new file mode 100644 index 0000000..145b7e8 --- /dev/null +++ b/src/app/api/services/[id]/files/file/download/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from 'next/server' +import { promises as fs, createReadStream } from 'fs' +import { checkPermissions, createApiRoute } from '@/lib/api-helpers' +import { isEnabled, resolveServiceDir, safeJoin } from '@/lib/serviceFs' +import { contentDispositionAttachment } from '@/lib/pathSafe' + +export const GET = createApiRoute(async (req, { params }) => { + if (!isEnabled()) return NextResponse.json({ error: 'disabled' }, { status: 501 }) + + const permissionCheck = await checkPermissions([ + 'cloudnet_rest:service_read', + 'global:admin' + ]) + if (permissionCheck) return NextResponse.json(permissionCheck, { status: permissionCheck.status }) + + const { id } = await params + const { searchParams } = new URL(req.url) + const sub = searchParams.get('path') || '' + if (!sub) return NextResponse.json({ error: 'path required' }, { status: 400 }) + + try { + const base = await resolveServiceDir(id) + const target = await safeJoin(base, sub) + const st = await fs.stat(target) + if (st.isDirectory()) return NextResponse.json({ error: 'is a directory' }, { status: 400 }) + + const nodeStream = createReadStream(target) + const webStream = new ReadableStream({ + start(controller) { + nodeStream.on('data', (chunk) => controller.enqueue(chunk)) + nodeStream.on('end', () => controller.close()) + nodeStream.on('error', (err) => controller.error(err)) + }, + cancel() { nodeStream.destroy() } + }) + + const filename = sub.split('/').pop() || 'file' + return new NextResponse(webStream, { + status: 200, + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': String(st.size), + 'Content-Disposition': contentDispositionAttachment(filename) + } + }) + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 400 }) + } +}) diff --git a/src/app/api/services/[id]/files/file/get/route.ts b/src/app/api/services/[id]/files/file/get/route.ts new file mode 100644 index 0000000..d7cc796 --- /dev/null +++ b/src/app/api/services/[id]/files/file/get/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from 'next/server' +import { promises as fs } from 'fs' +import { checkPermissions, createApiRoute } from '@/lib/api-helpers' +import { isEnabled, resolveServiceDir, safeJoin } from '@/lib/serviceFs' + +const MAX_INLINE_BYTES = 5 * 1024 * 1024 // 5 MB; anything bigger goes through /download + +export const GET = createApiRoute(async (req, { params }) => { + if (!isEnabled()) return NextResponse.json({ error: 'disabled' }, { status: 501 }) + + const permissionCheck = await checkPermissions([ + 'cloudnet_rest:service_read', + 'global:admin' + ]) + if (permissionCheck) return NextResponse.json(permissionCheck, { status: permissionCheck.status }) + + const { id } = await params + const { searchParams } = new URL(req.url) + const sub = searchParams.get('path') || '' + if (!sub) return NextResponse.json({ error: 'path required' }, { status: 400 }) + + try { + const base = await resolveServiceDir(id) + const target = await safeJoin(base, sub) + const st = await fs.stat(target) + if (st.isDirectory()) return NextResponse.json({ error: 'is a directory' }, { status: 400 }) + if (st.size > MAX_INLINE_BYTES) { + return NextResponse.json({ error: 'file too big for inline read; use /download' }, { status: 413 }) + } + const buf = await fs.readFile(target) + // Return text for the editor; the client decides if it's displayable. + return new NextResponse(buf.toString('utf8'), { + status: 200, + headers: { 'Content-Type': 'text/plain; charset=utf-8' } + }) + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 400 }) + } +}) diff --git a/src/app/api/services/[id]/files/file/update/route.ts b/src/app/api/services/[id]/files/file/update/route.ts new file mode 100644 index 0000000..8c753ed --- /dev/null +++ b/src/app/api/services/[id]/files/file/update/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server' +import { promises as fs } from 'fs' +import path from 'path' +import { checkPermissions, createApiRoute } from '@/lib/api-helpers' +import { isEnabled, resolveServiceDir, safeJoin } from '@/lib/serviceFs' + +// Text-file update. Body: { path: string, content: string } +export const POST = createApiRoute(async (req, { params }) => { + if (!isEnabled()) return NextResponse.json({ error: 'disabled' }, { status: 501 }) + + const permissionCheck = await checkPermissions([ + 'cloudnet_rest:service_write', + 'global:admin' + ]) + if (permissionCheck) return NextResponse.json(permissionCheck, { status: permissionCheck.status }) + + const { id } = await params + const body = await req.json() + const sub: string = body.path || '' + const content: string = typeof body.content === 'string' ? body.content : '' + if (!sub) return NextResponse.json({ error: 'path required' }, { status: 400 }) + + try { + const base = await resolveServiceDir(id) + const target = await safeJoin(base, sub) + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.writeFile(target, content, 'utf8') + return new NextResponse(null, { status: 204 }) + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 400 }) + } +}) diff --git a/src/app/api/services/[id]/files/file/upload/route.ts b/src/app/api/services/[id]/files/file/upload/route.ts new file mode 100644 index 0000000..2870573 --- /dev/null +++ b/src/app/api/services/[id]/files/file/upload/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from 'next/server' +import { promises as fs } from 'fs' +import path from 'path' +import { checkPermissions, createApiRoute } from '@/lib/api-helpers' +import { isEnabled, resolveServiceDir, safeJoin } from '@/lib/serviceFs' + +// Binary upload — raw bytes in request body, path in ?path=. +export const POST = createApiRoute(async (req, { params }) => { + if (!isEnabled()) return NextResponse.json({ error: 'disabled' }, { status: 501 }) + + const permissionCheck = await checkPermissions([ + 'cloudnet_rest:service_write', + 'global:admin' + ]) + if (permissionCheck) return NextResponse.json(permissionCheck, { status: permissionCheck.status }) + + const { id } = await params + const { searchParams } = new URL(req.url) + const sub = searchParams.get('path') || '' + if (!sub) return NextResponse.json({ error: 'path required' }, { status: 400 }) + + try { + const base = await resolveServiceDir(id) + const target = await safeJoin(base, sub) + await fs.mkdir(path.dirname(target), { recursive: true }) + const buf = Buffer.from(await req.arrayBuffer()) + await fs.writeFile(target, buf) + return new NextResponse(null, { status: 204, headers: { "X-Bytes": String(buf.length) } }) + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 400 }) + } +}) + +export const config = { + api: { bodyParser: false } +} diff --git a/src/app/api/services/[id]/files/rename/route.ts b/src/app/api/services/[id]/files/rename/route.ts new file mode 100644 index 0000000..c24617a --- /dev/null +++ b/src/app/api/services/[id]/files/rename/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server' +import { promises as fs } from 'fs' +import path from 'path' +import { checkPermissions, createApiRoute } from '@/lib/api-helpers' +import { isEnabled, resolveServiceDir, safeJoin } from '@/lib/serviceFs' + +// Body: { from: string, to: string } +// Native fs.rename works for files AND directories — no recursive dance +// needed on the local filesystem. +export const POST = createApiRoute(async (req, { params }) => { + if (!isEnabled()) return NextResponse.json({ error: 'disabled' }, { status: 501 }) + + const permissionCheck = await checkPermissions([ + 'cloudnet_rest:service_write', + 'global:admin' + ]) + if (permissionCheck) return NextResponse.json(permissionCheck, { status: permissionCheck.status }) + + const { id } = await params + const body = await req.json() + const from: string = body.from || '' + const to: string = body.to || '' + if (!from || !to || from === to) return NextResponse.json({ error: 'invalid from/to' }, { status: 400 }) + + try { + const base = await resolveServiceDir(id) + const src = await safeJoin(base, from) + const dst = await safeJoin(base, to) + if (src === base || dst === base) { + return NextResponse.json({ error: 'refuse to rename to/from service root' }, { status: 400 }) + } + await fs.mkdir(path.dirname(dst), { recursive: true }) + await fs.rename(src, dst) + return new NextResponse(null, { status: 204 }) + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 400 }) + } +}) diff --git a/src/app/api/services/[id]/save-as-template/route.ts b/src/app/api/services/[id]/save-as-template/route.ts new file mode 100644 index 0000000..8bcf9d9 --- /dev/null +++ b/src/app/api/services/[id]/save-as-template/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from 'next/server' +import { checkPermissions, makeApiRequest, createApiRoute } from '@/lib/api-helpers' +import { safeSegment } from '@/lib/pathSafe' + +// Body: { prefix: string, name: string, storage?: string } +// Snapshots the current runtime files of the service into a new template. +export const POST = createApiRoute(async (req, { params }) => { + const permissionCheck = await checkPermissions([ + 'cloudnet_rest:service_write', + 'cloudnet_rest:service_deploy_resources', + 'global:admin' + ]) + if (permissionCheck) return NextResponse.json(permissionCheck, { status: permissionCheck.status }) + + const { id } = await params + const body = await req.json() + let storage: string, prefix: string, name: string + try { + storage = safeSegment(body.storage || 'local') + prefix = safeSegment(body.prefix) + name = safeSegment(body.name) + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 400 }) + } + + const templateRef = { prefix, name, storage, priority: 0, alwaysCopyToStaticServices: false } + + // Create the template if it doesn't exist yet — POST create is idempotent. + await makeApiRequest( + `/template/${storage}/${prefix}/${name}/create`, + 'POST' + ) + + // Attach a one-shot deployment targeting our new template. + const addRes = await makeApiRequest( + `/service/${id}/add/deployment?flush=false`, + 'POST', + { + template: templateRef, + excludes: [], + includes: [], + properties: {} + }, + { stringifyBody: true, returnJson: false } + ) + if (addRes.status >= 400) { + return NextResponse.json({ step: 'add-deployment', ...addRes }, { status: addRes.status }) + } + + // Flush all pending deployments into their target templates, then discard them + // (?remove=true) so this one-shot deployment isn't kept in the service config. + const deployRes = await makeApiRequest( + `/service/${id}/deployResources?remove=true`, + 'POST', + undefined, + { returnJson: false } + ) + if (deployRes.status >= 400) { + return NextResponse.json({ step: 'deploy-resources', ...deployRes }, { status: deployRes.status }) + } + + return NextResponse.json({ status: 200, template: templateRef }) +}) diff --git a/src/components/blueprint/blueprintDialog.tsx b/src/components/blueprint/blueprintDialog.tsx new file mode 100644 index 0000000..05c89b0 --- /dev/null +++ b/src/components/blueprint/blueprintDialog.tsx @@ -0,0 +1,308 @@ +'use client' +import { useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Checkbox } from '@/components/ui/checkbox' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { toast } from 'sonner' +import { PlusIcon, ServerIcon, GamepadIcon, HomeIcon, WrenchIcon, WorkflowIcon } from 'lucide-react' +import { versionApi, blueprintApi } from '@/lib/client-api' + +type Preset = 'lobby' | 'survival' | 'minigame' | 'proxy' | 'custom' + +type PresetDef = { + key: Preset + label: string + icon: any + description: string + environment: string + groups: string[] + memory: number + static: boolean + minServiceCount: number + startPort: number + suggestedVersion?: { type: string; version: string } +} + +const PRESETS: Record = { + lobby: { + key: 'lobby', label: 'Lobby / Hub', icon: HomeIcon, + description: 'Single persistent hub with your spawn, NPCs and signs.', + environment: 'MINECRAFT_SERVER', + groups: ['Lobby', 'Global-Server'], + memory: 512, static: true, minServiceCount: 1, startPort: 44955, + suggestedVersion: { type: 'purpur', version: '26.2' } + }, + survival: { + key: 'survival', label: 'Survival / Creative', icon: WorkflowIcon, + description: 'Persistent server keeping worlds and player data across restarts.', + environment: 'MINECRAFT_SERVER', + groups: ['Global-Server'], + memory: 2048, static: true, minServiceCount: 1, startPort: 45000, + suggestedVersion: { type: 'purpur', version: '26.2' } + }, + minigame: { + key: 'minigame', label: 'Minigame / Event', icon: GamepadIcon, + description: 'Ephemeral server that resets to a clean map at every restart.', + environment: 'MINECRAFT_SERVER', + groups: ['Global-Server'], + memory: 1024, static: false, minServiceCount: 2, startPort: 45100, + suggestedVersion: { type: 'purpur', version: '26.2' } + }, + proxy: { + key: 'proxy', label: 'Proxy (Velocity)', icon: ServerIcon, + description: 'Front-end proxy that routes players to backend servers.', + environment: 'VELOCITY', + groups: ['Proxy', 'Global-Proxy'], + memory: 512, static: false, minServiceCount: 1, startPort: 25565, + suggestedVersion: { type: 'velocity', version: 'latest' } + }, + custom: { + key: 'custom', label: 'Custom', icon: WrenchIcon, + description: 'Blank slate — set every field yourself.', + environment: 'MINECRAFT_SERVER', + groups: [], + memory: 512, static: false, minServiceCount: 0, startPort: 45200 + } +} + +export default function BlueprintDialog({ trigger, onCreated }: { trigger?: React.ReactNode; onCreated?: () => void }) { + const router = useRouter() + const [open, setOpen] = useState(false) + const [step, setStep] = useState(1) + const [busy, setBusy] = useState(false) + const [progressMsg, setProgressMsg] = useState('') + + const [preset, setPreset] = useState('lobby') + const p = PRESETS[preset] + const [environment, setEnvironment] = useState(p.environment) + const [groups, setGroups] = useState(p.groups.join(', ')) + const [memory, setMemory] = useState(p.memory) + const [isStatic, setIsStatic] = useState(p.static) + const [minServiceCount, setMinServiceCount] = useState(p.minServiceCount) + const [startPort, setStartPort] = useState(p.startPort) + const [taskName, setTaskName] = useState('') + const [bootstrap, setBootstrap] = useState(true) + + // Version selection + const [versionType, setVersionType] = useState(p.suggestedVersion?.type || '') + const [version, setVersion] = useState(p.suggestedVersion?.version || '') + const [versionsData, setVersionsData] = useState }>>({}) + + useEffect(() => { + if (!open) return + versionApi.list().then((res: any) => { + const raw = res?.data ?? res + const types = raw?.serviceVersionTypes ?? {} + setVersionsData(types) + }).catch(() => {}) + }, [open]) + + useEffect(() => { + const def = PRESETS[preset] + setEnvironment(def.environment) + setGroups(def.groups.join(', ')) + setMemory(def.memory) + setIsStatic(def.static) + setMinServiceCount(def.minServiceCount) + setStartPort(def.startPort) + if (def.suggestedVersion) { + setVersionType(def.suggestedVersion.type) + setVersion(def.suggestedVersion.version) + } + }, [preset]) + + const availableTypes = Object.keys(versionsData).sort() + const availableVersions = versionsData[versionType]?.versions?.filter(v => !v.deprecated).map(v => v.name) ?? [] + + const submit = async () => { + if (!/^[A-Za-z0-9_-]{1,40}$/.test(taskName)) { + toast.error('Task name must be alphanumeric (a-z, 0-9, _ or -)') + return + } + setBusy(true) + setProgressMsg(bootstrap ? 'Creating template, installing jar, starting seed service…' : 'Creating template + task…') + try { + const res: any = await blueprintApi.create({ + taskName, + preset, + environment, + groups: groups.split(',').map(g => g.trim()).filter(Boolean), + static: isStatic, + memory, + minServiceCount, + startPort, + serviceVersionType: versionType || undefined, + serviceVersion: version || undefined, + bootstrap + }) + if ((res.status ?? 0) >= 400) { + toast.error(`Failed at step "${res.step ?? '?'}": HTTP ${res.status}`) + } else { + toast.success(`Task ${taskName} ready${bootstrap ? ' — configs generated' : ''}`) + setOpen(false) + setStep(1) + setTaskName('') + onCreated?.() + router.refresh() + } + } catch (e: any) { + toast.error(e.message || 'Blueprint failed') + } finally { + setBusy(false) + setProgressMsg('') + } + } + + return ( + { setOpen(o); if (!o) setStep(1) }}> + + {trigger ?? ( + + )} + + + + Create a new task {step > 1 && `— step ${step}/3`} + + Runs template + version install + task upsert{bootstrap ? ' + config bootstrap' : ''}. + + + + {step === 1 && ( +
+ +
+ {(Object.values(PRESETS)).map(def => ( + + ))} +
+
+ )} + + {step === 2 && ( +
+
+ + +
+
+ + +
+
+ setIsStatic(!!v)} /> +
+ +

+ Files (worlds, configs, plugin data) are kept across restarts. Uncheck for a fresh-every-restart minigame. +

+
+
+
+ )} + + {step === 3 && ( +
+
+ + setTaskName(e.target.value)} placeholder="Skyblock" /> +
+
+
+ + setMemory(Number(e.target.value))} /> +
+
+ + setMinServiceCount(Number(e.target.value))} /> +
+
+ + setStartPort(Number(e.target.value))} /> +
+
+ + setEnvironment(e.target.value)} /> +
+
+
+ + setGroups(e.target.value)} placeholder="Global-Server, Lobby" /> +
+
+ setBootstrap(!!v)} /> +
+ +

+ Runs the server once so Paper/Purpur creates its default configs (bukkit.yml, spigot.yml, paper-global.yml…), + then saves them into the template so you can edit them from the Templates browser. Adds ~25s to creation. +

+
+
+ {progressMsg && ( +
{progressMsg}
+ )} +
+ )} + + +
+ {step > 1 && ( + + )} +
+
+ + {step < 3 ? ( + + ) : ( + + )} +
+
+
+
+ ) +} diff --git a/src/components/blueprint/createGroupDialog.tsx b/src/components/blueprint/createGroupDialog.tsx new file mode 100644 index 0000000..4f3bbff --- /dev/null +++ b/src/components/blueprint/createGroupDialog.tsx @@ -0,0 +1,91 @@ +'use client' +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { toast } from 'sonner' +import { PlusIcon } from 'lucide-react' +import { groupApi } from '@/lib/client-api' + +export default function CreateGroupDialog() { + const router = useRouter() + const [open, setOpen] = useState(false) + const [name, setName] = useState('') + const [envs, setEnvs] = useState('MINECRAFT_SERVER') + const [busy, setBusy] = useState(false) + + const submit = async () => { + if (!/^[A-Za-z0-9_-]{1,40}$/.test(name)) { + toast.error('Group name must be alphanumeric') + return + } + setBusy(true) + try { + const body = { + name, + jvmOptions: [], + processParameters: [], + environmentVariables: {}, + targetEnvironments: envs.split(',').map(x => x.trim()).filter(Boolean), + templates: [], + deployments: [], + includes: [], + properties: {} + } + const res: any = await groupApi.update(body) + if ((res.status ?? 0) >= 400) { + toast.error(`Failed: HTTP ${res.status}`) + } else { + toast.success(`Group ${name} created`) + setOpen(false) + setName('') + router.refresh() + } + } catch (e: any) { + toast.error(e.message || 'Failed') + } finally { + setBusy(false) + } + } + + return ( + + + + + + + Create a new group + +
+
+ + setName(e.target.value)} placeholder="MyGroup" /> +
+
+ + setEnvs(e.target.value)} placeholder="MINECRAFT_SERVER" /> +

+ Common values: MINECRAFT_SERVER, VELOCITY, BUNGEECORD. Leave blank to keep the group manual-only. +

+
+
+ + + + +
+
+ ) +} diff --git a/src/components/blueprint/createServiceDialog.tsx b/src/components/blueprint/createServiceDialog.tsx new file mode 100644 index 0000000..ba19a4d --- /dev/null +++ b/src/components/blueprint/createServiceDialog.tsx @@ -0,0 +1,98 @@ +'use client' +import { useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { Checkbox } from '@/components/ui/checkbox' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { toast } from 'sonner' +import { PlusIcon } from 'lucide-react' +import { taskApi, serviceCreateApi } from '@/lib/client-api' + +export default function CreateServiceDialog() { + const router = useRouter() + const [open, setOpen] = useState(false) + const [tasks, setTasks] = useState([]) + const [taskName, setTaskName] = useState('') + const [autoStart, setAutoStart] = useState(true) + const [busy, setBusy] = useState(false) + + useEffect(() => { + if (!open) return + taskApi.list().then((res: any) => { + const raw = res?.data?.tasks ?? res?.tasks ?? [] + const names = Array.isArray(raw) ? raw.map((t: any) => t.name).sort() : [] + setTasks(names) + }).catch(() => {}) + }, [open]) + + const submit = async () => { + if (!taskName) { toast.error('Pick a task'); return } + setBusy(true) + try { + const res: any = await serviceCreateApi.create(taskName, autoStart) + if ((res.status ?? 0) >= 400) { + toast.error(`Failed: HTTP ${res.status}`) + } else { + toast.success(`Service ${taskName}-* created${autoStart ? ' & starting' : ''}`) + setOpen(false) + setTaskName('') + router.refresh() + } + } catch (e: any) { + toast.error(e.message || 'Failed') + } finally { + setBusy(false) + } + } + + return ( + + + + + + + Create a new service instance + Spawns a new service from an existing task. + +
+
+ + +
+
+ setAutoStart(!!v)} /> + +
+
+ + + + +
+
+ ) +} diff --git a/src/components/blueprint/saveAsTemplateDialog.tsx b/src/components/blueprint/saveAsTemplateDialog.tsx new file mode 100644 index 0000000..356b9a2 --- /dev/null +++ b/src/components/blueprint/saveAsTemplateDialog.tsx @@ -0,0 +1,81 @@ +'use client' +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { toast } from 'sonner' +import { SaveIcon } from 'lucide-react' +import { serviceCreateApi } from '@/lib/client-api' + +export default function SaveAsTemplateDialog({ serviceId, serviceName }: { serviceId: string; serviceName?: string }) { + const router = useRouter() + const [open, setOpen] = useState(false) + const [prefix, setPrefix] = useState(serviceName?.split('-')[0] || '') + const [name, setName] = useState('snapshot') + const [busy, setBusy] = useState(false) + + const submit = async () => { + if (!prefix || !name) { toast.error('Prefix and name required'); return } + setBusy(true) + try { + const res: any = await serviceCreateApi.saveAsTemplate(serviceId, prefix, name) + if ((res.status ?? 0) >= 400) { + toast.error(`Failed at step "${res.step ?? '?'}": HTTP ${res.status}`) + } else { + toast.success(`Saved to local/${prefix}/${name}`) + setOpen(false) + router.refresh() + } + } catch (e: any) { + toast.error(e.message || 'Failed') + } finally { + setBusy(false) + } + } + + return ( + + + + + + + Save current runtime as a template + + Captures the current files of this service into a new template. On a live service, the snapshot is what's on disk *right now* — some plugins buffer writes, save/flush first if needed. + + +
+
+ + +
+
+ + setPrefix(e.target.value)} placeholder="Skyblock" /> +
+
+ + setName(e.target.value)} placeholder="snapshot" /> +
+
+ + + + +
+
+ ) +} diff --git a/src/components/services/serviceFileBrowser.tsx b/src/components/services/serviceFileBrowser.tsx new file mode 100644 index 0000000..5eadae4 --- /dev/null +++ b/src/components/services/serviceFileBrowser.tsx @@ -0,0 +1,455 @@ +'use client' +import { useCallback, useEffect, useRef, useState } from 'react' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from '@/components/ui/table' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@/components/ui/dialog' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger +} from '@/components/ui/alert-dialog' +import { serviceFilesApi } from '@/lib/client-api' +import SaveAsTemplateDialog from '@/components/blueprint/saveAsTemplateDialog' +import { formatBytes } from '@/components/formatBytes' +import { formatDate } from '@/components/formatDate' +import { toast } from 'sonner' +import { + FolderIcon, + FileIcon, + UploadIcon, + DownloadIcon, + Trash2Icon, + PencilIcon, + FolderPlusIcon, + FilePlusIcon, + HomeIcon, + ChevronRightIcon, + RefreshCwIcon +} from 'lucide-react' + +type Entry = { + name: string + path: string + directory: boolean + size: number + lastModified: number +} + +const TEXT_EXTS = new Set([ + '.txt', '.log', '.yml', '.yaml', '.json', '.toml', '.properties', '.conf', '.cfg', + '.ini', '.md', '.sh', '.env', '.xml', '.js', '.ts', '.tsx', '.jsx', '.py', '.java', + '.html', '.css', '.gitignore', '.gitattributes' +]) +const looksTextual = (name: string) => { + const lower = name.toLowerCase() + if (lower === 'eula.txt' || lower === 'ops.json') return true + const dot = lower.lastIndexOf('.') + const ext = dot >= 0 ? lower.slice(dot) : lower + return TEXT_EXTS.has(ext) +} + +export default function ServiceFileBrowser({ serviceId }: { serviceId: string }) { + const [dir, setDir] = useState('') + const [items, setItems] = useState([]) + const [busy, setBusy] = useState(false) + const [dragActive, setDragActive] = useState(false) + const [progress, setProgress] = useState<{ done: number; total: number } | null>(null) + const fileInput = useRef(null) + + const load = useCallback(async (showToast = false) => { + setBusy(true) + try { + const res: any = await serviceFilesApi.list(serviceId, dir) + const raw = res?.data ?? res + const arr: Entry[] = Array.isArray(raw?.files) ? raw.files : [] + arr.sort((a, b) => (a.directory !== b.directory ? (a.directory ? -1 : 1) : a.name.localeCompare(b.name))) + setItems(arr) + if (showToast) toast.success('Refreshed') + } catch (e: any) { + toast.error(`List failed: ${e.message}`) + } finally { + setBusy(false) + } + }, [serviceId, dir]) + + useEffect(() => { load() }, [load]) + + const enter = (name: string) => setDir(dir ? `${dir}/${name}` : name) + const goTo = (path: string) => setDir(path) + const segments = dir ? dir.split('/') : [] + + const doUpload = async (files: FileList | File[]) => { + const list = Array.from(files) + if (!list.length) return + setProgress({ done: 0, total: list.length }) + let ok = 0 + for (let i = 0; i < list.length; i++) { + const f = list[i] + const rel = (f as any).webkitRelativePath || f.name + const target = dir ? `${dir}/${rel}` : rel + try { + const res = await serviceFilesApi.uploadFile(serviceId, target, f) + if (res.status >= 400) throw new Error(`HTTP ${res.status}`) + ok++ + } catch (e: any) { + toast.error(`${f.name}: ${e.message}`) + } + setProgress({ done: i + 1, total: list.length }) + } + setProgress(null) + if (ok) toast.success(`Uploaded ${ok}/${list.length}`) + await load() + } + + const del = async (e: Entry) => { + try { + const res: any = e.directory + ? await serviceFilesApi.deleteDirectory(serviceId, e.path) + : await serviceFilesApi.deleteFile(serviceId, e.path) + if ((res.status ?? 0) >= 400) throw new Error(`HTTP ${res.status}`) + toast.success(`Deleted ${e.name}`) + await load() + } catch (err: any) { + toast.error(err.message) + } + } + + const rename = async (from: string, toName: string) => { + const parent = from.includes('/') ? from.slice(0, from.lastIndexOf('/')) : '' + const target = parent ? `${parent}/${toName}` : toName + try { + const res: any = await serviceFilesApi.rename(serviceId, from, target) + if ((res.status ?? 0) >= 400) throw new Error(`HTTP ${res.status}`) + toast.success(`Renamed to ${toName}`) + await load() + } catch (err: any) { + toast.error(err.message) + } + } + + const mkdir = async (name: string) => { + const target = dir ? `${dir}/${name}` : name + try { + const res: any = await serviceFilesApi.createDirectory(serviceId, target) + if ((res.status ?? 0) >= 400) throw new Error(`HTTP ${res.status}`) + toast.success(`Created folder ${name}`) + await load() + } catch (err: any) { + toast.error(err.message) + } + } + + const mkfile = async (name: string) => { + const target = dir ? `${dir}/${name}` : name + try { + const res: any = await serviceFilesApi.updateText(serviceId, target, '') + if ((res.status ?? 0) >= 400) throw new Error(`HTTP ${res.status}`) + toast.success(`Created ${name}`) + await load() + } catch (err: any) { + toast.error(err.message) + } + } + + return ( +
+
+
+ + {segments.map((seg, i) => ( + + + + + ))} + +
+ +
+ e.target.files && doUpload(e.target.files)} + /> + + + + +
+
+ + {progress && ( +
+ Uploading {progress.done}/{progress.total}… +
+ )} + +
{ e.preventDefault(); setDragActive(false); if (e.dataTransfer?.files) doUpload(e.dataTransfer.files) }} + onDragOver={(e) => { e.preventDefault(); setDragActive(true) }} + onDragLeave={(e) => { e.preventDefault(); setDragActive(false) }} + className={`border rounded-lg overflow-hidden transition-colors ${dragActive ? 'border-primary bg-primary/5' : ''}`} + > +
+ + + Name + Size + Modified + Actions + + + + {items.map((e) => ( + + +
+ {e.directory ? ( + + ) : ( + + )} + {e.directory ? ( + + ) : ( + {e.name} + )} +
+
+ {e.directory ? '-' : formatBytes(e.size)} + {formatDate(new Date(e.lastModified))} + +
+ {!e.directory && looksTextual(e.name) && ( + + )} + {!e.directory && ( + + + + )} + + del(e)} /> +
+
+
+ ))} + {items.length === 0 && ( + + + Empty + + + )} +
+
+ + + ) +} + +function MkdirButton({ onCreate }: { onCreate: (n: string) => void }) { + const [open, setOpen] = useState(false) + const [name, setName] = useState('') + return ( + + + + + + Create folder +
setName(e.target.value)} />
+ + + + +
+
+ ) +} + +function MkfileButton({ onCreate }: { onCreate: (n: string) => void }) { + const [open, setOpen] = useState(false) + const [name, setName] = useState('') + return ( + + + + + + Create file +
setName(e.target.value)} placeholder="config.yml" />
+ + + + +
+
+ ) +} + +function RenameButton({ entry, onRename }: { entry: Entry; onRename: (from: string, to: string) => void }) { + const [open, setOpen] = useState(false) + const [name, setName] = useState(entry.name) + return ( + { setOpen(o); if (o) setName(entry.name) }}> + + + Rename {entry.directory ? 'folder' : 'file'} +
setName(e.target.value)} />
+ + + + +
+
+ ) +} + +function DeleteRowButton({ entry, onDelete }: { entry: Entry; onDelete: () => void }) { + return ( + + + + + Delete {entry.directory ? 'folder' : 'file'} {entry.name}? + This affects the running service immediately. + + + Cancel + Delete + + + + ) +} + +function EditFileButton({ serviceId, filePath, onSaved }: { serviceId: string; filePath: string; onSaved: () => void }) { + const [open, setOpen] = useState(false) + const [content, setContent] = useState('') + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + + const openEditor = async () => { + setLoading(true) + setOpen(true) + try { + const res = await serviceFilesApi.getText(serviceId, filePath) + if (res.status >= 400) { + toast.error(`Cannot open: HTTP ${res.status}`) + setOpen(false) + } else { + setContent(res.text) + } + } catch (e: any) { + toast.error(e.message) + setOpen(false) + } finally { + setLoading(false) + } + } + + const save = async () => { + setSaving(true) + try { + const res: any = await serviceFilesApi.updateText(serviceId, filePath, content) + if ((res.status ?? 0) >= 400) throw new Error(`HTTP ${res.status}`) + toast.success('Saved') + setOpen(false) + onSaved() + } catch (e: any) { + toast.error(e.message) + } finally { + setSaving(false) + } + } + + return ( + <> + + + + + {filePath} + + {loading ? ( +
Loading…
+ ) : ( +