Add user-field-type "file" (#977)

* Add user-field-type "file"

* Add userfield-type "picture"

* Also limit image height on userfieldsform

* Prevent empty userfields (cause warnings in tables after deleting a file)

* Show files in dialogs

Co-authored-by: Bernd Bestel <bernd@berrnd.de>
This commit is contained in:
fipwmaqzufheoxq92ebc 2020-08-31 20:07:46 +02:00 committed by GitHub
parent 318db53818
commit 07beee93a9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 192 additions and 48 deletions

View File

@ -2,8 +2,8 @@
namespace Grocy\Controllers;
use Grocy\Controllers\Users\User;
use \Grocy\Services\FilesService;
use Slim\Exception\HttpNotFoundException;
class FilesApiController extends BaseApiController
{
@ -16,14 +16,7 @@ class FilesApiController extends BaseApiController
{
try
{
if (IsValidFileName(base64_decode($args['fileName'])))
{
$fileName = base64_decode($args['fileName']);
}
else
{
throw new \Exception('Invalid filename');
}
$fileName = $this->checkFileName($args['fileName']);
$data = $request->getBody()->getContents();
file_put_contents($this->getFilesService()->GetFilePath($args['group'], $fileName), $data);
@ -40,41 +33,9 @@ class FilesApiController extends BaseApiController
{
try
{
if (IsValidFileName(base64_decode($args['fileName'])))
{
$fileName = base64_decode($args['fileName']);
}
else
{
throw new \Exception('Invalid filename');
}
$fileName = $this->checkFileName($args['fileName']);
$forceServeAs = null;
if (isset($request->getQueryParams()['force_serve_as']) && !empty($request->getQueryParams()['force_serve_as']))
{
$forceServeAs = $request->getQueryParams()['force_serve_as'];
}
if ($forceServeAs == FilesService::FILE_SERVE_TYPE_PICTURE)
{
$bestFitHeight = null;
if (isset($request->getQueryParams()['best_fit_height']) && !empty($request->getQueryParams()['best_fit_height']) && is_numeric($request->getQueryParams()['best_fit_height']))
{
$bestFitHeight = $request->getQueryParams()['best_fit_height'];
}
$bestFitWidth = null;
if (isset($request->getQueryParams()['best_fit_width']) && !empty($request->getQueryParams()['best_fit_width']) && is_numeric($request->getQueryParams()['best_fit_width']))
{
$bestFitWidth = $request->getQueryParams()['best_fit_width'];
}
$filePath = $this->getFilesService()->DownscaleImage($args['group'], $fileName, $bestFitHeight, $bestFitWidth);
}
else
{
$filePath = $this->getFilesService()->GetFilePath($args['group'], $fileName);
}
$filePath = $this->getFilePath($args['group'], $fileName, $request->getQueryParams());
if (file_exists($filePath))
{
@ -85,12 +46,39 @@ class FilesApiController extends BaseApiController
}
else
{
return $this->GenericErrorResponse($response, 'File not found', 404);
throw new HttpNotFoundException($request, 'File not found');
}
}
catch (\Exception $ex)
{
return $this->GenericErrorResponse($response, $ex->getMessage());
throw new HttpNotFoundException($request, $ex->getMessage(), $ex);
}
}
public function ShowFile(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args)
{
try
{
$fileInfo = explode('_', $args['fileName']);
$fileName = $this->checkFileName($fileInfo[1]);
$filePath = $this->getFilePath($args['group'], base64_decode($fileInfo[0]), $request->getQueryParams());
if (file_exists($filePath))
{
$response->write(file_get_contents($filePath));
$response = $response->withHeader('Cache-Control', 'max-age=2592000');
$response = $response->withHeader('Content-Type', mime_content_type($filePath));
return $response->withHeader('Content-Disposition', 'inline; filename="' . $fileName . '"');
}
else
{
throw new HttpNotFoundException($request, 'File not found');
}
}
catch (\Exception $ex)
{
throw new HttpNotFoundException($request, $ex->getMessage(), $ex);
}
}
@ -120,4 +108,50 @@ class FilesApiController extends BaseApiController
return $this->GenericErrorResponse($response, $ex->getMessage());
}
}
/**
* @param string $group The group the requested files belongs to.
* @param string $fileName The name of the requested file.
* @param array $queryParams Parameter, e.g. for scaling. Optional.
* @return string
*/
protected function getFilePath(string $group, string $fileName, array $queryParams = [])
{
$forceServeAs = null;
if (isset($queryParams['force_serve_as']) && !empty($queryParams['force_serve_as'])) {
$forceServeAs = $queryParams['force_serve_as'];
}
if ($forceServeAs == FilesService::FILE_SERVE_TYPE_PICTURE) {
$bestFitHeight = null;
if (isset($queryParams['best_fit_height']) && !empty($queryParams['best_fit_height']) && is_numeric($queryParams['best_fit_height'])) {
$bestFitHeight = $queryParams['best_fit_height'];
}
$bestFitWidth = null;
if (isset($queryParams['best_fit_width']) && !empty($queryParams['best_fit_width']) && is_numeric($queryParams['best_fit_width'])) {
$bestFitWidth = $queryParams['best_fit_width'];
}
$filePath = $this->getFilesService()->DownscaleImage($group, $fileName, $bestFitHeight, $bestFitWidth);
} else {
$filePath = $this->getFilesService()->GetFilePath($group, $fileName);
}
return $filePath;
}
/**
* @param string $fileName base64-encoded file-name
* @return false|string the decoded file-name
* @throws \Exception if the file-name is invalid.
*/
protected function checkFileName(string $fileName)
{
if (IsValidFileName(base64_decode($fileName))) {
$fileName = base64_decode($fileName);
} else {
throw new \Exception('Invalid filename');
}
return $fileName;
}
}

View File

@ -41,3 +41,9 @@ msgstr ""
msgid "link"
msgstr ""
msgid "file"
msgstr ""
msgid "image"
msgstr ""

16
migrations/0112.sql Normal file
View File

@ -0,0 +1,16 @@
DELETE FROM userfield_values
WHERE IFNULL(value, '') = '';
CREATE TRIGGER prevent_empty_userfields_INS AFTER INSERT ON userfield_values
BEGIN
DELETE FROM userfield_values
WHERE id = NEW.id
AND IFNULL(value, '') = '';
END;
CREATE TRIGGER prevent_empty_userfields_UPD2 AFTER UPDATE ON userfield_values
BEGIN
DELETE FROM userfield_values
WHERE id = NEW.id
AND IFNULL(value, '') = '';
END;

View File

@ -172,3 +172,6 @@ function animateCSS(selector, animationName, callback, speed = "faster")
nodes.on('animationend', handleAnimationEnd);
}
function RandomString() {
return Math.random().toString(36).substring(2, 100) + Math.random().toString(36).substring(2, 100);
}

