#!/usr/bin/env bash set -euo pipefail # Usage: # ./apply_blueprint_fixes.sh [PANEL_ROOT] # Example: # sudo bash apply_blueprint_fixes.sh /var/www/pterodactyl # # This script will: # - Backup the current files into: /.blueprint-patch-backups// # - Overwrite the target files with the provided fixed versions # - Run: blueprint -rerun-install (if blueprint is available) ROOT="${1:-$(pwd)}" # Normalize ROOT (remove trailing slash) ROOT="${ROOT%/}" if [[ ! -d "$ROOT" ]]; then echo "❌ Panel root does not exist: $ROOT" >&2 exit 1 fi # Basic sanity check if [[ ! -d "$ROOT/resources" || ! -d "$ROOT/app" ]]; then echo "❌ '$ROOT' doesn't look like a Pterodactyl panel root (missing resources/ or app/)." >&2 echo " Run it like: sudo bash $0 /var/www/pterodactyl" >&2 exit 1 fi TS="$(date +%Y%m%d_%H%M%S)" BACKUP_DIR="$ROOT/.blueprint-patch-backups/$TS" mkdir -p "$BACKUP_DIR" echo "📦 Backup dir: $BACKUP_DIR" put_file() { local rel="$1" local dst="$ROOT/$rel" mkdir -p "$(dirname "$dst")" local mode="" uid="" gid="" if [[ -f "$dst" ]]; then mkdir -p "$BACKUP_DIR/$(dirname "$rel")" cp -a "$dst" "$BACKUP_DIR/$rel" # Preserve permissions/ownership (best effort) mode="$(stat -c '%a' "$dst" 2>/dev/null || true)" uid="$(stat -c '%u' "$dst" 2>/dev/null || true)" gid="$(stat -c '%g' "$dst" 2>/dev/null || true)" fi # Write new content from stdin cat > "$dst" [[ -n "$mode" ]] && chmod "$mode" "$dst" 2>/dev/null || true [[ -n "$uid" && -n "$gid" ]] && chown "$uid:$gid" "$dst" 2>/dev/null || true echo "✅ Patched: $rel" } put_file 'resources/scripts/components/elements/CopyOnClick.tsx' <<'EOF' import React, { useEffect, useState } from 'react'; import Fade from '@/components/elements/Fade'; import Portal from '@/components/elements/Portal'; import copy from 'copy-to-clipboard'; import classNames from 'classnames'; interface CopyOnClickProps { text: string | number | null | undefined; showInNotification?: boolean; children: React.ReactNode; } const CopyOnClick = ({ text, showInNotification = true, children }: CopyOnClickProps) => { const [copied, setCopied] = useState(false); useEffect(() => { if (!copied) return; const timeout = setTimeout(() => { setCopied(false); }, 2500); return () => { clearTimeout(timeout); }; }, [copied]); if (!React.isValidElement(children)) { throw new Error('Component passed to must be a valid React element.'); } const child = !text ? React.Children.only(children) : React.cloneElement(React.Children.only(children), { className: classNames(children.props.className || '', 'cursor-pointer'), onClick: (e: React.MouseEvent) => { copy(String(text)); setCopied(true); if (typeof children.props.onClick === 'function') { children.props.onClick(e); } }, }); return ( <> {copied && (

