I am working on a control panel for a niche game server. I want a basic theme system for my app and the goal is to keep the theme assets (js/css/images) together with the views. I don't want the views in resources dir and the assets in public dir separately.
With that in mind; here's what I came up with.
Theme (views and assets) organised like this - i.e. default views dir removed:
new config/site.php
<?php
return [
'theme' => 'default',
];
modified config/views.php
<?php
return [
'paths' => [
resource_path('themes/' . config('site.theme')),
],
...
new route routes/web.php
Route::get('theme/{file?}', 'ThemeController#serve')
->where('file', '[a-zA-Z0-9\.\-\/]+');
new controller app/Http/Controllers/ThemeController.php
<?php
namespace App\Http\Controllers;
use Carbon\Carbon;
use Illuminate\Support\Facades\File;
class ThemeController extends Controller
{
/**
* #param $file
* #return \Symfony\Component\HttpFoundation\BinaryFileResponse
*/
public function serve($file)
{
$siteConfig = config('site');
$filePath = resource_path("themes/{$siteConfig['theme']}/{$file}");
if (!file_exists($filePath)) {
header("HTTP/1.0 404 Not Found");
exit;
}
$fileLastModified = Carbon::createFromTimestamp(filemtime($filePath))->format('D, j M Y H:i:s');
$fileEtag = md5_file($filePath);
$requestFileEtag = request()->getETags()[0] ?? null;
if (!empty($requestFileEtag) && strcmp($fileEtag, $requestFileEtag) === 0) {
header("Last-Modified: {$fileLastModified}");
header("Etag: {$fileEtag}");
header("HTTP/1.1 304 Not Modified");
exit;
}
return response()->file($filePath, [
'Cache-Control' => 'public, max-age=' . ($siteConfig['themeFilesCacheForMinutes'] * 60),
'Etag' => $fileEtag,
'Last-Modified' => $fileLastModified,
'Content-Type' => $this->guessMimeType($filePath)
]);
}
/**
* #param $filePath
* #return false|string
*/
private function guessMimeType($filePath) {
$ext = pathinfo($filePath, PATHINFO_EXTENSION);
switch ($ext) {
case 'css':
return 'text/css; charset=UTF-8';
case 'js':
return 'text/javascript; charset=UTF-8';
default:
return File::mimeType($filePath);
}
}
}
With that setup; if I want to include an asset from my theme, e.g. css/sb-admin-2.min.css in my master layout in <head>...</head>, this is what I do:
<link href="{{ url('theme/css/sb-admin-2.min.css') }}" rel="stylesheet">
So, using this technique I can keep the views and assets together and php to serve the static asset with caching capability (via headers + etag).
I've tested this locally and it works, initial load takes approx 900ms and once the cache is warmed up, it loads the page under 500ms.
My question; is this a bad approach? i.e. serving static files using php? is there a better way to do this?
If you want to package blades and static assets as an individual replaceable theme, just create a package for each theme and select desired theme using dependency injection. Inside each theme's ServiceProvider publish your assets to public directory.
Related
I want to make a private directory using Laravel 6.
Only users who have already logged in can access the directory.
So, I implemented below:
routes/web.php
Route::group(['middleware' => ['private_web']], function() { // 'private_web' includes auth
Route::get('/private/{path?}', 'PrivateController#index')->name('private')->where('path', '.*');
});
PrivateController.php
public function index(Request $request, $path = null) {
$storage_path = 'private/' . $path;
$mime_type = Storage::mimeType($storage_path);
$headers = [ 'Content-Type' => $mime_type, ];
return Storage::response($storage_path, null, $headers);
}
It is working.
But, when I got a html from the directory using Chrome, a css linked from the html wasn't applied (the css is in private directory and just downloaded successfully).
The cause is already known and it is Storage::mimeType returns 'text/plain' against css.
I can fix it by making a branch:
if (ends_with($path, '.css')) {
$mime_type = 'text/css';
} else {
$mime_type = Storage::mimeType($storage_path);
}
Question:
Is there more better solution?
I'm afraid of increasing such branch at each file type...
thanks.
I am having a problem getting SilverStripe Fluent module to work with content/page controllers. Whenever a locale url segment is provided, the controller returns 404. For example, http://site.local/search works but http://site.local/en/search returns 404.
I tried using route config by pointing mi/search to the controller name. The template renders but the current locale is not correct.
To reproduce:
Set up a SilverStripe project using composer create-project silverstripe/installer test
Require the module composer require tractorcow/silverstripe-fluent
Setup 2 locale
English with url segment 'en'
Maori with url segment 'mi'
Create a simple controller called SearchController
Create a route.yml in the config folder
Create template file called Search.ss in the template folder
<?php
namespace App\Controllers;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\CMS\Controllers\ContentController;
class SearchController extends ContentController
{
private static $allowed_actions = [
'index',
];
public function index(HTTPRequest $request)
{
return $this->renderWith('Search');
}
}
---
Name: approutes
After: framework/_config/routes#coreroutes
---
SilverStripe\Control\Director:
rules:
'search//': 'App\Controllers\SearchController'
# 'mi/search//': 'App\Controllers\SearchController'
# 'en/search//': 'App\Controllers\SearchController'
<h1>Search</h1>
$CurrentLocale
Navigate to <baseurl>/mi/search, the template should render:
<h1>Search</h1>
mi_NZ
But error 404 is returned.
Concept
So Fluent supports localising via query string out of the box, but appending locales to the URL is reserved for pages driven by the CMS. With your example I'm able to see correct results via /search?l=en and /search?l=mi.
In order to allow locales in the URL for non-SiteTree routes, we can patch FluentDirectorExtension, which is the class responsible for injecting Fluent's routing rules, and add support for explicit configuration of routes that should also be localisable. This can be achieved by adding Director rules that essentially do the same thing as above, but masking the /search?l=en URL as /en/search in the background.
My example configuration is something like this:
TractorCow\Fluent\Extension\FluentDirectorExtension:
static_routes: # Routes that should also allow URL segment based localisation
- 'search//'
This should match the rule key in your Director.rules config.
We can then construct the new URL to allow support for, and tell Director to use the existing configured controller while also passing the l argument for the locale transparently. We need to do this for each locale, and the rules need to be inserted before Fluent's default rules are. An example of what you could do:
diff --git a/src/Extension/FluentDirectorExtension.php b/src/Extension/FluentDirectorExtension.php
index 6ebf1d6..0cdd80b 100644
--- a/src/Extension/FluentDirectorExtension.php
+++ b/src/Extension/FluentDirectorExtension.php
## -116,7 +116,10 ## class FluentDirectorExtension extends Extension
protected function getExplicitRoutes($originalRules)
{
$queryParam = static::config()->get('query_param');
+ $staticRoutes = static::config()->get('static_routes');
$rules = [];
+ $prependRules = []; // we push these into the $rules before default fluent rules
+
/** #var Locale $localeObj */
foreach (Locale::getCached() as $localeObj) {
$locale = $localeObj->getLocale();
## -138,8 +141,22 ## class FluentDirectorExtension extends Extension
'Controller' => $controller,
$queryParam => $locale,
];
+
+ // Include opt-in static routes
+ foreach ($staticRoutes as $staticRoute) {
+ // Check for a matching rule in the Director configuration
+ if (!isset($originalRules[$staticRoute])) {
+ continue;
+ }
+
+ $prependRules[$url . '/' . $staticRoute] = [
+ 'Controller' => $originalRules[$staticRoute],
+ $queryParam => $locale,
+ ];
+ }
}
- return $rules;
+
+ return array_merge($prependRules, $rules);
}
/**
If you debug $rules at the end of the updateRules() method, you'll see that Fluent has now injected a new rule for that route in each locale:
'en/search//' =>
array (size=2)
'Controller' => string 'App\Controllers\SearchController' (length=42)
'l' => string 'en_NZ' (length=5)
'mi/search//' =>
array (size=2)
'Controller' => string 'App\Controllers\SearchController' (length=42)
'l' => string 'mi_NZ' (length=5)
Implementation
I'm going to formulate a pull request to the module for this change once I can back it up with some unit tests, but in the meantime, you can implement this by using an Injector override in your project code, and extend the protected getExplicitRoutes method to implement the changes above:
SilverStripe\Core\Injector\Injector:
TractorCow\Fluent\Extension\FluentDirectorExtension:
class: MyFluentDirectorExtension
class MyFluentDirectorExtension extends FluentDirectorExtension
{
protected function getExplicitRoutes($originalRules)
{
$rules = parent::getExplicitRoutes($originalRules);
$staticRoutes = static::config()->get('static_routes');
$queryParam = static::config()->get('query_param');
$prependRules = [];
// Include opt-in static routes
foreach (Locale::getCached() as $localeObj) {
foreach ($staticRoutes as $staticRoute) {
$locale = $localeObj->getLocale();
$url = urlencode($localeObj->getURLSegment());
// Check for a matching rule in the Director configuration
if (!isset($originalRules[$staticRoute])) {
continue;
}
$prependRules[$url . '/' . $staticRoute] = [
'Controller' => $originalRules[$staticRoute],
$queryParam => $locale,
];
}
}
return array_merge($prependRules, $rules);
}
}
I recently dove into the world of laravel (version 5.4). While initially confused, the concept of MVC makes a lot of sense in writing large applications. Applications that you want to be easily understood by outside developers.
Using laravel for this has greatly simplified coding in PHP and has made the language fun again. However, beyond dividing code into its respective models, views, and controllers, what happens if we need to divide controllers to prevent them from growing too large?
A solution that I have found to this is to define one controller each folder and then fill that controller with traits that further add functionalities to the controller. (All-caps = folder):
CONTROLLER
HOME
Controller.php
TRAITS
additionalFunctionality1.php
additionalFunctionality2.php
additionalFunctionality3.php
...
ADMIN
Controller.php
TRAITS
additionalFunctionality1.php
additionalFunctionality2.php
additionalFunctionality3.php
...
Within routes/web.php I woud initialize everything as so:
Route::namespace('Home')->group(function () {
Route::get('home', 'Controller.php#loadPage');
Route::post('test', 'Controller.php#fun1');
Route::post('test2', 'Controller.php#fun2');
Route::post('test3', 'Controller.php#fun3');
});
Route::namespace('Admin')->group(function () {
Route::get('Admin', 'Controller.php#loadPage');
Route::post('test', 'Controller.php#fun1');
Route::post('test2', 'Controller.php#fun2');
Route::post('test3', 'Controller.php#fun3');
});
With me being new to laravel, this seems like a simple and elegant way to organize my logic. It is however something I do not see while researching laravel controller organization.
The Question
Is there an issue, both in the short-run and in the long-run, of organizing my data like this? What is a better alternative?
Example Controller:
<?php
namespace App\Http\Controllers\Message;
use DB;
use Auth;
use Request;
use FileHelper;
use App\Http\Controllers\Message\Traits\MessageTypes;
use App\Http\Controllers\Controller;
class MessageController extends Controller
{
// Traits that are used within the message controller
use FileHelper, MessageTypes;
/**
* #var array $data Everything about the message is stored here
*/
protected $data = []; // everything about the message
/**
* #var booloean/array $sendableData Additional data that is registered through the send function
*/
protected $sendableData = false;
/**
* Create a new controller instance.
*
* #return void
*/
public function __construct()
{
$this->middleware('auth');
$this->middleware('access');
}
/**
* Enable sendableData by passing data to the variable
*
* #param array $data Addition data that needs to registrered
* #return MessageController
*/
protected function send ($data = []) {
// enable sendableData by passing data to the variable
$this->sendableData = $data;
return $this;
}
/**
* Enable sendableData by passing data to the variable
*
* #param string $type The type of message that we will serve to the view
* #return MessageController
*/
protected function serve ($type = "message") {
$this->ss();
$this->setData(array_merge($this->sendableData, $this->status[$type]));
$this->data->id = DB::table('messages')->insertGetId((array) $this->data);
}
/**
* Set the data of the message to be used to send or construct a message
* Note that this function turns "(array) $data" into "(object) $data"
*
* #param array $extend Override default settings
* #return MessageController
*/
protected function setData(array $extend = []) {
$defaults = [
"lobby" => Request::get('lobbyid'),
"type" => "text",
"subtype" => null,
"body" => null,
"time" => date("g:ia"),
"user" => Auth::User()->username,
"userid" => Auth::User()->id,
"day" => date("j"),
"month" => date("M"),
"timestamp" => time(),
"private" => Request::get('isPrivate') ? "1" : "0",
"name" => Request::get('displayname'),
"kicker" => null
];
$this->data = (object) array_merge($defaults, $extend);
// because a closure can not be saved in the database we will remove it after we need it
unset($this->data->message);
return $this;
}
/**
* Send out a response for PHP
*
* #return string
*/
public function build() {
if($this->data->type == "file") {
$filesize = #filesize("uploads/" . $this->data->lobby . "/" . $this->data->body);
$this->data->filesize = $this->human_filesize($filesize, 2);
}
// do not send unneccessary data
unset($this->data->body, $this->data->time, $this->data->kicker, $this->data->name, $this->data->timestamp);
return $this->data;
}
/**
* Send out a usable response for an AJAX request
*
* #return object
*/
public function json() {
return json_encode($this->build());
}
}
?>
Laravel architecture is simple enough for any size of the application.
Laravel provides several mechanisms for developers to tackle the fatty controllers in your Application.
Use Middlewares for authentications.
Use Requests for validations and manipulating data.
Use Policy for your aplication roles.
Use Repository for writing your database queries.
Use Transformers for your APIs to transform data.
It depends on your application. if it is too large and have different Modules or functionalities then you should use a modular approach.
A nice package is available for making independent modules here
Hope this helps.
I think you should do a little differently ! First you should use your traits at the same levels as the controllers since traits are not controllers, your tree should look more like :
Http
Controller
Controller.php
Home
YourControllers
Admin
Your admin controllers
Traits
Your Traits
Next your routes need to be more like that :
Route::group(['prefix' => 'home'], function()
{
Route::get('/', 'Home\YourController#index')->name('home.index');
}
Route::group(['prefix' => 'admin', 'middleware' => ['admin']], function()
{
Route::get('/', 'Admin\DashboardController#index')->name('dashboard.index');
}
You can use many kink or routes like :
Route::post('/action', 'yourControllers#store')->name('controller.store');
Route::patch('/action', 'yourControllers#update')->name('controller.update');
Route::resource('/action', 'yourController');
The Resource route creates automatically the most used your, like post, patch, edit, index.. You just need to write the action and the controller called with its action. You can check out your toutes with this command : php artisan route:list
Laravel also has many automated features, like the creation of a controller with this command : php artisan make:controller YourController.
For the routes the prefix creates portions of url, for example all the routes inside the route group with the prefix 'admin' will lool like : www.yourwebsite.com/admin/theroute, and can also be blocked for some users with a middleware.
To get familiar with laravel i suggest you follow the laravel 5.4 tutorial from scratch by Jeffrey Way on Laracasts, he's awesome at explaining and showing how laravel works. Here is a link : https://laracasts.com/series/laravel-from-scratch-2017
Hope it helps, ask me if you want to know anything else or have some precisions, i'll try to answer you !
Closed. This question needs to be more focused. It is not currently accepting answers.
Want to improve this question? Update the question so it focuses on one problem only by editing this post.
Closed 4 years ago.
Improve this question
I am developing an Yii 2 public (MIT) extension to change some of the yii\web\View behaviours (minify, combine and many other optimizations).
I can do it easily. But I really want to write as many tests (codeception) as possible for it. This is where I am very confused.
I already have some unit tests (for example: testing a specific minifing, or returning combined minified result). But I would like to test the entire result and the final integration between my extension and the Yii2 web application using it.
I just would like some guidelines for this process:
Should I have a real (complete) app inside my extension for testing purposes? If so, should it be 'installed' inside tests dir?
Would you use functional testing ? (I think so because the View will find files in AssetBundles, combine and minify them, publish the result as a single file and replace the assets' urls by new url (i.e., the optimized asset url) inside the view;
Could you provide some very basic examples/guidelines?
I just would like to highlight that I dont intend you do my testing job, I really want to learn how to do it. This is why I really would be very grateful for any tips.
Thank you so much.
My Own Guidelines
Ok, I've found my way based on tests inside yii2-smarty.
So, these are the guidelines for testing your own Yii2 extension development using phpunit:
1) The tests/bootstrap.php:
// ensure we get report on all possible php errors
error_reporting(-1);
define('YII_ENABLE_ERROR_HANDLER', false);
define('YII_DEBUG', true);
$_SERVER['SCRIPT_NAME'] = '/' . __DIR__;
$_SERVER['SCRIPT_FILENAME'] = __FILE__;
require_once(__DIR__ . '/../vendor/autoload.php');
require_once(__DIR__ . '/../vendor/yiisoft/yii2/Yii.php');
//optionals
Yii::setAlias('#testsBasePathOrWhateverYouWant', __DIR__);
Yii::setAlias('#slinstj/MyExtensionAlias', dirname(__DIR__));
2) Create a tests/TestCase base class extending \PHPUnit_Framework_TestCase:
namespace slinstj\MyExtension\tests;
use yii\di\Container;
/**
* This is the base class for all yii framework unit tests.
*/
abstract class TestCase extends \PHPUnit_Framework_TestCase
{
/**
* Clean up after test.
* By default the application created with [[mockApplication]] will be destroyed.
*/
protected function tearDown()
{
parent::tearDown();
$this->destroyApplication();
}
/**
* Populates Yii::$app with a new application
* The application will be destroyed on tearDown() automatically.
* #param array $config The application configuration, if needed
* #param string $appClass name of the application class to create
*/
protected function mockApplication($config = [], $appClass = '\yii\console\Application')
{
new $appClass(ArrayHelper::merge([
'id' => 'testapp',
'basePath' => __DIR__,
'vendorPath' => dirname(__DIR__) . '/vendor',
], $config));
}
protected function mockWebApplication($config = [], $appClass = '\yii\web\Application')
{
new $appClass(ArrayHelper::merge([
'id' => 'testapp',
'basePath' => __DIR__,
'vendorPath' => dirname(__DIR__) . '/vendor',
'components' => [
'request' => [
'cookieValidationKey' => 'wefJDF8sfdsfSDefwqdxj9oq',
'scriptFile' => __DIR__ .'/index.php',
'scriptUrl' => '/index.php',
],
]
], $config));
}
/**
* Destroys application in Yii::$app by setting it to null.
*/
protected function destroyApplication()
{
Yii::$app = null;
Yii::$container = new Container();
}
protected function debug($data)
{
return fwrite(STDERR, print_r($data, TRUE));
}
}
3) Create your testSomething classes extending TestCase:
namespace slinstj\MyExtension\tests;
use yii\web\AssetManager;
use slinstj\MyExtension\View;
use Yii;
/**
* Generated by PHPUnit_SkeletonGenerator on 2015-10-30 at 17:45:03.
*/
class ViewTest extends TestCase
{
/**
* Sets up the fixture, for example, opens a network connection.
* This method is called before a test is executed.
*/
protected function setUp()
{
parent::setUp();
$this->mockWebApplication();
}
public function testSomething()
{
$view = $this->mockView();
$content = $view->renderFile('#someAlias/views/index.php', ['data' => 'Hello World!']);
$this->assertEquals(1, preg_match('#something#', $content), 'Html view does not contain "something": ' . $content);
}
//other tests...
/**
* #return View
*/
protected function mockView()
{
return new View([
'someConfig' => 'someValue',
'assetManager' => $this->mockAssetManager(),
]);
}
protected function mockAssetManager()
{
$assetDir = Yii::getAlias('#the/path/to/assets');
if (!is_dir($assetDir)) {
mkdir($assetDir, 0777, true);
}
return new AssetManager([
'basePath' => $assetDir,
'baseUrl' => '/assets',
]);
}
protected function findByRegex($regex, $content, $match = 1)
{
$matches = [];
preg_match($regex, $content, $matches);
return $matches[$match];
}
}
That is all! This code skeleton is highly based in the yii2-smaty/tests code. Hope to help you (and me in further needs).
this approach works, but i had to make some small adjustments:
if you are developing an extension (in /vendor/you/extension directory) and the bootstrap.php file is inside a test-directory, the paths for autoloader and yii base class are most likely wrong. Better is:
require_once(__DIR__ . '/../../../autoload.php');
require_once(__DIR__ . '/../../../yiisoft/yii2/Yii.php');
i have tested an validator class which needed an application object. i have simply created an console application inside the bootstrap file (append to end of file):
$config = require(__DIR__ . '/../../../../config/console.php');
$application = new yii\console\Application($config);
I'm interested in having a unified backend environment for multiple users and having multiple frontend environments for users. All should run from a single application instance, which will be the equivalent of the app folder. I've gone back and forth on several configurations but keep running into inconsistencies once I get deeper into the app. Imagine something like the enterprise WordPress app: users need a unique webroot for their account for accessing their templates and digital assets, but one application instance runs the backend environment for all users. This is proving tricky on Lithium.
Right now, I set a basic environment parameter in the /[user]/webroot/index.php file, like so:
<?php
$env = ['webroot' => __DIR__, 'id' => 'generic_account'];
require dirname(dirname(__DIR__)) . '/app/config/bootstrap.php';
use lithium\action\Dispatcher;
use lithium\action\Request;
echo Dispatcher::run(new Request(compact('env')));
?>
Then, in the Dispatcher, I have an extension class map the account:
Dispatcher::applyFilter('run', function($self, $params, $chain) use (&$i) {
Environment::set($params['request']);
//Map $env['id'] value to stored database connection
if (isset($params['request']->id)) {
Accounts::load($params['request']);
}
foreach (array_reverse(Libraries::get()) as $name => $config) {
if ($name === 'lithium') {
continue;
}
$file = $config['path'] . '/config/routes.php';
file_exists($file) ? call_user_func(function() use ($file) { include $file; }) : null;
}
return $chain->next($self, $params, $chain);
});
Finally, in the Accounts::load() method, I pull connection settings from a master database and set those as the default Connection configuration:
<?php
namespace app\extensions\core;
use app\models\Routes;
use lithium\net\http\Router;
class Accounts {
public static function load(&$request) {
if (!is_object($request)) {
return false;
}
$class = [
'accounts' => 'app\models\Accounts',
'plugins' => 'app\extensions\core\Plugins',
'prefs' => 'app\extensions\core\Preferences',
'connections' => 'lithium\data\Connections',
'inflector' => 'lithium\util\Inflector',
'exception' => 'lithium\net\http\RoutingException'
];
$class['accounts']::meta('connection', 'master');
$bind = $class['prefs']::read('bind_account');
$key = $bind == 'domain' || $bind == 'subdomain' ? 'HTTP_HOST' : 'id';
$find = $class['accounts'] . '::' . $class['inflector']::camelize('find_by_' . $bind, false);
$account = call_user_func($find, $request->env($key));
if ($account == null) {
throw new $class['exception']('Account `' . $request->env($key) . '` doesn\'t exist.');
}
$class['connections']::add('default', json_decode($account->database, true));
$request->activeAccount = $account;
$request->params['webroot'] = $request->env('webroot');
$plugins = $class['plugins']::load();
return true;
}
/**
* Allows users to store customized route definitions in `routes` table,
* hence the use of `app\models\Routes`.
*/
public static function routes() {
$routes = Routes::all();
foreach ($routes as $route) {
Router::connect($route->match, [
'controller' => 'pages',
'action' => 'view',
'template' => $route->template,
'layout' => $route->layout
]);
}
}
}
?>
All this seems to work well for routing URLs and allowing for multiple front-end webroots. Here's the trick: when creating a webroot for admin interfaces, it's turning into a convoluted mess for keeping the asset paths straight. I've used Media::assets() to try to overcome this, but I have a feeling there's a more elegant solution out there. I've struggled to find any other examples or documentation that specifically addresses this kind of setup concern.
It's pretty straightforward, you're almost there. All you really need is a unique webroot/ directory per user, in addition to the normal bootstrap include and request-dispatching, you can include any other user-specific configuration, and register the main application, like so:
Libraries::add('yourApp', [
'path' => '/path/to/codebase',
'webroot' => __DIR__
]);
This gives you the centralized codebase, but also allows for a custom webroot per user.
I have two platforms on lithium with a similar setup. I wrote a plugin called li3_saas to facilitate it which I think I still need to put up on github. But it does some similar things with loading from a master database and setting the default database to be user specific.
I would recommend an entirely different app for a global admin interface that can load your main app using Libraries::add(), possibly with the 'bootstrap => false option to skip loading the bootstrap.
I accomplish some things - like reusing css or js - with symlinks on the file system.
I do use Media::assets() to let my admin interface know where uploaded files exist. I create a custom key in there called 'upload' and use that when creating assets paths and urls.
I could elaborate on that. Can you give a more specific use case that you are trying to solve?