diff --git a/README.md b/README.md
index 501b313..9e9d19b 100644
--- a/README.md
+++ b/README.md
@@ -79,6 +79,29 @@ Fill in `NEXT_PUBLIC_CLOUDNET_ADDRESS` in your .env file, like for example: `NEX
If you want to use a domain: `NEXT_PUBLIC_CLOUDNET_ADDRESS=https://cloudnet.example.com`.
+## Panel workflows
+
+See [`docs/PANEL_FEATURES.md`](docs/PANEL_FEATURES.md) for how the panel's
+task / group / template / service editors compose, when a change is picked
+up by a running server vs. only by future ones, and an end-to-end
+walkthrough of a minigame network using slime worlds.
+
+## Runtime service files (opt-in)
+
+The panel can expose a **Files** tab on each service that reads and writes
+the files of the running service in real time. This only makes sense when
+the panel is deployed **on the same host** as the CloudNet node, and it is
+disabled by default. To enable:
+
+1. In `docker-compose.yml` (or a `docker-compose.override.yml`) uncomment
+ the `volumes:` block that bind-mounts the node's `temp/services`
+ directory into `/services` inside the container.
+2. Set `CLOUDNET_SERVICES_PATH=/services` in your `.env`.
+3. Rebuild the container.
+
+The Files tab appears automatically when the endpoint reports it enabled
+and the user has `cloudnet_rest:service_write` (or `global:admin`).
+
## Bugs may occur
Meaning if you encounter any issues, please open up an issue. You are welcome to contribute to this project and create a PR.
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/docs/PANEL_FEATURES.md b/docs/PANEL_FEATURES.md
new file mode 100644
index 0000000..5629e27
--- /dev/null
+++ b/docs/PANEL_FEATURES.md
@@ -0,0 +1,129 @@
+# Panel features — how they compose
+
+This PR adds workflows on top of what CloudNet already exposes over REST.
+It does **not** change how CloudNet itself works — the same rules about
+ephemeral vs. static services still apply, and they matter more than the
+features themselves. This doc explains what each button does, when the
+change actually reaches the server, and walks through a realistic
+minigame + Advanced Slime World Manager (ASWM) setup end to end.
+
+## The one rule that governs everything
+
+CloudNet has two service modes, set on the **task**:
+
+| Mode | Task fields | Behaviour |
+|---|---|---|
+| **Ephemeral** (default) | `autoDeleteOnStop: true`, `staticServices: false` | Runtime directory is destroyed on stop. Next start recreates the service **from the template**. |
+| **Static** | `autoDeleteOnStop: false`, `staticServices: true` | Runtime directory persists. Template is applied **only on first creation**. Subsequent starts do **not** re-apply the template. |
+
+**Minigame servers are almost always ephemeral** — you want a clean map at the start of every match. **Lobby, Survival, Skyblock are static** — you want configs and worlds to survive restarts.
+
+The whole point of the features below is to make it easy to put things in the right place for each mode.
+
+## Feature-by-feature: does the running server actually pick it up?
+
+| Feature | Ephemeral service | Static service |
+|---|---|---|
+| **Blueprint wizard** (Tasks → New task) | Creates the task and template — used for every future instance. | Same. |
+| **Templates → Xxx/default → edit file** | Applied to every new instance (each match starts fresh from template). | Applied only on **first** creation; running static services already ran past that. |
+| **Task form editor** (Form / JSON tabs) | New instances see the change on their next spawn. | Existing static services keep their old config; stop + start to rebuild from the updated task. |
+| **Group form editor** | Same as task. | Same as task. |
+| **Files tab** (runtime file browser) | Change is visible immediately to the running server. **Lost when the match ends** — the runtime directory is destroyed. | Persists across restarts because the runtime directory is kept. |
+| **Actions → Attach template / Flush** | Copies template files into the running service now. **Lost when the match ends.** | Kept. |
+| **Actions → Add inclusion / Download now** | Downloads the URL into the runtime now. **Lost when the match ends.** | Kept. |
+| **Actions → Send command** | Runs the command in the console. Anything the plugin persists to disk shares the same rules. | Same. |
+| **Actions → Save as template** (in Files tab) | Snapshots the runtime into a **template** — future matches start from this new state. | Same — a static service can also be snapshotted this way. |
+| **Actions → Wipe files** | Effectively a no-op for ephemeral (the runtime resets on next start anyway). | Nuclear — deletes the persistent runtime. |
+
+**One takeaway**: for an ephemeral service, anything you want to keep across matches must end up in the template. The panel gives you four ways to get it there:
+
+1. `Blueprint wizard` with the **bootstrap** checkbox at creation
+2. `Templates → local → Xxx/default` — direct edit for text configs / drag & drop jars
+3. `Files tab → Save as template` — after live-testing, snapshot the runtime into the template
+4. `Actions → Add deployment target` + `Deploy resources now` — same as #3, in two clicks
+
+## End-to-end walkthrough: minigame network with slime worlds
+
+Assume you want a Bedwars-style minigame using **ASWM** (Advanced Slime World Manager) so every match loads a slime world from MySQL (fast, no world folder on disk). Each game server is ephemeral: one match, then throw away.
+
+### 1. Create the task
+
+`Tasks → New task`
+
+- Preset: **Minigame / Event**
+- Server software: `paper` or `purpur`, MC version of your choice
+- Persistence: **Ephemeral** (default for this preset)
+- Task name: `Bedwars`, memory 2048 MB, min instances 2, start port `45500`
+- **Check "Pre-generate config files"** — CloudNet spins up a seed service so Paper writes out `bukkit.yml`, `spigot.yml`, `paper-global.yml`, `config/…`, then saves them into the template. Adds ~25s.
+
+Result: task `Bedwars`, template `local/Bedwars/default` with the jar and all default configs.
+
+### 2. Drop the plugins into the template
+
+`Templates → local → Bedwars → default → plugins/`
+
+Drag & drop:
+- `AdvancedSlimeWorldManager.jar`
+- Your minigame plugin `MyBedwars.jar`
+- Any dependencies (LuckPerms, ProtocolLib, …)
+
+The panel uploads them straight into the template folder.
+
+### 3. Configure ASWM
+
+First give ASWM a chance to write its default config. Two options:
+
+- **Preferred**: `Services → New service` → pick `Bedwars` and start one — it comes up, ASWM writes `plugins/AdvancedSlimeWorldManager/config.yml`, then stop the service, go to the service's **Files** tab, click **Save as template** with `Bedwars/default`. Now the template has the ASWM default config. Delete the throwaway service.
+- **Faster**: create the config file manually in the template with the values you want.
+
+Then `Templates → local → Bedwars → default → plugins → AdvancedSlimeWorldManager → config.yml`, open it, set your MySQL / MongoDB data source, list the slime worlds ASWM should load, save.
+
+### 4. Configure your minigame plugin the same way
+
+`Templates → local → Bedwars → default → plugins → MyBedwars → config.yml` — set arena names to match the slime world names ASWM will load.
+
+### 5. Start playing
+
+`Services → New service → Bedwars` — 2 instances spawn (the `minServiceCount` from the task), each loads its slime world from the DB, one match runs, everyone leaves, service auto-deletes, next match starts fresh from the same template. **This is the whole point of ephemeral + template + slime worlds together**: no world files to clean up, no per-match config drift, every match starts identical.
+
+### 6. Iterate
+
+You want to change a plugin config for the next match:
+
+- **Persistent change** (all future matches): edit the file in `Templates → local → Bedwars → default → plugins → …/config.yml`. Next match spawned starts with the new config.
+- **Hot patch during a match** (live but disposable): edit the same file in the running service's **Files** tab, then `Actions → Send command` → `/mybedwars reload`. The change lasts for this match only.
+- **You did a hot patch and it's good, keep it**: on the running service's **Files** tab click **Save as template** → `Bedwars/default`. Next match uses the new state.
+
+### 7. Add a new arena
+
+- Slime worlds are stored in your DB. Add the new slime with ASWM's own tools (or upload the .slime file if you keep them on disk).
+- Update `plugins/AdvancedSlimeWorldManager/config.yml` in the template to list the new world.
+- Update `plugins/MyBedwars/config.yml` in the template to declare the new arena.
+- Next match sees the new arena.
+
+### 8. Upgrade a plugin across the network
+
+- Drop the new jar into `Templates → local → Bedwars → default → plugins/` (overwrites the old one).
+- New matches use the new jar. Currently-running matches keep the old one until they end.
+- To force everyone onto the new version now: on each running service, `Actions → Attach template / Flush` with `Bedwars/default` (copies the plugin into the running service; you'll still need `/reload confirm` or restart).
+
+## What NOT to do
+
+- **Don't** edit the runtime `Files` tab of an ephemeral service expecting the change to survive the match. Use the template instead.
+- **Don't** convert a minigame task to `Static` to keep runtime changes — you'll accumulate worlds, logs, plugin data per instance until the disk fills. If you want changes to stick, save them to the template.
+- **Don't** put a `.slime` file inside the template if you're storing worlds in a DB — ASWM will get confused. Choose one storage.
+- **Don't** rely on the `Wipe files` action for cleanup on ephemeral services — they clean themselves. `Wipe files` is a big red button that only makes sense on static services when you want to fully reset one.
+
+## Where each button hits CloudNet
+
+Everything goes through the existing REST API, no changes to the node:
+
+- Templates: `POST /template/{s}/{p}/{n}/create`, `POST /file/create`, `POST /deploy`, `POST /directory/create`, `GET /file/download`, `DELETE`
+- Tasks: `POST /task` (upsert), `DELETE /task/{name}`
+- Groups: `POST /group` (upsert), `DELETE /group/{name}`
+- Services: `POST /service/create/taskName`, `PATCH /service/{id}/lifecycle?target=`, `POST /service/{id}/add/template`, `POST /service/{id}/add/deployment`, `POST /service/{id}/add/inclusion`, `POST /service/{id}/deployResources`, `POST /service/{id}/command`, `DELETE /service/{id}/deleteFiles`
+- Runtime service files: filesystem access via `CLOUDNET_SERVICES_PATH` bind-mount (feature-flagged)
+
+## Tested on
+
+CloudNet 4.0.0-RC17, Purpur 26.2, Velocity 3.5.1, ASWM InfernalSuite `dev/26.2` branch.
diff --git a/src/app/[locale]/(dashboard)/dashboard/groups/[groupId]/page.client.tsx b/src/app/[locale]/(dashboard)/dashboard/groups/[groupId]/page.client.tsx
index a9db826..6cf17ac 100644
--- a/src/app/[locale]/(dashboard)/dashboard/groups/[groupId]/page.client.tsx
+++ b/src/app/[locale]/(dashboard)/dashboard/groups/[groupId]/page.client.tsx
@@ -9,6 +9,8 @@ import { Terminal } from 'lucide-react'
import { toast } from 'sonner'
import { groupApi } from '@/lib/client-api'
import { useTranslations } from 'gt-next/client'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import GroupFormEditor from '@/components/editors/groupFormEditor'
export default function GroupClientPage({
group,
@@ -85,17 +87,26 @@ export default function GroupClientPage({
{groupConfigData && (
-
JSON
-
-
+
+
+ Form
+ JSON
+
+
+
+
+
+ JSON
+
+
)}
>
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..acc46d5 100644
--- a/src/app/[locale]/(dashboard)/dashboard/services/[serviceId]/page.tsx
+++ b/src/app/[locale]/(dashboard)/dashboard/services/[serviceId]/page.tsx
@@ -17,6 +17,9 @@ 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'
+import ServiceActionsTab from '@/components/services/serviceActionsTab'
export default async function UserPage(props) {
const params = await props.params
@@ -129,6 +132,8 @@ export default async function UserPage(props) {
service?.configuration.serviceId.nameSplitter +
service?.configuration.serviceId.taskServiceId || serviceT('name')
+ const showFilesTab = serviceFilesEnabled() && hasEditPermissions
+
return (
@@ -141,6 +146,12 @@ export default async function UserPage(props) {
) && (
{serviceT('console')}
)}
+ {hasEditPermissions && (
+ Actions
+ )}
+ {showFilesTab && (
+ Files
+ )}
)}
+ {hasEditPermissions && (
+
+
+
+ )}
+ {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/[taskId]/page.client.tsx b/src/app/[locale]/(dashboard)/dashboard/tasks/[taskId]/page.client.tsx
index ad341dc..112ea74 100644
--- a/src/app/[locale]/(dashboard)/dashboard/tasks/[taskId]/page.client.tsx
+++ b/src/app/[locale]/(dashboard)/dashboard/tasks/[taskId]/page.client.tsx
@@ -20,6 +20,8 @@ import { Terminal } from 'lucide-react'
import { toast } from 'sonner'
import { taskApi } from '@/lib/client-api'
import { useTranslations } from 'gt-next/client'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import TaskFormEditor from '@/components/editors/taskFormEditor'
function DeleteButton({ taskId }: { taskId: string }) {
const router = useRouter()
@@ -122,16 +124,25 @@ export default function TaskClientPage({
{children}
-
{taskT('json')}
-
-
+
+
+ Form
+ {taskT('json')}
+
+
+
+
+
+ {taskT('json')}
+
+
)
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/[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/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]/actions/add-deployment/route.ts b/src/app/api/services/[id]/actions/add-deployment/route.ts
new file mode 100644
index 0000000..d39393f
--- /dev/null
+++ b/src/app/api/services/[id]/actions/add-deployment/route.ts
@@ -0,0 +1,39 @@
+import { NextResponse } from 'next/server'
+import { checkPermissions, makeApiRequest, createApiRoute } from '@/lib/api-helpers'
+import { safeSegment } from '@/lib/pathSafe'
+
+// Body: { storage?: string, prefix: string, name: string, flush?: boolean }
+export const POST = createApiRoute(async (req, { params }) => {
+ const permissionCheck = await checkPermissions([
+ 'cloudnet_rest:service_write',
+ 'cloudnet_rest:service_add_deployment',
+ '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 flush = body.flush === true
+
+ const res = await makeApiRequest(
+ `/service/${id}/add/deployment?flush=${flush}`,
+ 'POST',
+ {
+ template: { prefix, name, storage, priority: 0, alwaysCopyToStaticServices: false },
+ excludes: [],
+ includes: [],
+ properties: {}
+ },
+ { stringifyBody: true, returnJson: false }
+ )
+ if (res.status === 204) return new NextResponse(null, { status: 204 })
+ return NextResponse.json(res, { status: res.status })
+})
diff --git a/src/app/api/services/[id]/actions/add-inclusion/route.ts b/src/app/api/services/[id]/actions/add-inclusion/route.ts
new file mode 100644
index 0000000..d2f6b28
--- /dev/null
+++ b/src/app/api/services/[id]/actions/add-inclusion/route.ts
@@ -0,0 +1,36 @@
+import { NextResponse } from 'next/server'
+import { checkPermissions, makeApiRequest, createApiRoute } from '@/lib/api-helpers'
+
+// Body: { url: string, destination: string, flush?: boolean }
+// Adds a remote inclusion (URL → path inside the service) to be downloaded
+// on the next start, or immediately if flush=true.
+export const POST = createApiRoute(async (req, { params }) => {
+ const permissionCheck = await checkPermissions([
+ 'cloudnet_rest:service_write',
+ 'cloudnet_rest:service_add_inclusion',
+ 'global:admin'
+ ])
+ if (permissionCheck) return NextResponse.json(permissionCheck, { status: permissionCheck.status })
+
+ const { id } = await params
+ const body = await req.json()
+ const url: string = (body.url || '').trim()
+ const destination: string = (body.destination || '').trim()
+ const flush = body.flush === true
+
+ if (!/^https?:\/\//i.test(url)) {
+ return NextResponse.json({ error: 'url must be http(s)://' }, { status: 400 })
+ }
+ if (!destination || destination.startsWith('/') || destination.includes('..') || destination.includes('\\')) {
+ return NextResponse.json({ error: 'destination must be a relative path without ..' }, { status: 400 })
+ }
+
+ const res = await makeApiRequest(
+ `/service/${id}/add/inclusion?flush=${flush}`,
+ 'POST',
+ { url, destination, properties: {} },
+ { stringifyBody: true, returnJson: false }
+ )
+ if (res.status === 204) return new NextResponse(null, { status: 204 })
+ return NextResponse.json(res, { status: res.status })
+})
diff --git a/src/app/api/services/[id]/actions/add-template/route.ts b/src/app/api/services/[id]/actions/add-template/route.ts
new file mode 100644
index 0000000..4d9b558
--- /dev/null
+++ b/src/app/api/services/[id]/actions/add-template/route.ts
@@ -0,0 +1,37 @@
+import { NextResponse } from 'next/server'
+import { checkPermissions, makeApiRequest, createApiRoute } from '@/lib/api-helpers'
+import { safeSegment } from '@/lib/pathSafe'
+
+// Body: { storage?: string, prefix: string, name: string, flush?: boolean }
+// Attaches a template to a running service. The template is applied to the
+// next start unless flush=true, in which case CloudNet also copies it into
+// the current runtime immediately.
+export const POST = createApiRoute(async (req, { params }) => {
+ const permissionCheck = await checkPermissions([
+ 'cloudnet_rest:service_write',
+ 'cloudnet_rest:service_add_template',
+ '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 flush = body.flush === true
+
+ const res = await makeApiRequest(
+ `/service/${id}/add/template?flush=${flush}`,
+ 'POST',
+ { prefix, name, storage, priority: 0, alwaysCopyToStaticServices: false },
+ { stringifyBody: true, returnJson: false }
+ )
+ if (res.status === 204) return new NextResponse(null, { status: 204 })
+ return NextResponse.json(res, { status: res.status })
+})
diff --git a/src/app/api/services/[id]/actions/delete-files/route.ts b/src/app/api/services/[id]/actions/delete-files/route.ts
new file mode 100644
index 0000000..1de3640
--- /dev/null
+++ b/src/app/api/services/[id]/actions/delete-files/route.ts
@@ -0,0 +1,24 @@
+import { NextResponse } from 'next/server'
+import { checkPermissions, makeApiRequest, createApiRoute } from '@/lib/api-helpers'
+
+// DESTRUCTIVE — proxies DELETE /service/{id}/deleteFiles which wipes the
+// service's ENTIRE runtime tree (still keeps CloudNet wrapper metadata).
+// The panel gates this behind an explicit confirm dialog.
+export const POST = createApiRoute(async (_req, { params }) => {
+ const permissionCheck = await checkPermissions([
+ 'cloudnet_rest:service_write',
+ 'cloudnet_rest:service_delete_files',
+ 'global:admin'
+ ])
+ if (permissionCheck) return NextResponse.json(permissionCheck, { status: permissionCheck.status })
+
+ const { id } = await params
+ const res = await makeApiRequest(
+ `/service/${id}/deleteFiles`,
+ 'DELETE',
+ undefined,
+ { returnJson: false }
+ )
+ if (res.status === 204) return new NextResponse(null, { status: 204 })
+ return NextResponse.json(res, { status: res.status })
+})
diff --git a/src/app/api/services/[id]/actions/deploy-resources/route.ts b/src/app/api/services/[id]/actions/deploy-resources/route.ts
new file mode 100644
index 0000000..f5d8e79
--- /dev/null
+++ b/src/app/api/services/[id]/actions/deploy-resources/route.ts
@@ -0,0 +1,25 @@
+import { NextResponse } from 'next/server'
+import { checkPermissions, makeApiRequest, createApiRoute } from '@/lib/api-helpers'
+
+// ?remove=true|false — whether to clear the pending deployments afterwards
+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 { searchParams } = new URL(req.url)
+ const remove = searchParams.get('remove') !== 'false'
+
+ const res = await makeApiRequest(
+ `/service/${id}/deployResources?remove=${remove}`,
+ 'POST',
+ undefined,
+ { returnJson: false }
+ )
+ if (res.status === 204) return new NextResponse(null, { status: 204 })
+ return NextResponse.json(res, { status: res.status })
+})
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/app/api/templates/[storageId]/[prefixId]/[name]/create/route.ts b/src/app/api/templates/[storageId]/[prefixId]/[name]/create/route.ts
new file mode 100644
index 0000000..658196a
--- /dev/null
+++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/create/route.ts
@@ -0,0 +1,36 @@
+import { NextResponse } from 'next/server'
+import {
+ checkPermissions,
+ makeApiRequest,
+ createApiRoute
+} from '@/lib/api-helpers'
+import { safeTemplateTriple } from '@/lib/pathSafe'
+
+export const POST = createApiRoute(async (_req, { params }) => {
+ const p = await params
+ let storageId: string, prefixId: string, name: string
+ try {
+ ;({ storageId, prefixId, name } = safeTemplateTriple(p.storageId, p.prefixId, p.name))
+ } catch (e: any) {
+ return NextResponse.json({ error: e.message }, { status: 400 })
+ }
+
+ const requiredPermissions = [
+ 'cloudnet_rest:template_write',
+ 'cloudnet_rest:template_create',
+ 'global:admin'
+ ]
+
+ const permissionCheck = await checkPermissions(requiredPermissions)
+ if (permissionCheck) {
+ return NextResponse.json(permissionCheck, {
+ status: permissionCheck.status
+ })
+ }
+
+ const response = await makeApiRequest(
+ `/template/${storageId}/${prefixId}/${name}/create`,
+ 'POST'
+ )
+ return NextResponse.json(response)
+})
diff --git a/src/app/api/templates/[storageId]/[prefixId]/[name]/deploy/route.ts b/src/app/api/templates/[storageId]/[prefixId]/[name]/deploy/route.ts
new file mode 100644
index 0000000..e8bbf5b
--- /dev/null
+++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/deploy/route.ts
@@ -0,0 +1,59 @@
+import { NextResponse } from 'next/server'
+import { checkPermissions, createApiRoute } from '@/lib/api-helpers'
+import { getCookies } from '@/lib/server-calls'
+import { safeTemplateTriple } from '@/lib/pathSafe'
+
+export const POST = createApiRoute(async (req, { params }) => {
+ const p = await params
+ let storageId: string, prefixId: string, name: string
+ try {
+ ;({ storageId, prefixId, name } = safeTemplateTriple(p.storageId, p.prefixId, p.name))
+ } catch (e: any) {
+ return NextResponse.json({ error: e.message }, { status: 400 })
+ }
+
+ const requiredPermissions = [
+ 'cloudnet_rest:template_write',
+ 'cloudnet_rest:template_deploy',
+ 'global:admin'
+ ]
+
+ const permissionCheck = await checkPermissions(requiredPermissions)
+ if (permissionCheck) {
+ return NextResponse.json(permissionCheck, {
+ status: permissionCheck.status
+ })
+ }
+
+ const cookies = await getCookies()
+ const accessToken = cookies['at']
+ const address = cookies['add']
+
+ if (!accessToken || !address) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const bodyBuffer = await req.arrayBuffer()
+
+ const upstream = await fetch(
+ `${decodeURIComponent(address)}/template/${storageId}/${prefixId}/${name}/deploy`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/zip',
+ Authorization: `Bearer ${accessToken}`
+ },
+ body: bodyBuffer
+ }
+ )
+
+ const text = await upstream.text()
+ return new NextResponse(text || null, {
+ status: upstream.status,
+ headers: { 'Content-Type': upstream.headers.get('content-type') || 'application/json' }
+ })
+})
+
+export const config = {
+ api: { bodyParser: false }
+}
diff --git a/src/app/api/templates/[storageId]/[prefixId]/[name]/directory/create/route.ts b/src/app/api/templates/[storageId]/[prefixId]/[name]/directory/create/route.ts
new file mode 100644
index 0000000..a4ffec4
--- /dev/null
+++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/directory/create/route.ts
@@ -0,0 +1,38 @@
+import { NextResponse } from 'next/server'
+import {
+ checkPermissions,
+ makeApiRequest,
+ createApiRoute
+} from '@/lib/api-helpers'
+import { safeTemplatePath, safeTemplateTriple } from '@/lib/pathSafe'
+
+export const POST = createApiRoute(async (req, { params }) => {
+ const p = await params
+ let storageId: string, prefixId: string, name: string, path: string
+ try {
+ ;({ storageId, prefixId, name } = safeTemplateTriple(p.storageId, p.prefixId, p.name))
+ const { searchParams } = new URL(req.url)
+ path = safeTemplatePath(searchParams.get('path'))
+ } catch (e: any) {
+ return NextResponse.json({ error: e.message }, { status: 400 })
+ }
+
+ const requiredPermissions = [
+ 'cloudnet_rest:template_write',
+ 'cloudnet_rest:template_create',
+ 'global:admin'
+ ]
+
+ const permissionCheck = await checkPermissions(requiredPermissions)
+ if (permissionCheck) {
+ return NextResponse.json(permissionCheck, {
+ status: permissionCheck.status
+ })
+ }
+
+ const response = await makeApiRequest(
+ `/template/${storageId}/${prefixId}/${name}/directory/create?path=${encodeURIComponent(path)}`,
+ 'POST'
+ )
+ return NextResponse.json(response)
+})
diff --git a/src/app/api/templates/[storageId]/[prefixId]/[name]/download/route.ts b/src/app/api/templates/[storageId]/[prefixId]/[name]/download/route.ts
new file mode 100644
index 0000000..c9d99b0
--- /dev/null
+++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/download/route.ts
@@ -0,0 +1,56 @@
+import { NextResponse } from 'next/server'
+import { checkPermissions, createApiRoute } from '@/lib/api-helpers'
+import { getCookies } from '@/lib/server-calls'
+import { safeTemplateTriple, contentDispositionAttachment } from '@/lib/pathSafe'
+
+export const GET = createApiRoute(async (_req, { params }) => {
+ const p = await params
+ let storageId: string, prefixId: string, name: string
+ try {
+ ;({ storageId, prefixId, name } = safeTemplateTriple(p.storageId, p.prefixId, p.name))
+ } catch (e: any) {
+ return NextResponse.json({ error: e.message }, { status: 400 })
+ }
+
+ const requiredPermissions = [
+ 'cloudnet_rest:template_read',
+ 'cloudnet_rest:template_download',
+ 'global:admin'
+ ]
+
+ const permissionCheck = await checkPermissions(requiredPermissions)
+ if (permissionCheck) {
+ return NextResponse.json(permissionCheck, {
+ status: permissionCheck.status
+ })
+ }
+
+ const cookies = await getCookies()
+ const accessToken = cookies['at']
+ const address = cookies['add']
+
+ if (!accessToken || !address) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const upstream = await fetch(
+ `${decodeURIComponent(address)}/template/${storageId}/${prefixId}/${name}/download`,
+ {
+ method: 'GET',
+ headers: { Authorization: `Bearer ${accessToken}` }
+ }
+ )
+
+ if (!upstream.ok) {
+ const text = await upstream.text()
+ return new NextResponse(text || null, { status: upstream.status })
+ }
+
+ return new NextResponse(upstream.body, {
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/zip',
+ 'Content-Disposition': contentDispositionAttachment(`${prefixId}-${name}.zip`)
+ }
+ })
+})
diff --git a/src/app/api/templates/[storageId]/[prefixId]/[name]/file/download/route.ts b/src/app/api/templates/[storageId]/[prefixId]/[name]/file/download/route.ts
new file mode 100644
index 0000000..0543205
--- /dev/null
+++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/file/download/route.ts
@@ -0,0 +1,62 @@
+import { NextResponse } from 'next/server'
+import { checkPermissions, createApiRoute } from '@/lib/api-helpers'
+import { getCookies } from '@/lib/server-calls'
+import { safeTemplatePath, safeTemplateTriple, contentDispositionAttachment } from '@/lib/pathSafe'
+
+export const GET = createApiRoute(async (req, { params }) => {
+ const p = await params
+ let storageId: string, prefixId: string, name: string, path: string
+ try {
+ ;({ storageId, prefixId, name } = safeTemplateTriple(p.storageId, p.prefixId, p.name))
+ const { searchParams } = new URL(req.url)
+ path = safeTemplatePath(searchParams.get('path'))
+ } catch (e: any) {
+ return NextResponse.json({ error: e.message }, { status: 400 })
+ }
+ if (!path) {
+ return NextResponse.json({ error: 'path required' }, { status: 400 })
+ }
+
+ const requiredPermissions = [
+ 'cloudnet_rest:template_read',
+ 'cloudnet_rest:template_file_get',
+ 'global:admin'
+ ]
+
+ const permissionCheck = await checkPermissions(requiredPermissions)
+ if (permissionCheck) {
+ return NextResponse.json(permissionCheck, {
+ status: permissionCheck.status
+ })
+ }
+
+ const cookies = await getCookies()
+ const accessToken = cookies['at']
+ const address = cookies['add']
+
+ if (!accessToken || !address) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const upstream = await fetch(
+ `${decodeURIComponent(address)}/template/${storageId}/${prefixId}/${name}/file/download?path=${encodeURIComponent(path)}`,
+ {
+ method: 'GET',
+ headers: { Authorization: `Bearer ${accessToken}` }
+ }
+ )
+
+ if (!upstream.ok) {
+ const text = await upstream.text()
+ return new NextResponse(text || null, { status: upstream.status })
+ }
+
+ const filename = path.split('/').pop() || 'file'
+ return new NextResponse(upstream.body, {
+ status: 200,
+ headers: {
+ 'Content-Type': upstream.headers.get('content-type') || 'application/octet-stream',
+ 'Content-Disposition': contentDispositionAttachment(filename)
+ }
+ })
+})
diff --git a/src/app/api/templates/[storageId]/[prefixId]/[name]/file/upload/route.ts b/src/app/api/templates/[storageId]/[prefixId]/[name]/file/upload/route.ts
new file mode 100644
index 0000000..29ccd69
--- /dev/null
+++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/file/upload/route.ts
@@ -0,0 +1,65 @@
+import { NextResponse } from 'next/server'
+import { checkPermissions, createApiRoute } from '@/lib/api-helpers'
+import { getCookies } from '@/lib/server-calls'
+import { safeTemplatePath, safeTemplateTriple } from '@/lib/pathSafe'
+
+export const POST = createApiRoute(async (req, { params }) => {
+ const p = await params
+ let storageId: string, prefixId: string, name: string, path: string
+ try {
+ ;({ storageId, prefixId, name } = safeTemplateTriple(p.storageId, p.prefixId, p.name))
+ const { searchParams } = new URL(req.url)
+ path = safeTemplatePath(searchParams.get('path'))
+ } catch (e: any) {
+ return NextResponse.json({ error: e.message }, { status: 400 })
+ }
+ if (!path) {
+ return NextResponse.json({ error: 'path required' }, { status: 400 })
+ }
+
+ const requiredPermissions = [
+ 'cloudnet_rest:template_write',
+ 'cloudnet_rest:template_file_append',
+ 'global:admin'
+ ]
+
+ const permissionCheck = await checkPermissions(requiredPermissions)
+ if (permissionCheck) {
+ return NextResponse.json(permissionCheck, {
+ status: permissionCheck.status
+ })
+ }
+
+ const cookies = await getCookies()
+ const accessToken = cookies['at']
+ const address = cookies['add']
+
+ if (!accessToken || !address) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const bodyBuffer = await req.arrayBuffer()
+ const contentType = req.headers.get('content-type') || 'application/octet-stream'
+
+ const upstream = await fetch(
+ `${decodeURIComponent(address)}/template/${storageId}/${prefixId}/${name}/file/create?path=${encodeURIComponent(path)}`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': contentType,
+ Authorization: `Bearer ${accessToken}`
+ },
+ body: bodyBuffer
+ }
+ )
+
+ const responseText = await upstream.text()
+ return new NextResponse(responseText || null, {
+ status: upstream.status,
+ headers: { 'Content-Type': upstream.headers.get('content-type') || 'application/json' }
+ })
+})
+
+export const config = {
+ api: { bodyParser: false }
+}
diff --git a/src/app/api/templates/[storageId]/[prefixId]/[name]/rename/route.ts b/src/app/api/templates/[storageId]/[prefixId]/[name]/rename/route.ts
new file mode 100644
index 0000000..9f344a0
--- /dev/null
+++ b/src/app/api/templates/[storageId]/[prefixId]/[name]/rename/route.ts
@@ -0,0 +1,127 @@
+import { NextResponse } from 'next/server'
+import { checkPermissions, createApiRoute } from '@/lib/api-helpers'
+import { getCookies } from '@/lib/server-calls'
+import { safeTemplatePath, safeTemplateTriple } from '@/lib/pathSafe'
+
+// Emulates rename by download → upload with new path → delete old.
+// Body: { from: string, to: string, isDirectory?: boolean }
+export const POST = createApiRoute(async (req, { params }) => {
+ const p = await params
+ let storageId: string, prefixId: string, name: string
+ try {
+ ;({ storageId, prefixId, name } = safeTemplateTriple(p.storageId, p.prefixId, p.name))
+ } catch (e: any) {
+ return NextResponse.json({ error: e.message }, { status: 400 })
+ }
+
+ const requiredPermissions = [
+ 'cloudnet_rest:template_write',
+ 'cloudnet_rest:template_file_append',
+ 'global:admin'
+ ]
+
+ const permissionCheck = await checkPermissions(requiredPermissions)
+ if (permissionCheck) {
+ return NextResponse.json(permissionCheck, {
+ status: permissionCheck.status
+ })
+ }
+
+ const cookies = await getCookies()
+ const accessToken = cookies['at']
+ const address = cookies['add']
+
+ if (!accessToken || !address) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const body = await req.json()
+ const isDirectory = !!body.isDirectory
+ let from: string, to: string
+ try {
+ from = safeTemplatePath(body.from)
+ to = safeTemplatePath(body.to)
+ } catch (e: any) {
+ return NextResponse.json({ error: e.message }, { status: 400 })
+ }
+
+ if (!from || !to || from === to) {
+ return NextResponse.json({ error: 'Invalid from/to' }, { status: 400 })
+ }
+
+ const base = `${decodeURIComponent(address)}/template/${storageId}/${prefixId}/${name}`
+ const authHeader = { Authorization: `Bearer ${accessToken}` }
+
+ const listFiles = async (path: string): Promise> => {
+ const res = await fetch(
+ `${base}/directory/list?deep=true&directory=${encodeURIComponent(path)}`,
+ { headers: authHeader }
+ )
+ if (!res.ok) return []
+ const data = await res.json()
+ return Array.isArray(data) ? data : []
+ }
+
+ const copyFile = async (srcPath: string, dstPath: string) => {
+ const dl = await fetch(
+ `${base}/file/download?path=${encodeURIComponent(srcPath)}`,
+ { headers: authHeader }
+ )
+ if (!dl.ok) throw new Error(`download failed: ${dl.status}`)
+ const buf = await dl.arrayBuffer()
+ const up = await fetch(
+ `${base}/file/create?path=${encodeURIComponent(dstPath)}`,
+ {
+ method: 'POST',
+ headers: {
+ ...authHeader,
+ 'Content-Type': 'application/octet-stream'
+ },
+ body: buf
+ }
+ )
+ if (!up.ok) throw new Error(`upload failed: ${up.status}`)
+ }
+
+ const mkdir = async (path: string) => {
+ await fetch(
+ `${base}/directory/create?path=${encodeURIComponent(path)}`,
+ { method: 'POST', headers: authHeader }
+ )
+ }
+
+ const deleteFile = async (path: string) => {
+ await fetch(
+ `${base}/file?path=${encodeURIComponent(path)}`,
+ { method: 'DELETE', headers: authHeader }
+ )
+ }
+
+ try {
+ if (isDirectory) {
+ const items = await listFiles(from)
+ await mkdir(to)
+ for (const item of items) {
+ const relative = item.path.startsWith(from + '/')
+ ? item.path.slice(from.length + 1)
+ : item.path
+ const dstPath = `${to}/${relative}`
+ if (item.directory) {
+ await mkdir(dstPath)
+ } else {
+ await copyFile(item.path, dstPath)
+ }
+ }
+ for (const item of items.slice().reverse()) {
+ await deleteFile(item.path)
+ }
+ await deleteFile(from)
+ } else {
+ await copyFile(from, to)
+ await deleteFile(from)
+ }
+ return NextResponse.json({ status: 204 }, { status: 204 })
+ } catch (e: any) {
+ return NextResponse.json({ error: e.message || 'rename failed' }, { status: 500 })
+ }
+})
diff --git a/src/components/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 ?? (
+
+ New task
+
+ )}
+
+
+
+ Create a new task {step > 1 && `— step ${step}/3`}
+
+ Runs template + version install + task upsert{bootstrap ? ' + config bootstrap' : ''}.
+
+
+
+ {step === 1 && (
+
+
Server type
+
+ {(Object.values(PRESETS)).map(def => (
+
setPreset(def.key)}
+ className={`text-left border rounded-md p-3 hover:border-primary transition-colors ${preset === def.key ? 'border-primary bg-primary/5' : ''}`}
+ >
+
+ {def.label}
+
+ {def.description}
+
+ ))}
+
+
+ )}
+
+ {step === 2 && (
+
+
+ Server software
+
+
+
+ {availableTypes.map(t => {t} )}
+
+
+
+
+ Minecraft version
+
+
+
+ {availableVersions.map(v => {v} )}
+
+
+
+
+
setIsStatic(!!v)} />
+
+
Persistent (static)
+
+ Files (worlds, configs, plugin data) are kept across restarts. Uncheck for a fresh-every-restart minigame.
+
+
+
+
+ )}
+
+ {step === 3 && (
+
+
+ Task name
+ setTaskName(e.target.value)} placeholder="Skyblock" />
+
+
+
+ Groups (comma-separated)
+ setGroups(e.target.value)} placeholder="Global-Server, Lobby" />
+
+
+
setBootstrap(!!v)} />
+
+
Pre-generate config files
+
+ 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 && (
+ setStep(step - 1)} disabled={busy}>Back
+ )}
+
+
+ setOpen(false)} disabled={busy}>Cancel
+ {step < 3 ? (
+ setStep(step + 1)} disabled={busy}>Next
+ ) : (
+
+ {busy ? 'Working…' : 'Create'}
+
+ )}
+
+
+
+
+ )
+}
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 (
+
+
+
+ New group
+
+
+
+
+ Create a new group
+
+
+
+ Name
+ setName(e.target.value)} placeholder="MyGroup" />
+
+
+
Target environments (comma-separated)
+
setEnvs(e.target.value)} placeholder="MINECRAFT_SERVER" />
+
+ Common values: MINECRAFT_SERVER, VELOCITY, BUNGEECORD. Leave blank to keep the group manual-only.
+
+
+
+
+ setOpen(false)} disabled={busy}>Cancel
+ {busy ? 'Creating…' : 'Create'}
+
+
+
+ )
+}
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 (
+
+
+
+ New service
+
+
+
+
+ Create a new service instance
+ Spawns a new service from an existing task.
+
+
+
+ Task
+
+
+
+ {tasks.map(t => {t} )}
+
+
+
+
+ setAutoStart(!!v)} />
+ Start immediately
+
+
+
+ setOpen(false)} disabled={busy}>Cancel
+ {busy ? 'Creating…' : 'Create'}
+
+
+
+ )
+}
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 as template
+
+
+
+
+ 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.
+
+
+
+
+ setOpen(false)} disabled={busy}>Cancel
+ {busy ? 'Saving…' : 'Save'}
+
+
+
+ )
+}
diff --git a/src/components/editors/groupFormEditor.tsx b/src/components/editors/groupFormEditor.tsx
new file mode 100644
index 0000000..f196db0
--- /dev/null
+++ b/src/components/editors/groupFormEditor.tsx
@@ -0,0 +1,87 @@
+'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 { Textarea } from '@/components/ui/textarea'
+import { toast } from 'sonner'
+import { groupApi } from '@/lib/client-api'
+import { StringList, TemplateList, KeyValueList } from './taskFormEditor'
+
+export default function GroupFormEditor({ group, groupName }: { group: any; groupName: string }) {
+ const router = useRouter()
+ const [g, setG] = useState(() => JSON.parse(JSON.stringify(group)))
+ const [saving, setSaving] = useState(false)
+
+ const set = (k: string, v: any) => setG({ ...g, [k]: v })
+
+ const save = async () => {
+ setSaving(true)
+ try {
+ if (g.name !== groupName) { toast.warning('Renaming a group is not supported here'); return }
+ const res: any = await groupApi.update(g)
+ if ((res.status ?? 0) >= 400) toast.error(`Save failed (HTTP ${res.status})`)
+ else { toast.success('Group saved'); router.refresh() }
+ } catch (e: any) {
+ toast.error(e.message || 'Save failed')
+ } finally { setSaving(false) }
+ }
+
+ return (
+
+
+
+ Name
+
+
+
+
+
Target environments
+
set('targetEnvironments', v)} placeholder="MINECRAFT_SERVER" />
+
+ Services with any of these environments will automatically inherit this group's templates and JVM options.
+
+
+
+
+ Templates
+ set('templates', v)} />
+
+
+
+ JVM options
+ set('jvmOptions', v)} placeholder="-XX:+UseG1GC" />
+
+
+
+ Process parameters
+ set('processParameters', v)} placeholder="--nogui" />
+
+
+
+ Environment variables
+ set('environmentVariables', v)} />
+
+
+
+ Deployments (raw JSON)
+
+
+
+ Includes (raw JSON)
+
+
+
+
+ {saving ? 'Saving…' : 'Save changes'}
+
+
+ )
+}
diff --git a/src/components/editors/taskFormEditor.tsx b/src/components/editors/taskFormEditor.tsx
new file mode 100644
index 0000000..31b39cc
--- /dev/null
+++ b/src/components/editors/taskFormEditor.tsx
@@ -0,0 +1,295 @@
+'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 { Switch } from '@/components/ui/switch'
+import { Textarea } from '@/components/ui/textarea'
+import {
+ Select, SelectContent, SelectItem, SelectTrigger, SelectValue
+} from '@/components/ui/select'
+import { toast } from 'sonner'
+import { taskApi } from '@/lib/client-api'
+import { PlusIcon, XIcon } from 'lucide-react'
+
+const ENVIRONMENTS = [
+ 'MINECRAFT_SERVER', 'MODDED_MINECRAFT_SERVER', 'VELOCITY', 'BUNGEECORD',
+ 'NUKKIT', 'WATERDOG_PE', 'GLOWSTONE'
+]
+const RUNTIMES = ['jvm', 'docker-jvm']
+
+export default function TaskFormEditor({ task, taskName }: { task: any; taskName: string }) {
+ const router = useRouter()
+ const [t, setT] = useState(() => JSON.parse(JSON.stringify(task)))
+ const [saving, setSaving] = useState(false)
+
+ // Persistence is really a pair (autoDeleteOnStop, staticServices). Expose a
+ // single-choice UI to avoid users setting incompatible combos.
+ const persistence: 'ephemeral' | 'static' = t.staticServices ? 'static' : 'ephemeral'
+ const setPersistence = (p: 'ephemeral' | 'static') => {
+ setT({ ...t, staticServices: p === 'static', autoDeleteOnStop: p !== 'static' })
+ }
+
+ const setPath = (path: string, value: any) => {
+ const parts = path.split('.')
+ const next = JSON.parse(JSON.stringify(t))
+ let cur = next
+ for (let i = 0; i < parts.length - 1; i++) cur = cur[parts[i]] ??= {}
+ cur[parts[parts.length - 1]] = value
+ setT(next)
+ }
+
+ const setList = (key: keyof typeof t | 'processConfiguration.jvmOptions' | 'processConfiguration.processParameters', v: string[]) => {
+ setPath(key as string, v)
+ }
+
+ const save = async () => {
+ setSaving(true)
+ try {
+ if (t.name !== taskName) { toast.warning('Renaming a task is not supported here'); return }
+ const res: any = await taskApi.update(t)
+ if ((res.status ?? 0) >= 400) {
+ toast.error(`Save failed (HTTP ${res.status})`)
+ } else {
+ toast.success('Task saved')
+ router.refresh()
+ }
+ } catch (e: any) {
+ toast.error(e.message || 'Save failed')
+ } finally { setSaving(false) }
+ }
+
+ return (
+
+
+
+
+
+
+ setPersistence(v as any)}>
+
+
+ Ephemeral — files reset every restart
+ Static — files kept across restarts
+
+
+
+
+
+ setPath('maintenance', v)} />
+ {t.maintenance ? 'On (players blocked)' : 'Off'}
+
+
+
+ setPath('minServiceCount', Number(e.target.value))} />
+
+
+ setPath('startPort', Number(e.target.value))} />
+
+
+
+
+
+
+
+
+ setList('groups', v)}
+ placeholder="Global-Server"
+ />
+
+
+ setPath('templates', v)}
+ />
+
+
+
+
+
+ setPath('processConfiguration.jvmOptions', v)}
+ placeholder="-XX:+UseG1GC"
+ />
+
+
+ setPath('processConfiguration.processParameters', v)}
+ placeholder="--nogui"
+ />
+
+
+ setPath('processConfiguration.environmentVariables', v)}
+ />
+
+
+
+
+
+
+
+
+ {saving ? 'Saving…' : 'Save changes'}
+
+
+ )
+}
+
+function Section({ title, children, collapsed = false }: { title: string; children: React.ReactNode; collapsed?: boolean }) {
+ const [open, setOpen] = useState(!collapsed)
+ return (
+
+
setOpen(!open)}
+ className="w-full text-left px-4 py-3 font-medium flex items-center justify-between hover:bg-muted/40"
+ >
+ {title}
+ {open ? '▾' : '▸'}
+
+ {open &&
{children}
}
+
+ )
+}
+
+function Field({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+ {label}
+ {children}
+
+ )
+}
+
+export function StringList({ values, onChange, placeholder }: { values: string[]; onChange: (v: string[]) => void; placeholder?: string }) {
+ const [draft, setDraft] = useState('')
+ const add = () => {
+ const v = draft.trim()
+ if (v && !values.includes(v)) onChange([...values, v])
+ setDraft('')
+ }
+ return (
+
+
+ {values.map((v, i) => (
+
+ {v}
+ onChange(values.filter((_, j) => j !== i))} className="hover:text-destructive">
+
+
+
+ ))}
+
+
+
setDraft(e.target.value)}
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); add() } }}
+ placeholder={placeholder} />
+
+
+
+ )
+}
+
+export function TemplateList({ values, onChange }: { values: any[]; onChange: (v: any[]) => void }) {
+ const update = (i: number, field: string, v: any) => {
+ const next = values.slice()
+ next[i] = { ...next[i], [field]: v }
+ onChange(next)
+ }
+ const add = () => onChange([...values, { prefix: '', name: 'default', storage: 'local', priority: 0, alwaysCopyToStaticServices: false }])
+ return (
+
+ )
+}
+
+export function KeyValueList({ values, onChange }: { values: Record; onChange: (v: Record) => void }) {
+ const entries = Object.entries(values)
+ const update = (i: number, k: string, v: string) => {
+ const next: Record = {}
+ entries.forEach(([ek, ev], j) => {
+ if (j === i) next[k] = v
+ else next[ek] = ev
+ })
+ onChange(next)
+ }
+ const add = () => onChange({ ...values, '': '' })
+ return (
+
+ )
+}
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/services/serviceActionsTab.tsx b/src/components/services/serviceActionsTab.tsx
new file mode 100644
index 0000000..71481ac
--- /dev/null
+++ b/src/components/services/serviceActionsTab.tsx
@@ -0,0 +1,310 @@
+'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 { Checkbox } from '@/components/ui/checkbox'
+import {
+ Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger
+} from '@/components/ui/dialog'
+import {
+ AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
+ AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger
+} from '@/components/ui/alert-dialog'
+import { toast } from 'sonner'
+import { serviceApi, serviceActionsApi } from '@/lib/client-api'
+import {
+ PackagePlusIcon, UploadCloudIcon, DownloadCloudIcon, PlayIcon,
+ Trash2Icon, TerminalIcon
+} from 'lucide-react'
+
+export default function ServiceActionsTab({ serviceId, serviceName }: { serviceId: string; serviceName: string }) {
+ return (
+
+
}
+ title="Attach template"
+ description="Adds a template to this service. Applied on the next start unless you check Flush now, in which case CloudNet copies the template's files into the running service immediately."
+ trigger={
}
+ />
+
}
+ title="Add deployment target"
+ description="Sets a template as a deployment target — the next Deploy resources will copy this service's files into it. Use to snapshot state."
+ trigger={
}
+ />
+
}
+ title="Add remote inclusion"
+ description="Downloads a file from an HTTP(s) URL into the service at a given relative path. Useful for pulling a plugin jar or a config from a repo."
+ trigger={
}
+ />
+
}
+ title="Deploy resources now"
+ description="Flushes all pending deployments — copies files from the runtime into every attached deployment template right now."
+ trigger={
}
+ />
+
}
+ title="Send console command"
+ description="Runs a single command in the service's console — same as typing it in the Console tab."
+ trigger={
}
+ />
+
}
+ title="Wipe runtime files"
+ description="Deletes ALL files of this service's runtime tree. Static services lose their persistent data too. Cannot be undone."
+ trigger={
}
+ destructive
+ />
+
+ )
+}
+
+function ActionCard({ icon, title, description, trigger, destructive = false }: {
+ icon: React.ReactNode; title: string; description: string; trigger: React.ReactNode; destructive?: boolean
+}) {
+ return (
+
+
{icon}
+
+
{title}
+
{description}
+
+
{trigger}
+
+ )
+}
+
+function AddTemplateDialog({ serviceId }: { serviceId: string }) {
+ const router = useRouter()
+ const [open, setOpen] = useState(false)
+ const [storage, setStorage] = useState('local')
+ const [prefix, setPrefix] = useState('')
+ const [name, setName] = useState('default')
+ const [flush, setFlush] = useState(false)
+ 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 serviceActionsApi.addTemplate(serviceId, prefix, name, storage, flush)
+ if ((res.status ?? 0) >= 400) toast.error(`Failed (HTTP ${res.status})`)
+ else { toast.success(`Template ${prefix}/${name} attached`); setOpen(false); router.refresh() }
+ } finally { setBusy(false) }
+ }
+ return (
+
+ Attach
+
+ Attach template
+
+
+ setOpen(false)} disabled={busy}>Cancel
+ {busy ? 'Attaching…' : 'Attach'}
+
+
+
+ )
+}
+
+function AddDeploymentDialog({ serviceId }: { serviceId: string }) {
+ const router = useRouter()
+ const [open, setOpen] = useState(false)
+ const [storage, setStorage] = useState('local')
+ const [prefix, setPrefix] = useState('')
+ const [name, setName] = useState('default')
+ const [flush, setFlush] = useState(false)
+ 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 serviceActionsApi.addDeployment(serviceId, prefix, name, storage, flush)
+ if ((res.status ?? 0) >= 400) toast.error(`Failed (HTTP ${res.status})`)
+ else { toast.success(`Deployment target ${prefix}/${name} added`); setOpen(false); router.refresh() }
+ } finally { setBusy(false) }
+ }
+ return (
+
+ Add
+
+ Add deployment target
+
+
+ setOpen(false)} disabled={busy}>Cancel
+ {busy ? 'Adding…' : 'Add'}
+
+
+
+ )
+}
+
+function AddInclusionDialog({ serviceId }: { serviceId: string }) {
+ const router = useRouter()
+ const [open, setOpen] = useState(false)
+ const [url, setUrl] = useState('')
+ const [dest, setDest] = useState('plugins/')
+ const [flush, setFlush] = useState(true)
+ const [busy, setBusy] = useState(false)
+
+ const submit = async () => {
+ if (!url || !dest) { toast.error('URL and destination required'); return }
+ setBusy(true)
+ try {
+ const res: any = await serviceActionsApi.addInclusion(serviceId, url, dest, flush)
+ if ((res.status ?? 0) >= 400) {
+ const detail = res?.data?.detail || res?.error || `HTTP ${res.status}`
+ toast.error(`Failed: ${detail}`)
+ } else { toast.success(`Inclusion ${dest} queued`); setOpen(false); router.refresh() }
+ } finally { setBusy(false) }
+ }
+ return (
+
+ Add
+
+ Add remote inclusion
+
+
+ setOpen(false)} disabled={busy}>Cancel
+ {busy ? 'Queuing…' : 'Add'}
+
+
+
+ )
+}
+
+function DeployNowButton({ serviceId }: { serviceId: string }) {
+ const router = useRouter()
+ const [busy, setBusy] = useState(false)
+ const run = async () => {
+ setBusy(true)
+ try {
+ const res: any = await serviceActionsApi.deployResources(serviceId, true)
+ if ((res.status ?? 0) >= 400) toast.error(`Failed (HTTP ${res.status})`)
+ else { toast.success('Resources deployed'); router.refresh() }
+ } finally { setBusy(false) }
+ }
+ return {busy ? 'Deploying…' : 'Deploy'}
+}
+
+function SendCommandDialog({ serviceId, serviceName }: { serviceId: string; serviceName: string }) {
+ const [open, setOpen] = useState(false)
+ const [cmd, setCmd] = useState('')
+ const [busy, setBusy] = useState(false)
+ const run = async () => {
+ if (!cmd.trim()) return
+ setBusy(true)
+ try {
+ const res: any = await serviceApi.execute(serviceId, cmd)
+ if ((res.status ?? 0) >= 400) toast.error(`Failed (HTTP ${res.status})`)
+ else { toast.success(`Sent: ${cmd}`); setCmd(''); setOpen(false) }
+ } finally { setBusy(false) }
+ }
+ return (
+
+ Send
+
+
+ Send command to {serviceName}
+ The command runs in the service's own console — see the Console tab for output.
+
+
+
+ setCmd(e.target.value)}
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); run() } }}
+ placeholder="say Hello"
+ autoFocus
+ />
+
+
+
+ setOpen(false)} disabled={busy}>Cancel
+ {busy ? 'Sending…' : 'Send'}
+
+
+
+ )
+}
+
+function WipeFilesButton({ serviceId }: { serviceId: string }) {
+ const router = useRouter()
+ const [busy, setBusy] = useState(false)
+ const run = async () => {
+ setBusy(true)
+ try {
+ const res: any = await serviceActionsApi.wipeFiles(serviceId)
+ if ((res.status ?? 0) >= 400) toast.error(`Failed (HTTP ${res.status})`)
+ else { toast.success('Runtime files wiped'); router.refresh() }
+ } finally { setBusy(false) }
+ }
+ return (
+
+
+ {busy ? 'Wiping…' : 'Wipe'}
+
+
+
+ Wipe all runtime files?
+
+ This deletes every file of the service's runtime tree, including worlds, plugin data, and configs. Static services lose their persistent data too. Cannot be undone.
+
+
+
+ Cancel
+
+ Wipe files
+
+
+
+
+ )
+}
+
+function FieldRow({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+ {label}
+ {children}
+
+ )
+}
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 (
+
+
+
+ goTo('')}
+ title="Root"
+ >
+
+
+ {segments.map((seg, i) => (
+
+
+ goTo(segments.slice(0, i + 1).join('/'))}
+ >
+ {seg}
+
+
+ ))}
+ load(true)}
+ title="Refresh"
+ >
+
+
+
+
+
+ e.target.files && doUpload(e.target.files)}
+ />
+ fileInput.current?.click()}>
+ Upload
+
+
+
+
+
+
+
+ {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 ? (
+ enter(e.name)}
+ >
+ {e.name}
+
+ ) : (
+ {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 (
+
+
+ New folder
+
+
+ Create folder
+ Name setName(e.target.value)} />
+
+ setOpen(false)}>Cancel
+ { if (name) { onCreate(name); setName(''); setOpen(false) } }}>Create
+
+
+
+ )
+}
+
+function MkfileButton({ onCreate }: { onCreate: (n: string) => void }) {
+ const [open, setOpen] = useState(false)
+ const [name, setName] = useState('')
+ return (
+
+
+ New file
+
+
+ Create file
+ Name setName(e.target.value)} placeholder="config.yml" />
+
+ setOpen(false)}>Cancel
+ { if (name) { onCreate(name); setName(''); setOpen(false) } }}>Create
+
+
+
+ )
+}
+
+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'}
+ New name setName(e.target.value)} />
+
+ setOpen(false)}>Cancel
+ { onRename(entry.path, name); setOpen(false) }}>Rename
+
+
+
+ )
+}
+
+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…
+ ) : (
+
+
+ >
+ )
+}
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 (
+
+
+
+
+ New template
+
+
+
+
+ Create template
+
+ Creates an empty template at {s}/{p}/{n}.
+
+
+
+
+ setOpen(false)} disabled={busy}>
+ Cancel
+
+
+ {busy ? 'Creating…' : 'Create'}
+
+
+
+
+ )
+}
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])}
+ />
+
inputRef.current?.click()} disabled={uploading}>
+ Upload files
+
+
+
+
zipInputRef.current?.click()} disabled={uploading}>
+ Deploy zip
+
+
+
+ Download zip
+
+
+
+
+
+
+ {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)}
- >
-
- Delete
+ {file.directory ? '-' : formatBytes(file.size)}
+ {formatDate(new Date(file.lastModified))}
+
+
+ {!file.directory && (
+
+
+
-
-
-
- )
- })}
-
-
+
+ )}
+
+
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 (
+
+
+
+ New folder
+
+
+
+
+ Create folder
+
+
+ Folder name
+ setName(e.target.value)} placeholder="plugins" />
+
+
+ setOpen(false)}>Cancel
+ {
+ onCreate(name)
+ setName('')
+ setOpen(false)
+ }}
+ >
+ Create
+
+
+
+
+ )
+}
+
+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 (
+
+
+
+ New file
+
+
+
+
+ Create empty file
+
+
+ File name
+ setName(e.target.value)} placeholder="config.yml" />
+
+
+ setOpen(false)} disabled={busy}>Cancel
+ {
+ setBusy(true)
+ const target = currentDir ? `${currentDir}/${name}` : name
+ const blob = new Blob([''], { type: 'text/plain' })
+ const res = await templateStorageApi.uploadFile(
+ storageId,
+ prefixId,
+ templateId,
+ target,
+ blob
+ )
+ setBusy(false)
+ if (res.status >= 400) {
+ toast.error(`Failed (${res.status})`)
+ } else {
+ toast.success(`Created ${name}`)
+ setName('')
+ setOpen(false)
+ onCreated()
+ router.refresh()
+ }
+ }}
+ >
+ Create
+
+
+
+
+ )
+}
+
+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'}
+
+
+
New name
+
setName(e.target.value)} />
+ {item.directory && (
+
+ Note: renaming a folder copies every file inside then deletes the old — may be slow for large folders.
+
+ )}
+
+
+ setOpen(false)}>Cancel
+ {
+ onRename(item, name)
+ setOpen(false)
+ }}
+ >
+ Rename
+
+
+
+
)
}
-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 template
+
+
+
+
+ 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..31e8c8b 100644
--- a/src/lib/client-api.ts
+++ b/src/lib/client-api.ts
@@ -33,22 +33,17 @@ async function handleResponse(response: Response): Promise> {
}
const text = await response.text()
+ // 2xx with empty body (204 No Content, etc.) is a valid success — return
+ // just the status so callers can differentiate. Do NOT throw here.
if (!text) {
- throw new ApiError(
- response.status,
- response.statusText,
- response.statusText
- )
+ return { status: response.status } as ApiResponse
}
try {
return JSON.parse(text)
- } catch (e) {
- throw new ApiError(
- response.status,
- response.statusText,
- response.statusText
- )
+ } catch {
+ // 2xx with non-JSON body: also a valid success (raw text). Wrap it.
+ return { status: response.status, data: text as unknown as T } as ApiResponse
}
}
@@ -284,5 +279,175 @@ 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
+ })
+}
+
+// Live filesystem browser for RUNNING services. Enabled only when the panel
+// is deployed alongside the CloudNet node and CLOUDNET_SERVICES_PATH is set
+// server-side; the /enabled endpoint tells the UI whether to render the tab.
+export const serviceFilesApi = {
+ enabled: (id: string) =>
+ apiGet<{ enabled: boolean }>(`/api/services/${id}/files/enabled`),
+ list: (id: string, directory: string = '') =>
+ apiGet<{ files: any[] }>(
+ `/api/services/${id}/files/directory/list`,
+ { directory }
+ ),
+ getText: async (id: string, filePath: string) => {
+ const baseUrl = process.env.NEXT_PUBLIC_DOMAIN
+ const url = `${baseUrl}/api/services/${id}/files/file/get?path=${encodeURIComponent(filePath)}`
+ const res = await fetch(url)
+ const text = await res.text()
+ return { status: res.status, text }
+ },
+ updateText: (id: string, filePath: string, content: string) =>
+ apiPost(`/api/services/${id}/files/file/update`, { path: filePath, content }),
+ uploadFile: async (id: string, filePath: string, file: File | Blob) => {
+ const baseUrl = process.env.NEXT_PUBLIC_DOMAIN
+ const url = `${baseUrl}/api/services/${id}/files/file/upload?path=${encodeURIComponent(filePath)}`
+ const res = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': (file as any).type || 'application/octet-stream' },
+ body: file
+ })
+ return { status: res.status }
+ },
+ downloadUrl: (id: string, filePath: string) =>
+ `${process.env.NEXT_PUBLIC_DOMAIN}/api/services/${id}/files/file/download?path=${encodeURIComponent(filePath)}`,
+ createDirectory: (id: string, filePath: string) =>
+ apiPost(
+ `/api/services/${id}/files/directory/create`,
+ {},
+ { path: filePath }
+ ),
+ deleteFile: (id: string, filePath: string) =>
+ apiPost(
+ `/api/services/${id}/files/file/delete`,
+ {},
+ { path: filePath }
+ ),
+ deleteDirectory: (id: string, filePath: string) =>
+ apiPost(
+ `/api/services/${id}/files/directory/delete`,
+ {},
+ { path: filePath }
+ ),
+ rename: (id: string, from: string, to: string) =>
+ apiPost(`/api/services/${id}/files/rename`, { from, to })
+}
+
+// Extra creation helpers wired to the panel's proxy routes.
+export const serviceCreateApi = {
+ create: (taskName: string, start: boolean = true) =>
+ apiPost('/api/service/create', { taskName, start }),
+ saveAsTemplate: (id: string, prefix: string, name: string, storage: string = 'local') =>
+ apiPost(`/api/services/${id}/save-as-template`, { prefix, name, storage })
+}
+
+export const versionApi = {
+ list: () => apiGet('/api/serviceVersion/list')
+}
+
+export const blueprintApi = {
+ create: (body: {
+ taskName: string
+ preset: string
+ environment: string
+ groups: string[]
+ static: boolean
+ memory: number
+ minServiceCount: number
+ startPort: number
+ serviceVersionType?: string
+ serviceVersion?: string
+ javaCommand?: string
+ bootstrap: boolean
+ }) => apiPost('/api/blueprint', body)
+}
+
+// Live-service actions: attach template/deployment/inclusion, flush deploys,
+// wipe files. Each one is a thin passthrough to a CloudNet REST action.
+export const serviceActionsApi = {
+ addTemplate: (id: string, prefix: string, name: string, storage: string = 'local', flush: boolean = false) =>
+ apiPost(`/api/services/${id}/actions/add-template`, { prefix, name, storage, flush }),
+ addDeployment: (id: string, prefix: string, name: string, storage: string = 'local', flush: boolean = false) =>
+ apiPost(`/api/services/${id}/actions/add-deployment`, { prefix, name, storage, flush }),
+ addInclusion: (id: string, url: string, destination: string, flush: boolean = false) =>
+ apiPost(`/api/services/${id}/actions/add-inclusion`, { url, destination, flush }),
+ deployResources: (id: string, remove: boolean = true) =>
+ apiPost(`/api/services/${id}/actions/deploy-resources`, {}, { remove: String(remove) }),
+ wipeFiles: (id: string) =>
+ apiPost(`/api/services/${id}/actions/delete-files`, {})
}
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}`
+}
diff --git a/src/lib/serviceFs.ts b/src/lib/serviceFs.ts
new file mode 100644
index 0000000..b2dccb2
--- /dev/null
+++ b/src/lib/serviceFs.ts
@@ -0,0 +1,124 @@
+// Filesystem access to a running service's directory.
+//
+// Enabled only when CLOUDNET_SERVICES_PATH is set (feature flag). The
+// bind-mount is expected to point at CloudNet's `temp/services` directory —
+// each running service lives under `_` inside it.
+//
+// Every path handed to this module is treated as untrusted user input:
+// - the service id must match a real subdirectory (no `..`, no absolute paths)
+// - the file path must resolve, after normalization, inside that subdirectory
+// (guards against `..` inside the query, and symlinks whose target escapes)
+import { promises as fs, constants as fsc } from 'fs'
+import path from 'path'
+
+export function servicesRoot(): string | null {
+ const p = process.env.CLOUDNET_SERVICES_PATH
+ return p && p.trim() !== '' ? p : null
+}
+
+export function isEnabled(): boolean {
+ return servicesRoot() !== null
+}
+
+// Resolve the on-disk directory for a service identifier. Accepts both a
+// full directory name (`Lobby-1_edbc124c-...`) and a bare service uuid — in
+// the latter case we look up the subdir whose name ends with `_`.
+export async function resolveServiceDir(id: string): Promise {
+ const root = servicesRoot()
+ if (!root) throw new Error('service files browser disabled')
+
+ if (!id || id.includes('/') || id.includes('\\') || id.includes('..')) {
+ throw new Error('invalid service id')
+ }
+
+ // Try direct name first (fast path).
+ const direct = path.join(root, id)
+ try {
+ const st = await fs.stat(direct)
+ if (st.isDirectory()) {
+ const real = await fs.realpath(direct)
+ if (real === direct || real.startsWith(root + path.sep)) return real
+ }
+ } catch {
+ // fall through to uuid lookup
+ }
+
+ // UUID lookup — match a directory that ends with `_`.
+ const suffix = '_' + id.toLowerCase()
+ const entries = await fs.readdir(root, { withFileTypes: true })
+ for (const e of entries) {
+ if (e.isDirectory() && e.name.toLowerCase().endsWith(suffix)) {
+ const real = await fs.realpath(path.join(root, e.name))
+ if (real.startsWith(root + path.sep)) return real
+ }
+ }
+ throw new Error('service not found')
+}
+
+// Resolve `sub` relative to `base`, forbidding any escape. `sub` may be
+// empty (means the base itself). We resolve, then check the resolved path
+// is either equal to `base` or inside `base` — this catches both `..`
+// segments in the query and symlinks whose target lies outside.
+export async function safeJoin(base: string, sub: string | null | undefined): Promise {
+ const raw = (sub ?? '').trim()
+
+ if (/[\x00\r\n]/.test(raw)) throw new Error('unsafe path: control chars')
+ if (raw.includes('\\')) throw new Error('unsafe path: backslash')
+ if (raw.startsWith('/')) throw new Error('unsafe path: absolute')
+
+ const joined = path.resolve(base, raw)
+ // Reject anything that resolves outside the base (before realpath, in case
+ // the target does not exist yet — e.g. creating a new file).
+ if (joined !== base && !joined.startsWith(base + path.sep)) {
+ throw new Error('unsafe path: escapes service directory')
+ }
+
+ // If the target already exists, realpath it and re-check to catch symlinks
+ // whose target escapes.
+ try {
+ const real = await fs.realpath(joined)
+ if (real !== base && !real.startsWith(base + path.sep)) {
+ throw new Error('unsafe path: symlink escapes')
+ }
+ return real
+ } catch (e: any) {
+ if (e.code === 'ENOENT') return joined
+ throw e
+ }
+}
+
+export type Entry = {
+ name: string
+ path: string
+ directory: boolean
+ size: number
+ lastModified: number
+}
+
+export async function listDir(dir: string, base: string): Promise {
+ const items = await fs.readdir(dir, { withFileTypes: true })
+ const out: Entry[] = []
+ for (const it of items) {
+ const full = path.join(dir, it.name)
+ const rel = path.relative(base, full).split(path.sep).join('/')
+ try {
+ const st = await fs.stat(full) // follows symlink; that's ok, we've
+ // already verified `dir` itself resolves inside base
+ out.push({
+ name: it.name,
+ path: rel,
+ directory: st.isDirectory(),
+ size: st.isDirectory() ? 0 : st.size,
+ lastModified: st.mtimeMs
+ })
+ } catch {
+ // dangling symlink, race with delete — skip silently
+ }
+ }
+ return out
+}
+
+export function isProtectedName(name: string): boolean {
+ // Files CloudNet uses to track the service, deleting them breaks the node.
+ return name === '.wrapper' || name === '.token' || name === 'wrapper.jar'
+}