Added recaptcha verification option.

Added bulk delete function.
Added account clean function (closes #151)
This commit is contained in:
Sergio Brighenti 2020-03-04 15:25:45 +01:00
parent a22f6afc68
commit e5d70c00ce
20 changed files with 209 additions and 43 deletions

View file

@ -5,6 +5,10 @@
+ Added ability to choose between default and raw url on copy.
+ Added hide by default option.
+ Added user disk quota.
+ Added reCAPTCHA login protection.
+ Added bulk delete.
+ Added account clean function.
+ Added user disk quota system.
+ Fixed bug html files raws are rendered in a browser.
+ The theme is now re-applied after every system update.
+ Updated system settings page.

View file

@ -37,6 +37,9 @@ class AdminController extends Controller
'copy_url_behavior' => $this->getSetting('copy_url_behavior', 'off'),
'quota_enabled' => $this->getSetting('quota_enabled', 'off'),
'default_user_quota' => humanFileSize($this->getSetting('default_user_quota', stringToBytes('1G')), 0, true),
'recaptcha_enabled' => $this->getSetting('recaptcha_enabled', 'off'),
'recaptcha_site_key' => $this->getSetting('recaptcha_site_key'),
'recaptcha_secret_key' => $this->getSetting('recaptcha_secret_key'),
]);
}

View file

