-
-
Notifications
You must be signed in to change notification settings - Fork 9
feat(templates): full template management (create, upload, download, rename, mkdir, deploy, delete) #83
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat(templates): full template management (create, upload, download, rename, mkdir, deploy, delete) #83
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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`) | ||
| } | ||
|
Comment on lines
+49
to
+54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
# Inspect both download handlers and the directly relevant response/cache configuration.
for f in \
'src/app/api/templates/[storageId]/[prefixId]/[name]/download/route.ts' \
'src/app/api/templates/[storageId]/[prefixId]/[name]/file/download/route.ts'
do
echo "===== $f ====="
wc -l "$f"
cat -n "$f"
done
echo "===== cache-related configuration and middleware ====="
git ls-files | rg '(^|/)(middleware|next\.config|vercel\.json|netlify|nginx|.*cache.*|.*headers.*)' || true
rg -n --glob '!node_modules/**' --glob '!dist/**' --glob '!build/**' \
'Cache-Control|cache-control|no-store|revalidate|force-cache|dynamic|headers\s*\(' \
src next.config.* vercel.json 2>/dev/null || trueRepository: docimin/cloudnet-webinterface Length of output: 4800 🏁 Script executed: #!/bin/bash
set -eu
echo "===== next.config.ts ====="
cat -n next.config.ts | sed -n '1,90p'
echo "===== framework/runtime versions ====="
rg -n '"next"|"react"|"version"' package.json package-lock.json pnpm-lock.yaml yarn.lock 2>/dev/null | head -40 || trueRepository: docimin/cloudnet-webinterface Length of output: 3009 Sensitive Data Exposure (CWE-524) Reachability: External · Exploitability: Moderate Add explicit cache isolation to both download responses. Both handlers return session-authorized content without 📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -eu
for f in \
'src/app/api/templates/[storageId]/[prefixId]/[name]/file/upload/route.ts' \
'src/app/api/templates/[storageId]/[prefixId]/[name]/deploy/route.ts'
do
echo "===== $f ====="
wc -l "$f"
sed -n '1,180p' "$f"
done
echo "===== direct request forwarding and body-size controls ====="
rg -n --glob '*.ts' --glob '*.tsx' \
'arrayBuffer\(\)|req\.body|request\.body|fetch\(|content-length|Content-Length|bodySize|body size|maxBodySize|limit' \
src/app/api/templates src | head -240Repository: docimin/cloudnet-webinterface Length of output: 7937 🏁 Script executed: #!/bin/bash
set -eu
echo "===== src/lib/api-helpers.ts ====="
sed -n '1,150p' src/lib/api-helpers.ts
echo "===== package/runtime declarations ====="
for f in package.json next.config.js next.config.mjs next.config.ts vercel.json; do
if [ -f "$f" ]; then
echo "--- $f ---"
cat -n "$f"
fi
done
echo "===== route and middleware references ====="
rg -n --glob '*.ts' --glob '*.tsx' --glob '*.js' --glob '*.mjs' --glob '*.json' \
'createApiRoute|middleware|bodyParser|sizeLimit|experimental\.proxy|proxy' \
src package.json next.config.js next.config.mjs next.config.ts vercel.json 2>/dev/null | head -220Repository: docimin/cloudnet-webinterface Length of output: 21197 Bound or stream request bodies before forwarding them. Both handlers fully buffer request bodies with Apply a server-side size limit before buffering, or stream the request body upstream in both routes. 📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| 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 | ||
|
Comment on lines
+44
to
+52
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
for f in \
'src/app/api/templates/[storageId]/[prefixId]/[name]/file/upload/route.ts' \
'src/app/api/templates/[storageId]/[prefixId]/[name]/deploy/route.ts' \
'src/app/api/templates/[storageId]/[prefixId]/[name]/download/route.ts' \
'src/app/api/templates/[storageId]/[prefixId]/[name]/file/download/route.ts' \
'src/app/api/templates/[storageId]/[prefixId]/[name]/rename/route.ts'
do
echo "===== $f ====="
sed -n '1,120p' "$f"
done
printf '%s\n' '===== address/add configuration references ====='
rg -n --glob '!node_modules' --glob '!dist' --glob '!build' \
"cookies\\[['\"]add['\"]\\]|['\"]add['\"]|CloudNet|cloudnet|https?://" \
src .env* 2>/dev/null | head -200Repository: docimin/cloudnet-webinterface Length of output: 29596 Sensitive Data Exposure (CWE-319): Cleartext Transmission of Sensitive Information Reachability: External Reject non-HTTPS All five routes derive credentialed upstream requests from 📍 Affects 5 files
🤖 Prompt for AI Agents |
||
| } | ||
| ) | ||
|
|
||
| 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 } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Gate the create control with creation permissions.
The create route requires
cloudnet_rest:template_write,cloudnet_rest:template_create, orglobal:admin. These pages render the control after only read/list checks. A read-only user can open the dialog, but the create request fails at the API route.src/app/[locale]/(dashboard)/dashboard/templates/page.tsx#L52-L54: RenderCreateTemplateDialogonly when the user has the creation permission set.src/app/[locale]/(dashboard)/dashboard/templates/[storageId]/page.tsx#L69-L71: RenderCreateTemplateDialogonly when the user has the creation permission set.src/app/[locale]/(dashboard)/dashboard/templates/[storageId]/[storagePrefix]/page.tsx#L76-L78: RenderCreateTemplateDialogonly when the user has the creation permission set.📍 Affects 3 files
src/app/[locale]/(dashboard)/dashboard/templates/page.tsx#L52-L54(this comment)src/app/[locale]/(dashboard)/dashboard/templates/[storageId]/page.tsx#L69-L71src/app/[locale]/(dashboard)/dashboard/templates/[storageId]/[storagePrefix]/page.tsx#L76-L78🤖 Prompt for AI Agents