Merge 'dev/3.0.0' branch into master

This commit is contained in:
Chris Kankiewicz
2020-01-21 22:13:08 -07:00
110 changed files with 18601 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
# Build apache image
FROM php:7.4-apache
LABEL maintainer="Chris Kankiewicz <Chris@ChrisKankiewicz.com>"
COPY ./php/config/php.ini /usr/local/etc/php/php.ini
COPY ./apache/config/000-default.conf /etc/apache2/sites-available/000-default.conf
RUN a2enmod rewrite
RUN pecl install xdebug && docker-php-ext-enable xdebug

7
.docker/Dockerfile.nginx Normal file
View File

@@ -0,0 +1,7 @@
# Build php-fpm image
FROM php:7.4-fpm
LABEL maintainer="Chris Kankiewicz <Chris@ChrisKankiewicz.com>"
COPY ./php/config/php.ini /usr/local/etc/php/php.ini
RUN pecl install xdebug && docker-php-ext-enable xdebug

View File

@@ -0,0 +1,9 @@
<VirtualHost *:80>
DocumentRoot /var/www/html
ServerAdmin Chris@ChrisKankiewicz.com
LogLevel debug
ErrorLog /dev/stderr
CustomLog /dev/stderr combined
</VirtualHost>

View File

@@ -0,0 +1,30 @@
server {
listen 80 default_server;
root /var/www/html;
index index.php;
access_log /dev/stderr;
error_log /dev/stderr;
client_max_body_size 0;
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
location / {
try_files $uri /index.php$is_args$args;
}
location ~ \.php {
fastcgi_split_path_info ^(.+\.php)(.*)$;
fastcgi_param HTTP_PROXY "";
fastcgi_pass php-fpm:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}

View File

@@ -0,0 +1,4 @@
[PHP]
error_reporting = E_ALL
log_errors = On
memory_limit = -1

3
.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
.git
node_modules
vendor

24
.editorconfig Normal file
View File

@@ -0,0 +1,24 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.json]
indent_style = space
indent_size = 4
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
indent_style = space
[Makefile]
indent_size = 4
indent_style = tab

13
.env.example Normal file
View File

@@ -0,0 +1,13 @@
DARK_MODE=false
SORT_ORDER=type
REVERSE_SORT=false
HIDE_APP_FILES=true
HIDE_VCS_FILES=true
DISPLAY_READMES=true
DATE_FORMAT="Y-m-d H:i:s"
MAX_HASH_SIZE=1000000000

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @PHLAK

35
.github/CONTRIBUTING vendored Normal file
View File