@ -3,6 +3,7 @@
namespace App\Controllers\Auth;
use App\Controllers\Controller;
use App\Web\ValidationChecker;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
@ -25,6 +26,7 @@ class LoginController extends Controller
return view()->render($response, 'auth/login.twig', [
'register_enabled' => $this->getSetting('register_enabled', 'off'),
'recaptcha_site_key' => $this->getSetting('recaptcha_enabled') === 'on' ? $this->getSetting('recaptcha_site_key') : null,
]);
}
@ -38,21 +40,34 @@ class LoginController extends Controller
*/
public function login(Request $request, Response $response): Response
{
if ($this->getSetting('recaptcha_enabled') === 'on') {
$recaptcha = json_decode(file_get_contents('https://www.google.com/recaptcha/api/siteverify?secret='.$this->getSetting('recaptcha_secret_key').'&response='.param($request, 'recaptcha_token')));
if ($recaptcha->success && $recaptcha->score < 0.5) {
$this->session->alert(lang('recaptcha_failed'), 'danger');
return redirect($response, route('login'));
}
}
$username = param($request, 'username');
$user = $this->database->query('SELECT `id`, `email`, `username`, `password`,`is_admin`, `active`, `current_disk_quota`, `max_disk_quota` FROM `users` WHERE `username` = ? OR `email` = ? LIMIT 1', [$username, $username])->fetch();
if (!$user || !password_verify(param($request, 'password'), $user->password)) {
$this->session->alert(lang('bad_login'), 'danger');
return redirect($response, route('login'));
}
$validator = ValidationChecker::make()
->rules([
'login' => $user && password_verify(param($request, 'password'), $user->password),
'maintenance' => !isset($this->config['maintenance']) || !$this->config['maintenance'] || $user->is_admin,
'user_active' => $user->active,
])
->onFail(function ($rule) {
$alerts = [
'login' => lang('bad_login'),
'maintenance' => lang('maintenance_in_progress'),
'user_active' => lang('account_disabled'),
];
if (isset($this->config['maintenance']) && $this->config['maintenance'] && !$user->is_admin) {
$this->session->alert(lang('maintenance_in_progress'), 'info');
return redirect($response, route('login'));
}
if (!$user->active) {
$this->session->alert(lang('account_disabled'), 'danger');
$this->session->alert($alerts[$rule], $rule === 'maintenance' ? 'info' : 'danger');
});
if ($validator->fails()) {
return redirect($response, route('login'));
}

View file

@ -32,7 +32,9 @@ class RegisterController extends Controller
throw new HttpNotFoundException($request);
}
return view()->render($response, 'auth/register.twig');
return view()->render($response, 'auth/register.twig', [
'recaptcha_site_key' => $this->getSetting('recaptcha_enabled') === 'on' ? $this->getSetting('recaptcha_site_key') : null,
]);
}
/**
@ -52,6 +54,15 @@ class RegisterController extends Controller
throw new HttpNotFoundException($request);
}
if ($this->getSetting('recaptcha_enabled') === 'on') {
$recaptcha = json_decode(file_get_contents('https://www.google.com/recaptcha/api/siteverify?secret='.$this->getSetting('recaptcha_secret_key').'&response='.param($request, 'recaptcha_token')));
if ($recaptcha->success && $recaptcha->score < 0.5) {
$this->session->alert(lang('recaptcha_failed'), 'danger');
return redirect($response, route('login'));
}
}
$validator = $this->getUserCreateValidator($request);
if ($validator->fails()) {

View file

@ -185,10 +185,8 @@ class MediaController extends Controller
* @param int $id
*
* @return Response
* @throws HttpUnauthorizedException
*
* @throws HttpNotFoundException
* @throws FileNotFoundException
* @throws HttpUnauthorizedException
*/
public function delete(Request $request, Response $response, int $id): Response
{
@ -199,11 +197,12 @@ class MediaController extends Controller
}
if ($this->session->get('admin', false) || $media->user_id === $this->session->get('user_id')) {
$size = $this->deleteMedia($request, $media->storage_path, $id);
$this->updateUserQuota($request, $media->user_id, $size, true);
$this->deleteMedia($request, $media->storage_path, $id, $media->user_id);
$this->logger->info('User '.$this->session->get('username').' deleted a media.', [$id]);
if ($media->user_id === $this->session->get('user_id')) {
$user = make(UserQuery::class)->get($request, $id, true);
$user = make(UserQuery::class)->get($request, $media->user_id, true);
$this->setSessionQuotaInfo($user->current_disk_quota, $user->max_disk_quota);
}
} else {
@ -252,8 +251,7 @@ class MediaController extends Controller
}
if ($this->session->get('admin', false) || $user->id === $media->user_id) {
$size = $this->deleteMedia($request, $media->storage_path, $media->mediaId);
$this->updateUserQuota($request, $media->user_id, $size, true);
$this->deleteMedia($request, $media->storage_path, $media->mediaId, $user->id);
$this->logger->info('User '.$user->username.' deleted a media via token.', [$media->mediaId]);
} else {
throw new HttpUnauthorizedException($request);
@ -262,20 +260,50 @@ class MediaController extends Controller
return redirect($response, route('home'));
}
/**
* @param Request $request
* @param Response $response
* @param int $id
* @return Response
*/
public function clearUserMedia(Request $request, Response $response, int $id): Response
{
$user = make(UserQuery::class)->get($request, $id, true);
$medias = $this->database->query('SELECT * FROM `uploads` WHERE `user_id` = ?', $user->id);
foreach ($medias as $media) {
try {
$this->storage->delete($media->storage_path);
} catch (FileNotFoundException $e) {
}
}
$this->database->query('DELETE FROM `uploads` WHERE `user_id` = ?', $user->id);
$this->database->query('UPDATE `users` SET `current_disk_quota`=? WHERE `id` = ?', [
0,
$user->id,
]);
$this->session->alert(lang('account_media_deleted'), 'success');
return redirect($response, route('user.edit', ['id' => $id]));
}
/**
* @param Request $request
* @param string $storagePath
* @param int $id
*
* @return bool|false|int
* @param int $userId
* @return void
* @throws HttpNotFoundException
*/
protected function deleteMedia(Request $request, string $storagePath, int $id)
protected function deleteMedia(Request $request, string $storagePath, int $id, int $userId)
{
try {
$size = $this->storage->getSize($storagePath);
$this->storage->delete($storagePath);
return $size;
$this->updateUserQuota($request, $userId, $size, true);
} catch (FileNotFoundException $e) {
throw new HttpNotFoundException($request);
} finally {
@ -293,12 +321,10 @@ class MediaController extends Controller
{
$mediaCode = pathinfo($mediaCode)['filename'];
$media = $this->database->query('SELECT `uploads`.*, `users`.*, `users`.`id` AS `userId`, `uploads`.`id` AS `mediaId` FROM `uploads` INNER JOIN `users` ON `uploads`.`user_id` = `users`.`id` WHERE `user_code` = ? AND `uploads`.`code` = ? LIMIT 1', [
return $this->database->query('SELECT `uploads`.*, `users`.*, `users`.`id` AS `userId`, `uploads`.`id` AS `mediaId` FROM `uploads` INNER JOIN `users` ON `uploads`.`user_id` = `users`.`id` WHERE `user_code` = ? AND `uploads`.`code` = ? LIMIT 1', [
$userCode,
$mediaCode,
])->fetch();
return $media;
}
/**

View file

@ -22,6 +22,11 @@ class SettingController extends Controller
return redirect($response, route('system'));
}
if (param($request, 'recaptcha_enabled', 'off') === 'on' && (empty(param($request, 'recaptcha_site_key')) || empty(param($request, 'recaptcha_secret_key')))) {
$this->session->alert(lang('recaptcha_keys_required', 'danger'));
return redirect($response, route('system'));
}
$this->updateSetting('register_enabled', param($request, 'register_enabled', 'off'));
$this->updateSetting('hide_by_default', param($request, 'hide_by_default', 'off'));
$this->updateSetting('quota_enabled', param($request, 'quota_enabled', 'off'));
@ -36,6 +41,11 @@ class SettingController extends Controller
$this->applyLang($request);
$this->updateSetting('custom_head', param($request, 'custom_head'));
$this->updateSetting('recaptcha_enabled', param($request, 'recaptcha_enabled', 'off'));
$this->updateSetting('recaptcha_site_key', param($request, 'recaptcha_site_key'));
$this->updateSetting('recaptcha_secret_key', param($request, 'recaptcha_secret_key'));
$this->session->alert(lang('settings_saved'));
return redirect($response, route('system'));

View file

@ -41,6 +41,7 @@ class ViewFactory
$twig->addFunction(new TwigFunction('isDisplayableImage', 'isDisplayableImage'));
$twig->addFunction(new TwigFunction('inPath', 'inPath'));
$twig->addFunction(new TwigFunction('humanFileSize', 'humanFileSize'));
$twig->addFunction(new TwigFunction('param', 'param'));
return new View($twig);
}

View file

@ -49,6 +49,7 @@ $app->group('', function (RouteCollectorProxy $group) {
$group->get('/{id}/edit', [UserController::class, 'edit'])->setName('user.edit');
$group->post('/{id}', [UserController::class, 'update'])->setName('user.update');
$group->get('/{id}/delete', [UserController::class, 'delete'])->setName('user.delete');
$group->get('/{id}/clear', [MediaController::class, 'clearUserMedia'])->setName('user.clear');
})->add(AdminMiddleware::class);
$group->get('/profile', [ProfileController::class, 'profile'])->setName('profile');

View file

@ -143,4 +143,15 @@ If it wasn\'t you who requested the password reset, simply ignore this email.',
'recalculate_user_quota' => 'Recalculate user quota from disk',
'quota_recalculated' => 'User quota recalculated from the disk successfully.',
'used_space' => 'Used Space',
'delete_selected' => 'Delete Selected',
'delete_all' => 'Delete All',
'clear_account' => 'Clear Account',
'account_media_deleted' => 'All media in the account have been deleted.',
'danger_zone' => 'Danger Zone',
'recaptcha_failed' => 'reCAPTCHA Failed',
'recaptcha_enabled' => 'reCAPTCHA Enabled',
'recaptcha_keys_required' => 'All reCAPTCHA keys are required.',
'only_recaptcha_v3' => 'Only reCAPTCHA v3 is supported.',
'recaptcha_site_key' => 'reCAPTCHA Site Key.',
'recaptcha_secret_key' => 'reCAPTCHA Secret Key.',
];

View file

@ -35,6 +35,9 @@
</div>
<div class="row">
<div class="col-md-12">
{% if recaptcha_site_key is not null %}
<input type="hidden" name="recaptcha_token" id="recaptcha_token">
{% endif %}
<label for="username" class="sr-only">{{ lang('login.username') }}</label>
<input type="text" id="username" class="form-control first" placeholder="{{ lang('login.username') }}" name="username" required autofocus>
<label for="password" class="sr-only">{{ lang('password') }}</label>
@ -60,4 +63,10 @@
</div>
</form>
</div>
{% endblock %}
{% block js %}
{% if recaptcha_site_key is not null %}
{% include 'comp/recaptcha.twig' %}
{% endif %}
{% endblock %}

View file

@ -35,6 +35,9 @@
</div>
<div class="row">
<div class="col-md-12">
{% if recaptcha_site_key is not null %}
<input type="hidden" name="recaptcha_token" id="recaptcha_token">
{% endif %}
<label for="username" class="sr-only">{{ lang('username') }}</label>
<input type="text" id="username" class="form-control first" placeholder="{{ lang('username') }}" name="username" required autofocus>
<label for="email" class="sr-only">E-Mail</label>
@ -53,4 +56,10 @@
</div>
</form>
</div>
{% endblock %}
{% block js %}
{% if recaptcha_site_key is not null %}
{% include 'comp/recaptcha.twig' %}
{% endif %}
{% endblock %}

View file

@ -53,5 +53,6 @@
<script src="{{ asset('/static/bootstrap/js/bootstrap4-toggle.min.js') }}"></script>
<script src="{{ asset('/static/app/app.js') }}"></script>
<script>hljs.initHighlightingOnLoad();</script>
{% block js %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,8 @@
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}"></script>
<script>
grecaptcha.ready(function () {
grecaptcha.execute('{{ recaptcha_site_key }}', {action: 'auth'}).then(function (token) {
$('#recaptcha_token').val(token);
});
});
</script>

View file

@ -10,7 +10,7 @@
{% if medias|length > 0 %}
<div class="row">
{% for media in medias %}
<div class="col-md-4" id="media_{{ media.id }}">
<div class="col-md-4 bulk-selector" id="media_{{ media.id }}" data-id="{{ media.id }}">
<div class="card mb-4 shadow-sm">
{% if isDisplayableImage(media.mimetype) %}
<img class="card-img" src="{{ urlFor('/' ~ media.user_code ~ '/' ~ media.code ~ '.' ~ media.extension ~ '/raw?width=348&height=267') }}" alt="Card image">

View file

@ -28,7 +28,7 @@
</thead>
<tbody>
{% for media in medias %}
<tr id="media_{{ media.id }}">
<tr id="media_{{ media.id }}" class="bulk-selector" data-id="{{ media.id }}">
<td class="text-center">
{% if isDisplayableImage(media.mimetype) %}
{% if media.username is not null %}

View file

@ -2,7 +2,7 @@
<div class="col-md-3">
<form method="get" action="{{ route('home') }}">
<div class="input-group mb-3">
<input type="text" name="search" class="form-control" placeholder="{{ lang('dotted_search') }}" aria-label="{{ lang('dotted_search') }}" value="{{ request.param('search', '') }}">
<input type="text" name="search" class="form-control" placeholder="{{ lang('dotted_search') }}" aria-label="{{ lang('dotted_search') }}" value="{{ param(request, 'search', '') }}">
<div class="input-group-append">
<button type="submit" class="btn btn-outline-secondary"><i class="fas fa-search"></i></button>
</div>
@ -28,5 +28,6 @@
<i class="fas {{ request.queryParams['order'] is same as('ASC') ? 'fa-sort-amount-up' : 'fa-sort-amount-down' }}"></i>
</a>
</div>
<a href="javascript:void(0)" id="bulk-delete" class="btn btn-outline-danger disabled" data-toggle="tooltip" title="{{ lang('delete_selected') }}"><i class="fas fa-trash"></i></a>
</div>
</div>

View file

@ -85,19 +85,6 @@
</select>
</div>
</div>
<div class="form-group row">
<label for="quota_enabled" class="col-sm-4 col-form-label">{{ lang('quota_enabled') }}</label>
<div class="col-sm-8">
<input type="checkbox" name="quota_enabled" data-toggle="toggle" {{ quota_enabled == 'on' ? 'checked' }}>
</div>
</div>
<div class="form-group row">
<label for="default_user_quota" class="col-sm-4 col-form-label">{{ lang('default_user_quota') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="default_user_quota" name="default_user_quota" pattern="[0-9]+[K|M|G|T]" title="512M, 2G, 1T, ..." placeholder="1G" value="{{ default_user_quota }}">
<small>512M, 2G, 1T, ...</small>
</div>
</div>
<div class="form-group row">
<label for="lang" class="col-sm-4 col-form-label">{{ lang('enforce_language') }}</label>
<div class="col-sm-8">
@ -117,6 +104,40 @@
<small>{{ lang('custom_head_html_hint') }}</small>
</div>
</div>
<hr>
<div class="form-group row">
<label for="quota_enabled" class="col-sm-4 col-form-label">{{ lang('quota_enabled') }}</label>
<div class="col-sm-8">
<input type="checkbox" name="quota_enabled" data-toggle="toggle" {{ quota_enabled == 'on' ? 'checked' }} onchange="document.getElementById('default_user_quota').toggleAttribute('readonly')">
</div>
</div>
<div class="form-group row">
<label for="default_user_quota" class="col-sm-4 col-form-label">{{ lang('default_user_quota') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="default_user_quota" name="default_user_quota" pattern="[0-9]+[K|M|G|T]" title="512M, 2G, 1T, ..." placeholder="1G" value="{{ default_user_quota }}" {{ quota_enabled == 'off' ? 'readonly' }}>
<small>512M, 2G, 1T, ...</small>
</div>
</div>
<hr>
<div class="form-group row">
<label for="recaptcha_enabled" class="col-sm-4 col-form-label">{{ lang('recaptcha_enabled') }}</label>
<div class="col-sm-8">
<input type="checkbox" name="recaptcha_enabled" data-toggle="toggle" {{ recaptcha_enabled == 'on' ? 'checked' }} onchange="document.getElementById('recaptcha_site_key').toggleAttribute('readonly');document.getElementById('recaptcha_secret_key').toggleAttribute('readonly')">
<br><small>{{ lang('only_recaptcha_v3') }}</small>
</div>
</div>
<div class="form-group row">
<label for="recaptcha_site_key" class="col-sm-4 col-form-label">{{ lang('recaptcha_site_key') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="recaptcha_site_key" name="recaptcha_site_key" value="{{ recaptcha_site_key }}" {{ recaptcha_enabled == 'off' ? 'readonly' }}>
</div>
</div>
<div class="form-group row">
<label for="recaptcha_secret_key" class="col-sm-4 col-form-label">{{ lang('recaptcha_secret_key') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="recaptcha_secret_key" name="recaptcha_secret_key" value="{{ recaptcha_secret_key }}" {{ recaptcha_enabled == 'off' ? 'readonly' }}>
</div>
</div>
<button type="submit" class="btn btn-outline-success float-right mt-3">
<i class="fas fa-save fa-fw"></i> {{ lang('apply') }}
</button>

View file

@ -66,6 +66,8 @@
</div>
</div>
{% if not profile %}
<h6 class="text-danger">{{ lang('danger_zone') }}</h6>
<hr>
{% if quota_enabled == 'on' %}
<div class="form-group row">
<label for="max_user_quota" class="col-sm-2 col-form-label">{{ lang('max_user_quota') }}</label>
@ -87,6 +89,14 @@
<input type="checkbox" name="is_active" data-toggle="toggle" data-off="{{ lang('no') }}" data-on="{{ lang('yes') }}" {{ user.active ? 'checked' }}>
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label">{{ lang('delete_all') }}</label>
<div class="col-sm-10">
<div class="btn-group">
<a href="{{ route('user.clear', {'id': user.id}) }}" class="btn btn-lg btn-outline-danger"><i class="fas fa-fw fa-recycle"></i> {{ lang('clear_account') }}</a>
</div>
</div>
</div>
{% endif %}
<div class="form-group row justify-content-md-end">
<div class="col-sm-10">

View file

@ -125,4 +125,8 @@ body {
.pdf-viewer {
width: 100%;
height: 85vh
}
}
.grecaptcha-badge {
top: 5px !important;
}

View file

@ -25,6 +25,9 @@ var app = {
$('#themes').mousedown(app.loadThemes);
$('.checkForUpdatesButton').click(app.checkForUpdates);
$('.bulk-selector').contextmenu(app.bulkSelect);
$('#bulk-delete').click(app.bulkDelete);
$('.alert').fadeTo(10000, 500).slideUp(500, function () {
$('.alert').slideUp(500);
});
@ -116,6 +119,24 @@ var app = {
$('#doUpgradeButton').prop('disabled', true);
}
});
},
bulkSelect: function (e) {
e.preventDefault();
$(this).toggleClass('bg-light').toggleClass('text-danger').toggleClass('bulk-selected');
var $bulkDelete = $('#bulk-delete');
if ($bulkDelete.hasClass('disabled')) {
$bulkDelete.removeClass('disabled');
}
},
bulkDelete: function () {
$('.bulk-selected').each(function (index, media) {
$.post(window.AppConfig.base_url + '/upload/' + $(media).data('id') + '/delete', function () {
$(media).fadeOut(200, function () {
$(this).remove();
});
});
});
$(this).addClass('disabled');
}
};