Skip to content

Commit c90fcea

Browse files
committed
Add basic file listing functionality
1 parent ecb5384 commit c90fcea

File tree

7 files changed

+269
-11
lines changed

7 files changed

+269
-11
lines changed

app/Http/Controllers/Api/Client/ClientApiController.php

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Webmozart\Assert\Assert;
66
use Illuminate\Container\Container;
7+
use Pterodactyl\Transformers\Daemon\BaseDaemonTransformer;
78
use Pterodactyl\Transformers\Api\Client\BaseClientTransformer;
89
use Pterodactyl\Http\Controllers\Api\Application\ApplicationApiController;
910

@@ -19,10 +20,15 @@ public function getTransformer(string $abstract)
1920
{
2021
/** @var \Pterodactyl\Transformers\Api\Client\BaseClientTransformer $transformer */
2122
$transformer = Container::getInstance()->make($abstract);
22-
Assert::isInstanceOf($transformer, BaseClientTransformer::class);
23+
Assert::isInstanceOfAny($transformer, [
24+
BaseClientTransformer::class,
25+
BaseDaemonTransformer::class,
26+
]);
2327

24-
$transformer->setKey($this->request->attributes->get('api_key'));
25-
$transformer->setUser($this->request->user());
28+
if ($transformer instanceof BaseClientTransformer) {
29+
$transformer->setKey($this->request->attributes->get('api_key'));
30+
$transformer->setUser($this->request->user());
31+
}
2632

2733
return $transformer;
2834
}