@@ -0,0 +1,35 @@
# Contributing
Contributions are **welcome** via Pull Requests on [GitHub](https://github.com/DirectoryLister/DirectoryLister).
**Before contributing** we encourage you to discuss the change on the
[Spectrum Community](https://spectrum.chat/directory-lister) to verify
fit with the overall direction and goals of Directory Lister.
## Pull Requests Requirements
- **[PSR-2 Coding Standard.](https://www.php-fig.org/psr/psr-2/)** The easiest
way to apply the conventions is to install and run
[PHP CS Fixer](https://github.com/FriendsOfPHP/PHP-CS-Fixer).
- **Test all the tings!** Your patch won't be accepted if it doesn't have tests.
- **Document changes in behaviour.** Make sure relevant documentation is kept
up to date.
- **One feature per pull request.** If you want to do change more than one
thing, send multiple pull requests.
## Checking your work
### Static Analysis
$ vendor/bin/psalm
### Run Tests
$ vendor/bin/phpunit
---
*Thank you and happy coding!*

3
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,3 @@
github: PHLAK
patreon: PHLAK
custom: https://paypal.me/ChrisKankiewicz

31
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,31 @@
---
name: Bug report
about: Create a report to help us fix something that is broken
title: ''
labels: Bug
assignees: PHLAK
---
**Describe the bug**
A description of the observed behavior.
**To Reproduce**
Steps to reproduce the behavior:
1. Do something...
2. Followed by something else...
3. Then do another thing...
4. See error
**Expected behavior**
A description of what you expected to happen.
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,27 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: Enhancement
assignees: PHLAK
---
**Is your feature request related to a problem? Please describe.**
A description of what the problem is. Ex. I'm always frustrated when...
**Describe the solution you'd like**
A description of what you want to happen.
**Describe alternatives you've considered**
A description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
/app/dist/
/node_modules/
/vendor/
.env
.php_cs.cache
.phpunit.result.cache
/mix-manifest.json

12
.htaccess Normal file
View File

@@ -0,0 +1,12 @@
<IfModule mod_rewrite.c>
RewriteEngine On
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Handle Front Controller...
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>

33
.php_cs.dist Normal file
View File

@@ -0,0 +1,33 @@
<?php
$finder = PhpCsFixer\Finder::create()->in([
__DIR__ . DIRECTORY_SEPARATOR . 'app',
__DIR__ . DIRECTORY_SEPARATOR . 'tests'
])->notPath('cache');
return PhpCsFixer\Config::create()->setRules([
'@PSR1' => true,
'@PSR2' => true,
'@Symfony' => true,
'array_indentation' => true,
'array_syntax' => [
'syntax' => 'short'
],
'concat_space' => [
'spacing' => 'one'
],
'linebreak_after_opening_tag' => true,
'new_with_braces' => false,
'no_multiline_whitespace_before_semicolons' => true,
'no_superfluous_phpdoc_tags' => false,
'no_useless_else' => true,
'no_useless_return' => true,
'not_operator_with_successor_space' => true,
'phpdoc_to_comment' => false,
'phpdoc_no_empty_return' => false,
'phpdoc_order' => true,
'semicolon_after_instruction' => true,
'single_trait_insert_per_statement' => false,
'trailing_comma_in_multiline_array' => false,
'yoda_style' => null
])->setFinder($finder);

20
.styleci.yml Normal file
View File

@@ -0,0 +1,20 @@
preset: laravel
risky: false
enabled:
- concat_with_spaces
disabled:
- concat_without_spaces
- post_increment
- trailing_comma_in_multiline_array
finder:
exclude:
- "vendor"
name:
- "*.php"
path:
- "app"
- "tests"

27
.travis.yml Normal file
View File

@@ -0,0 +1,27 @@
sudo: false
language: php
php:
- 7.3
- 7.4
- nightly
matrix:
allow_failures:
- php: nightly
cache:
directories:
- $HOME/.composer/cache
- vendor
before_install: phpenv config-rm xdebug.ini || true
install: composer install
before_script:
- vendor/bin/php-cs-fixer fix --diff --dry-run
- vendor/bin/psalm
script: vendor/bin/phpunit --coverage-text

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Chris Kankiewicz <Chris@ChrisKankiewicz.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

33
Makefile Normal file
View File

@@ -0,0 +1,33 @@
ARTIFACT_FILES=$$(paste --delimiters ' ' --serial artifacts.include)
ARTIFACT_NAME="DirectoryLister-$$(git describe --tags --exact-match HEAD 2> /dev/null || git rev-parse --short HEAD)"
dev development: # Build application for development
@composer install --no-interaction
@npm install && npm run dev
prod production: # Build application for production
@composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
@npm install --no-save && npm run production && npm prune --production
update upgrade: # Update application dependencies
@composer update && npm update && npm install
test: # Run coding standards/static analysis checks and tests
@php-cs-fixer fix --diff --dry-run && psalm --show-info=false && phpunit --coverage-text
tunnel: # Expose the application via secure tunnel
@ngrok http -host-header=rewrite http://directory-lister.local:80
clear-cache: # Clear the application cache
@rm app/cache/* -rfv
tar: # Generate tarball
@tar --verbose --create --gzip --exclude-vcs --exclude-vcs-ignores \
--exclude app/cache/* --file artifacts/$(ARTIFACT_NAME).tar.gz \
$(ARTIFACT_FILES)
zip: # Generate zip file
@zip --exclude "app/cache/**" --exclude "*.git*" \
--recurse-paths artifacts/$(ARTIFACT_NAME).zip $(ARTIFACT_FILES)
artifacts: clear-cache production tar zip # Generate release artifacts

64
README.md Normal file
View File

@@ -0,0 +1,64 @@
<p align="center">
<img src="directory-lister.svg" alt="Directory Lister" width="66%">
</p>
<p align="center">
<a href="https://github.com/DirectoryLister/DirectoryLister/blob/master/LICENSE"><img src="https://img.shields.io/github/license/DirectoryLister/DirectoryLister?style=flat-square" alt="License"></a>
<a href="https://travis-ci.org/DirectoryLister/DirectoryLister"><img src="https://img.shields.io/travis/DirectoryLister/DirectoryLister.svg?style=flat-square" alt="Build Status"></a>
<a href="https://styleci.io/repos/1375774"><img src="https://styleci.io/repos/1375774/shield?branch=master" alt="StyleCI"></a>
<a href="https://www.ChrisKankiewicz.com"><img src="https://img.shields.io/badge/created_by-Chris%20Kankiewicz-319795.svg?style=flat-square" alt="Author"></a>
</p>
---
Directory Lister is the easiest way to expose the contents of any web-accessable
folder for browsing and sharing. With a zero configuration, drag-and-drop
installation you'll be up and running in less than a minute.
Features
--------
- **Simple installation** allows you to be up and running in less than a minute.
- **Light and dark themes** to suit your professional needs or personal style.
- **Custom sort ordering** gives you control of the ordering of your files/folders.
- **File search** helps you locate the files you need quickly and efficiently.
- **File hashes** instill confidence when downloading files through verification.
- **Readme rendering** allows exposing the contents of READMEs directly on the page.
Requirements
------------
- [PHP](https://php.net) >= 7.2
Installation
------------
1. [Downoad Directory Lister](https://github.com/DirectoryLister/DirectoryLister/releases)
2. Extract the zip/tar archive
3. Copy extracted files/folders to your web server
Configuration
-------------
1. Copy `.env.example` to `.env`
2. Edit the configuration values in `.env`
Changelog
---------
A list of changes can be found on the [GitHub Releases](https://github.com/DirectoryLister/DirectoryLister/releases) page.
Troubleshooting
---------------
For general help and support join our [Spectrum Community](https://spectrum.chat/directory-lister).
Additionally join our [Slack Workspace](https://ln.phlak.net/join-slack) or
reach out on [Twitter](https://twitter.com/DirectoryLister).
Please report bugs to the [GitHub Issue Tracker](https://github.com/DirectoryLister/DirectoryLister/issues).
Copyright
---------
This project is licensed under the [MIT License](https://github.com/DirectoryLister/DirectoryLister/blob/master/LICENSE).

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Bootstrap;
use App\Providers;
use DI\Bridge\Slim\Bridge;
use DI\Container;
use Invoker\CallableResolver;
use PHLAK\Config\Config;
use Slim\App;
use Tightenco\Collect\Support\Collection;
class AppManager
{
/** @const Array of application providers */
protected const PROVIDERS = [
Providers\ConfigProvider::class,
Providers\FinderProvider::class,
Providers\TwigProvider::class,
];
/** @var Container The applicaiton container */
protected $container;
/** @var Config The application config */
protected $config;
/** @var CallableResolver The callable resolver */
protected $callableResolver;
/**
* Create a new Provider object.
*
* @param \DI\Container $container
*/
public function __construct(Container $container, Config $config, CallableResolver $callableResolver)
{
$this->container = $container;
$this->config = $config;
$this->callableResolver = $callableResolver;
}
/**
* Setup and configure the application.
*
* @return \Slim\App
*/
public function __invoke(): App
{
$this->registerProviders();
return Bridge::create($this->container);
}
/**
* Register application providers.
*
* @return void
*/
protected function registerProviders(): void
{
Collection::make(self::PROVIDERS)->merge(
$this->config->get('app.providers', [])
)->each(function (string $provider) {
$this->container->call(
$this->callableResolver->resolve($provider)
);
});
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace App\Controllers;
use DI\Container;
use PHLAK\Config\Config;
use Slim\Psr7\Request;
use Slim\Psr7\Response;
use Slim\Views\Twig;
use Symfony\Component\Finder\Exception\DirectoryNotFoundException;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
use Tightenco\Collect\Support\Collection;
class DirectoryController
{
/** @var Config App configuration component */
protected $config;
/** @var Container Application container */
protected $container;
/** @var Twig Twig templating component */
protected $view;
/**
* Create a new DirectoryController object.
*
* @param \DI\Container $container
* @param \PHLAK\Config\Config $config
* @param \Slim\Views\Twig $view
*/
public function __construct(Container $container, Config $config, Twig $view)
{
$this->container = $container;
$this->config = $config;
$this->view = $view;
}
/**
* Invoke the DirectoryController.
*
* @param \Symfony\Component\Finder\Finder $files
* @param \Slim\Psr7\Response $response
* @param string $path
*
* @return \Psr\Http\Message\ResponseInterface
*/
public function __invoke(
Finder $files,
Request $request,
Response $response,
string $path = '.'
) {
$search = $request->getQueryParams()['search'] ?? null;
try {
$files = $files->in($path);
} catch (DirectoryNotFoundException $exception) {
return $this->view->render($response->withStatus(404), '404.twig');
}
return $this->view->render($response, 'index.twig', [
'files' => $search ? $files->name(
sprintf('/(?:.*)%s(?:.*)/i', preg_quote($search, '/'))
) : $files->depth(0),
'path' => $this->relativePath($path),
'is_root' => $this->isRoot($path),
'readme' => $this->readme($path),
'search' => $search,
]);
}
/**
* Return the relative path given a full path.
*
* @param string $path
*
* @return string
*/
protected function relativePath(string $path): string
{
$realPath = realpath($this->container->get('base_path') . '/' . $path);
return Collection::make(explode('/', $realPath))->diff(
explode('/', $this->container->get('base_path'))
)->filter()->implode('/');
}
/**
* Determine if a provided path is the root path.
*
* @param string $path
*
* @return bool
*/
protected function isRoot(string $path): bool
{
return realpath($path) === realpath($this->container->get('base_path'));
}
/**
* Return the README file for a given path.
*
* @param string $path
*
* @return \Symfony\Component\Finder\SplFileInfo|null
*/
protected function readme($path): ?SplFileInfo
{
if (! $this->config->get('app.display_readmes', true)) {
return null;
}
$readmes = Finder::create()->in($path)->depth(0)->name('/^README(?:\..+)?$/i');
$readmes->filter(function (SplFileInfo $file) {
return (bool) preg_match('/text\/.+/', mime_content_type($file->getPathname()));
});
$readmes->sort(function (SplFileInfo $file1, SplFileInfo $file2) {
return $file1->getExtension() <=> $file2->getExtension();
});
if (! $readmes->hasResults()) {
return null;
}
$readmeArray = iterator_to_array($readmes);
return array_shift($readmeArray);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Controllers;
use DI\Container;
use PHLAK\Config\Config;
use Slim\Psr7\Response;
use SplFileInfo;
class FileInfoController
{
/** @var Container The application container */
protected $container;
/** @var Config App configuration component */
protected $config;
/**
* Create a new FileInfoController object.
*
* @param \DI\Container $container
* @param \PHLAK\Config\Config $config
*/
public function __construct(Container $container, Config $config)
{
$this->container = $container;
$this->config = $config;
}
/**
* Invoke the FileInfoController.
*
* @param \Slim\Psr7\Response $response
* @param string $path
*/
public function __invoke(Response $response, string $path = '.')
{
$file = new SplFileInfo(
realpath($this->container->get('base_path') . '/' . $path)
);
if (! $file->isFile()) {
return $response->withStatus(404, 'File not found');
}
if ($file->getSize() >= $this->config->get('app.max_hash_size', 1000000000)) {
return $response->withStatus(500, 'File size too large');
}
$response->getBody()->write(json_encode([
'hashes' => [
'md5' => hash('md5', file_get_contents($file->getPathname())),
'sha1' => hash('sha1', file_get_contents($file->getPathname())),
'sha256' => hash('sha256', file_get_contents($file->getPathname())),
]
]));
return $response->withHeader('Content-Type', 'application/json');
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Providers;
use DI\Container;
use PHLAK\Config\Config;
class ConfigProvider
{
/** @var Container The applicaiton container */
protected $container;
/**
* Create a new ConfigProvider object.
*
* @param \DI\Container $container
*/
public function __construct(Container $container)
{
$this->container = $container;
}
/**
* Initialize and register the Config component.
*
* @return void
*/
public function __invoke(): void
{
$this->container->set(Config::class, Config::createFromDirectory('app/config'));
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Providers;
use App\SortMethods;
use Closure;
use DI\Container;
use PHLAK\Config\Config;
use RuntimeException;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
use Tightenco\Collect\Support\Collection;
class FinderProvider
{
/** @const Application paths to be hidden */
protected const APP_FILES = [
'app', 'node_modules', 'vendor', 'mix-manifest.json', 'index.php'
];
/** @const Array of sort options mapped to their respective methods */
public const SORT_METHODS = [
'accessed' => SortMethods\Accessed::class,
'changed' => SortMethods\Changed::class,
'modified' => SortMethods\Modified::class,
'name' => SortMethods\Name::class,
'natural' => SortMethods\Natural::class,
'type' => SortMethods\Type::class,
];
/** @var Config Application config */
protected $config;
/** @var Container The application container */
protected $container;
/**
* Create a new ConfigProvider object.
*
* @param \DI\Container $container
* @param \PHLAK\Config\Config $config
*/
public function __construct(Container $container, Config $config)
{
$this->container = $container;
$this->config = $config;
}
/**
* Initialize and register the Finder component.
*
* @return void
*/
public function __invoke(): void
{
$finder = Finder::create()->followLinks();
$finder->ignoreVCS($this->config->get('app.hide_vcs_files', false));
$finder->filter(function (SplFileInfo $file) {
foreach ($this->hiddenFiles() as $hiddenPath) {
if (strpos($file->getRealPath(), $hiddenPath) === 0) {
return false;
}
}
return true;
});
$sortOrder = $this->config->get('app.sort_order', 'type');
if ($sortOrder instanceof Closure) {
$finder->sort($sortOrder);
} else {
if (! array_key_exists($sortOrder, self::SORT_METHODS)) {
throw new RuntimeException("Invalid sort option '{$sortOrder}'");
}
$sortMethod = self::SORT_METHODS[$sortOrder];
call_user_func(new $sortMethod, $finder);
}
if ($this->config->get('app.reverse_sort', false)) {
$finder->reverseSorting();
}
$this->container->set(Finder::class, $finder);
}
/**
* Get a collection of hidden files.
*
* @return \Tightenco\Collect\Support\Collection
*/
protected function hiddenFiles(): Collection
{
return Collection::make(
$this->config->get('app.hidden_files', [])
)->when($this->config->get('app.hide_app_files', true), function (Collection $collection) {
return $collection->merge(self::APP_FILES);
})->map(function (string $file) {
return glob(
$this->container->get('base_path') . '/' . $file,
GLOB_BRACE | GLOB_NOSORT
);
})->flatten()->map(function (string $file) {
return realpath($file);
})->unique();
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Providers;
use App\ViewFunctions;
use DI\Container;
use PHLAK\Config\Config;
use Slim\Views\Twig;
use Twig\Extension\CoreExtension;
use Twig\Loader\FilesystemLoader;
use Twig\TwigFunction;
class TwigProvider
{
/** @const Constant description */
protected const VIEW_FUNCTIONS = [
ViewFunctions\Asset::class,
ViewFunctions\Breadcrumbs::class,
ViewFunctions\Config::class,
ViewFunctions\Icon::class,
ViewFunctions\Markdown::class,
ViewFunctions\ParentDir::class,
ViewFunctions\SizeForHumans::class,
];
/** @var Container The application container */
protected $container;
/** @var Config Application config */
protected $config;
/**
* Create a new ViewProvider object.
*
* @param \DI\Container $container
* @param \PHLAK\Config\Config $config
*/
public function __construct(Container $container, Config $config)
{
$this->container = $container;
$this->config = $config;
}
/**
* Initialize and register the Twig component.
*
* @return void
*/
public function __invoke(): void
{
$twig = new Twig(new FilesystemLoader('app/resources/views'));
$twig->getEnvironment()->setCache(
$this->config->get('view.cache', 'app/cache/views')
);
$twig->getEnvironment()->getExtension(CoreExtension::class)->setDateFormat(
$this->config->get('app.date_format', 'Y-m-d H:i:s'), '%d days'
);
foreach (self::VIEW_FUNCTIONS as $function) {
$function = new $function($this->container, $this->config);
$twig->getEnvironment()->addFunction(
new TwigFunction($function->name(), $function)
);
}
$this->container->set(Twig::class, $twig);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\SortMethods;
use Symfony\Component\Finder\Finder;
class Accessed extends SortMethod
{
/**
* Sort by file accessed time.
*
* @param \Symfony\Component\Finder\Finder $finder
*
* @return void
*/
public function __invoke(Finder $finder): void
{
$finder->sortByAccessedTime();
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\SortMethods;
use Symfony\Component\Finder\Finder;
class Changed extends SortMethod
{
/**
* Sory by file changed time.
*
* @param \Symfony\Component\Finder\Finder $finder
*
* @return void
*/
public function __invoke(Finder $finder): void
{
$finder->sortByChangedTime();
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\SortMethods;
use Symfony\Component\Finder\Finder;
class Modified extends SortMethod
{
/**
* Sort by file modified time.
*
* @param \Symfony\Component\Finder\Finder $finder
*
* @return void
*/
public function __invoke(Finder $finder): void
{
$finder->sortByModifiedTime();
}
}

20
app/SortMethods/Name.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
namespace App\SortMethods;
use Symfony\Component\Finder\Finder;
class Name extends SortMethod
{
/**
* Sort by file name.
*
* @param \Symfony\Component\Finder\Finder $finder
*
* @return void
*/
public function __invoke(Finder $finder): void
{
$finder->sortByName();
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\SortMethods;
use Symfony\Component\Finder\Finder;
class Natural extends SortMethod
{
/**
* Sort by (natural) file name.
*
* @param \Symfony\Component\Finder\Finder $finder
*
* @return void
*/
public function __invoke(Finder $finder): void
{
$finder->sortByName(true);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\SortMethods;
use Symfony\Component\Finder\Finder;
abstract class SortMethod
{
/**
* Run the sort method.
*
* @param \Symfony\Component\Finder\Finder $finder
*
* @return void
*/
abstract public function __invoke(Finder $finder): void;
}

20
app/SortMethods/Type.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
namespace App\SortMethods;
use Symfony\Component\Finder\Finder;
class Type extends SortMethod
{
/**
* Sory by file type.
*
* @param \Symfony\Component\Finder\Finder $finder
*
* @return void
*/
public function __invoke(Finder $finder): void
{
$finder->sortByType();
}
}

34
app/Support/Helpers.php Normal file
View File

@@ -0,0 +1,34 @@
<?php
namespace App\Support;
class Helpers
{
/**
* Return the value of an environment vairable.
*
* @param string $envar The name of an environment variable
* @param mixed $default Default value to return if no environment variable is set
*
* @return mixed
*/
public static function env($envar, $default = null)
{
$value = getenv($envar);
if ($value === false) {
return $default;
}
switch (strtolower($value)) {
case 'true':
return true;
case 'false':
return false;
case 'null':
return null;
}
return preg_replace('/^"(.*)"$/', '$1', $value);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\ViewFunctions;
use Tightenco\Collect\Support\Collection;
class Asset extends ViewFunction
{
/** @const Constant description */
protected const ASSET_PATH = '/app/dist/';
/** @var string The function name */
protected $name = 'asset';
/**
* Return the path to an asset.
*
* @param string $path
*
* @return string
*/
public function __invoke(string $path): string
{
$assetPath = self::ASSET_PATH . $path;
if ($this->mixManifest()->has($assetPath)) {
return $this->mixManifest()->get($assetPath);
}
return $assetPath;
}
/**
* Return the mix manifest collection.
*
* @return \Tightenco\Collect\Support\Collection
*/
protected function mixManifest(): Collection
{
$mixManifest = $this->container->get('base_path') . '/mix-manifest.json';
if (! is_file($mixManifest)) {
return new Collection();
}
return Collection::make(
json_decode(file_get_contents($mixManifest), true) ?? []
);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\ViewFunctions;
use Tightenco\Collect\Support\Collection;
class Breadcrumbs extends ViewFunction
{
/** @var string The function name */
protected $name = 'breadcrumbs';
/**
* Build an array of breadcrumbs for a given path.
*
* @param string $path
*
* @return array
*/
public function __invoke(string $path)
{
$breadcrumbs = Collection::make(explode('/', $path))->diff(
explode('/', $this->container->get('base_path'))
)->filter();
return $breadcrumbs->filter(function (string $crumb) {
return $crumb !== '.';
})->reduce(function (array $carry, string $crumb) {
$carry[$crumb] = end($carry) . "/{$crumb}";
return $carry;
}, []);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\ViewFunctions;
class Config extends ViewFunction
{
/** @var string The function name */
protected $name = 'config';
/**
* Retrieve an item from the view config.
*
* @param string $key
* @param mixed $default
*
* @return mixed
*/
public function __invoke(string $key, $default = null)
{
$viewConfig = $this->config->split('view');
return $viewConfig->get($key, $default);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\ViewFunctions;
use Symfony\Component\Finder\SplFileInfo;
class Icon extends ViewFunction
{
/** @var string The function name */
protected $name = 'icon';
/**
* Retrieve the icon markup for a file.
*
* @param \Symfony\Component\Finder\SplFileInfo $file
*
* @return string
*/
public function __invoke(SplFileInfo $file): string
{
$iconConfig = $this->config->split('icons');
$icon = $file->isDir() ? 'fas fa-folder'
: $iconConfig->get($file->getExtension(), 'fas fa-file');
return "<i class=\"{$icon} fa-fw fa-lg\"></i>";
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\ViewFunctions;
use Parsedown;
class Markdown extends ViewFunction
{
/** @var string The function name */
protected $name = 'markdown';
/**
* Parses a string of markdown into HTML.
*
* @param string $string
*
* @return string
*/
public function __invoke(string $string)
{
return Parsedown::instance()->parse($string);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\ViewFunctions;
use Tightenco\Collect\Support\Collection;
class ParentDir extends ViewFunction
{
/** @var string The function name */
protected $name = 'parent_dir';
/**
* Get the parent directory for a given path.
*
* @param string $path
*
* @return string
*/
public function __invoke(string $path)
{
return Collection::make(
explode('/', $path)
)->filter()->slice(0, -1)->implode('/');
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\ViewFunctions;
use Symfony\Component\Finder\SplFileInfo;
class SizeForHumans extends ViewFunction
{
/** @var string The function name */
protected $name = 'sizeForHumans';
/**
* Get the human readable file size from a file object.
*
* @param SplFileInfo $file A file object
*
* @return string
*/
public function __invoke(SplFileInfo $file): string
{
$sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
$factor = (int) floor((strlen((string) $file->getSize()) - 1) / 3);
return sprintf('%.2f', $file->getSize() / pow(1024, $factor)) . $sizes[$factor];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\ViewFunctions;
use DI\Container;
use PHLAK\Config\Config;
abstract class ViewFunction
{
/** @var string The function name */
protected $name = '';
/** @var Container The application container */
protected $container;
/** @var Config App configuration component */
protected $config;
/**
* Create a new ViewFunction object.
*
* @param \PHLAK\Config\Config $config
*/
public function __construct(Container $container, Config $config)
{
$this->container = $container;
$this->config = $config;
}
/**
* Get the function name.
*
* @return string
*/
public function name(): string
{
return $this->name;
}
}

2
app/cache/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

68
app/config/app.php Normal file
View File

@@ -0,0 +1,68 @@
<?php
use App\Support\Helpers;
return [
/**
* Sorting order of files and folders.
*
* Possible values: type, natural, name, accessed, changed, modified
* Default value: type
*/
'sort_order' => Helpers::env('SORT_ORDER'),
/**
* Reverse the sort order.
*
* Default value: false
*/
'reverse_sort' => Helpers::env('REVERSE_SORT'),
/**
* Array of files that will be hidden from the listing.
* Supports glob patterns.
*
* Default value: []
*/
'hidden_files' => [
// ...
],
/**
* Whether or not to hide application files/directories form the listing.
*
* Default value: true
*/
'hide_app_files' => Helpers::env('HIDE_APP_FILES'),
/**
* Hide version control system files (e.g. .git directories) from listing.
*
* Default value: true
*/
'hide_vcs_files' => Helpers::env('HIDE_VSC_FILES'),
/**
* Parse and render README files on the page.
*
* Default value: true
*/
'display_readmes' => Helpers::env('DISPLAY_READMES'),
/**
* The maximum file size (in bytes) that can be hashed. This helps to
* prevent timeouts for excessively large files.
*
* Defualt value: 1000000000
*/
'max_hash_size' => Helpers::env('MAX_HASH_SIZE'),
/**
* Additional providers to be loaded during application initialization.
*
* Default value: []
*/
'providers' => [
// ...
],
];

131
app/config/icons.php Normal file
View File

@@ -0,0 +1,131 @@
<?php
return [
// Images
'ai' => 'fas fa-image',
'bmp' => 'fas fa-image',
'eps' => 'fas fa-image',
'gif' => 'fas fa-image',
'jpg' => 'fas fa-image',
'jpeg' => 'fas fa-image',
'png' => 'fas fa-image',
'ps' => 'fas fa-image',
'psd' => 'fas fa-image',
'svg' => 'fas fa-image',
'tga' => 'fas fa-image',
'tif' => 'fas fa-image',
'drw' => 'fas fa-image',
// Data
'csv' => 'fas fa-file-csv',
'json' => 'fas fa-file-alt',
'yaml' => 'fas fa-file-alt',
// Code
'c' => 'fas fa-code',
'class' => 'fas fa-code',
'cpp' => 'fas fa-code',
'css' => 'fab fab fa-css3',
'erb' => 'fas fa-code',
'htm' => 'fab fa-html5',
'html' => 'fab fa-html5',
'java' => 'fab fa-java',
'js' => 'fab fa-js',
'php' => 'fab fa-php',
'pl' => 'fas fa-code',
'py' => 'fab fa-python',
'rb' => 'fas fa-code',
'xhtml' => 'fas fa-code',
'xml' => 'fas fa-code',
// Text and Markup
'cfg' => 'fas fa-file-alt',
'ini' => 'fas fa-file-alt',
'log' => 'fas fa-file-alt',
'md' => 'fab fa-markdown',
'rtf' => 'fas fa-file-alt',
'txt' => 'fas fa-file-alt',
// Documents
'doc' => 'fas fa-file-word',
'docx' => 'fas fa-file-word',
'odt' => 'fas fa-file-alt',
'pdf' => 'fas fa-file-pdf',
'ppt' => 'fas fa-file-powerpoint',
'pptx' => 'fas fa-file-powerpoint',
'xls' => 'fas fa-file-excel',
'xlsx' => 'fas fa-file-excel',
// Archives
'7z' => 'fas fa-file-archive',
'bz' => 'fas fa-file-archive',
'gz' => 'fas fa-file-archive',
'rar' => 'fas fa-file-archive',
'tar' => 'fas fa-file-archive',
'zip' => 'fas fa-file-archive',
// Audio
'aac' => 'fas fa-music',
'flac' => 'fas fa-music',
'mid' => 'fas fa-music',
'midi' => 'fas fa-music',
'mp3' => 'fas fa-music',
'ogg' => 'fas fa-music',
'wma' => 'fas fa-music',
'wav' => 'fas fa-music',
// Databases
'accdb' => 'fas fa-database',
'db' => 'fas fa-database',
'dbf' => 'fas fa-database',
'mdb' => 'fas fa-database',
'pdb' => 'fas fa-database',
'sql' => 'fas fa-database',
// Executables
'app' => 'fas fa-window',
'com' => 'fas fa-window',
'exe' => 'fas fa-window',
'jar' => 'fas fa-window',
'msi' => 'fas fa-window',
'vb' => 'fas fa-window',
// Fonts
'eot' => 'fas fa-font-case',
'otf' => 'fas fa-font-case',
'ttf' => 'fas fa-font-case',
'woff' => 'fas fa-font-case',
// Game Files
'gam' => 'fas fa-gamepad',
'nes' => 'fas fa-gamepad',
'rom' => 'fas fa-gamepad',
'sav' => 'fas fa-save',
// Package Files
'box' => 'fas fa-archive',
'deb' => 'fas fa-archive',
'rpm' => 'fas fa-archive',
// Scripts
'bat' => 'fas fa-terminal',
'cmd' => 'fas fa-terminal',
'sh' => 'fas fa-terminal',
// Video
'avi' => 'fas fa-video',
'flv' => 'fas fa-video',
'mkv' => 'fas fa-video',
'mov' => 'fas fa-video',
'mp4' => 'fas fa-video',
'mpg' => 'fas fa-video',
'ogv' => 'fas fa-video',
'webm' => 'fas fa-video',
'wmv' => 'fas fa-video',
'swf' => 'fas fa-video',
// Miscellaneous
'bak' => 'fas fa-save',
'lock' => 'fas fa-lock',
'msg' => 'fas fa-envelope',
];

28
app/config/view.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
use App\Support\Helpers;
return [
/**
* Enable dark mode?
*
* Default value: false
*/
'dark_mode' => Helpers::env('DARK_MODE'),
/**
* Default date format. For additional info on date formatting see:
* https://www.php.net/manual/en/function.date.php.
*
* Default value: 'Y-m-d H:i:s'
*/
'date_format' => Helpers::env('DATE_FORMAT'),
/**
* Path to the view cache directory.
* Set to 'false' to disable view caching entirely.
*
* Default value: 'app/cache/views'
*/
'cache' => Helpers::env('VIEW_CACHE'),
];

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

29
app/resources/js/app.js Normal file
View File

@@ -0,0 +1,29 @@
window.Vue = require('vue');
Vue.component('file-info-modal', require('./components/file-info-modal.vue').default);
const app = new Vue({
el: "#app",
data: function() {
return {
search: ''
};
},
methods: {
showFileInfo(filePath) {
this.$refs.fileInfoModal.show(filePath);
}
},
beforeMount: function() {
this.search = this.$el.querySelector('input[name="search"]').value;
}
});
let link = document.getElementById('scroll-to-top');
window.addEventListener('scroll', function() {
if (window.scrollY > 10) {
link.classList.remove('hidden');
} else {
link.classList.add('hidden');
}
});

View File

@@ -0,0 +1,87 @@
<template>
<div id="file-info-modal"
class="fixed top-0 flex justify-center items-center w-screen h-screen p-4 z-50"
style="background-color: hsla(218, 23%, 23%, 0.5)"
v-bind:class="this.styles"
v-on:click.self="hide()"
>
<div id="file-info-dialogue" class="bg-white rounded-lg shadow-lg overflow-hidden" v-show="! loading">
<header class="flex justify-between items-center bg-blue-600 p-4">
<i class="fas fa-info-circle fa-lg text-white"></i>
<div class="items-center text-xl text-white font-mono mx-4">
{{ title }}
</div>
<button
class="flex justify-center items-center rounded-full w-6 h-6 text-blue-900 text-sm hover:bg-red-700 hover:text-white hover:shadow"
v-on:click="hide()"
>
<i class="fas fa-times"></i>
</button>
</header>
<content class="flex justify-center items-center p-4">
<div class="overflow-x-auto">
<table class="table-auto">
<tbody>
<tr v-for="(hash, title) in this.hashes" v-bind:key="hash">
<td class="border font-bold px-4 py-2">{{ title }}</td>
<td class="border font-mono px-4 py-2">{{ hash }}</td>
</tr>
</tbody>
</table>
</div>
</content>
</div>
<i class="fas fa-spinner fa-pulse fa-5x text-white" v-show="loading"></i>
</div>
</template>
<script>
const axios = require('axios').default;
export default {
data: function () {
return {
filePath: 'file-info.txt',
hashes: {
'md5': '••••••••••••••••••••••••••••••••',
'sha1': '••••••••••••••••••••••••••••••••••••••••',
'sha256': '••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••'
},
loading: true,
visible: false,
};
},
computed: {
styles() {
return { 'hidden': ! this.visible };
},
title() {
return this.filePath.split('/').pop();
}
},
methods: {
async show(filePath) {
this.filePath = filePath;
this.visible = true;
await axios.get('/file-info/' + filePath).then(function (response) {
this.hashes = response.data.hashes;
this.loading = false;
}.bind(this)).catch(
response => this.hide() && console.error(response)
);
},
hide() {
this.visible = false;
this.loading = true;
}
},
mounted() {
window.addEventListener('keyup', e => e.keyCode == 27 && this.hide());
}
}
</script>

View File

@@ -0,0 +1,17 @@
// Tailwind
@tailwind base;
@tailwind components;
@tailwind utilities;
// Icons
$fa-font-path: "./webfonts";
@import "~@fortawesome/fontawesome-free/scss/fontawesome.scss";
@import "~@fortawesome/fontawesome-free/scss/brands.scss";
@import "~@fortawesome/fontawesome-free/scss/solid.scss";
// Fonts
@import url("https://fonts.googleapis.com/css?family=Source+Code+Pro|Work+Sans:200,400&display=swap");
// Additional Styles
@import "markdown.scss";
@import "dark-mode.scss";

View File

@@ -0,0 +1,104 @@
#app.dark-mode {
@apply bg-gray-800;
header {
@apply bg-purple-700;
#search {
@apply bg-purple-800;
i.fa-search {
@apply text-purple-900;
}
a {
@apply text-purple-900;
&:hover {
@apply text-white;
}
}
}
}
#content {
@apply text-white;
}
#file-list a:hover {
@apply bg-purple-700;
button:hover {
@apply bg-purple-900;
}
}
footer {
@apply text-white;
@apply border-white;
a:hover {
@apply text-purple-600;
}
}
#scroll-to-top {
@apply bg-purple-700;
}
#file-info-modal {
background-color: hsla(218, 23%, 33%, 0.5) !important;
#file-info-dialogue {
@apply bg-gray-800;
@apply text-white;
header {
@apply bg-purple-700;
button {
@apply text-purple-900;
&:hover {
@apply text-white;
}
}
}
}
}
#readme {
article {
@apply bg-gray-900;
@apply border-0;
}
}
.markdown {
a {
@apply text-purple-600;
&:hover {
@apply text-purple-800;
}
}
h1,
h2 {
@apply border-gray-800;
}
h6 {
@apply text-base;
@apply text-gray-600;
}
code {
@apply bg-gray-800;
}
hr {
@apply border-gray-800;
}
}
}

View File

@@ -0,0 +1,114 @@
.markdown {
@apply break-words;
a {
@apply text-blue-600;
@apply underline;
&:hover {
@apply text-blue-800;
}
}
img {
@apply inline;
}
p,
blockquote,
ul,
ol,
dl,
table,
pre {
@apply my-4;
}
ul,
ol {
@apply pl-8;
}
ul {
@apply list-disc;
}
ol {
@apply list-decimal;
}
h1 {
@apply text-4xl;
@apply border-b;
@apply border-gray-400;
}
h2 {
@apply text-3xl;
@apply border-b;
@apply border-gray-400;
}
h3 {
@apply text-2xl;
}
h4 {
@apply text-xl;
}
h5 {
@apply text-base;
}
h6 {
@apply text-base;
@apply text-gray-600;
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply font-bold;
@apply leading-relaxed;
@apply mt-8;
@apply mb-4;
}
h1 + p,
h2 + p,
h3 + p {
@apply mt-4;
}
code {
@apply bg-gray-200;
@apply rounded-sm;
@apply p-1;
@apply font-mono;
@apply overflow-y-auto;
}
pre > code {
@apply block;
@apply p-4;
@apply text-sm;
@apply whitespace-pre;
}
hr {
@apply border-t-2;
@apply border-gray-400;
}
& > :first-child {
@apply mt-0;
}
& > :last-child {
@apply mb-0;
}
}

View File

@@ -0,0 +1,13 @@
{% extends 'layouts/app.twig' %}
{% block content %}
{% include 'components/header.twig' %}
<div id="content" class="flex-grow container flex justify-center items-center mx-auto px-4">
<p class="font-thin text-4xl text-gray-600">
404 &bull; Not Found
</p>
</div>
{% include 'components/footer.twig' %}
{% endblock %}

View File

@@ -0,0 +1,42 @@
<a
href="/{{ parentDir ? parent_dir(path) : file.getPathname }}"
class="flex flex-col items-center rounded-lg font-mono group hover:bg-gray-200 hover:shadow"
>
<div class="flex justify-between items-center p-4 w-full">
<div class="pr-2">
{% if parentDir %}
<i class="fas fa-level-up-alt fa-fw fa-lg"></i>
{% else %}
{{ icon(file) | raw }}
{% endif %}
</div>
<div class="flex-1 truncate">
{{ parentDir ? '..' : file.getRelativePathname }}
</div>
{% if file.isFile %}
<div class="ml-2">
<button
title="File Info"
class="flex justify-center items-center rounded-full p-2 -m-1 md:invisible hover:bg-gray-400 hover:shadow group-hover:visible"
v-on:click.prevent="showFileInfo('{{ file.getPathname }}')"
>
<i class="fas fa-info-circle"></i>
</button>
</div>
{% endif %}
<div class="hidden whitespace-no-wrap text-right mx-2 w-1/6 sm:block">
{% if parentDir or file.isDir %}
{% else %}
{{ sizeForHumans(file) }}
{% endif %}
</div>
<div class="hidden whitespace-no-wrap text-right truncate ml-2 w-1/4 sm:block">
{{ parentDir ? '—' : file.getMTime | date }}
</div>
</div>
</a>

View File

@@ -0,0 +1,21 @@
<footer class="container border-t-2 border-gray-800 text-center mx-auto px-4 py-8">
<div class="flex flex-col justify-center items-center">
<p class="mb-4">
Powered by <a href="https://www.directorylister.com" class="underline hover:text-blue-700">Directory Lister</a>
</p>
<div class="flex">
<a href="https://github.com/DirectoryLister/DirectoryLister" title="GitHub" class="text-gray-400 mx-2 hover:text-github">
<i class="fab fa-github fa-lg"></i>
</a>
<a href="https://twitter.com/DirectoryLister" title="Twitter" class="text-gray-400 mx-2 hover:text-twitter">
<i class="fab fa-twitter fa-lg"></i>
</a>
<a href="https://spectrum.chat/directory-lister" title="Spectrum" class="text-gray-400 mx-2 hover:text-spectrum">
<i class="fas fa-comments fa-lg"></i>
</a>
</div>
</div>
</footer>

View File

@@ -0,0 +1,32 @@
<header id="header" class="bg-blue-600 shadow sticky top-0">
<div class="container flex flex-col justify-between items-center mx-auto p-4 md:flex-row">
<div class="font-mono text-white text-sm tracking-tight mb-2 md:my-1">
<a href="/" class="hover:underline">Home</a>
{% if path %}
{% for name, path in breadcrumbs(path) %}
/ <a href="{{ path }}" class="hover:underline">{{ name }}</a>
{% endfor %}
{% endif %}
</div>
<form action="." method="get" id="search" class="group relative block w-full bg-blue-700 rounded-full shadow-inner md:w-4/12 md:-my-2">
<input type="text" name="search" placeholder="Search this directory..." value="{{ search }}"
class="bg-transparent placeholder-gray-900 text-white w-full px-10 py-2"
v-model="search"
>
<div class="flex items-center absolute left-0 inset-y-0 ml-2 pointer-events-none">
<div class="flex justify-center items-center text-blue-900 w-6 h-6">
<i class="fas fa-search fa-fw"></i>
</div>
</div>
<div class="flex items-center absolute right-0 inset-y-0 mr-2" v-show="search">
<a href="." class="flex justify-center items-center rounded-full text-blue-900 w-6 h-6 hover:bg-red-700 hover:text-white hover:shadow">
<i class="fas fa-times"></i>
</a>
</div>
</form>
</div>
</header>

View File

@@ -0,0 +1,13 @@
<div id="readme" class="rounded-lg overflow-hidden shadow-md my-8">
<header class="flex items-center bg-blue-600 px-4 py-3 text-white">
<i class="fas fa-book fa-lg pr-3"></i> README.md
</header>
<article class="bg-gray-100 rounded-b-lg px-4 py-8 sm:px-6 md:px-8 lg:px-12 {{ readme.getExtension == 'md' ? 'markdown' : 'font-mono' }}">
{% if readme.getExtension == 'md' %}
{{ markdown(readme.getContents) | raw }}
{% else %}
{{ readme.getContents | nl2br }}
{% endif %}
</article>
</div>

View File

@@ -0,0 +1,10 @@
<div class="fixed bottom-0 left-0 right-0 pointer-events-none">
<div class="container flex justify-end mx-auto px-4 py-10">
<a id="scroll-to-top"
class="flex justify-center items-center w-12 h-12 right-0 rounded-full shadow-lg bg-blue-600 text-white cursor-pointer pointer-events-auto hover:bg-blue-700 hidden"
onclick="window.scrollTo({ top: 0, left: 0, behavior: 'smooth' });" title="Scroll to Top"
>
<i class="fas fa-arrow-up fa-lg"></i>
</a>
</div>
</div>

View File

@@ -0,0 +1,46 @@
{% extends "layouts/app.twig" %}
{% block content %}
{% include "components/header.twig" %}
<div id="content" class="flex-grow container mx-auto px-4">
<div class="my-4">
<div class="flex justify-between font-bold p-4">
<div class="flex-grow font-mono mr-2">
File Name
</div>
<div class="font-mono text-right w-1/6 mx-2 hidden sm:block">
Size
</div>
<div class="font-mono text-right w-1/4 ml-2 hidden sm:block">
Date
</div>
</div>
<ul id="file-list">
<li>
{% if not search and not is_root %}
{{ include('components/file.twig', { parentDir: true }) }}
{% endif %}
</li>
<li>
{% for file in files %}
{{ include('components/file.twig') }}
{% endfor %}
</li>
</ul>
</div>
{% if readme and not search %}
{% include 'components/readme.twig' %}
{% endif %}
</div>
{% include 'components/footer.twig' %}
{% include 'components/scroll-to-top.twig' %}
<file-info-modal ref="fileInfoModal"></file-info-modal>
{% endblock %}

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="/app/resources/images/favicon.{{ config('dark_mode') ? 'dark' : 'light' }}.png">
<link rel="stylesheet" href="{{ asset('app.css') }}">
<title>{{ path | default('Home') }} &bull; Directory Lister</title>
<div id="app" class="flex flex-col min-h-screen font-sans {{ config('dark_mode') ? 'dark-mode' : 'light-mode' }}">
{% block content %}{% endblock %}
</div>
<script src="{{ asset('app.js') }}" defer></script>

9
artifact.files Normal file
View File

@@ -0,0 +1,9 @@
app
node_modules
vendor
.env.example
directory-lister.svg
LICENSE
mix-manifest.json
README.md
index.php

9
artifacts.include Normal file
View File

@@ -0,0 +1,9 @@
app
node_modules
vendor
.env.example
directory-lister.svg
LICENSE
mix-manifest.json
README.md
index.php

2
artifacts/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

45
composer.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "phlak/directory-lister",
"description": "PHP directory lister",
"type": "project",
"license": "MIT",
"authors": [
{
"name": "Chris Kankiewicz",
"email": "Chris@ChrisKankiewicz.com"
}
],
"require": {
"php": ">=7.2",
"erusev/parsedown": "^1.7",
"phlak/config": "^6.0",
"php-di/php-di": "^6.0",
"php-di/slim-bridge": "^3.0",
"slim/psr7": "^1.0",
"slim/slim": "^4.3",
"slim/twig-view": "3.0.0",
"symfony/finder": "^5.0",
"tightenco/collect": "^6.4",
"vlucas/phpdotenv": "^4.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^2.3",
"phpunit/phpunit": "^7.0 || ^8.0",
"psy/psysh": "^0.9.9",
"vimeo/psalm": "^3.6"
},
"autoload": {
"psr-4": {
"App\\": "app/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"config": {
"sort-packages": true,
"optimize-autoloader": true
}
}

5114
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

1
directory-lister.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

37
docker-compose.yaml Normal file
View File

@@ -0,0 +1,37 @@
version: '3.4'
services:
apache:
container_name: directory-lister-apache
build:
context: .docker
dockerfile: Dockerfile.apache
environment:
VIRTUAL_HOST: directory-lister.local,directory-lister.apache.local
volumes:
- ./:/var/www/html
restart: unless-stopped
nginx:
container_name: directory-lister-nginx
image: nginx:1.17
environment:
VIRTUAL_HOST: directory-lister.nginx.local
volumes:
- ./.docker/nginx/config/default.conf:/etc/nginx/conf.d/default.conf
- ./:/var/www/html
restart: unless-stopped
php-fpm:
container_name: directory-lister-php-fpm
build:
context: .docker
dockerfile: Dockerfile.nginx
volumes:
- ./:/var/www/html
restart: unless-stopped
networks:
default:
external:
name: development

28
index.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
use App\Bootstrap\AppManager;
use App\Controllers;
use DI\Container;
use Dotenv\Dotenv;
require __DIR__ . '/vendor/autoload.php';
// Set file access restrictions
ini_set('open_basedir', __DIR__);
// Initialize environment variable handler
Dotenv::createImmutable(__DIR__)->safeLoad();
// Initialize the container
$container = new Container();
$container->set('base_path', __DIR__);
// Configure the application
$app = $container->call(AppManager::class);
// Register routes
$app->get('/file-info/[{path:.*}]', Controllers\FileInfoController::class);
$app->get('/[{path:.*}]', Controllers\DirectoryController::class);
// Enagage!
$app->run();

10212
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"author": "Chris Kankiewicz",
"license": "MIT",
"scripts": {
"dev": "npm run development",
"development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"watch": "npm run development -- --watch",
"hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js",
"prod": "npm run production",
"production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js"
},
"dependencies": {
"axios": "^0.19.1",
"vue": "^2.6.11"
},
"devDependencies": {
"@fortawesome/fontawesome-free": "^5.12.0",
"cross-env": "^6.0.3",
"laravel-mix": "^5.0.1",
"laravel-mix-purgecss": "^4.2.0",
"resolve-url-loader": "^3.1.1",
"sass": "^1.25.0",
"sass-loader": "^8.0.2",
"tailwindcss": "^1.1.4",
"vue-template-compiler": "^2.6.11"
}
}

16
phpunit.xml Normal file
View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
<testsuites>
<testsuite name="Directory Lister Test Suite">
<directory suffix="Test.php">tests/</directory>
</testsuite>
</testsuites>
<filter>
<whitelist processUncoveredFilesFromWhitelist="true">
<directory suffix=".php">app/</directory>
<exclude>
<directory>app/cache</directory>
</exclude>
</whitelist>
</filter>
</phpunit>

2
psalm-baseline.xml Normal file
View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<files psalm-version="3.8.3@389af1bfc739bfdff3f9e3dc7bd6499aee51a831"/>

63
psalm.xml Normal file
View File

@@ -0,0 +1,63 @@
<?xml version="1.0"?>
<psalm
totallyTyped="false"
resolveFromConfigFile="true"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://getpsalm.org/schema/config"
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
errorBaseline="psalm-baseline.xml"
>
<projectFiles>
<directory name="app" />
<ignoreFiles>
<directory name="vendor" />
</ignoreFiles>
</projectFiles>
<issueHandlers>
<UndefinedInterfaceMethod>
<errorLevel type="suppress">
<referencedMethod name="Twig\Extension\ExtensionInterface::setdateformat" />
<file name="app/Providers/TwigProvider.php" />
</errorLevel>
</UndefinedInterfaceMethod>
<LessSpecificReturnType errorLevel="info" />
<!-- level 3 issues - slightly lazy code writing, but provably low false-negatives -->
<DeprecatedMethod errorLevel="info" />
<DeprecatedProperty errorLevel="info" />
<DeprecatedClass errorLevel="info" />
<DeprecatedConstant errorLevel="info" />
<DeprecatedFunction errorLevel="info" />
<DeprecatedInterface errorLevel="info" />
<DeprecatedTrait errorLevel="info" />
<InternalMethod errorLevel="info" />
<InternalProperty errorLevel="info" />
<InternalClass errorLevel="info" />
<MissingClosureReturnType errorLevel="info" />
<MissingReturnType errorLevel="info" />
<MissingPropertyType errorLevel="info" />
<InvalidDocblock errorLevel="info" />
<MisplacedRequiredParam errorLevel="info" />
<PropertyNotSetInConstructor errorLevel="info" />
<MissingConstructor errorLevel="info" />
<MissingClosureParamType errorLevel="info" />
<MissingParamType errorLevel="info" />
<RedundantCondition errorLevel="info" />
<DocblockTypeContradiction errorLevel="info" />
<RedundantConditionGivenDocblockType errorLevel="info" />
<UnresolvableInclude errorLevel="info" />
<RawObjectIteration errorLevel="info" />
<InvalidStringClass errorLevel="info" />
</issueHandlers>
</psalm>

41
tailwind.config.js Normal file
View File

@@ -0,0 +1,41 @@
module.exports = {
theme: {
extend: {
fontFamily: {
mono: [
'Source Code Pro',
'Menlo',
'Monaco',
'Consolas',
'"Liberation Mono"',
'"Courier New"',
'monospace',
],
sans: [
'Work Sans',
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"Helvetica Neue"',
'Arial',
'"Noto Sans"',
'sans-serif',
'"Apple Color Emoji"',
'"Segoe UI Emoji"',
'"Segoe UI Symbol"',
'"Noto Color Emoji"',
]
},
textColor: {
github: '#171515',
spectrum: '#7B16FF',
twitter: '#1DA1F2'
}
}
},
variants: {
visibility: ['responsive', 'hover', 'group-hover']
},
plugins: []
};

View File

@@ -0,0 +1,19 @@
<?php
namespace Tests\Bootstrap;
use App\Bootstrap\AppManager;
use Invoker\CallableResolver;
use Slim\App;
use Tests\TestCase;
class AppManangerTest extends TestCase
{
public function test_it_returns_an_app_instance(): void
{
$callableResolver = $this->container->get(CallableResolver::class);
$app = (new AppManager($this->container, $this->config, $callableResolver))();
$this->assertInstanceOf(App::class, $app);
}
}

View File

@@ -0,0 +1,142 @@
<?php
namespace Tests\Controllers;
use App\Controllers\DirectoryController;
use App\Providers\TwigProvider;
use Psr\Http\Message\ResponseInterface;
use Slim\Psr7\Request;
use Slim\Psr7\Response;
use Slim\Views\Twig;
use Symfony\Component\Finder\Finder;
use Tests\TestCase;
class DirectoryControllerTest extends TestCase
{
/** @dataProvider configOptions */
public function test_it_returns_a_successful_response(
bool $hideAppFiles,
bool $hideVcsFiles,
bool $displayReadmes
): void {
$this->config->set('app.hide_app_files', $hideAppFiles);
$this->config->set('app.hide_vcs_files', $hideVcsFiles);
$this->config->set('app.display_readmes', $displayReadmes);
$this->container->call(TwigProvider::class);
$controller = new DirectoryController(
$this->container,
$this->config,
$this->container->get(Twig::class)
);
chdir($this->filePath('.'));
$response = $controller(
new Finder(),
$this->createMock(Request::class),
new Response()
);
$this->assertInstanceOf(ResponseInterface::class, $response);
$this->assertEquals(200, $response->getStatusCode());
}
/** @dataProvider configOptions */
public function test_it_returns_a_successful_response_when_listing_a_subdirectory(
bool $hideAppFiles,
bool $hideVcsFiles,
bool $displayReadmes
): void {
$this->config->set('app.hide_app_files', $hideAppFiles);
$this->config->set('app.hide_vcs_files', $hideVcsFiles);
$this->config->set('app.display_readmes', $displayReadmes);
$this->container->call(TwigProvider::class);
$controller = new DirectoryController(
$this->container,
$this->config,
$this->container->get(Twig::class)
);
chdir($this->filePath('.'));
$response = $controller(
new Finder(),
$this->createMock(Request::class),
new Response(),
'subdir'
);
$this->assertInstanceOf(ResponseInterface::class, $response);
$this->assertEquals(200, $response->getStatusCode());
}
public function test_it_returns_a_404_error_when_not_found(): void
{
$this->container->call(TwigProvider::class);
$controller = new DirectoryController(
$this->container,
$this->config,
$this->container->get(Twig::class)
);
chdir($this->filePath('.'));
$response = $controller(
new Finder(),
$this->createMock(Request::class),
new Response(),
'404'
);
$this->assertInstanceOf(ResponseInterface::class, $response);
$this->assertEquals(404, $response->getStatusCode());
}
public function test_it_returns_a_successful_response_for_a_search_request(): void
{
$this->container->call(TwigProvider::class);
$controller = new DirectoryController(
$this->container,
$this->config,
$this->container->get(Twig::class)
);
$request = $this->createMock(Request::class);
$request->method('getQueryParams')->willReturn([
'search' => 'charlie'
]);
chdir($this->filePath('.'));
$response = $controller(
new Finder(),
$request,
new Response()
);
$this->assertInstanceOf(ResponseInterface::class, $response);
$this->assertEquals(200, $response->getStatusCode());
}
/**
* Provide config options in the following order:
* [ app.hide_app_files, app.hide_vcs_files, app.display_readmes ].
*
* @return array
*/
public function configOptions(): array
{
return [
[true, false, false],
[true, true, false],
[true, false, true],
[true, true, true],
[false, true, false],
[false, true, true],
[false, false, true],
[false, false, false],
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Tests\Controllers;
use App\Controllers\FileInfoController;
use Psr\Http\Message\ResponseInterface;
use Slim\Psr7\Response;
use Tests\TestCase;
class FileInfoControllerTest extends TestCase
{
public function test_it_can_return_a_successful_response(): void
{
$controller = new FileInfoController($this->container, $this->config);
$response = $controller(new Response(), 'README.md');
$this->assertInstanceOf(ResponseInterface::class, $response);
$this->assertEquals(200, $response->getStatusCode());
}
public function test_it_can_return_a_not_found_response(): void
{
$controller = new FileInfoController($this->container, $this->config);
$response = $controller(new Response(), 'not_a_file.test');
$this->assertInstanceOf(ResponseInterface::class, $response);
$this->assertEquals(404, $response->getStatusCode());
}
public function test_it_returns_an_error_when_file_size_is_too_large(): void
{
$this->config->set('app.max_hash_size', 10);
$controller = new FileInfoController($this->container, $this->config);
$response = $controller(new Response(), 'README.md');
$this->assertInstanceOf(ResponseInterface::class, $response);
$this->assertEquals(500, $response->getStatusCode());
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Tests\Providers;
use App\Providers\ConfigProvider;
use PHLAK\Config\Config;
use Tests\TestCase;
class ConfigProviderTest extends TestCase
{
public function test_it_can_compose_the_config_component(): void
{
(new ConfigProvider($this->container))();
$config = $this->container->get(Config::class);
$this->assertInstanceOf(Config::class, $config);
$this->assertTrue($config->has('app'));
$this->assertTrue($config->has('icons'));
$this->assertTrue($config->has('view'));
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace Tests\Providers;
use App\Providers\FinderProvider;
use RuntimeException;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
use Tests\TestCase;
class FinderProviderTest extends TestCase
{
public function test_it_can_compose_the_finder_component(): void
{
(new FinderProvider($this->container, $this->config))();
$finder = $this->container->get(Finder::class);
$finder->in($this->filePath('subdir'))->depth(0);
$this->assertInstanceOf(Finder::class, $finder);
$this->assertEquals([
'alpha.scss',
'bravo.js',
'charlie.bash',
'delta.html',
'echo.yaml',
], $this->getFilesArray($finder));
}
public function test_it_can_sort_by_a_user_provided_closure(): void
{
$this->config->set('app.sort_order', function (SplFileInfo $file1, SplFileInfo $file2) {
return $file1->getSize() <=> $file2->getSize();
});
(new FinderProvider($this->container, $this->config))();
$finder = $this->container->get(Finder::class);
$finder->in($this->filePath('subdir'))->depth(0);
$this->assertEquals([
'alpha.scss',
'bravo.js',
'echo.yaml',
'charlie.bash',
'delta.html',
], $this->getFilesArray($finder));
}
public function test_it_can_reverse_the_sort_order(): void
{
$this->config->set('app.reverse_sort', true);
(new FinderProvider($this->container, $this->config))();
$finder = $this->container->get(Finder::class);
$finder->in($this->filePath('subdir'))->depth(0);
$this->assertEquals([
'echo.yaml',
'delta.html',
'charlie.bash',
'bravo.js',
'alpha.scss',
], $this->getFilesArray($finder));
}
public function test_it_does_not_return_hidden_files(): void
{
$this->config->set('app.hidden_files', [
'subdir/alpha.scss', 'subdir/charlie.bash', '**/*.yaml'
]);
(new FinderProvider($this->container, $this->config))();
$finder = $this->container->get(Finder::class);
$finder->in($this->filePath('subdir'))->depth(0);
$this->assertInstanceOf(Finder::class, $finder);
$this->assertEquals([
'bravo.js',
'delta.html',
], $this->getFilesArray($finder));
}
public function test_it_throws_a_runtime_exception_with_an_invalid_sort_order(): void
{
$this->config->set('app.sort_order', 'invalid');
$this->expectException(RuntimeException::class);
(new FinderProvider($this->container, $this->config))();
}
protected function getFilesArray(Finder $finder): array
{
$files = array_map(function (SplFileInfo $file) {
return $file->getFilename();
}, iterator_to_array($finder));
return array_values($files);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Tests\Providers;
use App\Providers\TwigProvider;
use App\ViewFunctions;
use PHLAK\Config\Config;
use Slim\Views\Twig;
use Tests\TestCase;
class TwigProviderTest extends TestCase
{
public function test_it_can_compose_the_view_component(): void
{
(new TwigProvider($this->container, new Config))();
$twig = $this->container->get(Twig::class);
$this->assertInstanceOf(Twig::class, $twig);
$this->assertEquals('app/cache/views', $twig->getEnvironment()->getCache());
$this->assertInstanceOf(
ViewFunctions\Asset::class,
$twig->getEnvironment()->getFunction('asset')->getCallable()
);
$this->assertInstanceOf(
ViewFunctions\Config::class,
$twig->getEnvironment()->getFunction('config')->getCallable()
);
$this->assertInstanceOf(
ViewFunctions\Icon::class,
$twig->getEnvironment()->getFunction('icon')->getCallable()
);
$this->assertInstanceOf(
ViewFunctions\SizeForHumans::class,
$twig->getEnvironment()->getFunction('sizeForHumans')->getCallable()
);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Tests\SortMethods;
use App\SortMethods\Accessed;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Finder\Finder;
class AccessedTest extends TestCase
{
public function test_it_can_sort_by_accessed_time(): void
{
$finder = $this->createMock(Finder::class);
$finder->expects($this->once())->method('sortByAccessedTime');
(new Accessed)($finder);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Tests\SortMethods;
use App\SortMethods\Changed;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Finder\Finder;
class ChangedTest extends TestCase
{
public function test_it_can_sort_by_changed_time(): void
{
$finder = $this->createMock(Finder::class);
$finder->expects($this->once())->method('sortByChangedTime');
(new Changed)($finder);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Tests\SortMethods;
use App\SortMethods\Modified;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Finder\Finder;
class ModifiedTest extends TestCase
{
public function test_it_can_sort_by_modified_time(): void
{
$finder = $this->createMock(Finder::class);
$finder->expects($this->once())->method('sortByModifiedTime');
(new Modified)($finder);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Tests\SortMethods;
use App\SortMethods\Name;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Finder\Finder;
class NameTest extends TestCase
{
public function test_it_can_sort_by_file_name(): void
{
$finder = $this->createMock(Finder::class);
$finder->expects($this->once())->method('sortByName')->with(null);
(new Name)($finder);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Tests\SortMethods;
use App\SortMethods\Natural;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Finder\Finder;
class NaturalTest extends TestCase
{
public function test_it_can_sort_by_natural_file_name(): void
{
$finder = $this->createMock(Finder::class);
$finder->expects($this->once())->method('sortByName')->with(true);
(new Natural)($finder);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Tests\SortMethods;
use App\SortMethods\Type;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Finder\Finder;
class TypeTest extends TestCase
{
public function test_it_can_sort_by_file_type(): void
{
$finder = $this->createMock(Finder::class);
$finder->expects($this->once())->method('sortByType');
(new Type)($finder);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Tests\Support;
use App\Support\Helpers;
use PHPUnit\Framework\TestCase;
class HelpersTest extends TestCase
{
public function test_it_can_get_an_environment_variable(): void
{
putenv('TEST_STRING=Test string; please ignore');
$env = Helpers::env('TEST_STRING');
$this->assertEquals('Test string; please ignore', $env);
}
public function test_it_can_return_a_default_value(): void
{
$env = Helpers::env('DEFAULT_TEST', 'Test default; please ignore');
$this->assertEquals('Test default; please ignore', $env);
}
public function test_it_can_a_retrieve_boolean_value(): void
{
putenv('TRUE_TEST=true');
putenv('FALSE_TEST=false');
$this->assertTrue(Helpers::env('TRUE_TEST'));
$this->assertFalse(Helpers::env('FALSE_TEST'));
}
public function test_it_can_retrieve_a_null_value(): void
{
putenv('NULL_TEST=null');
$this->assertNull(Helpers::env('NULL_TEST'));
}
public function test_it_can_be_surrounded_bys_quotation_marks(): void
{
putenv('QUOTES_TEST="Test charlie; please ignore"');
$env = Helpers::env('QUOTES_TEST');
$this->assertEquals('Test charlie; please ignore', $env);
}
}

58
tests/TestCase.php Normal file
View File

@@ -0,0 +1,58 @@
<?php
namespace Tests;
use DI\Container;
use PHLAK\Config\Config;
use PHPUnit\Framework\TestCase as PHPUnitTestCase;
class TestCase extends PHPUnitTestCase
{
/** @var Container The test container */
protected $container;
/** @var Config The test config */
protected $config;
/** @var string Path to test files directory */
protected $testFilesPath = __DIR__ . '/_files';
/**
* This method is called before each test.
*
* @return void
*/
public function setUp(): void
{
$this->config = new Config([
'app' => [
'sort_order' => 'type',
'reverse_sort' => false,
'hidden_files' => [],
'hide_app_files' => true,
'hide_vcs_files' => false,
'display_readmes' => true,
'max_hash_size' => 1000000000,
],
'view' => [
'cache' => false
],
]);
$this->container = new Container();
$this->container->set('base_path', $this->testFilesPath);
$this->container->set(Config::class, $this->config);
}
/**
* Get the file path to a test file.
*
* @param string $filePath
*
* @return string
*/
protected function filePath(string $filePath): string
{
return realpath($this->testFilesPath . '/' . $filePath);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Tests\ViewFunctions;
use App\ViewFunctions\Asset;
use Tests\TestCase;
class AssetTest extends TestCase
{
public function test_it_can_return_an_asset_path(): void
{
$asset = new Asset($this->container, $this->config);
$this->assertEquals('/app/dist/test.css', $asset('test.css'));
$this->assertEquals(
'/app/dist/app.css?id=417c7a9bc03852aafb27',
$asset('app.css')
);
}
public function test_it_can_return_an_asset_path_without_a_mix_manifest(): void
{
$this->container->set('base_path', $this->filePath('subdir'));
$asset = new Asset($this->container, $this->config);
$this->assertEquals('/app/dist/test.css', $asset('test.css'));
$this->assertEquals('/app/dist/app.css', $asset('app.css'));
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Tests\ViewFunctions;
use App\ViewFunctions\Breadcrumbs;
use Tests\TestCase;
class BreadcrumbsTest extends TestCase
{
public function test_it_can_parse_breadcrumbs_from_the_path(): void
{
$breadcrumbs = new Breadcrumbs($this->container, $this->config);
$this->assertEquals([
'foo' => '/foo',
'bar' => '/foo/bar',
'baz' => '/foo/bar/baz',
], $breadcrumbs('foo/bar/baz'));
}
public function test_it_can_parse_breadcrumbs_for_dot_path(): void
{
$breadcrumbs = new Breadcrumbs($this->container, $this->config);
$this->assertEquals([], $breadcrumbs('.'));
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Tests\ViewFunctions;
use App\ViewFunctions\Config;
use PHLAK\Config\Config as AppConfig;
use Tests\TestCase;
class ConfigTest extends TestCase
{
/** @var AppConfig Application config */
protected $config;
public function setUp(): void
{
parent::setUp();
$this->config = new AppConfig([
'foo' => false,
'bar' => 'Red herring',
'view' => [
'foo' => 'Test value; please ignore'
],
]);
}
public function test_it_can_retrieve_a_config_item(): void
{
$config = new Config($this->container, $this->config);
$this->assertEquals('Test value; please ignore', $config('foo'));
}
public function test_it_returns_a_default_value(): void
{
$config = new Config($this->container, $this->config);
$this->assertEquals('Default value', $config('bar', 'Default value'));
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Tests\ViewFunctions;
use App\ViewFunctions\Icon;
use PHLAK\Config\Config;
use Symfony\Component\Finder\SplFileInfo;
use Tests\TestCase;
class IconTest extends TestCase
{
/** @var Config Application config */
protected $config;
public function setUp(): void
{
parent::setUp();
$this->config = new Config([
'php' => false,
'icons' => [
'php' => 'fab fa-php',
],
]);
}
public function test_it_can_return_icon_markup_for_a_file(): void
{
$icon = new Icon($this->container, $this->config);
$file = $this->createMock(SplFileInfo::class);
$file->method('isDir')->willReturn(false);
$file->method('getExtension')->willReturn('php');
$this->assertEquals('<i class="fab fa-php fa-fw fa-lg"></i>', $icon($file));
}
public function test_it_can_return_icon_markup_for_a_directory(): void
{
$icon = new Icon($this->container, $this->config);
$file = $this->createMock(SplFileInfo::class);
$file->method('isDir')->willReturn(true);
$this->assertEquals('<i class="fas fa-folder fa-fw fa-lg"></i>', $icon($file));
}
public function test_it_can_return_the_default_icon_markup(): void
{
$icon = new Icon($this->container, $this->config);
$file = $this->createMock(SplFileInfo::class);
$file->method('isDir')->willReturn(false);
$file->method('getExtension')->willReturn('default');
$this->assertEquals('<i class="fas fa-file fa-fw fa-lg"></i>', $icon($file));
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Tests\ViewFunctions;
use App\ViewFunctions\Markdown;
use Tests\TestCase;
class MarkdownTest extends TestCase
{
public function test_it_can_parse_markdown_into_html(): void
{
$markdown = new Markdown($this->container, $this->config);
$this->assertEquals(
'<p><strong>Test</strong> <code>markdown</code>, <del>please</del> <em>ignore</em></p>',
$markdown('**Test** `markdown`, ~~please~~ _ignore_')
);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Tests\ViewFunctions;
use App\ViewFunctions\ParentDir;
use Tests\TestCase;
class ParentDirTest extends TestCase
{
public function test_it_can_get_the_parent_directory_from_a_path(): void
{
$parentDir = new ParentDir($this->container, $this->config);
$this->assertEquals('foo/bar', $parentDir('foo/bar/baz'));
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace Tests\ViewFunctions;
use App\ViewFunctions\SizeForHumans;
use Symfony\Component\Finder\SplFileInfo;
use Tests\TestCase;
class SizeForHumansTest extends TestCase
{
public function test_it_can_convert_bytes_to_bytes(): void
{
$file = $this->createMock(SplFileInfo::class);
$file->method('getSize')->willReturn(13);
$sizeForHumans = new SizeForHumans($this->container, $this->config);
$this->assertEquals('13.00B', $sizeForHumans($file));
}
public function test_it_can_convert_bytes_to_kibibytes(): void
{
$file = $this->createMock(SplFileInfo::class);
$file->method('getSize')->willReturn(13690);
$sizeForHumans = new SizeForHumans($this->container, $this->config);
$this->assertEquals('13.37KB', $sizeForHumans($file));
}
public function test_it_can_convert_bytes_to_mebibytes(): void
{
$file = $this->createMock(SplFileInfo::class);
$file->method('getSize')->willReturn(14019461);
$sizeForHumans = new SizeForHumans($this->container, $this->config);
$this->assertEquals('13.37MB', $sizeForHumans($file));
}
public function test_it_can_convert_bytes_to_gibibytes(): void
{
$file = $this->createMock(SplFileInfo::class);
$file->method('getSize')->willReturn(14355900000);
$sizeForHumans = new SizeForHumans($this->container, $this->config);
$this->assertEquals('13.37GB', $sizeForHumans($file));
}
public function test_it_can_convert_bytes_to_tebibytes(): void
{
$file = $this->createMock(SplFileInfo::class);
$file->method('getSize')->willReturn(14700500000000);
$sizeForHumans = new SizeForHumans($this->container, $this->config);
$this->assertEquals('13.37TB', $sizeForHumans($file));
}
public function test_it_can_convert_bytes_to_pebibytes(): void
{
$file = $this->createMock(SplFileInfo::class);
$file->method('getSize')->willReturn(15053300000000000);
$sizeForHumans = new SizeForHumans($this->container, $this->config);
$this->assertEquals('13.37PB', $sizeForHumans($file));
}
public function test_it_can_convert_bytes_to_exbibytes(): void
{
$file = $this->createMock(SplFileInfo::class);
$file->method('getSize')->willReturn(PHP_INT_MAX);
$sizeForHumans = new SizeForHumans($this->container, $this->config);
$this->assertEquals('8.00EB', $sizeForHumans($file));
}
}

1
tests/_files/README.md Normal file
View File

@@ -0,0 +1 @@
Test README.md; please ignore

1
tests/_files/README.txt Normal file
View File

@@ -0,0 +1 @@
Test README.txt; please ignore

View File

@@ -0,0 +1,3 @@
{
"contents": "Test file; please ignore"
}

Some files were not shown because too many files have changed in this diff Show More