{showInNotification ? `Copied "${String(text)}" to clipboard.` : 'Copied text to clipboard.'}

)} {child} ); }; export default CopyOnClick; EOF put_file 'resources/scripts/components/server/files/FileNameModal.tsx' <<'EOF' import React from 'react'; import Modal, { RequiredModalProps } from '@/components/elements/Modal'; import { Form, Formik, FormikHelpers } from 'formik'; import { object, string } from 'yup'; import Field from '@/components/elements/Field'; import { ServerContext } from '@/state/server'; const join = (...paths: string[]) => paths.filter(Boolean).join('/'); import tw from 'twin.macro'; import Button from '@/components/elements/Button'; type Props = RequiredModalProps & { onFileNamed: (name: string) => void; }; interface Values { fileName: string; } export default ({ onFileNamed, onDismissed, ...props }: Props) => { const directory = ServerContext.useStoreState((state) => state.files.directory); const submit = (values: Values, { setSubmitting }: FormikHelpers) => { onFileNamed(join(directory, values.fileName)); setSubmitting(false); }; return ( {({ resetForm }) => ( { resetForm(); onDismissed(); }} {...props} >
)}
); }; EOF put_file 'resources/scripts/components/server/files/FileObjectRow.tsx' <<'EOF' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faFileAlt, faFileArchive, faFileImport, faFolder } from '@fortawesome/free-solid-svg-icons'; import { encodePathSegments } from '@/helpers'; import { differenceInHours, format, formatDistanceToNow } from 'date-fns'; import React, { memo } from 'react'; import { FileObject } from '@/api/server/files/loadDirectory'; import FileDropdownMenu from '@/components/server/files/FileDropdownMenu'; import { ServerContext } from '@/state/server'; import { NavLink, useRouteMatch } from 'react-router-dom'; import tw from 'twin.macro'; import isEqual from 'react-fast-compare'; import SelectFileCheckbox from '@/components/server/files/SelectFileCheckbox'; import { usePermissions } from '@/plugins/usePermissions'; const join = (...paths: string[]) => paths.filter(Boolean).join('/'); import { bytesToString } from '@/lib/formatters'; import styles from './style.module.css'; const Clickable: React.FC<{ file: FileObject }> = memo(({ file, children }) => { const [canRead] = usePermissions(['file.read']); const [canReadContents] = usePermissions(['file.read-content']); const directory = ServerContext.useStoreState((state) => state.files.directory); const match = useRouteMatch(); return (file.isFile && (!file.isEditable() || !canReadContents)) || (!file.isFile && !canRead) ? (
{children}
) : ( {children} ); }, isEqual); const FileObjectRow = ({ file }: { file: FileObject }) => (
{ e.preventDefault(); window.dispatchEvent(new CustomEvent(`pterodactyl:files:ctx:${file.key}`, { detail: e.clientX })); }} >
{file.isFile ? ( ) : ( )}
{file.name}
{file.isFile && }
); export default memo(FileObjectRow, (prevProps, nextProps) => { /* eslint-disable @typescript-eslint/no-unused-vars */ const { isArchiveType, isEditable, ...prevFile } = prevProps.file; const { isArchiveType: nextIsArchiveType, isEditable: nextIsEditable, ...nextFile } = nextProps.file; /* eslint-enable @typescript-eslint/no-unused-vars */ return isEqual(prevFile, nextFile); }); EOF put_file 'resources/scripts/components/server/files/NewDirectoryButton.tsx' <<'EOF' import React, { useContext, useEffect, useState } from 'react'; import { ServerContext } from '@/state/server'; import { Form, Formik, FormikHelpers } from 'formik'; import Field from '@/components/elements/Field'; const join = (...paths: string[]) => paths.filter(Boolean).join('/'); import { object, string } from 'yup'; import createDirectory from '@/api/server/files/createDirectory'; import tw from 'twin.macro'; import { Button } from '@/components/elements/button/index'; import { FileObject } from '@/api/server/files/loadDirectory'; import { useFlashKey } from '@/plugins/useFlash'; import useFileManagerSwr from '@/plugins/useFileManagerSwr'; import { WithClassname } from '@/components/types'; import FlashMessageRender from '@/components/FlashMessageRender'; import { Dialog, DialogWrapperContext } from '@/components/elements/dialog'; import Code from '@/components/elements/Code'; import asDialog from '@/hoc/asDialog'; interface Values { directoryName: string; } const schema = object().shape({ directoryName: string().required('A valid directory name must be provided.'), }); const generateDirectoryData = (name: string): FileObject => ({ key: `dir_${name.split('/', 1)[0] ?? name}`, name: name.replace(/^(\/*)/, '').split('/', 1)[0] ?? name, mode: 'drwxr-xr-x', modeBits: '0755', size: 0, isFile: false, isSymlink: false, mimetype: '', createdAt: new Date(), modifiedAt: new Date(), isArchiveType: () => false, isEditable: () => false, }); const NewDirectoryDialog = asDialog({ title: 'Create Directory', })(() => { const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid); const directory = ServerContext.useStoreState((state) => state.files.directory); const { mutate } = useFileManagerSwr(); const { close } = useContext(DialogWrapperContext); const { clearAndAddHttpError } = useFlashKey('files:directory-modal'); useEffect(() => { return () => { clearAndAddHttpError(); }; }, []); const submit = ({ directoryName }: Values, { setSubmitting }: FormikHelpers) => { createDirectory(uuid, directory, directoryName) .then(() => mutate((data) => [...data, generateDirectoryData(directoryName)], false)) .then(() => close()) .catch((error) => { setSubmitting(false); clearAndAddHttpError(error); }); }; return ( {({ submitForm, values }) => ( <>

This directory will be created as  /home/container/ {join(directory, values.directoryName).replace(/^(\.\.\/|\/)+/, '')}

Cancel )}
); }); export default ({ className }: WithClassname) => { const [open, setOpen] = useState(false); return ( <> Create Directory ); }; EOF put_file 'resources/scripts/components/server/files/RenameFileModal.tsx' <<'EOF' import React from 'react'; import Modal, { RequiredModalProps } from '@/components/elements/Modal'; import { Form, Formik, FormikHelpers } from 'formik'; import Field from '@/components/elements/Field'; const join = (...paths: string[]) => paths.filter(Boolean).join('/'); import renameFiles from '@/api/server/files/renameFiles'; import { ServerContext } from '@/state/server'; import tw from 'twin.macro'; import Button from '@/components/elements/Button'; import useFileManagerSwr from '@/plugins/useFileManagerSwr'; import useFlash from '@/plugins/useFlash'; interface FormikValues { name: string; } type OwnProps = RequiredModalProps & { files: string[]; useMoveTerminology?: boolean }; const RenameFileModal = ({ files, useMoveTerminology, ...props }: OwnProps) => { const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid); const { mutate } = useFileManagerSwr(); const { clearFlashes, clearAndAddHttpError } = useFlash(); const directory = ServerContext.useStoreState((state) => state.files.directory); const setSelectedFiles = ServerContext.useStoreActions((actions) => actions.files.setSelectedFiles); const submit = ({ name }: FormikValues, { setSubmitting }: FormikHelpers) => { clearFlashes('files'); const len = name.split('/').length; if (files.length === 1) { if (!useMoveTerminology && len === 1) { // Rename the file within this directory. mutate((data) => data.map((f) => (f.name === files[0] ? { ...f, name } : f)), false); } else if (useMoveTerminology || len > 1) { // Remove the file from this directory since they moved it elsewhere. mutate((data) => data.filter((f) => f.name !== files[0]), false); } } let data; if (useMoveTerminology && files.length > 1) { data = files.map((f) => ({ from: f, to: join(name, f) })); } else { data = files.map((f) => ({ from: f, to: name })); } renameFiles(uuid, directory, data) .then((): Promise => (files.length > 0 ? mutate() : Promise.resolve())) .then(() => setSelectedFiles([])) .catch((error) => { mutate(); setSubmitting(false); clearAndAddHttpError({ key: 'files', error }); }) .then(() => props.onDismissed()); }; return ( 1 ? '' : files[0] || '' }}> {({ isSubmitting, values }) => (
{useMoveTerminology && (

New location:  /home/container/{join(directory, values.name).replace(/^(\.\.\/|\/)+/, '')}

)}
)}
); }; export default RenameFileModal; EOF put_file 'resources/scripts/components/server/files/UploadButton.tsx' <<'EOF' import axios from 'axios'; import getFileUploadUrl from '@/api/server/files/getFileUploadUrl'; import tw from 'twin.macro'; import { Button } from '@/components/elements/button/index'; import React, { useEffect, useRef } from 'react'; import { ModalMask } from '@/components/elements/Modal'; import Fade from '@/components/elements/Fade'; import useEventListener from '@/plugins/useEventListener'; import { useFlashKey } from '@/plugins/useFlash'; import useFileManagerSwr from '@/plugins/useFileManagerSwr'; import { ServerContext } from '@/state/server'; import { WithClassname } from '@/components/types'; import Portal from '@/components/elements/Portal'; import { CloudUploadIcon } from '@heroicons/react/outline'; import { useSignal } from '@preact/signals-react'; function isFileOrDirectory(event: DragEvent): boolean { if (!event.dataTransfer?.types) { return false; } return event.dataTransfer.types.some((value) => value.toLowerCase() === 'files'); } export default ({ className }: WithClassname) => { const fileUploadInput = useRef(null); const visible = useSignal(false); const timeouts = useSignal([]); const { mutate } = useFileManagerSwr(); const { addError, clearAndAddHttpError } = useFlashKey('files'); const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid); const directory = ServerContext.useStoreState((state) => state.files.directory); const { clearFileUploads, removeFileUpload, pushFileUpload, setUploadProgress } = ServerContext.useStoreActions( (actions) => actions.files ); useEventListener( 'dragenter', (e) => { e.preventDefault(); e.stopPropagation(); if (isFileOrDirectory(e)) { visible.value = true; } }, { capture: true } ); useEventListener('dragexit', () => (visible.value = false), { capture: true }); useEventListener('keydown', () => (visible.value = false)); useEffect(() => { return () => timeouts.value.forEach(clearTimeout); }, []); const onUploadProgress = (data: ProgressEvent, name: string) => { setUploadProgress({ name, loaded: data.loaded }); }; const onFileSubmission = (files: FileList) => { clearAndAddHttpError(); const list = Array.from(files); if (list.some((file) => !file.type && (!file.size || file.size === 4096))) { return addError('Folder uploads are not supported.', 'Error'); } const uploads = list.map((file) => { const controller = new AbortController(); pushFileUpload({ name: file.name, data: { abort: controller, loaded: 0, total: file.size }, }); return () => getFileUploadUrl(uuid).then((url) => axios .post( url, { files: file }, { signal: controller.signal, headers: { 'Content-Type': 'multipart/form-data' }, params: { directory }, onUploadProgress: (data) => onUploadProgress(data, file.name), } ) .then(() => timeouts.value.push(setTimeout(() => removeFileUpload(file.name), 500))) ); }); Promise.all(uploads.map((fn) => fn())) .then(() => mutate()) .catch((error) => { clearFileUploads(); clearAndAddHttpError(error); }); }; return ( <> (visible.value = false)} onDragOver={(e) => e.preventDefault()} onDrop={(e) => { e.preventDefault(); e.stopPropagation(); visible.value = false; if (!e.dataTransfer?.files.length) return; onFileSubmission(e.dataTransfer.files); }} >

Drag and drop files to upload.

{ if (!e.currentTarget.files) return; onFileSubmission(e.currentTarget.files); if (fileUploadInput.current) { fileUploadInput.current.files = null; } }} multiple /> ); }; EOF put_file 'app/Enum/ResourceLimit.php' <<'EOF' name}"); } /** * Returns a middleware that will throttle the specific resource by server. This * throttle applies to any user making changes to that resource on the specific * server, it is NOT per-user. */ public function middleware(): string { return ThrottleRequests::using($this->throttleKey()); } public function limit(): Limit { return match($this) { self::Backup => Limit::perMinutes(15, 3), self::Database => Limit::perMinute(2), self::FilePull => Limit::perMinutes(10, 5), self::Subuser => Limit::perMinutes(15, 10), self::Websocket => Limit::perMinute(5), default => Limit::perMinute(2), }; } public static function boot(): void { foreach (self::cases() as $case) { RateLimiter::for($case->throttleKey(), function (Request $request) use ($case) { $server = $request->route()?->parameter('server'); if (is_string($server) || !$server instanceof Server) { return Limit::none(); } return $case->limit()->by($server->uuid); }); } } } EOF echo "" echo "🧩 Done patching files." # Try to run blueprint rerun install (best effort) if command -v blueprint >/dev/null 2>&1; then echo "🚀 Running: blueprint -rerun-install" blueprint -rerun-install elif [[ -x "$ROOT/blueprint" ]]; then echo "🚀 Running: $ROOT/blueprint -rerun-install" "$ROOT/blueprint" -rerun-install else echo "⚠️ Could not find 'blueprint' in PATH or at $ROOT/blueprint" echo " Run this manually from your panel root:" echo " blueprint -rerun-install" fi echo "✅ All done."