app/Http/Controllers/Api/Client/Servers/FileController.php

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@
77
use Illuminate\Http\Response;
88
use Pterodactyl\Models\Server;
99
use Illuminate\Http\JsonResponse;
10+
use GuzzleHttp\Exception\TransferException;
11+
use Pterodactyl\Transformers\Daemon\FileObjectTransformer;
1012
use Illuminate\Contracts\Cache\Repository as CacheRepository;
1113
use Illuminate\Contracts\Config\Repository as ConfigRepository;
1214
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
1315
use Pterodactyl\Contracts\Repository\Daemon\FileRepositoryInterface;
16+
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
1417
use Pterodactyl\Http\Requests\Api\Client\Servers\Files\CopyFileRequest;
1518
use Pterodactyl\Http\Requests\Api\Client\Servers\Files\ListFilesRequest;
1619
use Pterodactyl\Http\Requests\Api\Client\Servers\Files\DeleteFileRequest;
@@ -57,16 +60,23 @@ public function __construct(ConfigRepository $config, FileRepositoryInterface $f
5760
* Returns a listing of files in a given directory.
5861
*
5962
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\ListFilesRequest $request
60-
* @return \Illuminate\Http\JsonResponse
63+
* @return array
64+
*
65+
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
6166
*/
62-
public function listDirectory(ListFilesRequest $request): JsonResponse
67+
public function listDirectory(ListFilesRequest $request): array
6368
{
64-
return JsonResponse::create([
65-
'contents' => $this->fileRepository->setServer($request->getModel(Server::class))->getDirectory(
66-
$request->get('directory') ?? '/'
67-
),
68-
'editable' => $this->config->get('pterodactyl.files.editable', []),
69-
]);
69+
try {
70+
$contents = $this->fileRepository
71+
->setServer($request->getModel(Server::class))
72+
->getDirectory($request->get('directory') ?? '/');
73+
} catch (TransferException $exception) {
74+
throw new DaemonConnectionException($exception, true);
75+
}
76+
77+
return $this->fractal->collection($contents)
78+
->transformWith($this->getTransformer(FileObjectTransformer::class))
79+
->toArray();
7080
}
7181

7282
/**
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
namespace Pterodactyl\Transformers\Daemon;
4+
5+
use League\Fractal\TransformerAbstract;
6+
7+
abstract class BaseDaemonTransformer extends TransformerAbstract
8+
{
9+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
namespace Pterodactyl\Transformers\Daemon;
4+
5+
use Carbon\Carbon;
6+
use Illuminate\Support\Arr;
7+
8+
class FileObjectTransformer extends BaseDaemonTransformer
9+
{
10+
/**
11+
* An array of files we allow editing in the Panel.
12+
*
13+
* @var array
14+
*/
15+
private $editable = [];
16+
17+
/**
18+
* FileObjectTransformer constructor.
19+
*/
20+
public function __construct()
21+
{
22+
$this->editable = config('pterodactyl.files.editable', []);
23+
}
24+
25+
/**
26+
* Transform a file object response from the daemon into a standardized response.
27+
*
28+
* @param array $item
29+
* @return array
30+
*/
31+
public function transform(array $item)
32+
{
33+
return [
34+
'name' => Arr::get($item, 'name'),
35+
'mode' => Arr::get($item, 'mode'),
36+
'size' => Arr::get($item, 'size'),
37+
'is_file' => Arr::get($item, 'file', true),
38+
'is_symlink' => Arr::get($item, 'symlink', false),
39+
'is_editable' => in_array(Arr::get($item, 'mime', ''), $this->editable),
40+
'mimetype' => Arr::get($item, 'mime'),
41+
'created_at' => Carbon::parse(explode(' ', Arr::get($item, 'created', ''))[0] ?? '')->toIso8601String(),
42+
'modified_at' => Carbon::parse(explode(' ', Arr::get($item, 'modified', ''))[0] ?? '')->toIso8601String(),
43+
];
44+
}
45+
46+
/**
47+
* @return string
48+
*/
49+
public function getResourceName(): string
50+
{
51+
return 'file_object';
52+
}
53+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import http from '@/api/http';
2+
3+
export interface FileObject {
4+
name: string;
5+
mode: string;
6+
size: number;
7+
isFile: boolean;
8+
isSymlink: boolean;
9+
isEditable: boolean;
10+
mimetype: string;
11+
createdAt: Date;
12+
modifiedAt: Date;
13+
}
14+
15+
export default (uuid: string, directory?: string): Promise<FileObject[]> => {
16+
return new Promise((resolve, reject) => {
17+
http.get(`/api/client/servers/${uuid}/files/list`, {
18+
params: { directory },
19+
})
20+
.then(response => resolve((response.data.data || []).map((item: any): FileObject => ({
21+
name: item.attributes.name,
22+
mode: item.attributes.mode,
23+
size: Number(item.attributes.size),
24+
isFile: item.attributes.is_file,
25+
isSymlink: item.attributes.is_symlink,
26+
isEditable: item.attributes.is_editable,
27+
mimetype: item.attributes.mimetype,
28+
createdAt: new Date(item.attributes.created_at),
29+
modifiedAt: new Date(item.attributes.modified_at),
30+
}))))
31+
.catch(reject);
32+
});
33+
};
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import React, { useEffect, useState } from 'react';
2+
import FlashMessageRender from '@/components/FlashMessageRender';
3+
import { ServerContext } from '@/state/server';
4+
import loadDirectory, { FileObject } from '@/api/server/files/loadDirectory';
5+
import { Actions, useStoreActions } from 'easy-peasy';
6+
import { ApplicationStore } from '@/state';
7+
import { httpErrorToHuman } from '@/api/http';
8+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
9+
import { faFolder } from '@fortawesome/free-solid-svg-icons/faFolder';
10+
import { faFileAlt } from '@fortawesome/free-solid-svg-icons/faFileAlt';
11+
import format from 'date-fns/format';
12+
import differenceInHours from 'date-fns/difference_in_hours';
13+
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
14+
import { bytesToHuman } from '@/helpers';
15+
import { CSSTransition } from 'react-transition-group';
16+
import { Link } from 'react-router-dom';
17+
import Spinner from '@/components/elements/Spinner';
18+
19+
export default () => {
20+
const [ loading, setLoading ] = useState(true);
21+
const [ files, setFiles ] = useState<FileObject[]>([]);
22+
const server = ServerContext.useStoreState(state => state.server.data!);
23+
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
24+
25+
const currentDirectory = window.location.hash.replace(/^#(\/)+/, '/');
26+
27+
const load = () => {
28+
setLoading(true);
29+
clearFlashes();
30+
loadDirectory(server.uuid, currentDirectory)
31+
.then(files => {
32+
setFiles(files);
33+
setLoading(false);
34+
})
35+
.catch(error => {
36+
if (error.response && error.response.status === 404) {
37+
window.location.hash = '#/';
38+
return;
39+
}
40+
41+
console.error(error.message, { error });
42+
addError({ message: httpErrorToHuman(error), key: 'files' });
43+
});
44+
};
45+
46+
const breadcrumbs = (): { name: string; path?: string }[] => currentDirectory.split('/')
47+
.filter(directory => !!directory)
48+
.map((directory, index, dirs) => {
49+
if (index === dirs.length - 1) {
50+
return { name: directory };
51+
}
52+
53+
return { name: directory, path: `/${dirs.slice(0, index + 1).join('/')}` };
54+
});
55+
56+
useEffect(() => {
57+
load();
58+
}, [ window.location.hash ]);
59+
60+
return (
61+
<div className={'my-10 mb-6'}>
62+
<FlashMessageRender byKey={'files'}/>
63+
<React.Fragment>
64+
<div className={'flex items-center text-sm mb-4 text-neutral-500'}>
65+
/<span className={'px-1 text-neutral-300'}>home</span>/
66+
<Link to={'#'} className={'px-1 text-neutral-200 no-underline hover:text-neutral-100'}>
67+
container
68+
</Link>/
69+
{
70+
breadcrumbs().map((crumb, index) => (
71+
crumb.path ?
72+
<React.Fragment key={index}>
73+
<Link
74+
to={`#${crumb.path}`}
75+
className={'px-1 text-neutral-200 no-underline hover:text-neutral-100'}
76+
>
77+
{crumb.name}
78+
</Link>/
79+
</React.Fragment>
80+
:
81+
<span key={index} className={'px-1 text-neutral-300'}>{crumb.name}</span>
82+
))
83+
}
84+
</div>
85+
{
86+
loading ?
87+
<Spinner large={true} centered={true}/>
88+
:
89+
!files.length ?
90+
<p className={'text-sm text-neutral-600 text-center'}>
91+
This directory seems to be empty.
92+
</p>
93+
:
94+
<CSSTransition classNames={'fade'} timeout={250} appear={true} in={true}>
95+
<div>
96+
{
97+
files.map(file => (
98+
<a
99+
key={file.name}
100+
href={`#${currentDirectory}/${file.name}`}
101+
className={`
102+
flex px-4 py-3 bg-neutral-700 text-neutral-300 rounded-sm mb-px text-sm
103+
border border-transparent hover:text-neutral-100 hover:border-neutral-600
104+
cursor-pointer items-center no-underline
105+
`}
106+
>
107+
<div className={'flex-none text-neutral-500 mr-4 text-lg'}>
108+
{file.isFile ?
109+
<FontAwesomeIcon icon={faFileAlt}/>
110+
:
111+
<FontAwesomeIcon icon={faFolder}/>
112+
}
113+
</div>
114+
<div className={'flex-1'}>
115+
{file.name}
116+
</div>
117+
{file.isFile &&
118+
<div className={'w-1/6 text-right mr-4'}>
119+
{bytesToHuman(file.size)}
120+
</div>
121+
}
122+
<div
123+
className={'w-1/5 text-right'}
124+
title={file.modifiedAt.toString()}
125+
>
126+
{Math.abs(differenceInHours(file.modifiedAt, new Date())) > 48 ?
127+
format(file.modifiedAt, 'MMM Do, YYYY h:mma')
128+
:
129+
distanceInWordsToNow(file.modifiedAt, { includeSeconds: true })
130+
}
131+
</div>
132+
</a>
133+
))
134+
}
135+
</div>
136+
</CSSTransition>
137+
}
138+
</React.Fragment>
139+
</div>
140+
);
141+
};

resources/scripts/helpers.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export function bytesToHuman (bytes: number): string {
2+
const i = Math.floor(Math.log(bytes) / Math.log(1000));
3+
4+
// @ts-ignore
5+
return `${(bytes / Math.pow(1000, i)).toFixed(2) * 1} ${['Bytes', 'kB', 'MB', 'GB', 'TB'][i]}`;
6+
}

0 commit comments

Comments
 (0)