View File

@ -28,6 +28,36 @@ Grocy.Components.UserfieldsForm.Save = function(success, error)
jsonData[fieldName] = "1";
}
}
else if (input.attr("type") == "file")
{
var old_file = input.data('old-file')
if (old_file) {
Grocy.Api.Delete('files/userfiles/' + old_file, null, null,
function (xhr) {
Grocy.FrontendHelpers.ShowGenericError('Could not delete file', xhr);
});
jsonData[fieldName] = "";
}
if (input[0].files.length > 0){
// Files service requires an extension
var fileName = RandomString() + '.' + input[0].files[0].name.split('.').reverse()[0];
jsonData[fieldName] = btoa(fileName) + '_' + btoa(input[0].files[0].name);
Grocy.Api.UploadFile(input[0].files[0], 'userfiles', fileName,
function (result)
{
},
function (xhr)
{
Grocy.FrontendHelpers.ShowGenericError('Error while saving, probably this item already exists', xhr.response)
}
);
}
else
{
//jsonData[fieldName] = null;
}
}
else if ($(this).hasAttr("multiple"))
{
jsonData[fieldName] = $(this).val().join(",");
@ -79,6 +109,28 @@ Grocy.Components.UserfieldsForm.Load = function()
input.val(value.split(","));
$(".selectpicker").selectpicker("render");
}
if (input.attr('type') == "file")
{
if (value != null && !value.isEmpty()) {
var file_name = atob(value.split('_')[1]);
var file_src = value.split('_')[0];
input.hide();
var file_info = input.siblings('.userfield-file');
file_info.removeClass('d-none');
file_info.find('a.userfield-current-file')
.attr('href', U('/files/userfiles/' + value))
.text(file_name);
file_info.find('img.userfield-current-file')
.attr('src', U('/files/userfiles/' + value + '?force_serve_as=picture&best_fit_width=250&best_fit_height=250'))
file_info.find('button.userfield-file-delete').click(
function () {
file_info.addClass('d-none');
input.data('old-file', file_src);
input.show();
}
);
}
}
else
{
input.val(value);

View File

@ -7,7 +7,7 @@
if ($("#instruction-manual")[0].files.length > 0)
{
var someRandomStuff = Math.random().toString(36).substring(2, 100) + Math.random().toString(36).substring(2, 100);
var someRandomStuff = RandomString();
jsonData.instruction_manual_file_name = someRandomStuff + $("#instruction-manual")[0].files[0].name;
}

View File

@ -34,7 +34,9 @@ $app->group('', function(RouteCollectorProxy $group)
$group->get('/user/{userId}/permissions', '\Grocy\Controllers\UsersController:PermissionList');
$group->get('/usersettings', '\Grocy\Controllers\UsersController:UserSettings');
// Stock routes
$group->get('/files/{group}/{fileName}', '\Grocy\Controllers\FilesApiController:ShowFile');
// Stock routes
if (GROCY_FEATURE_FLAG_STOCK)
{
$group->get('/stockoverview', '\Grocy\Controllers\StockController:Overview');

View File

@ -14,8 +14,10 @@ class UserfieldsService extends BaseService
const USERFIELD_TYPE_PRESET_LIST = 'preset-list';
const USERFIELD_TYPE_PRESET_CHECKLIST = 'preset-checklist';
const USERFIELD_TYPE_LINK = 'link';
const USERFIELD_TYPE_FILE = 'file';
const USERFIELD_TYPE_IMAGE = 'image';
public function __construct()
public function __construct()
{
parent::__construct();
}

View File

@ -12,6 +12,13 @@
{!! str_replace(',', '<br>', $userfieldObject->value) !!}
@elseif($userfield->type == \Grocy\Services\UserfieldsService::USERFIELD_TYPE_LINK)
<a href="{{ $userfieldObject->value }}" target="_blank">{{ $userfieldObject->value }}</a>
@elseif($userfield->type == \Grocy\Services\UserfieldsService::USERFIELD_TYPE_FILE)
<a class="show-as-dialog-link" href="{{ $U('/files/userfiles/'. $userfieldObject->value) }}" target="_blank">{{ base64_decode(explode('_', $userfieldObject->value)[1]) }}</a>
@elseif($userfield->type == \Grocy\Services\UserfieldsService::USERFIELD_TYPE_IMAGE)
<a class="show-as-dialog-link" href="{{ $U('/files/userfiles/'. $userfieldObject->value . '?force_serve_as=picture') }}">
<img src="{{ $U('/files/userfiles/'. $userfieldObject->value . '?force_serve_as=picture&best_fit_width=32&best_fit_height=32') }}"
class="lazy" title="{{ base64_decode(explode('_', $userfieldObject->value)[1]) }}" alt="{{ base64_decode(explode('_', $userfieldObject->value)[1]) }}">
</a>
@else
{{ $userfieldObject->value }}
@endif

View File

@ -98,6 +98,28 @@
<label for="name">{{ $userfield->caption }}</label>
<input type="link" class="form-control userfield-input" data-userfield-name="{{ $userfield->name }}">
</div>
@elseif($userfield->type == \Grocy\Services\UserfieldsService::USERFIELD_TYPE_FILE)
<div class="form-group">
<label for="{{ $userfield->name }}">{{ $userfield->caption }}</label>
<input type="file" class="form-control userfield-input" data-userfield-name="{{ $userfield->name }}">
<div class="d-none userfield-file">
<a href="" class="userfield-current-file" data-uf-name="{{ $userfield->name }}"></a>
<button type="button" class="userfield-current-file btn btn-danger userfield-file-delete" data-uf-name="{{ $userfield->name }}">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
@elseif($userfield->type == \Grocy\Services\UserfieldsService::USERFIELD_TYPE_IMAGE)
<div class="form-group">
<label for="{{ $userfield->name }}">{{ $userfield->caption }}</label>
<input type="file" class="form-control userfield-input" data-userfield-name="{{ $userfield->name }}">
<div class="d-none userfield-file">
<img src="" alt="{{ $userfield->name }}" class="userfield-current-file" data-uf-name="{{ $userfield->name }}"/>
<button type="button" class="userfield-current-file btn btn-danger userfield-file-delete" data-uf-name="{{ $userfield->name }}">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
@endif
@endforeach