diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..16a9eb0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.github +.vscode +app/react/node_modules +app/public/content +vendor +*.log +.DS_Store diff --git a/.gitignore b/.gitignore index 0d64a3c..f68b37a 100755 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ *.log composer.lock app/database/db.sqlite -public/content/* -public/assets/js/tinymce +app/public/content/* +app/public/assets/js/tinymce +app/public/assets/js/admin.* +app/react/node_modules +app/react/package-lock.json vendor \ No newline at end of file diff --git a/.htaccess b/.htaccess index 3e45d8a..6ab68af 100755 --- a/.htaccess +++ b/.htaccess @@ -1,5 +1,6 @@ Options -Indexes RewriteEngine on -RewriteRule ^$ public/ [L] -RewriteRule ^((?!public/).*)$ public/$1 [L,NC] +RewriteRule ^$ app/public/ [L] +RewriteRule ^public/(.*)$ app/public/$1 [L,NC] +RewriteRule ^((?!app/public/).*)$ app/public/$1 [L,NC] diff --git a/README.md b/README.md index e7a37ba..744e2e3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Aurora logo + Aurora logo
Aurora
diff --git a/app/bootstrap/config.php b/app/bootstrap/config.php index a399fe7..e25b142 100644 --- a/app/bootstrap/config.php +++ b/app/bootstrap/config.php @@ -1,6 +1,6 @@ $db, 'content' => 'public/content', 'per_page' => 20, + 'max_active_sessions' => 10, 'mail' => fn($to, $subject, $message) => mail($to, $subject, $message), - 'views' => 'app/views', + 'views' => 'views', ]; diff --git a/app/bootstrap/index.php b/app/bootstrap/index.php index 8076e98..7e64012 100644 --- a/app/bootstrap/index.php +++ b/app/bootstrap/index.php @@ -26,20 +26,14 @@ function setting(?string $key = null): mixed $settings = $db->query('SELECT `key`, value FROM settings')->fetchAll(\PDO::FETCH_KEY_PAIR); header('X-Content-Type-Options: nosniff'); - ini_set('session.cookie_httponly', 1); ini_set('error_log', \Aurora\Core\Helper::getPath($settings['log_file'])); - ini_set('display_errors', $settings['display_errors'] ? 1 : 0); - ini_set('display_startup_errors', $settings['display_errors'] ? 1 : 0); + ini_set('display_errors', 0); + ini_set('display_startup_errors', 0); error_reporting($settings['log_errors'] ? E_ALL : 0); date_default_timezone_set($settings['timezone']); - session_set_cookie_params([ - 'lifetime' => (int) $settings['session_lifetime'], - 'samesite' => $settings['samesite_cookie'], - ]); - session_start(); $languages = []; - foreach (glob(\Aurora\Core\Helper::getPath('app/languages/*.php')) as $file) { + foreach (glob(\Aurora\Core\Helper::getPath('languages/*.php')) as $file) { $languages[pathinfo($file, PATHINFO_FILENAME)] = require_once($file); } @@ -47,11 +41,13 @@ function setting(?string $key = null): mixed $view = new \Aurora\Core\View(\Aurora\Core\Helper::getPath($kernel->config('views')), new \Aurora\App\ViewHelper($kernel->config('date_format'), $lang)); - \Aurora\App\Permission::set($db->query('SELECT permission, role_level FROM roles_permissions ORDER BY permission')->fetchAll(\PDO::FETCH_KEY_PAIR), $_SESSION['user']['role'] ?? 0); - \Aurora\App\Permission::addMethod('impersonate', fn($user) => ($user['status'] ?? false) && $user['role'] <= ($_SESSION['user']['role'] ?? 0) && \Aurora\App\Permission::can('impersonate')); - \Aurora\App\Permission::addMethod('edit_user', fn($user) => ($user['role'] ?? 0) <= ($_SESSION['user']['role'] ?? 0) && \Aurora\App\Permission::can('edit_users')); + $user = &$GLOBALS['user']; + \Aurora\App\Permission::set($db->query('SELECT permission, role_level FROM roles_permissions ORDER BY permission')->fetchAll(\PDO::FETCH_KEY_PAIR), $user['role'] ?? 0); + \Aurora\App\Permission::addMethod('editUser', function ($subject) use (&$user) { + return \Aurora\App\Modules\User::canEdit($user, $subject); + }); \Aurora\App\Setting::set($settings); \Aurora\App\Media::setDirectory($kernel->config('content')); - (require('routes.php'))($kernel, $db, $view, $lang); + (require('routes.php'))($kernel, $db, $view, $lang, $user); }; diff --git a/app/bootstrap/routes.php b/app/bootstrap/routes.php index bf7301a..4abb587 100644 --- a/app/bootstrap/routes.php +++ b/app/bootstrap/routes.php @@ -2,7 +2,7 @@ use Aurora\Core\{DB, Helper, Kernel, Language, View}; -return function (\Aurora\Core\Kernel $kernel, DB $db, View $view, Language $lang) { +return function (\Aurora\Core\Kernel $kernel, DB $db, View $view, Language $lang, ?array &$user) { $user_mod = new \Aurora\App\Modules\User($db, $lang); $tag_mod = new \Aurora\App\Modules\Tag($db, $lang); $link_mod = new \Aurora\App\Modules\Link($db, $lang); @@ -14,871 +14,605 @@ $rss = \Aurora\App\Setting::get('rss'); $router = $kernel->router; - $router->middleware('*', function() use ($db, $view, $lang, $theme_dir) { - if (Helper::isValidId($_SESSION['user']['id'] ?? false)) { - $_SESSION['user'] = $db->query('SELECT * FROM users WHERE id = ? AND status', $_SESSION['user']['id'])->fetch(); - } - - if (\Aurora\App\Setting::get('maintenance') && !str_starts_with(Helper::getCurrentPath(), 'admin') && !Helper::isValidId($_SESSION['user']['id'] ?? false)) { - echo $view->get("$theme_dir/information.html", [ - 'description' => $lang->get('under_maintenance'), - 'subdescription' => $lang->get('come_back_soon'), + $getAuthToken = function() { + return preg_match('/Bearer\s(\S+)/i', Helper::getAuthorizationHeader(), $matches) + ? $matches[1] + : ($_COOKIE['auth_token'] ?? false); + }; + + $setAuthToken = function($token, $time) { + return setcookie('auth_token', + $token, + [ + 'expires' => $time, + 'path' => '/', + 'domain' => '', + 'secure' => \Aurora\Core\Helper::isHttps(), + 'httponly' => true, + 'samesite' => 'Lax', ]); - exit; - } + }; - if ($_SERVER['REQUEST_METHOD'] === 'POST' && !Helper::isCsrfTokenValid($_POST['csrf'] ?? '')) { - echo json_encode([ 'reload' => true ]); - exit; - } - }); - - $router->code(404, function() use ($view, $lang, $theme_dir) { - return $view->get("$theme_dir/information.html", [ - 'title' => '404', - 'description' => $lang->get('not_found'), - 'subdescription' => $lang->get('not_found_desc'), - ]); + $router->get([ 'admin', 'admin/*' ], function() use ($view) { + return $view->get('admin.html'); }); /** - * ADMIN + * BLOG */ - $router->middleware('admin/*', function() use ($db) { - if ((!Helper::isValidId($_SESSION['user']['id'] ?? false) || !($_SESSION['user']['status'] ?? false)) && - !in_array(Helper::getCurrentPath(), [ 'admin', 'admin/login', 'admin/send_password_restore', 'admin/new_password', 'admin/password_restore' ])) { - header('Location: /admin'); - exit; - } + $router->get('json:api/blog/posts', function() use ($view, $post_mod, $theme_dir) { + $current_page = max(1, (int) ($_GET['page'] ?? 1)); + $per_page = \Aurora\App\Setting::get('per_page'); + $where = [ $post_mod->getCondition([ 'status' => 1 ]) ]; - if (Helper::isValidId($_SESSION['user']['id'] ?? false)) { - $db->update('users', [ 'last_active' => time() ], $_SESSION['user']['id']); + if (!empty($_GET['user'])) { + $where[] = 'posts.user_id = ' . ((int) $_GET['user']); } - }); - $router->get('admin', function() use ($view) { - if (Helper::isValidId($_SESSION['user']['id'] ?? false)) { - header('Location: /admin/dashboard'); + if (!empty($_GET['tag'])) { + $where[] = 'posts.id IN (SELECT post_id FROM posts_to_tags WHERE tag_id = ' . ((int) $_GET['tag']) . ')'; } - return $view->get('admin/login.html'); - }); - - $router->post('json:admin/login', function() use ($user_mod) { - $errors = $user_mod->handleLogin($_POST['email'], $_POST['password']); + $where = implode(' AND ', $where); return json_encode([ - 'success' => empty($errors), - 'msg' => null, - 'errors' => $errors, + 'next_page' => $post_mod->isNextPageAvailable($current_page, $per_page, $where), + 'html' => $view->get("$theme_dir/partials/posts_page.html", [ + 'posts' => $post_mod->getPage($current_page, $per_page, $where), + ]), ]); }); - $router->post('json:admin/send_password_restore', function() use ($view, $user_mod) { - $hash = bin2hex(random_bytes(18)); - $errors = $user_mod->requestPasswordRestore($_POST['email'], - $hash, - $view->get('admin/emails/password_restore.html', [ 'hash' => $hash ])); - - return json_encode([ - 'success' => empty($errors), - 'errors' => $errors, - ]); - }); + $router->get($blog_url, function() use ($db, $view, $lang, $link_mod, $post_mod, $theme_dir) { + $current_page = max(1, (int) ($_GET['page'] ?? 1)); + $per_page = \Aurora\App\Setting::get('per_page'); + $where = $post_mod->getCondition([ 'status' => 1 ]); + $search = $db->escape($_GET['search'] ?? ''); - $router->get('admin/new_password', function() use ($view) { - return $view->get('admin/password_restore.html', [ 'hash' => $_GET['hash'] ]); - }); + if (!empty($search)) { + $where .= " AND (posts.title LIKE '%$search%' OR posts.description LIKE '%$search%')"; + } - $router->post('json:admin/password_restore', function() use ($user_mod) { - $error = $user_mod->passwordRestore($_POST['hash'], $_POST['password'], $_POST['password_confirm']); - return json_encode([ - 'success' => empty($error), - 'errors' => [ $error ], + return $view->get("$theme_dir/blog.html", [ + 'header_links' => $link_mod->getHeaderLinks(), + 'title' => $lang->get('blog'), + 'posts' => $post_mod->getPage($current_page, $per_page, $where, 'date', false, true), + 'next_page' => $post_mod->isNextPageAvailable($current_page, $per_page, $where), + 'current_page' => $current_page, ]); }); - $router->get('admin/logout', function() { - session_destroy(); - header('Location: /admin'); - }); + $router->get("$blog_url/author/{author}", function() use ($view, $user_mod, $link_mod, $post_mod, $theme_dir) { + $current_page = max(1, (int) ($_GET['page'] ?? 1)); + $author = $user_mod->get([ 'slug' => $_GET['author'] ]); + $per_page = \Aurora\App\Setting::get('per_page'); - $router->get('admin/dashboard', function() use ($db, $view, $link_mod, $post_mod) { - return $view->get('admin/dashboard.html', [ - 'links' => $link_mod->getPage(null, null, '', 'order'), - 'posts' => $post_mod->getPage(1, 6, $post_mod->getCondition([ 'status' => 1 ]), 'published_at', false), - 'total_posts' => $db->count('posts', '', $post_mod->getCondition([ 'status' => 1 ])), - 'total_scheduled_posts' => $db->count('posts', '', $post_mod->getCondition([ 'status' => 'scheduled' ])), - 'total_draft_posts' => $db->count('posts', '', 'status != 1'), - 'total_pages' => $db->count('pages', '', 'status = 1'), - 'total_draft_pages' => $db->count('pages', '', 'status != 1'), - 'total_users' => $db->count('users', '', 'status = 1'), - 'total_inactive_users' => $db->count('users', '', 'status != 1'), + if (!$author) { + return [ '', 404 ]; + } + + $where = implode(' AND ', [ + $post_mod->getCondition([ 'status' => 1 ]), + 'users.id = ' . ((int) $author['id']), ]); - }); - /* PAGES */ - - $router->get('admin/pages', function() use ($view, $lang, $page_mod) { - return $view->get('admin/list.html', [ - 'title' => $lang->get('pages'), - 'show_add_button' => \Aurora\App\Permission::can('edit_pages'), - 'columns' => [ - [ 'title' => '', 'class' => 'w100' ], - [ 'title' => $lang->get('slug'), 'class' => 'w20' ], - [ 'title' => $lang->get('edited'), 'class' => 'w20' ], - [ 'title' => $lang->get('number_views'), 'class' => 'w10 numeric', 'condition' => \Aurora\App\Setting::get('views_count') ], - [ 'title' => '', 'class' => 'w10 row-actions' ], - ], - 'extra_header' => 'admin/partials/extra_headers/pages.html', - 'filters' => [ - 'status' => [ - 'title' => $lang->get('status'), - 'options' => [ - '' => $lang->get('all'), - '1' => $lang->get('published'), - '0' => $lang->get('draft'), - ], - ], - 'order' => [ - 'title' => $lang->get('sort_by'), - 'options' => [ - 'title' => $lang->get('title'), - 'status' => $lang->get('status'), - 'slug' => $lang->get('slug'), - 'edited' => $lang->get('edited'), - 'views' => $lang->get('number_views'), - ], - ], - 'sort' => [ - 'options' => [ - 'asc' => $lang->get('ascending'), - 'desc' => $lang->get('descending'), - ], - ], - ], - 'defaults' => [ - 'order' => $page_mod::DEFAULT_ORDER, - 'sort' => $page_mod::DEFAULT_SORT, - ], + return $view->get("$theme_dir/blog.html", [ + 'header_links' => $link_mod->getHeaderLinks(), + 'title' => $author['name'], + 'user' => $author, + 'posts' => $post_mod->getPage($current_page, $per_page, $where, 'date', false, true), + 'next_page' => $post_mod->isNextPageAvailable($current_page, $per_page, $where), + 'current_page' => $current_page, ]); }); - $router->get('admin/pages/edit', function() use ($view, $link_mod, $page_mod, $theme_dir) { - $page = Helper::isValidId($_GET['id'] ?? false) ? $page_mod->get([ 'id' => $_GET['id'] ]) : []; - if (!$page && isset($_GET['id'])) { - http_response_code(404); - return; - } - - $absolute_theme_dir = Helper::getPath(Kernel::config('views') . "/$theme_dir"); - $view_files = []; + $router->get("$blog_url/tag/{tag}", function() use ($view, $tag_mod, $link_mod, $post_mod, $theme_dir) { + $current_page = max(1, (int) ($_GET['page'] ?? 1)); + $per_page = \Aurora\App\Setting::get('per_page'); + $tag = $tag_mod->get([ 'slug' => $_GET['tag'] ]); - foreach (Helper::getFileIterator($absolute_theme_dir) as $file) { - if ($file->isFile()) { - $view_files[] = mb_substr($file->getPathname(), mb_strlen($absolute_theme_dir) + 1); - } + if (!$tag) { + return [ '', 404 ]; } - natcasesort($view_files); + $where = implode(' AND ', [ + $post_mod->getCondition([ 'status' => 1 ]), + 'posts.id IN (SELECT post_id FROM posts_to_tags WHERE tag_id = ' . ((int) $tag['id']) . ')', + ]); - return $view->get('admin/page.html', [ + return $view->get("$theme_dir/blog.html", [ 'header_links' => $link_mod->getHeaderLinks(), - 'page' => $page, - 'view_files' => $view_files, + 'title' => $tag['name'], + 'tag' => $tag, + 'posts' => $post_mod->getPage($current_page, $per_page, $where, 'date', false, true), + 'next_page' => $post_mod->isNextPageAvailable($current_page, $per_page, $where), + 'current_page' => $current_page, ]); }); - $router->post('json:admin/pages/remove', function() use ($lang, $page_mod) { - if (!\Aurora\App\Permission::can('edit_pages')) { - http_response_code(403); - return json_encode([ 'errors' => [ $lang->get('no_permission') ] ]); - } - - if (!$page_mod->remove(explode(',', $_POST['id']))) { - http_response_code(500); - return json_encode([ 'errors' => [ $lang->get('unexpected_error') ] ]); - } + $router->get("$blog_url/{slug}", function() use ($db, $view, $link_mod, $post_mod, $theme_dir, &$user) { + $post_cond = $post_mod->getCondition([ 'status' => 1 ]); - return json_encode([ 'success' => true ]); - }); + $post = $post_mod->get([ + 'slug' => $_GET['slug'] ?? '', + empty($user) ? $post_cond : '', + ]); - /* POSTS */ + if (!$post) { + return [ '', 404 ]; + } - $router->get('admin/posts', function() use ($view, $lang, $post_mod, $user_mod) { - $authors = [ '' => $lang->get('all') ]; - foreach ($user_mod->getPage() as $user) { - $authors[$user['id']] = $user['name']; + if (\Aurora\App\Setting::get('views_count')) { + $db->replace('views', [ + 'type' => 'post', + 'item_id' => $post['id'], + 'ip' => Helper::getUserIP(), + 'date' => time(), + ]); } - return $view->get('admin/list.html', [ - 'title' => $lang->get('posts'), - 'show_add_button' => \Aurora\App\Permission::can('edit_posts'), - 'columns' => [ - [ 'title' => '', 'class' => 'w100' ], - [ 'title' => $lang->get('author'), 'class' => 'w20' ], - [ 'title' => $lang->get('publish_date'), 'class' => 'w20' ], - [ 'title' => $lang->get('number_views'), 'class' => 'w10 numeric', 'condition' => \Aurora\App\Setting::get('views_count') ], - [ 'title' => '', 'class' => 'w10 row-actions' ], - ], - 'extra_header' => 'admin/partials/extra_headers/posts.html', - 'filters' => [ - 'user' => [ - 'title' => $lang->get('author'), - 'options' => $authors, - ], - 'status' => [ - 'title' => $lang->get('status'), - 'options' => [ - '' => $lang->get('all'), - '1' => $lang->get('published'), - 'scheduled' => $lang->get('scheduled'), - '0' => $lang->get('draft'), - ], - ], - 'order' => [ - 'title' => $lang->get('sort_by'), - 'options' => [ - 'title' => $lang->get('title'), - 'author' => $lang->get('author'), - 'date' => $lang->get('publish_date'), - 'views' => $lang->get('number_views'), - ], - ], - 'sort' => [ - 'options' => [ - 'asc' => $lang->get('ascending'), - 'desc' => $lang->get('descending'), - ], - ], - ], - 'defaults' => [ - 'order' => $post_mod::DEFAULT_ORDER, - 'sort' => $post_mod::DEFAULT_SORT, - ], + return $view->get("$theme_dir/post.html", [ + 'header_links' => $link_mod->getHeaderLinks(), + 'post' => $post, + 'related_posts' => empty($post['tags_id']) + ? [] + : $post_mod->getPage(1, 3, "$post_cond AND p2t.tag_id IN (" . $post['tags_id'] . ') AND posts.id != ' . $post['id']), ]); }); - $router->get('admin/posts/edit', function() use ($view, $user_mod, $tag_mod, $post_mod) { - $post = Helper::isValidId($_GET['id'] ?? false) ? $post_mod->get([ 'id' => $_GET['id'] ]) : []; - if (!$post && isset($_GET['id'])) { - http_response_code(404); - return; - } + if (!empty($rss)) { + $router->get("xml:$rss", function() use ($post_mod, $view, $theme_dir) { + return $view->get("$theme_dir/rss.html", [ + 'posts' => $post_mod->getPage(null, null, $post_mod->getCondition([ 'status' => 1 ]), 'date', false), + ]); + }); + } - return $view->get('admin/post.html', [ - 'users' => $user_mod->getPage(), - 'tags' => $tag_mod->getPage(null, null, '', 'name'), - 'post' => $post, + $router->get([ '/', '{slug}' ], function() use ($db, $view, $link_mod, $page_mod, $theme_dir, &$user) { + $page = $page_mod->get([ + 'slug' => $_GET['slug'] ?? '', + empty($user) ? $page_mod->getCondition([ 'status' => 1 ]) : '', ]); - }); - $router->post('json:admin/posts/remove', function() use ($lang, $post_mod) { - if (!\Aurora\App\Permission::can('edit_posts')) { - http_response_code(403); - return json_encode([ 'errors' => [ $lang->get('no_permission') ] ]); + if (!$page) { + return [ '', 404 ]; } - if (!$post_mod->remove(explode(',', $_POST['id']))) { - http_response_code(500); - return json_encode([ 'errors' => [ $lang->get('unexpected_error') ] ]); + if (\Aurora\App\Setting::get('views_count')) { + $db->replace('views', [ + 'type' => 'page', + 'item_id' => $page['id'], + 'ip' => Helper::getUserIP(), + 'date' => time(), + ]); } - return json_encode([ 'success' => true ]); - }); - - $router->any('json:admin/posts/upload_image', function() { - $path = Kernel::config('content') . '/' . date('Y/m/'); - \Aurora\App\Media::uploadFile($_FILES['file'], $path); - - return json_encode([ 'location' => "/$path/" . $_FILES['file']['name'] ]); - }); - - /* USERS */ + $template = !empty($page['static']) && !empty($page['static_file']) + ? $page['static_file'] + : 'page.html'; - $router->get('admin/users', function() use ($db, $view, $lang, $user_mod) { - $roles = [ '' => $lang->get('all') ]; - foreach ($db->query('SELECT * FROM roles ORDER BY level ASC')->fetchAll() as $role) { - $roles[$role['level']] = $lang->get($role['slug']); - } - - return $view->get('admin/list.html', [ - 'title' => $lang->get('users'), - 'show_add_button' => \Aurora\App\Permission::can('edit_users'), - 'columns' => [ - [ 'title' => '', 'class' => 'w100' ], - [ 'title' => $lang->get('role'), 'class' => 'w20' ], - [ 'title' => $lang->get('last_active'), 'class' => 'w20' ], - [ 'title' => $lang->get('number_posts'), 'class' => 'w10 numeric' ], - [ 'title' => '', 'class' => 'w10 row-actions' ], - ], - 'extra_header' => 'admin/partials/extra_headers/users.html', - 'filters' => [ - 'status' => [ - 'title' => $lang->get('status'), - 'options' => [ - '' => $lang->get('all'), - '1' => $lang->get('active'), - '0' => $lang->get('inactive'), - ], - ], - 'role' => [ - 'title' => $lang->get('role'), - 'options' => $roles, - ], - 'order' => [ - 'title' => $lang->get('sort_by'), - 'options' => [ - 'name' => $lang->get('name'), - 'email' => $lang->get('email'), - 'status' => $lang->get('status'), - 'role' => $lang->get('role'), - 'last_active' => $lang->get('last_active'), - 'posts' => $lang->get('number_posts'), - ], - ], - 'sort' => [ - 'options' => [ - 'asc' => $lang->get('ascending'), - 'desc' => $lang->get('descending'), - ], - ], - ], - 'defaults' => [ - 'order' => $user_mod::DEFAULT_ORDER, - 'sort' => $user_mod::DEFAULT_SORT, - ], + return $view->get("$theme_dir/$template", [ + 'header_links' => $link_mod->getHeaderLinks(), + ...$page, ]); }); - $router->get('admin/users/edit', function() use ($db, $view, $user_mod) { - $user = Helper::isValidId($_GET['id'] ?? false) ? $user_mod->get([ 'id' => $_GET['id'] ]) : []; - if (!$user && isset($_GET['id'])) { - http_response_code(404); - return; - } - - return $view->get('admin/user.html', [ - 'user' => $user, - 'roles' => $db->query('SELECT * FROM roles ORDER BY level ASC')->fetchAll(), - ]); - }); + $login = function($user_id) use ($db, $setAuthToken) { + $token = bin2hex(random_bytes(64)); - $router->post('json:admin/users/remove', function() use ($lang, $user_mod) { - if (!\Aurora\App\Permission::can('edit_users')) { - http_response_code(403); - return json_encode([ 'errors' => [ $lang->get('no_permission') ] ]); + try { + $success = (bool) $db->insert('tokens', [ + 'user_id' => $user_id, + 'token' => $token, + 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '', + 'ip' => Helper::getUserIP(), + 'created_at' => time(), + 'updated_at' => time(), + ]); + } catch (\Exception) { + return [ + 'success' => false, + 'error' => 'error_generic', + ]; } - $ids = array_map(fn($id) => (int) $id, explode(',', $_POST['id'])); - $valid_ids = []; - - foreach ($user_mod->getPage(null, null, 'users.id IN (' . implode(',', $ids) . ')') as $user) { - if (\Aurora\App\Permission::edit_user($user) && $user['id'] != $_SESSION['user']['id']) { - $valid_ids[] = $user['id']; + if ($success) { + $total = (int) $db->query('SELECT COUNT(*) FROM tokens WHERE user_id = ?', $user_id)->fetchColumn(); + $to_remove = (int) ($total - \Aurora\Core\Kernel::config('max_active_sessions')); + if ($to_remove > 0) { + $db->query('DELETE FROM tokens WHERE user_id = ? ORDER BY created_at ASC, token ASC LIMIT ?', $user_id, $to_remove); } - } - if (!$user_mod->remove($valid_ids)) { - http_response_code(500); - return json_encode([ 'errors' => [ $lang->get('unexpected_error') ] ]); + $setAuthToken($token, time() + (60 * 60 * 24 * 30)); // 30 days } - return json_encode([ 'success' => true ]); + return [ 'success' => $success ]; + }; + + /** + * NEW + */ + + $router->code(404, function() use ($view, $lang, $theme_dir) { + return $view->get("$theme_dir/information.html", [ + 'title' => '404', + 'description' => $lang->get('not_found'), + 'subdescription' => $lang->get('not_found_desc'), + ]); }); - $router->get('admin/users/impersonate', function() use ($user_mod) { + $router->middleware('*', function() use ($db, $view, $lang, $theme_dir, $user_mod, $getAuthToken, &$user) { + $token = $getAuthToken(); $user = $user_mod->get([ - 'id' => $_GET['id'] ?? 0, + 'id' => $db->query('SELECT user_id FROM tokens WHERE token = ?', $token)->fetchColumn(), 'status' => 1, - ]); + ]) ?: null; - if (!\Aurora\App\Permission::impersonate($user)) { - http_response_code(403); - return; + if ($user) { + $user['token'] = $token; } - $_SESSION['user'] = $user; - header('Location: /admin/users'); + \Aurora\App\Permission::set($db->query('SELECT permission, role_level FROM roles_permissions ORDER BY permission')->fetchAll(\PDO::FETCH_KEY_PAIR), $user['role'] ?? 0); + + if (\Aurora\App\Setting::get('maintenance') && !str_starts_with(Helper::getCurrentPath(), 'admin') && !str_starts_with(Helper::getCurrentPath(), 'api') && !Helper::isValidId($user['id'] ?? false)) { + echo $view->get("$theme_dir/information.html", [ + 'description' => $lang->get('under_maintenance'), + 'subdescription' => $lang->get('come_back_soon'), + ]); + exit; + } }); - /* LINKS */ - - $router->get('admin/links', function() use ($view, $lang, $link_mod) { - return $view->get('admin/list.html', [ - 'title' => $lang->get('links'), - 'show_add_button' => \Aurora\App\Permission::can('edit_links'), - 'columns' => [ - [ 'title' => '', 'class' => 'w100' ], - [ 'title' => $lang->get('url'), 'class' => 'w20' ], - [ 'title' => $lang->get('status'), 'class' => 'w20' ], - [ 'title' => $lang->get('order'), 'class' => 'w10 numeric' ], - [ 'title' => '', 'class' => 'w10 row-actions' ], - ], - 'extra_header' => 'admin/partials/extra_headers/links.html', - 'filters' => [ - 'status' => [ - 'title' => $lang->get('status'), - 'options' => [ - '' => $lang->get('all'), - '1' => $lang->get('active'), - '0' => $lang->get('inactive'), - ], - ], - 'order' => [ - 'title' => $lang->get('sort_by'), - 'options' => [ - 'title' => $lang->get('title'), - 'url' => $lang->get('url'), - 'status' => $lang->get('status'), - 'order' => $lang->get('order'), - ], - ], - 'sort' => [ - 'options' => [ - 'asc' => $lang->get('ascending'), - 'desc' => $lang->get('descending'), - ], - ], - ], - 'defaults' => [ - 'order' => $link_mod::DEFAULT_ORDER, - 'sort' => $link_mod::DEFAULT_SORT, - ], + $router->post('json:api/password-reset/request', function($body) use ($db, $lang, $user_mod, $view) { + $hash = bin2hex(random_bytes(18)); + $user = $user_mod->get([ + 'email' => $body['email'], + 'status' => 1, ]); - }); - $router->get('admin/links/edit', function() use ($view, $link_mod) { - $link = Helper::isValidId($_GET['id'] ?? false) ? $link_mod->get([ 'id' => $_GET['id'] ]) : []; - if (!$link && isset($_GET['id'])) { - http_response_code(404); - return; + if ($user) { + $db->replace('password_restores', [ + 'user_id' => $user['id'], + 'hash' => $hash, + 'created_at' => time(), + ]); + \Aurora\Core\Kernel::config('mail')($user['email'], $lang->get('restore_your_password'), $view->get('emails/password_restore.html', [ 'hash' => $hash ])); } - return $view->get('admin/link.html', [ - 'link' => $link, - ]); + return json_encode([ 'success' => true ]); }); - $router->post('json:admin/links/remove', function() use ($lang, $link_mod) { - if (!\Aurora\App\Permission::can('edit_links')) { - http_response_code(403); - return json_encode([ 'errors' => [ $lang->get('no_permission') ] ]); - } + $router->post('json:api/password-reset/confirm', function($body) use ($db, $user_mod, $login) { + $hash = $body['hash'] ?? ''; + $password = $body['password'] ?? ''; + $restore = $db->query('SELECT * FROM password_restores WHERE hash = ?', $hash)->fetch(); - if (!$link_mod->remove(explode(',', $_POST['id']))) { - http_response_code(500); - return json_encode([ 'errors' => [ $lang->get('unexpected_error') ] ]); + if (empty($restore) || $restore['created_at'] < strtotime('-2 hours')) { + return json_encode([ + 'success' => false, + 'error' => 'expired_restore', + ]); } - return json_encode([ 'success' => true ]); - }); + $error = $user_mod->checkPassword($password, $body['password_confirm'] ?? ''); + if (empty($error)) { + $user = $user_mod->get([ + 'id' => $restore['user_id'], + 'status' => 1, + ]); - /* TAGS */ - - $router->get('admin/tags', function() use ($view, $lang, $tag_mod) { - return $view->get('admin/list.html', [ - 'title' => $lang->get('tags'), - 'show_add_button' => \Aurora\App\Permission::can('edit_tags'), - 'columns' => [ - [ 'title' => '', 'class' => 'w100' ], - [ 'title' => $lang->get('slug'), 'class' => 'w30' ], - [ 'title' => $lang->get('number_posts'), 'class' => 'w10 numeric' ], - [ 'title' => '', 'class' => 'w10 row-actions' ], - ], - 'extra_header' => 'admin/partials/extra_headers/tags.html', - 'filters' => [ - 'order' => [ - 'title' => $lang->get('sort_by'), - 'options' => [ - 'name' => $lang->get('name'), - 'slug' => $lang->get('slug'), - 'posts' => $lang->get('number_posts'), - ], - ], - 'sort' => [ - 'options' => [ - 'asc' => $lang->get('ascending'), - 'desc' => $lang->get('descending'), - ], - ], - ], - 'defaults' => [ - 'order' => $tag_mod::DEFAULT_ORDER, - 'sort' => $tag_mod::DEFAULT_SORT, - ], - ]); - }); + if (!$user) { + return json_encode([ + 'success' => false, + 'error' => 'no_active_user', + ]); + } - $router->get('admin/tags/edit', function() use ($view, $tag_mod) { - $tag = Helper::isValidId($_GET['id'] ?? false) ? $tag_mod->get([ 'id' => $_GET['id'] ]) : []; - if (!$tag && isset($_GET['id'])) { - http_response_code(404); - return; + $db->delete('password_restores', $hash, 'hash'); + $db->query('DELETE FROM tokens WHERE user_id = ?', $user['id']); + $db->update($user_mod->getTable(), [ 'password' => $user_mod->getPassword($password) ], $user['id']); + return json_encode($login($user['id'])); } - return $view->get('admin/tag.html', [ - 'tag' => $tag, + return json_encode([ + 'success' => false, + 'error' => $error, ]); }); - $router->post('json:admin/tags/remove', function() use ($lang, $tag_mod) { - if (!\Aurora\App\Permission::can('edit_tags')) { - http_response_code(403); - return json_encode([ 'errors' => [ $lang->get('no_permission') ] ]); + $router->middleware('api/*', function() use ($db, &$user) { + $path = Helper::getCurrentPath(); + + if (str_starts_with($path, 'api/blog')) { + return; } - if (!$tag_mod->remove(explode(',', $_POST['id']))) { - http_response_code(500); - return json_encode([ 'errors' => [ $lang->get('unexpected_error') ] ]); + if (empty($user) && !in_array($path, [ 'api/auth', 'api/password-reset/request', 'api/password-reset/confirm', 'api/logout' ])) { + return [ '', 401 ]; } - return json_encode([ 'success' => true ]); + if (!empty($user['id'])) { + $now = time(); + $db->query('UPDATE tokens + SET updated_at = ?, user_agent = ? + WHERE token = ?', $now, $_SERVER['HTTP_USER_AGENT'] ?? '', $user['token']); + $db->query('UPDATE users SET last_active = ? WHERE id = ?', $now, $user['id']); + } }); - /* MEDIA */ + $router->post('json:api/auth', function($body) use ($user_mod, $login) { + $user_id = $user_mod->authenticate((string) ($body['email'] ?? ''), (string) ($body['password'] ?? '')); - $router->get('admin/media', function() use ($view, $lang) { - $folders = [ Kernel::config('content') => '/' ]; - $root_dir = Helper::getPath(); - $content_dir = Helper::getPath(Kernel::config('content')); - $path = $_GET['path'] ?? Kernel::config('content'); - $absolute_path = Helper::getPath($path); + return json_encode($user_id === false + ? [ 'success' => false, 'error' => 'invalid_credentials' ] + : $login($user_id)); + }); - if ($path == Kernel::config('content') && !file_exists($absolute_path)) { - mkdir($absolute_path, \Aurora\App\Media::FOLDER_PERMISSION); - } + $router->post('json:api/logout', function() use ($db, $getAuthToken, $setAuthToken) { + $token = $getAuthToken(); - if (!\Aurora\App\Media::isValidPath($absolute_path) || !file_exists($absolute_path)) { - http_response_code(404); - return; + if ($token) { + $db->delete('tokens', $token, 'token'); } - foreach (new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($content_dir, FilesystemIterator::SKIP_DOTS), - RecursiveIteratorIterator::SELF_FIRST - ) as $file) { - if ($file->isDir()) { - $folder_dir = $file->getPathname(); - $folders[mb_substr($folder_dir, mb_strlen($root_dir) + 1)] = mb_substr($folder_dir, mb_strlen($content_dir) + 1); + return json_encode([ 'success' => $setAuthToken('', time() - 3600) ]); + }); + + $router->get('json:api/me', function() use (&$user) { + $me = $user; + + if ($me) { + unset($me['password'], $me['token']); + foreach (\Aurora\App\Permission::getPermissions() as $action) { + $me['actions'][$action] = \Aurora\App\Permission::can($action); } } - natcasesort($folders); + return json_encode($me); + }); - return $view->get('admin/list.html', [ - 'title' => $lang->get('media'), - 'custom_header' => $view->get('admin/partials/media_header.html', [ - 'path' => $path, - 'folders' => $folders, - ]), - 'columns' => [ - [ 'title' => '', 'class' => 'w100' ], - [ 'title' => $lang->get('information'), 'class' => 'w20 file-info' ], - [ 'title' => $lang->get('last_modification'), 'class' => 'w20' ], - [ 'title' => '', 'class' => 'w10 row-actions' ], - ], - 'extra_header' => 'admin/partials/extra_headers/media.html', - 'filters' => [ - 'order' => [ - 'title' => $lang->get('sort_by'), - 'options' => [ - 'name' => $lang->get('name'), - 'type' => $lang->get('type'), - 'size' => $lang->get('size'), - ] - ], - 'sort' => [ - 'options' => [ - 'asc' => $lang->get('ascending'), - 'desc' => $lang->get('descending'), - ], - ], - ], - 'defaults' => [ - 'order' => 'type', - 'sort' => 'asc', - ], - ]); + $router->get('json:api/me/sessions', function() use ($db, &$user) { + return json_encode(array_map(fn($t) => [ + 'id' => $t['id'], + 'user_agent' => $t['user_agent'], + 'ip' => $t['ip'], + 'current' => $t['token'] == $user['token'], + 'created_at' => $t['created_at'], + 'updated_at' => $t['updated_at'], + ], $db->query('SELECT * FROM tokens WHERE user_id = ?', $user['id'])->fetchAll())); }); - $router->post('json:admin/media/upload', function() use ($lang) { - if (!\Aurora\App\Permission::can('edit_media')) { - http_response_code(403); - return json_encode([ 'errors' => [ $lang->get('no_permission') ] ]); - } + $router->delete('json:api/me/sessions/{id}', function() use ($db, &$user) { + $stmt = $db->query('DELETE FROM tokens WHERE id = ? AND user_id = ?', $_GET['id'], $user['id']); + return json_encode([ 'success' => $stmt->rowCount() > 0 ]); + }); - $success = true; - $path = $_GET['path'] ?? Kernel::config('content'); - $files = []; + $router->get('json:api/settings', function() use ($db, $lang) { + $settings = \Aurora\App\Setting::get(); - if (isset($_FILES['file']['name']) && !is_array($_FILES['file']['name'])) { - $files[] = $_FILES['file']; - } else { - foreach (array_keys($_FILES['file']['name'] ?? []) as $i) { - foreach (array_keys($_FILES['file']) as $prop) { - $files[$i][$prop] = $_FILES['file'][$prop][$i]; - } - } + if (!empty($settings['logo'])) { + $settings['logo'] = Helper::getContentPath($settings['logo']); } - foreach ($files as $file) { - if (!\Aurora\App\Media::uploadFile($file, $path)) { - $success = false; - } + if (!\Aurora\App\Permission::can('edit_settings')) { + return json_encode(array_intersect_key($settings, + array_flip([ 'blog_url', 'views_count', 'language', 'timezone', 'date_format' ]))); } + $themes_dir = Helper::getPath(Kernel::config('views') . '/themes'); + return json_encode([ - 'success' => $success, - 'errors' => $success ? [] : [ $lang->get('error_upload_file') ], + ...$settings, + 'meta' => [ + 'roles' => $db->query('SELECT * FROM roles ORDER BY level ASC')->fetchAll(), + 'themes' => array_filter(scandir($themes_dir), fn($file) => is_dir("$themes_dir/$file") && $file != '.' && $file != '..'), + 'languages' => $lang->getAll(), + 'timezones' => \DateTimeZone::listIdentifiers(), + 'db_dsn' => $db->dsn, + ], ]); }); - $router->post('json:admin/media/create_folder', function() use ($lang) { - if (!\Aurora\App\Permission::can('edit_media')) { - http_response_code(403); - return json_encode([ 'errors' => [ $lang->get('no_permission') ] ]); - } + $router->post('json:api/users/impersonate', function($body) use ($user_mod, $login, &$user) { + $subject = $user_mod->get([ + 'id' => $body['id'] ?? 0, + 'status' => 1, + ]); - try { - $success = \Aurora\App\Media::addFolder($_GET['path'] ?? Kernel::config('content'), $_POST['name'] ?? ''); - } catch (Exception) { - $success = false; + if (!\Aurora\App\Permission::can('impersonate') || empty($subject) || (int) ($subject['role'] ?? 0) >= (int) ($user['role'] ?? 0)) { + return [ '', 403 ]; } - return json_encode([ - 'success' => $success, - 'errors' => $success ? [] : [ $lang->get('error_create_folder') ], - ]); + return json_encode($login($subject['id'])); }); - $router->post('json:admin/media/remove', function() use ($lang) { + $router->post('json:api/media/create_folder', function($body) { if (!\Aurora\App\Permission::can('edit_media')) { - http_response_code(403); - return json_encode([ 'errors' => [ $lang->get('no_permission') ] ]); + return [ '', 403 ]; } - $paths = json_decode($_POST['paths'] ?? '') ?? []; - $done = 0; - try { - foreach ($paths as $path) { - $done += \Aurora\App\Media::remove($path); - } - - $success = $done == count($paths); + $success = \Aurora\App\Media::addFolder(Kernel::config('content') . '/' . ltrim($body['name'] ?? '', '/')); } catch (Exception) { $success = false; } - return json_encode([ - 'success' => $success, - 'errors' => $success - ? [] - : [ $lang->get($done == 0 ? 'error_remove_item' : 'error_remove_some_items') ], - ]); + return json_encode([ 'success' => $success ]); }); - $router->post('json:admin/media/save', function() use ($lang) { - if (empty($_POST['name']) || str_contains($_POST['name'], '/')) { + $router->post('json:api/media/duplicate', function($body) { + if (empty($body['name']) || str_contains($body['name'], '/')) { return json_encode([ 'success' => false, - 'errors' => [ 'name' => $lang->get('invalid_value') ] + 'error' => 'invalid_value', ]); } if (!\Aurora\App\Permission::can('edit_media')) { - http_response_code(403); - return json_encode([ 'errors' => [ $lang->get('no_permission') ] ]); + return [ '', 403 ]; } try { - $success = \Aurora\App\Media::rename($_POST['path'] ?? '', $_POST['name']); + $success = \Aurora\App\Media::duplicate($body['path'] ?? '', $body['name']); } catch (Exception) { $success = false; } - return json_encode([ - 'success' => $success, - 'errors' => $success ? [] : [ $lang->get('error_rename_item') ], - ]); + return json_encode([ 'success' => $success ]); }); - $router->post('json:admin/media/move', function() use ($lang) { - if (!\Aurora\App\Permission::can('edit_media')) { - http_response_code(403); - return json_encode([ 'errors' => [ $lang->get('no_permission') ] ]); + $router->post('json:api/media/rename', function($body) { + if (empty($body['name']) || str_contains($body['name'], '/')) { + return json_encode([ 'success' => false ]); } - $paths = json_decode($_POST['paths'] ?? '') ?? []; - $done = 0; + if (!\Aurora\App\Permission::can('edit_media')) { + return [ '', 403 ]; + } try { - foreach ($paths as $path) { - $done += \Aurora\App\Media::move($path, $_POST['name']); - } - - $success = $done == count($paths); + $success = \Aurora\App\Media::rename($body['path'] ?? '', $body['name']); } catch (Exception) { $success = false; } - return json_encode([ - 'success' => $success, - 'errors' => $success - ? [] - : [ $lang->get($done == 0 ? 'error_move_item' : 'error_move_some_items') ], - ]); + return json_encode([ 'success' => $success ]); }); - $router->post('json:admin/media/duplicate', function() use ($lang) { - if (empty($_POST['name']) || str_contains($_POST['name'], '/')) { - return json_encode([ - 'success' => false, - 'errors' => [ 'name' => $lang->get('invalid_value') ] - ]); - } - + $router->post('json:api/media/move', function($body) { if (!\Aurora\App\Permission::can('edit_media')) { - http_response_code(403); - return json_encode([ 'errors' => [ $lang->get('no_permission') ] ]); + return [ '', 403 ]; } try { - $success = \Aurora\App\Media::duplicate($_POST['path'] ?? '', $_POST['name']); + $success = \Aurora\App\Media::move($body['path'] ?? '', $body['name']); } catch (Exception) { $success = false; } - return json_encode([ - 'success' => $success, - 'errors' => $success ? [] : [ $lang->get('error_duplicate_item') ], - ]); + return json_encode([ 'success' => $success ]); }); - $router->get('admin/image_dialog', function() use ($view) { - $path = $_GET['path'] ?? Kernel::config('content'); - $files = \Aurora\App\Media::getFiles($path, '', 'name', true, true); - if ($files === false) { - http_response_code(404); - return; + $router->post('json:api/media/upload', function() { + if (!\Aurora\App\Permission::can('edit_media')) { + return [ '', 403 ]; } - return $view->get('admin/partials/images_dialog.html', [ - 'path' => $path, - 'files' => array_filter($files, fn($file) => !$file['is_file'] || $file['is_image']), - ]); - }); + $success = true; + $path = Kernel::config('content') . '/' . ltrim($_GET['path'] ?? '', '/'); + $files = []; - /* SETTINGS */ + if (isset($_FILES['file']['name']) && !is_array($_FILES['file']['name'])) { + $files[] = $_FILES['file']; + } else { + foreach (array_keys($_FILES['file']['name'] ?? []) as $i) { + foreach (array_keys($_FILES['file']) as $prop) { + $files[$i][$prop] = $_FILES['file'][$prop][$i]; + } + } + } - $router->get('admin/settings', function() use ($view, $db, $lang) { - $themes_dir = Helper::getPath(Kernel::config('views') . '/themes'); + foreach ($files as $file) { + if (!\Aurora\App\Media::uploadFile($file, $path)) { + $success = false; + } + } - return $view->get('admin/settings.html', [ - 'roles' => $db->query('SELECT * FROM roles ORDER BY level ASC')->fetchAll(), - 'themes' => array_filter(scandir($themes_dir), fn($file) => is_dir("$themes_dir/$file") && $file != '.' && $file != '..'), - 'languages' => $lang->getAll(), - 'timezones' => \DateTimeZone::listIdentifiers(), - 'db_dsn' => $db->dsn, - ]); + return json_encode([ 'success' => $success ]); }); - $router->post('json:admin/settings/save', function() use ($db, $lang) { - if (!\Aurora\App\Permission::can('edit_settings')) { - http_response_code(403); - return json_encode([ 'errors' => [ $lang->get('no_permission') ] ]); + $router->get('api/media/download', function() { + $file_path = Helper::getPath('content.zip'); + $path = Helper::getPath(Kernel::config('content') . '/' . ltrim($_GET['path'] ?? '', '/')); + + if (!\Aurora\App\Media::isValidPath($path)) { + return [ '', 403 ]; } - try { - $db->connection->beginTransaction(); + $zip = new ZipArchive(); + $zip->open($file_path, ZipArchive::CREATE | ZipArchive::OVERWRITE); - foreach ($_POST as $key => $val) { - $db->replace('settings', [ 'key' => $key, 'value' => $val ]); + foreach (\Aurora\Core\Helper::getFileIterator($path) as $file) { + $real_path = $file->getRealPath(); + $relative_path = mb_substr($real_path, mb_strlen($path) + 1); + + if (!$file->isDir()) { + $zip->addFile($real_path, $relative_path); + } elseif ($relative_path !== false) { + $zip->addEmptyDir($relative_path); } + } - $success = $db->connection->commit(); - } catch (\PDOException $e) { - $db->connection->rollBack(); - error_log($e->getMessage()); - $success = false; + $zip->close(); + Helper::downloadFile($file_path, 'media.zip', 'application/zip'); + }); + + $router->get('json:api/media/folders', function() { + $folders = [ '/' ]; + $content_dir = Helper::getPath(Kernel::config('content')); + + foreach (new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($content_dir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ) as $file) { + if ($file->isDir()) { + $folders[] = mb_substr($file->getPathname(), mb_strlen($content_dir) + 1); + } } - return json_encode([ 'success' => $success ]); + natcasesort($folders); + + return json_encode(array_values($folders)); }); - $router->get('json:admin/{mod}/page', function() use ($kernel, $view, $page_mod, $post_mod, $user_mod, $tag_mod, $link_mod) { - $mod_str = $_GET['mod'] ?? ''; - switch ($mod_str) { - case 'pages': $mod = $page_mod; break; - case 'posts': $mod = $post_mod; break; - case 'users': $mod = $user_mod; break; - case 'tags': $mod = $tag_mod; break; - case 'links': $mod = $link_mod; break; - case 'media': - $files = \Aurora\App\Media::getFiles($_GET['path'] ?? Kernel::config('content'), $_GET['search'] ?? '', $_GET['order'] ?? 'type', ($_GET['sort'] ?? 'asc') == 'asc'); - return json_encode([ - 'next_page' => false, - 'count' => count($files), - 'html' => $view->get('admin/partials/lists/media.html', [ 'files' => $files ]), - ]); - default: - http_response_code(404); - return; + $router->any('json:api/media/upload_image', function() { + if (!\Aurora\App\Permission::can('edit_media')) { + return [ '', 403 ]; } - $per_page = $kernel->config('per_page'); - $where = $mod->getCondition($_GET); + $file_path = \Aurora\App\Media::uploadFile($_FILES['file'], Kernel::config('content') . '/' . date('Y/m/')); - return json_encode([ - 'next_page' => $mod->isNextPageAvailable($_GET['page'], $per_page, $where), - 'count' => $mod->count($where), - 'html' => $view->get("admin/partials/lists/$mod_str.html", [ - $mod_str => $mod->getPage($_GET['page'], $per_page, $where, $_GET['order'] ?? $mod::DEFAULT_ORDER, ($_GET['sort'] ?? ($mod::DEFAULT_SORT ?? 'asc')) == 'asc'), - ]), - ]); + if ($file_path === false) { + return [ '', 500 ]; + } + + return json_encode([ 'location' => '/' . $file_path ]); }); - $router->post('json:admin/{mod}/save', function() use ($page_mod, $post_mod, $user_mod, $tag_mod, $link_mod) { - switch ($_GET['mod']) { - case 'pages': $mod = $page_mod; break; - case 'posts': $mod = $post_mod; break; - case 'users': $mod = $user_mod; break; - case 'tags': $mod = $tag_mod; break; - case 'links': $mod = $link_mod; break; - default: - http_response_code(404); - return; + $router->post('json:api/media', function() { + if (!\Aurora\App\Permission::can('edit_media')) { + return [ '', 403 ]; } - $id = $_GET['id'] ?? ''; - $errors = $mod->checkFields($_POST, $id); - if (!empty($errors)) { - return json_encode([ - 'success' => false, - 'errors' => $errors, - ]); + $success = true; + $path = Kernel::config('content') . '/' . ltrim($_GET['path'] ?? '', '/'); + $files = []; + + if (isset($_FILES['file']['name']) && !is_array($_FILES['file']['name'])) { + $files[] = $_FILES['file']; + } else { + foreach (array_keys($_FILES['file']['name'] ?? []) as $i) { + foreach (array_keys($_FILES['file']) as $prop) { + $files[$i][$prop] = $_FILES['file'][$prop][$i]; + } + } } - $success = Helper::isValidId($id) - ? $mod->save($id, $_POST) - : ($id = $mod->add($_POST)) !== false; + foreach ($files as $file) { + if (!\Aurora\App\Media::uploadFile($file, $path)) { + $success = false; + } + } - return json_encode([ - 'success' => $success, - 'id' => $id, - ]); + return json_encode([ 'success' => $success ]); }); - $router->get('json:admin/settings/db', function() use ($db) { + $router->get('json:api/db', function() use ($db) { if (!\Aurora\App\Permission::can('edit_settings')) { - http_response_code(403); - return; + return [ '', 403 ]; } - header('Content-disposition: attachment; filename=db.json'); - return json_encode([ 'meta' => [ 'created' => date('Y-m-d H:i:s'), @@ -888,269 +622,317 @@ ]); }); - $router->post('json:admin/settings/db_upload', function() use ($db, $lang) { + $router->post('json:api/db', function() use ($db, $lang) { if (!\Aurora\App\Permission::can('edit_settings')) { - http_response_code(403); - return json_encode([ 'errors' => [ $lang->get('no_permission') ] ]); + return [ '', 403 ]; } - $errors = []; + $error = false; try { - $json = json_decode(file_get_contents($_FILES['db']['tmp_name'] ?? ''), true); + $json = json_decode(file_get_contents($_FILES['file']['tmp_name'] ?? ''), true); $version = is_scalar($json['meta']['version'] ?? null) ? explode('.', (string) $json['meta']['version']) : null; if (!isset($version) || explode('.', \Aurora\Core\Kernel::VERSION)[0] != $version[0]) { - $errors[] = $lang->get('invalid_db_version'); + $error = 'invalid_db_version'; } elseif (!(new \Aurora\App\Migration($db))->import($json['tables'] ?? false)) { - $errors[] = $lang->get('invalid_db_file'); + $error = 'invalid_db_file'; } } catch (\Throwable) { - $errors[] = $lang->get('invalid_db_file'); + $error = 'invalid_db_file'; } - return json_encode([ - 'success' => empty($errors), - 'errors' => $errors, - ]); + $data = [ + 'success' => $error === false, + ]; + + if ($error !== false) { + $data['error'] = $error; + } + + return json_encode($data); }); - $router->post('json:admin/settings/reset_views_count', function() use ($db, $lang) { + $router->get('api/logs', function() { if (!\Aurora\App\Permission::can('edit_settings')) { - http_response_code(403); - return json_encode([ 'errors' => [ $lang->get('no_permission') ] ]); + return [ '', 403 ]; } - return json_encode([ 'success' => $db->delete('views') ]); + $path = \Aurora\Core\Helper::getPath(\Aurora\App\Setting::get('log_file')); + return file_exists($path) ? file_get_contents($path) : ''; }); - $router->post('json:admin/settings/logs_clear', function() use ($lang) { + $router->delete('json:api/logs', function() { if (!\Aurora\App\Permission::can('edit_settings')) { - http_response_code(403); - return json_encode([ 'errors' => [ $lang->get('no_permission') ] ]); + return [ '', 403 ]; } - return json_encode([ 'success' => unlink(Helper::getPath(\Aurora\App\Setting::get('log_file'))) ]); - }); + $path = Helper::getPath(\Aurora\App\Setting::get('log_file')); - $router->get('admin/settings/logs_download', function() { - Helper::downloadFile(Helper::getPath(\Aurora\App\Setting::get('log_file')), 'Aurora ' . date('Y-m-d H:i:s') . '.log', 'text/plain'); + return json_encode([ 'success' => !file_exists($path) || (is_writable($path) && unlink($path)) ]); }); - $router->get('admin/settings/media_download', function() use ($lang) { - $file_path = Helper::getPath('content.zip'); - $path = $_GET['path'] ?? ''; - $absolute_path = Helper::getPath($path); + $router->post('json:api/reset_views_count', function() use ($db) { + if (!\Aurora\App\Permission::can('edit_settings')) { + return [ '', 403 ]; + } - if (!\Aurora\App\Media::isValidPath($absolute_path)) { - http_response_code(403); - return json_encode([ 'errors' => [ $lang->get('no_permission') ] ]); + return json_encode([ 'success' => $db->delete('views') ]); + }); + + $router->post('json:api/settings', function($body) use ($db) { + if (!\Aurora\App\Permission::can('edit_settings')) { + return [ '', 403 ]; } - $zip = new ZipArchive(); - $zip->open($file_path, ZipArchive::CREATE | ZipArchive::OVERWRITE); + try { + $db->connection->beginTransaction(); - foreach (\Aurora\Core\Helper::getFileIterator($absolute_path) as $file) { - $real_path = $file->getRealPath(); - $relative_path = mb_substr($real_path, mb_strlen($absolute_path) + 1); + foreach ($body as $key => $val) { + if ($key === 'logo') { + $val = Helper::normalizeContentPath($val); + } - if (!$file->isDir()) { - $zip->addFile($real_path, $relative_path); - } elseif ($relative_path !== false) { - $zip->addEmptyDir($relative_path); + $db->replace('settings', [ 'key' => $key, 'value' => $val ]); } + + $success = $db->connection->commit(); + } catch (\PDOException $e) { + $db->connection->rollBack(); + error_log($e->getMessage()); + $success = false; } - $zip->close(); - Helper::downloadFile($file_path, urldecode(trim($path, '/')) . ' ' . date('Y-m-d H:i:s') . '.zip', 'application/zip'); + return json_encode([ 'success' => $success ]); + }); + + $router->get('json:api/server', function() use ($db) { + if (!\Aurora\App\Permission::can('edit_settings')) { + return [ '', 403 ]; + } + + return json_encode([ + 'os' => php_uname('s') . ' ' . php_uname('r'), + 'php_version' => phpversion(), + 'db_dsn' => $db->dsn, + 'root_folder' => rtrim(\Aurora\Core\Helper::getPath(), '/'), + 'date' => date('Y-m-d H:i:s'), + 'memory_limit' => \Aurora\Core\Helper::getPhpSize(ini_get('memory_limit')), + 'file_size_limit' => \Aurora\App\Media::getMaxUploadFileSize(), + ]); }); - $router->get('json:admin/settings/update_version', function() { + $router->get('json:api/update_version', function() { + if (!\Aurora\App\Permission::can('update')) { + return [ '', 403 ]; + } + return json_encode((new \Aurora\App\Update())->getLatestRelease()); }); - $router->post('json:admin/settings/update', function() use ($lang) { + $router->post('json:api/update', function($body) { if (!\Aurora\App\Permission::can('update')) { - http_response_code(403); - return json_encode([ 'errors' => [ $lang->get('no_permission') ] ]); + return [ '', 403 ]; } - $result = (new \Aurora\App\Update)->run($_POST['zip'] ?? ''); + $result = (new \Aurora\App\Update())->run($body['zip'] ?? '', fn($line) => Helper::log($line)); $error = match ($result) { - \Aurora\App\Update::ERROR_CONNECTION => $lang->get('update_error_connection'), - \Aurora\App\Update::ERROR_ZIP => $lang->get('update_error_zip'), - \Aurora\App\Update::ERROR_COPY => $lang->get('update_error_copy'), + \Aurora\App\Update::ERROR_CONNECTION => 'update_error_connection', + \Aurora\App\Update::ERROR_ZIP => 'update_error_zip', + \Aurora\App\Update::ERROR_COPY => 'update_error_copy', + \Aurora\App\Update::ERROR_BUILD => 'update_error_build', + \Aurora\App\Update::ERROR_COMPOSER => 'update_error_composer', default => null, }; return json_encode([ 'success' => $result === true, - 'errors' => [ $error ], + 'error' => $error, ]); }); - /** - * BLOG - */ + $router->get('json:api/stats', function() use ($db, $post_mod) { + return json_encode([ + 'total_posts' => $db->count('posts', '', $post_mod->getCondition([ 'status' => 1 ])), + 'total_scheduled_posts' => $db->count('posts', '', $post_mod->getCondition([ 'status' => 'scheduled' ])), + 'total_draft_posts' => $db->count('posts', '', 'status != 1'), + 'total_pages' => $db->count('pages', '', 'status = 1'), + 'total_draft_pages' => $db->count('pages', '', 'status != 1'), + 'total_users' => $db->count('users', '', 'status = 1'), + 'total_inactive_users' => $db->count('users', '', 'status != 1'), + ]); + }); - $router->get('json:api/posts', function() use ($view, $post_mod, $theme_dir) { - $current_page = max(1, (int) ($_GET['page'] ?? 1)); - $per_page = \Aurora\App\Setting::get('per_page'); - $where = [ $post_mod->getCondition([ 'status' => 1 ]) ]; + $router->get('json:api/view_files', function() use ($theme_dir) { + $absolute_theme_dir = Helper::getPath(Kernel::config('views') . "/$theme_dir"); + $view_files = []; - if (!empty($_GET['user'])) { - $where[] = 'posts.user_id = ' . ((int) $_GET['user']); + foreach (Helper::getFileIterator($absolute_theme_dir) as $file) { + if ($file->isFile()) { + $view_files[] = mb_substr($file->getPathname(), mb_strlen($absolute_theme_dir) + 1); + } } - if (!empty($_GET['tag'])) { - $where[] = 'posts.id IN (SELECT post_id FROM posts_to_tags WHERE tag_id = ' . ((int) $_GET['tag']) . ')'; + natcasesort($view_files); + return json_encode(array_values($view_files)); + }); + + $router->post('json:api/{mod}', function($body) use ($page_mod, $post_mod, $user_mod, $tag_mod, $link_mod, &$user) { + switch ($_GET['mod']) { + case 'pages': $mod = $page_mod; break; + case 'posts': $mod = $post_mod; break; + case 'users': $mod = $user_mod; break; + case 'tags': $mod = $tag_mod; break; + case 'links': $mod = $link_mod; break; + default: + return [ '', 404 ]; } - $where = implode(' AND ', $where); + $id = $_GET['id'] ?? ''; + $errors = $mod->checkFields($body, $id, $user); + + if (!empty($errors)) { + return [ + json_encode([ + 'success' => false, + 'errors' => $errors, + ]), + array_intersect([ 'no_permission', 'no_publish_permission' ], $errors) ? 403 : 200, + ]; + } return json_encode([ - 'next_page' => $post_mod->isNextPageAvailable($current_page, $per_page, $where), - 'html' => $view->get("$theme_dir/partials/posts_page.html", [ - 'posts' => $post_mod->getPage($current_page, $per_page, $where), - ]), + 'success' => Helper::isValidId($id) + ? $mod->save($id, $body, $user) + : ($id = $mod->add($body)) !== false, + 'id' => $id, ]); }); - $router->get($blog_url, function() use ($db, $view, $lang, $link_mod, $post_mod, $theme_dir) { - $current_page = max(1, (int) ($_GET['page'] ?? 1)); - $per_page = \Aurora\App\Setting::get('per_page'); - $where = $post_mod->getCondition([ 'status' => 1 ]); - $search = $db->escape($_GET['search'] ?? ''); + $router->delete('json:api/{mod}', function($body) use ($page_mod, $post_mod, $user_mod, $tag_mod, $link_mod, &$user) { + $ids = isset($body['id']) + ? array_map(fn($id) => (int) $id, is_array($body['id']) ? $body['id'] : explode(',', $body['id'])) + : null; + $mod_str = $_GET['mod'] ?? ''; - if (!empty($search)) { - $where .= " AND (posts.title LIKE '%$search%' OR posts.description LIKE '%$search%')"; + if (!\Aurora\App\Permission::can("edit_$mod_str")) { + return [ '', 403 ]; } - return $view->get("$theme_dir/blog.html", [ - 'header_links' => $link_mod->getHeaderLinks(), - 'title' => $lang->get('blog'), - 'posts' => $post_mod->getPage($current_page, $per_page, $where, 'date', false, true), - 'next_page' => $post_mod->isNextPageAvailable($current_page, $per_page, $where), - 'current_page' => $current_page, - ]); - }); + if ($mod_str !== 'media' && empty($ids)) { + return [ '', 400 ]; + } - $router->get("$blog_url/author/{author}", function() use ($view, $user_mod, $link_mod, $post_mod, $theme_dir) { - $current_page = max(1, (int) ($_GET['page'] ?? 1)); - $user = $user_mod->get([ 'slug' => $_GET['author'] ]); - $per_page = \Aurora\App\Setting::get('per_page'); + $success = match ($mod_str) { + 'pages' => $page_mod->remove($ids), + 'posts' => $post_mod->remove($ids), + 'tags' => $tag_mod->remove($ids), + 'links' => $link_mod->remove($ids), + 'users' => (function() use ($user_mod, $ids, &$user) { + $valid_ids = []; - if (!$user) { - http_response_code(404); - return; - } + foreach ($user_mod->getPage(null, null, 'users.id IN (' . implode(',', $ids) . ')') as $row) { + if (\Aurora\App\Permission::editUser($row) && $row['id'] != $user['id']) { + $valid_ids[] = $row['id']; + } + } - $where = implode(' AND ', [ - $post_mod->getCondition([ 'status' => 1 ]), - 'users.id = ' . ((int) $user['id']), - ]); + $ids = $valid_ids; + return $user_mod->remove($ids); + })(), + 'media' => (function() use ($body) { + $done = 0; - return $view->get("$theme_dir/blog.html", [ - 'header_links' => $link_mod->getHeaderLinks(), - 'title' => $user['name'], - 'user' => $user, - 'posts' => $post_mod->getPage($current_page, $per_page, $where, 'date', false, true), - 'next_page' => $post_mod->isNextPageAvailable($current_page, $per_page, $where), - 'current_page' => $current_page, - ]); - }); + try { + foreach ($body as $path) { + $done += \Aurora\App\Media::remove($path); + } - $router->get("$blog_url/tag/{tag}", function() use ($view, $tag_mod, $link_mod, $post_mod, $theme_dir) { - $current_page = max(1, (int) ($_GET['page'] ?? 1)); - $per_page = \Aurora\App\Setting::get('per_page'); - $tag = $tag_mod->get([ 'slug' => $_GET['tag'] ]); + $success = $done == count($body); + } catch (Exception) { + $success = false; + } - if (!$tag) { - http_response_code(404); - return; - } + return $success; + })(), + default => null, + }; - $where = implode(' AND ', [ - $post_mod->getCondition([ 'status' => 1 ]), - 'posts.id IN (SELECT post_id FROM posts_to_tags WHERE tag_id = ' . ((int) $tag['id']) . ')', - ]); + if ($success === null) { + return [ '', 404 ]; + } - return $view->get("$theme_dir/blog.html", [ - 'header_links' => $link_mod->getHeaderLinks(), - 'title' => $tag['name'], - 'tag' => $tag, - 'posts' => $post_mod->getPage($current_page, $per_page, $where, 'date', false, true), - 'next_page' => $post_mod->isNextPageAvailable($current_page, $per_page, $where), - 'current_page' => $current_page, - ]); + return json_encode([ 'success' => $success ]); }); - $router->get("$blog_url/{slug}", function() use ($db, $view, $link_mod, $post_mod, $theme_dir) { - $post_cond = $post_mod->getCondition([ 'status' => 1 ]); + $router->get('json:api/roles', function() use ($db) { + $roles = []; + $permissions_data = $db->query('SELECT role_level, permission FROM roles_permissions ORDER BY role_level ASC, permission ASC')->fetchAll(); + foreach ($db->query('SELECT * FROM roles ORDER BY level ASC')->fetchAll() as $role) { + $role_permissions = []; - $post = $post_mod->get([ - 'slug' => $_GET['slug'] ?? '', - empty($_SESSION['user']) ? $post_cond : '', - ]); + foreach ($permissions_data as $permission) { + if ($role['level'] >= $permission['role_level']) { + $role_permissions[] = $permission['permission']; + } + } - if (!$post) { - http_response_code(404); - return; - } + sort($role_permissions); - if (\Aurora\App\Setting::get('views_count')) { - $db->replace('views', [ - 'type' => 'post', - 'item_id' => $post['id'], - 'ip' => Helper::getUserIP(), - 'date' => time(), - ]); + $roles[] = [ + 'level' => (int) $role['level'], + 'slug' => $role['slug'], + 'permissions' => $role_permissions + ]; } - return $view->get("$theme_dir/post.html", [ - 'header_links' => $link_mod->getHeaderLinks(), - 'post' => $post, - 'related_posts' => empty($post['tags_id']) - ? [] - : $post_mod->getPage(1, 3, "$post_cond AND p2t.tag_id IN (" . $post['tags_id'] . ') AND posts.id != ' . $post['id']), - ]); + return json_encode($roles); }); - if (!empty($rss)) { - $router->get("xml:$rss", function() use ($post_mod, $view, $theme_dir) { - return $view->get("$theme_dir/rss.html", [ - 'posts' => $post_mod->getPage(null, null, $post_mod->getCondition([ 'status' => 1 ]), 'date', false), - ]); - }); - } - - $router->get([ '/', '{slug}' ], function() use ($db, $view, $link_mod, $page_mod, $theme_dir) { - $page = $page_mod->get([ - 'slug' => $_GET['slug'] ?? '', - empty($_SESSION['user']) ? $page_mod->getCondition([ 'status' => 1 ]) : '', - ]); + $router->get('json:api/{mod}', function() use ($kernel, $page_mod, $post_mod, $user_mod, $tag_mod, $link_mod) { + switch ($_GET['mod'] ?? '') { + case 'pages': $mod = $page_mod; break; + case 'posts': $mod = $post_mod; break; + case 'users': $mod = $user_mod; break; + case 'tags': $mod = $tag_mod; break; + case 'links': $mod = $link_mod; break; + case 'media': + $files = \Aurora\App\Media::getFiles(Kernel::config('content') . '/' . ltrim($_GET['path'] ?? '', '/'), + $_GET['search'] ?? '', + $_GET['order'] ?? 'type', + ($_GET['sort'] ?? 'asc') == 'asc'); - if (!$page) { - http_response_code(404); - return; - } + if ($_GET['images'] ?? false) { + $files = array_filter($files, fn($file) => !$file['is_file'] || $file['is_image']); + } - if (\Aurora\App\Setting::get('views_count')) { - $db->replace('views', [ - 'type' => 'page', - 'item_id' => $page['id'], - 'ip' => Helper::getUserIP(), - 'date' => time(), - ]); + return json_encode([ + 'data' => $files, + 'meta' => [ + 'current_page' => 1, + 'per_page' => false, + 'prev_page' => false, + 'next_page' => false, + 'total_items' => count($files), + ], + ]); + default: + return [ '', 404 ]; } - $template = !empty($page['static']) && !empty($page['static_file']) - ? $page['static_file'] - : 'page.html'; + $page = (int) max($_GET['page'] ?? 1, 1); + $per_page = $kernel->config('per_page'); + $where = $mod->getCondition($_GET); - return $view->get("$theme_dir/$template", [ - 'header_links' => $link_mod->getHeaderLinks(), - ...$page, + return json_encode([ + 'data' => $mod->getPage($page, $per_page, $where, $_GET['order'] ?? $mod::DEFAULT_ORDER, ($_GET['sort'] ?? ($mod::DEFAULT_SORT ?? 'asc')) == 'asc'), + 'meta' => [ + 'current_page' => $page, + 'per_page' => $per_page, + 'prev_page' => $page > 1, + 'next_page' => $mod->isNextPageAvailable($page, $per_page, $where), + 'total_items' => $mod->count($where), + ], ]); }); }; diff --git a/app/controllers/Media.php b/app/controllers/Media.php index d569cbd..d972628 100644 --- a/app/controllers/Media.php +++ b/app/controllers/Media.php @@ -7,14 +7,14 @@ final class Media public const FOLDER_PERMISSION = 0755; /** - * The content directory relative to the project root directory + * The content directory relative to the app root directory * @var string */ private static string $directory = ''; /** * Sets the content directory - * @param string $directory the content directory relative to the project root directory + * @param string $directory the content directory relative to the app root directory */ public static function setDirectory(string $directory): void { @@ -24,7 +24,7 @@ public static function setDirectory(string $directory): void /** * Returns the files and folders in the given path * @throws \InvalidArgumentException - * @param string $path the path relative to the project root directory + * @param string $path the path relative to the app root directory * @param [string] $search the search string * @param [string] $order the order (name, type, size) * @param [bool] $asc the order direction. true for ascending, false for descending @@ -43,9 +43,11 @@ public static function getFiles(string $path, string $search = '', string $order $files = array_map(function($file) use ($content_path_length) { $mime = mime_content_type($file); + $relative_path = mb_substr($file, $content_path_length); + return [ 'name' => basename($file), - 'path' => mb_substr($file, $content_path_length), + 'path' => \Aurora\Core\Helper::getContentPath($relative_path), 'mime' => $mime, 'is_file' => is_file($file), 'is_image' => str_starts_with($mime, 'image/'), @@ -80,29 +82,24 @@ public static function getFiles(string $path, string $search = '', string $order /** * Creates a new folder with the given name in the given path * @throws \InvalidArgumentException - * @param string $path the path relative to the project root directory - * @param string $name the folder name + * @param string $path the folder path relative to the project root directory * @return bool true if the folder was created successfully, false otherwise */ - public static function addFolder(string $path, string $name): bool + public static function addFolder(string $path): bool { $path = \Aurora\Core\Helper::getPath($path); - if (empty(trim($name))) { - throw new \InvalidArgumentException('Folder name is empty'); - } - if (!self::isValidPath($path)) { throw new \InvalidArgumentException("Path '$path' is not a valid path within " . self::$directory); } - return mkdir("$path/$name", self::FOLDER_PERMISSION); + return mkdir($path, self::FOLDER_PERMISSION); } /** * Deletes the file/folder with given path * @throws \InvalidArgumentException - * @param string $path the path relative to the project root directory + * @param string $path the path relative to the app root directory * @return bool true if the file/folder was deleted successfully, false otherwise */ public static function remove(string $path): bool @@ -119,7 +116,7 @@ public static function remove(string $path): bool /** * Renames the file/folder with given path * @throws \InvalidArgumentException - * @param string $path the path relative to the project root directory + * @param string $path the path relative to the app root directory * @param string $name the new name * @return bool true if the file/folder was renamed successfully, false otherwise */ @@ -137,8 +134,8 @@ public static function rename(string $path, string $name): bool /** * Moves the file/folder with given path to the given folder * @throws \InvalidArgumentException - * @param string $path the path relative to the project root directory - * @param string $name the destination folder relative to the project root directory + * @param string $path the path relative to the app root directory + * @param string $folder the destination folder relative to the app root directory * @return bool true if the file/folder was moved successfully, false otherwise */ public static function move(string $path, string $folder): bool @@ -160,7 +157,7 @@ public static function move(string $path, string $folder): bool /** * Duplicates the file with given path with the given name * @throws \InvalidArgumentException - * @param string $path the path relative to the project root directory + * @param string $path the path relative to the app root directory * @param string $name the new name * @return bool true if the file was duplicated successfully, false otherwise */ @@ -200,13 +197,12 @@ public static function isValidPath(string $path): bool * Uploads the given file to the given path * @throws \InvalidArgumentException * @param array $file the file - * @param string $path the path relative to the project root directory - * @return bool true if the file was uploaded successfully, false otherwise + * @param string $path the path relative to the app root directory + * @return string|false the file path relative to the app root directory on success, false otherwise */ - public static function uploadFile($file, string $path): bool + public static function uploadFile($file, string $path): string|false { $path = \Aurora\Core\Helper::getPath($path); - $container_path = mb_substr($path, 0, mb_strrpos($path, '/') + 1); if (!$file) { throw new \InvalidArgumentException('File is empty'); @@ -216,11 +212,16 @@ public static function uploadFile($file, string $path): bool throw new \InvalidArgumentException("Path '$path' is not a valid path within " . self::$directory); } + $destination = self::getFilePath($path, $file['name']); + $container_path = dirname($destination); + if (!file_exists($container_path)) { mkdir($container_path, self::FOLDER_PERMISSION, true); } - return move_uploaded_file($file['tmp_name'], self::getFilePath($path, $file['name'])); + return move_uploaded_file($file['tmp_name'], $destination) + ? mb_substr($destination, mb_strlen(\Aurora\Core\Helper::getPath()) + 1) + : false; } /** diff --git a/app/controllers/Migration.php b/app/controllers/Migration.php index 0053e9d..d490806 100644 --- a/app/controllers/Migration.php +++ b/app/controllers/Migration.php @@ -71,6 +71,16 @@ final class Migration 'meta_title' => 'TEXT', 'meta_description' => 'TEXT', ], + 'tokens' => [ + 'id' => 'INTEGER PRIMARY KEY', + 'user_id' => 'INTEGER', + 'token' => 'TEXT', + 'user_agent' => 'TEXT', + 'ip' => 'TEXT', + 'created_at' => 'INTEGER', + 'updated_at' => 'INTEGER', + '' => 'CONSTRAINT tokens_pk UNIQUE (`user_id`, `token`)', + ], 'users' => [ 'id' => 'INTEGER PRIMARY KEY', 'name' => 'TEXT', diff --git a/app/controllers/ModuleBase.php b/app/controllers/ModuleBase.php index e9ddb15..5218c2d 100644 --- a/app/controllers/ModuleBase.php +++ b/app/controllers/ModuleBase.php @@ -169,6 +169,15 @@ public function remove(array $ids): bool return $success; } + /** + * Returns the table name + * @return string the table name + */ + public function getTable(): string + { + return $this->table; + } + /** * Returns the row with additional data mapped into it * @param mixed $data the row diff --git a/app/controllers/ModuleInterface.php b/app/controllers/ModuleInterface.php index b29fc69..e6c37b3 100644 --- a/app/controllers/ModuleInterface.php +++ b/app/controllers/ModuleInterface.php @@ -6,9 +6,9 @@ interface ModuleInterface { public function add(array $data): string|bool; - public function save(int $id, array $data): bool; + public function save(int $id, array $data, ?array $user = null): bool; - public function checkFields(array $data, $id = null): array; + public function checkFields(array $data, $id, $user): array; public function getCondition(array $search): string; } diff --git a/app/controllers/Permission.php b/app/controllers/Permission.php index 6a1984c..0409214 100644 --- a/app/controllers/Permission.php +++ b/app/controllers/Permission.php @@ -66,4 +66,13 @@ public static function can(string $key): bool return self::$user_role >= self::$permissions[$key]; } + + /** + * Returns the list of permission keys + * @return array the list of permission keys + */ + public static function getPermissions(): array + { + return array_keys(self::$permissions); + } } diff --git a/app/controllers/Update.php b/app/controllers/Update.php index 217496b..ee523e7 100644 --- a/app/controllers/Update.php +++ b/app/controllers/Update.php @@ -5,16 +5,16 @@ final class Update { private const UPDATE_DIRECTORIES = [ - 'app/bootstrap/index.php', - 'app/bootstrap/routes.php', + 'app/bootstrap', 'app/controllers', + 'app/core', 'app/database', 'app/languages', + 'app/public', + 'app/react', 'app/views', 'bin', - 'core', 'docs', - 'public', 'tests', '.htaccess', 'aurora', @@ -23,42 +23,139 @@ final class Update public const ERROR_CONNECTION = 1; public const ERROR_ZIP = 2; public const ERROR_COPY = 3; + public const ERROR_BUILD = 4; + public const ERROR_COMPOSER = 5; /** * Updates the system to the given release zip - * @param string path to the release zip file + * @param string $zip path to the release zip file + * @param callable|null $on_build_output optional callback invoked with each line of composer and React build output * @return int|bool true on success, an error code otherwise */ - public function run(string $zip): int|bool + public function run(string $zip, ?callable $on_build_output = null): int|bool { - $temp_dir = sys_get_temp_dir(); - $zip_dir = tempnam($temp_dir, 'aurora-update'); + $temp = sys_get_temp_dir(); + $zip_file = tempnam($temp, 'aurora-update'); - if (!file_put_contents($zip_dir, fopen($zip, 'r', false, self::getStreamContext()))) { + if (!file_put_contents($zip_file, fopen($zip, 'r', false, self::getStreamContext()))) { return self::ERROR_CONNECTION; } - $zip = new \ZipArchive(); - - if ($zip->open($zip_dir) !== true || !$zip->extractTo($temp_dir) || !($index = $zip->getNameIndex(0))) { + $archive = new \ZipArchive(); + if ($archive->open($zip_file) !== true || !$archive->extractTo($temp) || !($index = $archive->getNameIndex(0))) { + @unlink($zip_file); return self::ERROR_ZIP; } - $new_version_dir = "$temp_dir/" . trim($index, '/'); + $archive->close(); + @unlink($zip_file); - if (!$zip->close()) { - return self::ERROR_ZIP; + $root = \Aurora\Core\Helper::getProjectPath(); + $update = "$temp/" . trim($index, '/'); + $backup = "$temp/" . uniqid('aurora-backup-'); + mkdir($backup); + + foreach (self::UPDATE_DIRECTORIES as $dir) { + if (file_exists("$root/$dir") && !\Aurora\Core\Helper::copy("$root/$dir", "$backup/$dir")) { + \Aurora\Core\Helper::removeDirRecursive($backup); + return self::ERROR_COPY; + } } foreach (self::UPDATE_DIRECTORIES as $dir) { - if (!\Aurora\Core\Helper::copy("$new_version_dir/$dir", \Aurora\Core\Helper::getPath("/$dir"))) { + if (!file_exists("$update/$dir")) { + continue; + } + + if (!\Aurora\Core\Helper::copy("$update/$dir", "$root/$dir")) { + $this->restore($backup, $root); + \Aurora\Core\Helper::removeDirRecursive($backup); return self::ERROR_COPY; } } + if (!$this->installComposer($on_build_output)) { + $this->restore($backup, $root); + \Aurora\Core\Helper::removeDirRecursive($backup); + return self::ERROR_COMPOSER; + } + + if (!$this->buildReact($on_build_output)) { + $this->restore($backup, $root); + \Aurora\Core\Helper::removeDirRecursive($backup); + return self::ERROR_BUILD; + } + + \Aurora\Core\Helper::removeDirRecursive($backup); return true; } + /** + * Installs PHP dependencies via Composer + * @param callable|null $on_output optional callback invoked with each line of Composer output + * @return bool true if dependencies were installed successfully, false otherwise + */ + private function installComposer(?callable $on_output = null): bool + { + $root = \Aurora\Core\Helper::getProjectPath(); + + if (!is_file("$root/composer.json")) { + return true; + } + + $output = []; + $return_var = 0; + exec('composer install --no-interaction --prefer-dist --working-dir "' . $root . '" 2>&1', $output, $return_var); + + if ($on_output && $output) { + foreach ($output as $line) { + $on_output($line); + } + } + + return $return_var === 0; + } + + /** + * Builds the React admin panel + * @param callable|null $on_output optional callback invoked with each line of React build output + * @return bool true if the React admin panel was built successfully, false otherwise + */ + private function buildReact(?callable $on_output = null): bool + { + $react_dir = \Aurora\Core\Helper::getPath('react'); + + if (!is_file("$react_dir/package.json")) { + return true; + } + + $output = []; + $return_var = 0; + exec('npm --prefix "' . $react_dir . '" install && npm --prefix "' . $react_dir . '" run build 2>&1', $output, $return_var); + + if ($on_output && $output) { + foreach ($output as $line) { + $on_output($line); + } + } + + return $return_var === 0; + } + + /** + * Restores the backup files + * @param string $backup the backup directory + * @param string $root the root directory + */ + private function restore(string $backup, string $root): void + { + foreach (self::UPDATE_DIRECTORIES as $dir) { + if (file_exists("$backup/$dir")) { + \Aurora\Core\Helper::copy("$backup/$dir", "$root/$dir"); + } + } + } + /** * Returns an array with data about the latest release compatible with the current version (same major version) * @return array|bool|int the array with data about the latest release, diff --git a/app/controllers/ViewHelper.php b/app/controllers/ViewHelper.php index 8dfbf42..2dd0651 100644 --- a/app/controllers/ViewHelper.php +++ b/app/controllers/ViewHelper.php @@ -88,32 +88,4 @@ public function getLanguageCode(): string { return $this->language->getCode(); } - - /** - * @see \Aurora\Core\Helper::getUrl - */ - public function url(string $path = ''): string - { - return \Aurora\Core\Helper::getUrl($path); - } - - /** - * Returns the current CSRF token, it creates it if it's not set - * @return string the CSRF token - */ - public function csrfToken(): string - { - if (!isset($_COOKIE['csrf_token'])) { - $token = bin2hex(random_bytes(8)); - - $_COOKIE['csrf_token'] = $token; - setcookie('csrf_token', $token, [ - 'path' => '/', - 'httponly' => true, - 'samesite' => 'Lax', - ]); - } - - return $_COOKIE['csrf_token']; - } } diff --git a/app/controllers/modules/Link.php b/app/controllers/modules/Link.php index 53d12dd..51946b5 100755 --- a/app/controllers/modules/Link.php +++ b/app/controllers/modules/Link.php @@ -38,30 +38,31 @@ public function add(array $data): string|bool * Updates an existing link * @param int $id the link id * @param array $data the new data + * @param array|null $user the user performing the action * @return bool true on success, false otherwise */ - public function save(int $id, array $data): bool + public function save(int $id, array $data, ?array $user = null): bool { - return $this->db->update($this->table, $this->getBaseData($data), $id) ? $id : false; + return $this->db->update($this->table, $this->getBaseData($data), $id); } /** * Returns an array with all the link fields that contain an error * @param array $data the link fields - * @param [mixed] $id the link id + * @param mixed $id the link id + * @param mixed $user the user data * @return array the array with the link fields that contain an error */ - public function checkFields(array $data, $id = null): array + public function checkFields(array $data, $id, $user): array { $errors = []; if (empty($data['title'])) { - $errors['title'] = $this->language->get('invalid_value'); + $errors[] = 'invalid_title'; } if (!\Aurora\App\Permission::can('edit_links')) { - http_response_code(403); - $errors[0] = $this->language->get('no_permission'); + $errors[] = 'no_permission'; } return $errors; @@ -76,6 +77,10 @@ public function getCondition(array $filters): string { $where = []; + if (isset($filters['id']) && \Aurora\Core\Helper::isValidId($filters['id'])) { + $where[] = 'links.id = ' . ((int) $filters['id']); + } + if (isset($filters['status']) && $filters['status'] !== '') { $where[] = 'links.status = ' . ((int) $filters['status']); } @@ -96,10 +101,10 @@ public function getCondition(array $filters): string private function getBaseData(array $data): array { return [ - 'title' => $data['title'], - 'url' => $data['url'], - 'order' => $data['order'], - 'status' => $data['status'], + 'title' => $data['title'] ?? '', + 'url' => $data['url'] ?? '', + 'order' => $data['order'] ?? 0, + 'status' => $data['status'] ?? false, ]; } } diff --git a/app/controllers/modules/Page.php b/app/controllers/modules/Page.php index bc52644..f430e08 100755 --- a/app/controllers/modules/Page.php +++ b/app/controllers/modules/Page.php @@ -33,38 +33,39 @@ public function add(array $data): string|bool * Updates an existing page * @param int $id the page id * @param array $data the new data + * @param array|null $user the user performing the action * @return bool true on success, false otherwise */ - public function save(int $id, array $data): bool + public function save(int $id, array $data, ?array $user = null): bool { - return $this->db->update($this->table, $this->getBaseData($data), $id) ? $id : false; + return $this->db->update($this->table, $this->getBaseData($data), $id); } /** * Returns an array with all the page fields that contain an error * @param array $data the page fields - * @param [mixed] $id the page id + * @param mixed $id the page id + * @param mixed $user the user data * @return array the array with the page fields that contain an error */ - public function checkFields(array $data, $id = null): array + public function checkFields(array $data, $id, $user): array { $errors = []; if (empty($data['title'])) { - $errors['title'] = $this->language->get('invalid_value'); + $errors[] = 'invalid_title'; } if (isset($data['slug']) && !empty($this->get([ 'slug' => $data['slug'], '!id' => $id ]))) { - $errors['slug'] = $this->language->get('repeated_slug'); + $errors[] = 'repeated_slug'; } if (!empty($data['slug']) && !\Aurora\Core\Helper::isSlugValid($data['slug'])) { - $errors['slug'] = $this->language->get('invalid_slug'); + $errors[] = 'invalid_slug'; } if (!\Aurora\App\Permission::can('edit_pages')) { - http_response_code(403); - $errors[0] = $this->language->get('no_permission'); + $errors[] = 'no_permission'; } return $errors; @@ -79,6 +80,10 @@ public function getCondition(array $filters): string { $where = []; + if (isset($filters['id']) && \Aurora\Core\Helper::isValidId($filters['id'])) { + $where[] = 'pages.id = ' . ((int) $filters['id']); + } + if (isset($filters['status']) && $filters['status'] !== '') { $where[] = 'pages.status = ' . ((int) $filters['status']); } @@ -99,15 +104,15 @@ public function getCondition(array $filters): string private function getBaseData(array $data): array { return [ - 'title' => $data['title'], - 'slug' => $data['slug'], - 'html' => $data['html'], - 'status' => $data['status'], - 'static' => $data['static'], - 'static_file' => $data['static_file'], - 'meta_title' => $data['meta_title'], - 'meta_description' => $data['meta_description'], - 'canonical_url' => $data['canonical_url'], + 'title' => $data['title'] ?? '', + 'slug' => $data['slug'] ?? '', + 'html' => $data['html'] ?? '', + 'status' => $data['status'] ?? false, + 'static' => $data['static'] ?? false, + 'static_file' => $data['static_file'] ?? '', + 'meta_title' => $data['meta_title'] ?? '', + 'meta_description' => $data['meta_description'] ?? '', + 'canonical_url' => $data['canonical_url'] ?? '', 'edited_at' => time(), ]; } diff --git a/app/controllers/modules/Post.php b/app/controllers/modules/Post.php index 91fe2dc..a4af55a 100755 --- a/app/controllers/modules/Post.php +++ b/app/controllers/modules/Post.php @@ -53,9 +53,10 @@ public function add(array $data): string|bool * Updates an existing post * @param int $id the post id * @param array $data the new data + * @param array|null $user the user performing the action * @return bool true on success, false otherwise */ - public function save(int $id, array $data): bool + public function save(int $id, array $data, ?array $user = null): bool { try { $this->db->connection->beginTransaction(); @@ -74,32 +75,32 @@ public function save(int $id, array $data): bool /** * Returns an array with all the post fields that contain an error * @param array $data the post fields - * @param [mixed] $id the post id + * @param mixed $id the post id + * @param mixed $user the user data * @return array the array with the post fields that contain an error */ - public function checkFields(array $data, $id = null): array + public function checkFields(array $data, $id, $user): array { $errors = []; if (empty($data['title'])) { - $errors['title'] = $this->language->get('invalid_value'); + $errors[] = 'invalid_title'; } if (isset($data['slug']) && !empty($this->get([ 'slug' => $data['slug'], '!id' => $id ]))) { - $errors['slug'] = $this->language->get('repeated_slug'); + $errors[] = 'repeated_slug'; } if (empty($data['slug']) || !\Aurora\Core\Helper::isSlugValid($data['slug'])) { - $errors['slug'] = $this->language->get('invalid_slug'); + $errors[] = 'invalid_slug'; } if (!\Aurora\App\Permission::can('edit_posts')) { - http_response_code(403); - $errors[0] = $this->language->get('no_permission'); + $errors[] = 'no_permission'; } if (!empty($data['status']) && !\Aurora\App\Permission::can('publish_posts')) { - $errors[0] = $this->language->get('published_posts_permission_error'); + $errors[] = 'no_publish_permission'; } return $errors; @@ -114,12 +115,21 @@ public function getCondition(array $filters): string { $where = []; + if (isset($filters['id']) && \Aurora\Core\Helper::isValidId($filters['id'])) { + $where[] = 'posts.id = ' . ((int) $filters['id']); + } + if (isset($filters['status']) && $filters['status'] !== '') { - $where[] = match (strval($filters['status'])) { + $val = match (strval($filters['status'])) { '1' => 'posts.status AND posts.published_at <= ' . time(), '0' => 'posts.status = 0', 'scheduled' => 'posts.status AND posts.published_at > ' . time(), + default => '', }; + + if ($val) { + $where[] = $val; + } } if (isset($filters['user']) && $filters['user'] !== '') { @@ -142,6 +152,13 @@ public function getCondition(array $filters): string protected function getRowData($data): mixed { $data['tags'] = $this->getTags(empty($data['tags_id']) ? [] : explode(',', $data['tags_id'])); + + foreach ([ 'image', 'user_image' ] as $key) { + if (!empty($data[$key])) { + $data[$key] = \Aurora\Core\Helper::getContentPath($data[$key]); + } + } + return $data; } @@ -191,18 +208,18 @@ private function setTags(int $id, array $tags): void private function getBaseData(array $data): array { return [ - 'title' => $data['title'], - 'slug' => $data['slug'], - 'description' => $data['description'], - 'html' => $data['html'], - 'user_id' => $data['user_id'], - 'image' => $data['image'] ?? null, - 'image_alt' => $data['image_alt'], - 'status' => $data['status'], - 'meta_title' => $data['meta_title'], - 'meta_description' => $data['meta_description'], - 'canonical_url' => $data['canonical_url'], - 'published_at' => (int) strtotime($data['published_at']), + 'title' => $data['title'] ?? '', + 'slug' => $data['slug'] ?? '', + 'description' => $data['description'] ?? '', + 'html' => $data['html'] ?? '', + 'user_id' => $data['user_id'] ?? 0, + 'image' => \Aurora\Core\Helper::normalizeContentPath($data['image'] ?? null), + 'image_alt' => $data['image_alt'] ?? '', + 'status' => $data['status'] ?? false, + 'meta_title' => $data['meta_title'] ?? '', + 'meta_description' => $data['meta_description'] ?? '', + 'canonical_url' => $data['canonical_url'] ?? '', + 'published_at' => (int) ($data['published_at'] ?? 0), ]; } } diff --git a/app/controllers/modules/Tag.php b/app/controllers/modules/Tag.php index 18dbf8c..1df9f1d 100755 --- a/app/controllers/modules/Tag.php +++ b/app/controllers/modules/Tag.php @@ -31,39 +31,40 @@ public function add(array $data): string|bool * Updates an existing tag * @param int $id the tag id * @param array $data the new data + * @param array|null $user the user performing the action * @return bool true on success, false otherwise */ - public function save(int $id, array $data): bool + public function save(int $id, array $data, ?array $user = null): bool { - return $this->db->update($this->table, $this->getBaseData($data), $id) ? $id : false; + return $this->db->update($this->table, $this->getBaseData($data), $id); } /** * Returns an array with all the tag fields that contain an error * @param array $data the tag fields - * @param [mixed] $id the tag id + * @param mixed $id the tag id + * @param mixed $user the user data * @return array the array with the tag fields that contain an error */ - public function checkFields(array $data, $id = null): array + public function checkFields(array $data, $id, $user): array { $errors = []; if (empty($data['name'])) { - $errors['name'] = $this->language->get('invalid_value'); + $errors[] = 'invalid_name'; } if (!empty($data['slug']) && !empty($this->get([ 'slug' => $data['slug'], '!id' => $id ]))) { - $errors['slug'] = $this->language->get('repeated_slug'); + $errors[] = 'repeated_slug'; } if (empty($data['slug']) || !\Aurora\Core\Helper::isSlugValid($data['slug'])) { - $errors['slug'] = $this->language->get('invalid_slug'); + $errors[] = 'invalid_slug'; } if (!\Aurora\App\Permission::can('edit_tags')) { - http_response_code(403); - $errors[0] = $this->language->get('no_permission'); + $errors[] = 'no_permission'; } return $errors; @@ -78,6 +79,10 @@ public function getCondition(array $filters): string { $where = []; + if (isset($filters['id']) && \Aurora\Core\Helper::isValidId($filters['id'])) { + $where[] = 'tags.id = ' . ((int) $filters['id']); + } + if (!empty($filters['search'])) { $search = $this->db->escape($filters['search']); $where[] = "(tags.name LIKE '%$search%' OR tags.slug LIKE '%$search%')"; @@ -94,11 +99,11 @@ public function getCondition(array $filters): string private function getBaseData(array $data): array { return [ - 'name' => $data['name'], - 'slug' => $data['slug'], - 'description' => $data['description'], - 'meta_title' => $data['meta_title'], - 'meta_description' => $data['meta_description'], + 'name' => $data['name'] ?? '', + 'slug' => $data['slug'] ?? '', + 'description' => $data['description'] ?? '', + 'meta_title' => $data['meta_title'] ?? '', + 'meta_description' => $data['meta_description'] ?? '', ]; } } diff --git a/app/controllers/modules/User.php b/app/controllers/modules/User.php index 7f782eb..ac1492e 100755 --- a/app/controllers/modules/User.php +++ b/app/controllers/modules/User.php @@ -12,7 +12,10 @@ final class User extends \Aurora\App\ModuleBase protected string $join = 'LEFT JOIN posts ON posts.user_id = users.id LEFT JOIN roles ON roles.level = users.role'; protected string $group_by = 'users.id'; - protected array $relations = [ 'password_restores' => 'user_id' ]; + protected array $relations = [ + 'password_restores' => 'user_id', + 'tokens' => 'user_id', + ]; protected array $orders = [ 'name' => 'users.name', 'email' => 'users.email', @@ -27,22 +30,29 @@ final class User extends \Aurora\App\ModuleBase * Updates an existing user * @param int $id the user id * @param array $data the new data + * @param array|null $user the user performing the save, its current token will be kept when revoking sessions after a password change * @return bool true on success, false otherwise */ - public function save(int $id, array $data): bool + public function save(int $id, array $data, ?array $user = null): bool { $res = $this->db->update($this->table, [ 'name' => $data['name'], 'slug' => $data['slug'], 'email' => $data['email'], 'status' => $data['status'], - 'image' => $data['image'], + 'image' => \Aurora\Core\Helper::normalizeContentPath($data['image']), 'bio' => $data['bio'], 'role' => $data['role'], ], $id); if ($res && !empty($data['password'])) { $this->db->update($this->table, [ 'password' => $this->getPassword($data['password']) ], $id); + + if ($id == ($user['id'] ?? 0)) { + $this->db->query('DELETE FROM tokens WHERE user_id = ? AND token != ?', $id, ($user['token'] ?? null)); + } else { + $this->db->query('DELETE FROM tokens WHERE user_id = ?', $id); + } } return $res; @@ -57,169 +67,104 @@ public function add(array $data): string|bool { $time = time(); return $this->db->insert($this->table, [ - 'name' => $data['name'], - 'slug' => $data['slug'], - 'email' => $data['email'], + 'name' => $data['name'] ?? '', + 'slug' => $data['slug'] ?? '', + 'email' => $data['email'] ?? '', 'password' => $this->getPassword($data['password']), - 'status' => $data['status'], - 'image' => $data['image'] ?? null, - 'bio' => $data['bio'], - 'role' => $data['role'], + 'status' => $data['status'] ?? false, + 'image' => \Aurora\Core\Helper::normalizeContentPath($data['image'] ?? null), + 'bio' => $data['bio'] ?? '', + 'role' => $data['role'] ?? 0, 'created_at' => $time, 'last_active' => $time, ]); } - /** - * Handles the login of an user - * @param string $email the user's email - * @param string $password the user's password - * @return array the array with the login errors, if empty it means the user has successfully logged in. - */ - public function handleLogin(string $email, string $password): array - { - $user = $this->get([ - 'email' => $email, - 'status' => 1, - ]); - $errors = []; - - if (!$user) { - $errors['email'] = $this->language->get('no_active_user'); - } elseif (!password_verify($password, $user['password'])) { - $errors['password'] = $this->language->get('wrong_password'); - } - - if (empty($errors)) { - $_SESSION['user'] = $user; - } - - return $errors; - } - - /** - * Sends an email to restore the password of an user - * @param string $email the user's email - * @param string $hash the hash to restore the password - * @param string $message the email's content - * @return array the array with the errors, if empty it means the email has been sent. - */ - public function requestPasswordRestore(string $email, string $hash, string $message): array - { - $user = $this->get([ - 'email' => $email, - 'status' => 1, - ]); - $errors = []; - - if (!$user) { - $errors['email'] = $this->language->get('no_active_user'); - } - - if (empty($errors)) { - $this->db->replace('password_restores', [ - 'user_id' => $user['id'], - 'hash' => $hash, - 'created_at' => time(), - ]); - - if (!\Aurora\Core\Kernel::config('mail')($email, $this->language->get('restore_your_password'), $message)) { - $errors['email'] = $this->language->get('error_sending_email'); - } - } - - return $errors; - } - - /** - * Restores an user's password - * @param string $hash the hash to restore the password - * @param string $password the new password - * @param string $password_confirm the confirmation of the new password - * @return string the error message, if empty it means the password has been successfully been restored - */ - public function passwordRestore(string $hash, string $password, string $password_confirm): string - { - $restore = $this->db->query('SELECT * FROM password_restores WHERE hash = ?', $hash)->fetch(); - - if (empty($restore) || $restore['created_at'] < strtotime('-2 hours')) { - return $this->language->get('error_expired_restore'); - } - - $error = $this->checkPassword($password, $password_confirm); - if (empty($error)) { - $user = $this->get([ - 'id' => $restore['user_id'], - 'status' => 1, - ]); - - if (!$user) { - return $this->language->get('no_active_user'); - } - - $this->db->delete('password_restores', $hash, 'hash'); - $this->db->update($this->table, [ 'password' => $this->getPassword($password) ], $user['id']); - $_SESSION['user'] = $user; - } - - return $error; - } - /** * Returns an array with all the user fields that contain an error * @param array $data the user fields - * @param [mixed] $id the user id + * @param mixed $id the user id + * @param mixed $user the user data * @return array the array with the user fields that contain an error */ - public function checkFields(array $data, $id = null): array + public function checkFields(array $data, $id, $user): array { $errors = []; if (empty($data['name'])) { - $errors['name'] = $this->language->get('invalid_value'); + $errors[] = 'invalid_name'; } if (!empty($data['slug']) && !empty($this->get([ 'slug' => $data['slug'], '!id' => $id ]))) { - $errors['slug'] = $this->language->get('repeated_slug'); + $errors[] = 'repeated_slug'; } if (empty($data['slug']) || !\Aurora\Core\Helper::isSlugValid($data['slug'])) { - $errors['slug'] = $this->language->get('invalid_slug'); + $errors[] = 'invalid_slug'; } if (!empty($data['email']) && !empty($this->get([ 'email' => $data['email'], '!id' => $id ]))) { - $errors['email'] = $this->language->get('repeated_email'); + $errors[] = 'repeated_email'; } if (empty($data['email']) || filter_var($data['email'], FILTER_VALIDATE_EMAIL) === false) { - $errors['email'] = $this->language->get('invalid_value'); + $errors[] = 'invalid_value'; } if (empty($id) && empty($data['password'])) { - $errors['password'] = $this->language->get('bad_password'); + $errors[] = 'bad_password'; } if (!empty($data['password'])) { - $password_error = $this->checkPassword($data['password'], $data['password_confirm']); + $password_error = $this->checkPassword($data['password'], $data['password_confirm'] ?? ''); if (!empty($password_error)) { - $errors['password'] = $password_error; + $errors[] = $password_error; } } - $can_edit = empty($id) - ? \Aurora\App\Permission::can('edit_users') - : \Aurora\App\Permission::edit_user($this->get([ 'id' => $id ])); - - if (!$can_edit) { - http_response_code(403); - $errors[0] = $this->language->get('no_permission'); + if (!$user || !self::canEdit($user, \Aurora\Core\Helper::isValidId($id) ? $this->get([ 'id' => $id ]) : null, $data)) { + $errors[] = 'no_permission'; } return $errors; } + /** + * Returns true if the given actor user can edit the subject user and assign the given role + * @param array $current_user the user performing the action + * @param array|null $subject the user being edited, null when creating a new user + * @param array|null $new_data the new data for the subject + * @return bool true if the actor can edit the subject user, false otherwise + */ + public static function canEdit(array $current_user, ?array $subject, ?array $new_data = null): bool + { + $current_user_role = (int) ($current_user['role'] ?? 0); + $is_owner = $current_user_role == 4; + $new_role = $new_data['role'] ?? null; + + if ($subject !== null && (int) ($subject['id'] ?? 0) === (int) ($current_user['id'] ?? 0)) { + if (isset($new_data['status']) && (int) $new_data['status'] !== (int) ($subject['status'] ?? 0)) { + return false; + } + + return $new_role === null || $new_role <= $current_user_role; + } + + if (!\Aurora\App\Permission::can('edit_users')) { + return false; + } + + if ($subject !== null && !$is_owner && (int) ($subject['role'] ?? 0) >= $current_user_role) { + return false; + } + + $max_assignable_role = $is_owner ? $current_user_role : $current_user_role - 1; + + return $new_role === null || $new_role <= $max_assignable_role; + } + /** * Returns the query conditions to obtain users based on the given filters * @param array $filters the filters @@ -229,6 +174,10 @@ public function getCondition(array $filters): string { $where = []; + if (isset($filters['id']) && \Aurora\Core\Helper::isValidId($filters['id'])) { + $where[] = 'users.id = ' . ((int) $filters['id']); + } + if (isset($filters['status']) && $filters['status'] !== '') { $where[] = 'users.status = ' . ((int) $filters['status']); } @@ -245,6 +194,21 @@ public function getCondition(array $filters): string return implode(' AND ', $where); } + /** + * Returns the user id if the given credentials are valid, false otherwise + * @param string $email the user email + * @param string $password the plain password + * @return int|false the user id on success, false otherwise + */ + public function authenticate(string $email, string $password): int|false + { + $row = $this->db->query("SELECT id, password FROM $this->table WHERE email = ? AND status = 1", $email)->fetch(); + + return !$row || !password_verify($password, $row['password']) + ? false + : ((int) $row['id']); + } + /** * Returns the given password hashed * @param string $password the password @@ -259,18 +223,33 @@ public function getPassword(string $password): string * Checks the given password and its confirmation * @param string $password the password * @param string $password_confirm the password confirmation - * @return string the error message, if empty it means both passwords are equal and valid + * @return string|false the error message, if false it means both passwords are equal and valid */ - private function checkPassword(string $password, string $password_confirm): string + public function checkPassword(string $password, string $password_confirm): string|false { if (mb_strlen($password) < 8) { - return $this->language->get('bad_password'); + return 'bad_password'; } if ($password !== $password_confirm) { - return $this->language->get('bad_password_confirm'); + return 'bad_password_confirm'; + } + + return false; + } + + /** + * Returns the user with additional data mapped into it + * @param mixed $data the user data + * @return mixed the user with additional data + */ + protected function getRowData($data): mixed + { + if (!empty($data['image'])) { + $data['image'] = \Aurora\Core\Helper::getContentPath($data['image']); } - return ''; + unset($data['password']); + return $data; } } diff --git a/core/DB.php b/app/core/DB.php similarity index 100% rename from core/DB.php rename to app/core/DB.php diff --git a/core/Helper.php b/app/core/Helper.php similarity index 53% rename from core/Helper.php rename to app/core/Helper.php index 4b8520c..b10fc80 100755 --- a/core/Helper.php +++ b/app/core/Helper.php @@ -4,14 +4,24 @@ final class Helper { + /** + * Returns the given path relative to the app root directory + * @param string $path the path + * @return string the given path relative to the app root directory + */ + public static function getPath(string $path = ''): string + { + return dirname(__DIR__) . (empty($path) ? '' : '/' . trim($path, '/')); + } + /** * Returns the given path relative to the project root directory * @param string $path the path * @return string the given path relative to the project root directory */ - public static function getPath(string $path = ''): string + public static function getProjectPath(string $path = ''): string { - return dirname(__DIR__) . (empty($path) ? '' : '/' . ltrim($path, '/')); + return dirname(self::getPath()) . (empty($path) ? '' : '/' . trim($path, '/')); } /** @@ -24,6 +34,38 @@ public static function getCurrentPath(): string return mb_substr($url, 0, mb_strpos($url, '?')); } + /** + * Returns the path relative to the content directory with the content directory prefix + * @param string|null $path the path + * @return string|null the path with the content directory prefix + */ + public static function getContentPath(?string $path = ''): ?string + { + if ($path === null || $path === '' || parse_url($path, PHP_URL_HOST)) { + return $path; + } + + $content = trim(\Aurora\Core\Kernel::config('content'), '/'); + + return "/$content/" . ltrim($path, '/'); + } + + /** + * Returns the path relative to the content directory without the content directory prefix + * @param string|null $path the path + * @return string|null the path without the content directory prefix + */ + public static function normalizeContentPath(?string $path): ?string + { + if ($path === null || $path === '' || parse_url($path, PHP_URL_HOST)) { + return $path; + } + + $content = trim(\Aurora\Core\Kernel::config('content'), '/'); + + return mb_substr(ltrim($path, '/'), mb_strlen($content) + 1); + } + /** * Returns the full url of the given path * @param [string] $path the path @@ -32,9 +74,16 @@ public static function getCurrentPath(): string public static function getUrl(string $path = ''): string { $path = ltrim($path, '/'); - $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || ($_SERVER['SERVER_PORT'] ?? 80) == 443; + return 'http' . (self::isHttps() ? 's' : '') . '://' . ($_SERVER['SERVER_NAME'] ?? 'localhost') . (empty($path) ? '' : "/$path"); + } - return 'http' . ($https ? 's' : '') . '://' . ($_SERVER['SERVER_NAME'] ?? 'localhost') . (empty($path) ? '' : "/$path"); + /** + * Returns true if the current request is made via HTTPS, false otherwise + * @return bool true if the current request is made via HTTPS, false otherwise + */ + public static function isHttps(): bool + { + return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || ($_SERVER['SERVER_PORT'] ?? 80) == 443; } /** @@ -69,6 +118,37 @@ public static function getUserIP(): mixed return 'UNKNOWN'; } + /** + * Returns the Authorization header value from the current request + * @return string the Authorization header value, or an empty string if missing + */ + public static function getAuthorizationHeader(): string + { + foreach ([ 'HTTP_AUTHORIZATION', 'REDIRECT_HTTP_AUTHORIZATION' ] as $key) { + if (!empty($_SERVER[$key])) { + return $_SERVER[$key]; + } + } + + $headers = function_exists('getallheaders') ? getallheaders() : []; + + if ($headers === false) { + $headers = []; + } + + if (function_exists('apache_request_headers')) { + $headers = array_merge($headers, apache_request_headers() ?: []); + } + + foreach ($headers as $name => $value) { + if (strcasecmp($name, 'Authorization') === 0) { + return $value; + } + } + + return ''; + } + /** * Copies the given source (file or directory) to the given destination * @param string $source the source @@ -79,18 +159,19 @@ public static function getUserIP(): mixed public static function copy(string $source, string $destination, int $permission = 0755): bool { if (is_file($source)) { - return copy($source, $destination); + mkdir(dirname($destination), $permission, true); + return @copy($source, $destination); } - if (!is_dir($destination)) { - mkdir($destination, $permission); + if (!is_dir($destination) && !mkdir($destination, $permission, true)) { + return false; } $iterator = self::getFileIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS, \RecursiveIteratorIterator::SELF_FIRST); foreach ($iterator as $item) { $new_path = "$destination/" . call_user_func([ $iterator, 'getSubPathname' ]); $res = $item->isDir() - ? (is_dir($new_path) || mkdir($new_path, $permission)) + ? (is_dir($new_path) || mkdir($new_path, $permission, true)) : copy($item, $new_path); if (!$res) { @@ -149,16 +230,6 @@ public static function getPhpSize(string $size_str): int return (int) $size; } - /** - * Returns true if the given CSRF token is valid, false otherwise - * @param string $value the CSRF token - * @return bool true if the given CSRF token is valid, false otherwise - */ - public static function isCsrfTokenValid(string $value): bool - { - return isset($_COOKIE['csrf_token']) && $_COOKIE['csrf_token'] === $value; - } - /** * Returns true if the given slug is valid * @param string $value the slug @@ -189,4 +260,67 @@ public static function downloadFile(string $file_path, string $filename, string return $file_exists ? readfile($file_path) : false; } + + public static function getRequestData(): array + { + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && !empty($_POST)) { + return $_POST; + } + + $raw_input = file_get_contents('php://input'); + + if (empty($raw_input)) { + return []; + } + + $content_type = $_SERVER['CONTENT_TYPE'] ?? ''; + + if (stripos($content_type, 'application/json') !== false) { + $decoded = json_decode($raw_input, true); + return is_array($decoded) ? $decoded : []; + } + + if (stripos($content_type, 'application/x-www-form-urlencoded') !== false) { + parse_str($raw_input, $data); + return $data; + } + + return [ '_raw' => $raw_input ]; + } + + /** + * Appends a message to the log file + * @param string $message the message + * @param string|null $path the log file path. Defaults to the configured log file path + */ + public static function log(string $message, ?string $path = null): void + { + $path ??= ini_get('error_log'); + + if (empty($path)) { + return; + } + + file_put_contents($path, sprintf("[%s UTC] %s\n", gmdate('d-M-Y H:i:s'), $message), FILE_APPEND | LOCK_EX); + } + + /** + * Removes the given directory recursively + * @param string $dir the directory + */ + public static function removeDirRecursive(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + foreach (new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ) as $item) { + $item->isDir() ? rmdir($item) : unlink($item); + } + + rmdir($dir); + } } diff --git a/core/Kernel.php b/app/core/Kernel.php similarity index 94% rename from core/Kernel.php rename to app/core/Kernel.php index 9433f7c..c954655 100644 --- a/core/Kernel.php +++ b/app/core/Kernel.php @@ -42,7 +42,7 @@ public function __construct(array $config) */ public function init(string $url): void { - $this->router->handleRoute($url); + $this->router->handleRoute($url, \Aurora\Core\Helper::getRequestData()); $this->router->handleRouteCode(http_response_code()); } diff --git a/core/Language.php b/app/core/Language.php similarity index 100% rename from core/Language.php rename to app/core/Language.php diff --git a/core/Route.php b/app/core/Route.php similarity index 81% rename from core/Route.php rename to app/core/Route.php index 9c1deb0..7253dd2 100755 --- a/core/Route.php +++ b/app/core/Route.php @@ -108,15 +108,16 @@ public function code(int $code, \Closure $func): void public function handleRouteCode(int $code): void { if (array_key_exists($code, $this->codes)) { - echo $this->codes[$code](); + $this->outputResponse($this->codes[$code]()); } } /** * Handles a url * @param string $url the url to handle + * @param array $request_body the request body data */ - public function handleRoute(string $url): void + public function handleRoute(string $url, array $request_body = []): void { $current = array_filter(explode('/', $url)); $len = count($current) - 1; @@ -126,7 +127,12 @@ public function handleRoute(string $url): void if ($this->matchesRoute($current, $len, $route)) { $this->mapParameters($current, $route); - $middleware['action'](); + $response = $middleware['action']($request_body); + + if ($this->isResponseTuple($response)) { + $this->outputResponse($response); + return; + } } } @@ -141,8 +147,7 @@ public function handleRoute(string $url): void if ($this->matchesRoute($current, $len, $route)) { $this->mapParameters($current, $route); header('Content-Type: ' . $val['content_type']); - http_response_code($val['status'] ?? 200); - echo $val['action'](); + $this->outputResponse($val['action']($request_body), $val['status'] ?? 200); return; } } @@ -241,4 +246,37 @@ private function isGet(string $str): bool { return preg_match(self::GET_FORMAT, $str); } + + /** + * Sends the route handler response. + * A string is used as the response body. + * A two-element list [body, statusCode] sets both body and HTTP status. + * @param mixed $response the handler return value + * @param int|null $default_status the HTTP status when the response is not a tuple + */ + private function outputResponse(mixed $response, ?int $default_status = null): void + { + $body = $response; + $status = $default_status; + + if ($this->isResponseTuple($response)) { + $body = $response[0]; + $status = (int) $response[1]; + } + + if (isset($status)) { + http_response_code($status); + } + + echo $body; + } + + /** + * Returns true if the value is a [body, statusCode] response tuple + * @param mixed $response the handler return value + */ + private function isResponseTuple(mixed $response): bool + { + return is_array($response) && array_keys($response) === [ 0, 1 ]; + } } diff --git a/core/View.php b/app/core/View.php similarity index 100% rename from core/View.php rename to app/core/View.php diff --git a/app/database/fixtures.json b/app/database/fixtures.json index c02d21e..92f87ed 100644 --- a/app/database/fixtures.json +++ b/app/database/fixtures.json @@ -208,22 +208,6 @@ "key": "post_code", "value": "" }, - { - "key": "editor_code", - "value": " diff --git a/app/views/admin/base.html b/app/views/admin/base.html deleted file mode 100755 index b72cf00..0000000 --- a/app/views/admin/base.html +++ /dev/null @@ -1,91 +0,0 @@ - - - - - <?php $this->sectionStart('title') ?><?php $this->sectionEnd() ?> - <?= e(setting('title')) ?> - - include('admin/partials/head.html') ?> - - - - - - include('admin/partials/snackbar.html') ?> - sectionStart('content') ?> - sectionEnd() ?> - - sectionStart('extra') ?> - sectionEnd() ?> - - diff --git a/app/views/admin/dashboard.html b/app/views/admin/dashboard.html deleted file mode 100755 index 636fd38..0000000 --- a/app/views/admin/dashboard.html +++ /dev/null @@ -1,95 +0,0 @@ -extend('admin/base.html') ?> - -sectionStart('title') ?> - t('dashboard') ?> -sectionEnd() ?> - -sectionStart('content') ?> -
-
-
- include('admin/partials/menu_btn.html') ?> -

t('dashboard') ?>

-
-
-
-
-
- -
-

t('links') ?>

- -
- -
-

t('latest_published_posts') ?>

- -
-
-
- -
-

t('statistics') ?>

-
-
- t('posts') ?> - - t('published') ?>, - t('scheduled') ?>, - t('draft') ?> - -
-
- t('pages') ?> - - t('published') ?>, - t('draft') ?> - -
-
- t('users') ?> - - t('active') ?>, - t('inactive') ?> - -
-
-
-
-
-
-
-sectionEnd() ?> diff --git a/app/views/admin/link.html b/app/views/admin/link.html deleted file mode 100755 index fc0f746..0000000 --- a/app/views/admin/link.html +++ /dev/null @@ -1,84 +0,0 @@ -extend('admin/base.html') ?> - -sectionStart('title') ?> - t('link') ?> -sectionEnd() ?> - -sectionStart('content') ?> - -sectionEnd() ?> - -sectionStart('extra') ?> - -sectionEnd() ?> diff --git a/app/views/admin/list.html b/app/views/admin/list.html deleted file mode 100755 index 4e05631..0000000 --- a/app/views/admin/list.html +++ /dev/null @@ -1,88 +0,0 @@ -extend('admin/base.html') ?> - -sectionStart('title') ?> - -sectionEnd() ?> - -sectionStart('content') ?> -
- -
- - - -
- include('admin/partials/menu_btn.html') ?> -
-

-   - -
-
- - " class="button" title="t('new') ?>">+ t('new') ?> - - -
-
- $filter): ?> -
- - - - -
- - - -
- - include($extra_header) ?> - -
-
-
- !isset($column['condition']) || $column['condition']) as $column): ?> -
- -
-
-
-
- -
-sectionEnd() ?> - -sectionStart('extra') ?> - -sectionEnd() ?> diff --git a/app/views/admin/login.html b/app/views/admin/login.html deleted file mode 100755 index 2e2e776..0000000 --- a/app/views/admin/login.html +++ /dev/null @@ -1,59 +0,0 @@ - - - - <?= $this->t('sign_in') . ' - ' . e(setting('title')) ?> - include('admin/partials/head.html') ?> - - - include('admin/partials/snackbar.html') ?> -
- - - -
- - -
-
- - -
- - - -
- - - - diff --git a/app/views/admin/page.html b/app/views/admin/page.html deleted file mode 100755 index b309f3f..0000000 --- a/app/views/admin/page.html +++ /dev/null @@ -1,140 +0,0 @@ -extend('admin/base.html') ?> - -sectionStart('title') ?> - t('page') ?> -sectionEnd() ?> - -sectionStart('content') ?> -
- -
-
- include('admin/partials/menu_btn.html') ?> -

t('page') ?>

-
-
- - - - - -
-
-
-
-
-
- - -
-
-
- -
-
-
-
-
- - - -
- -
- t('id') ?>: - - t('number_views') ?>: - -
- -
-
-
- -
- checked > - -
-
-
- -
- checked oninput="toggleEditor(!this.checked)"> - -
-
-
- - -
-
-
-
- - -
-
- - -
-
- - -
-
-
- -
-
-sectionEnd() ?> - -sectionStart('extra') ?> - - -sectionEnd() ?> diff --git a/app/views/admin/partials/extra_headers/links.html b/app/views/admin/partials/extra_headers/links.html deleted file mode 100644 index 6b28c6e..0000000 --- a/app/views/admin/partials/extra_headers/links.html +++ /dev/null @@ -1,14 +0,0 @@ -
-
- - - -
- -
diff --git a/app/views/admin/partials/extra_headers/media.html b/app/views/admin/partials/extra_headers/media.html deleted file mode 100644 index 875077c..0000000 --- a/app/views/admin/partials/extra_headers/media.html +++ /dev/null @@ -1,9 +0,0 @@ -
-
- - - - -
- -
diff --git a/app/views/admin/partials/extra_headers/pages.html b/app/views/admin/partials/extra_headers/pages.html deleted file mode 100644 index 3cc46c3..0000000 --- a/app/views/admin/partials/extra_headers/pages.html +++ /dev/null @@ -1,14 +0,0 @@ -
-
- - - -
- -
diff --git a/app/views/admin/partials/extra_headers/posts.html b/app/views/admin/partials/extra_headers/posts.html deleted file mode 100644 index aa8eb4c..0000000 --- a/app/views/admin/partials/extra_headers/posts.html +++ /dev/null @@ -1,14 +0,0 @@ -
-
- - - -
- -
diff --git a/app/views/admin/partials/extra_headers/tags.html b/app/views/admin/partials/extra_headers/tags.html deleted file mode 100644 index 17ec383..0000000 --- a/app/views/admin/partials/extra_headers/tags.html +++ /dev/null @@ -1,14 +0,0 @@ -
-
- - - -
- -
diff --git a/app/views/admin/partials/extra_headers/users.html b/app/views/admin/partials/extra_headers/users.html deleted file mode 100644 index 006bae1..0000000 --- a/app/views/admin/partials/extra_headers/users.html +++ /dev/null @@ -1,14 +0,0 @@ -
-
- - - -
- -
diff --git a/app/views/admin/partials/head.html b/app/views/admin/partials/head.html deleted file mode 100755 index ca6b813..0000000 --- a/app/views/admin/partials/head.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - disabled > - - diff --git a/app/views/admin/partials/images_dialog.html b/app/views/admin/partials/images_dialog.html deleted file mode 100755 index 315112a..0000000 --- a/app/views/admin/partials/images_dialog.html +++ /dev/null @@ -1,78 +0,0 @@ -
-
-

t('image_picker') ?>

- - include('icons/x.svg') ?> - -
-
-
- - - - -
-
-
-
-
-
-
t('information') ?>
-
t('last_modification') ?>
-
- $file): ?> - -
- onclick="ImageDialog.close(); ImageDialog.setImage();" - - onclick="ImageDialog.setImagePage()" - - > - -
- -

- -

-
-
- dateFormat($file['time'])) ?> -
-
- - - t('no_items') ?> - -
-
-
- - $folder): ?> - - - include('icons/home.svg') : e($folder) ?> - / - - -
-
diff --git a/app/views/admin/partials/lists/links.html b/app/views/admin/partials/lists/links.html deleted file mode 100755 index 81f6b60..0000000 --- a/app/views/admin/partials/lists/links.html +++ /dev/null @@ -1,37 +0,0 @@ - - -
-

-
-
- -
-
- - t('active') ?> - - t('inactive') ?> - -
-
- -
-
-
- include('icons/dots.svg') ?> - -
-
-
- diff --git a/app/views/admin/partials/lists/media.html b/app/views/admin/partials/lists/media.html deleted file mode 100644 index fc44dc5..0000000 --- a/app/views/admin/partials/lists/media.html +++ /dev/null @@ -1,44 +0,0 @@ - $file): ?> - -
- -
- -

- -

-
-
- dateFormat($file['time'])) ?> -
-
-
- include('icons/dots.svg') ?> - -
-
-
- diff --git a/app/views/admin/partials/lists/pages.html b/app/views/admin/partials/lists/pages.html deleted file mode 100755 index caa2a8f..0000000 --- a/app/views/admin/partials/lists/pages.html +++ /dev/null @@ -1,40 +0,0 @@ - - -
-

- - - t('draft') ?> - -

-
-
- / -
-
- dateFormat($page['edited_at']) ?> -
- -
- -
- -
-
- include('icons/dots.svg') ?> - -
-
-
- diff --git a/app/views/admin/partials/lists/posts.html b/app/views/admin/partials/lists/posts.html deleted file mode 100755 index cc47e17..0000000 --- a/app/views/admin/partials/lists/posts.html +++ /dev/null @@ -1,46 +0,0 @@ - - -
- <?= e($post['image_alt'] ?? '') ?> style="visibility: hidden;" /> -
-

- - - t('draft') ?> - time()): ?> - t('scheduled') ?> - -

-

-
-
-
- -
-
- dateFormat($post['published_at'])) ?> -
- -
- -
- -
-
- include('icons/dots.svg') ?> - -
-
-
- diff --git a/app/views/admin/partials/lists/tags.html b/app/views/admin/partials/lists/tags.html deleted file mode 100755 index 10f6d3c..0000000 --- a/app/views/admin/partials/lists/tags.html +++ /dev/null @@ -1,30 +0,0 @@ - - -
-

-
-
- -
-
- -
-
-
- include('icons/dots.svg') ?> - -
-
-
- diff --git a/app/views/admin/partials/lists/users.html b/app/views/admin/partials/lists/users.html deleted file mode 100755 index 674d8b8..0000000 --- a/app/views/admin/partials/lists/users.html +++ /dev/null @@ -1,52 +0,0 @@ - - -
-
- <?= e($user['name'] ?? '') ?> style="visibility: hidden;" /> -
-
-

- - - (t('you') ?>) - - - t('inactive') ?> - -

-

-
-
-
- t($user['role_slug']) ?> -
-
- dateFormat($user['last_active'])) ?> -
-
- -
-
-
- include('icons/dots.svg') ?> - -
-
-
- diff --git a/app/views/admin/partials/media_header.html b/app/views/admin/partials/media_header.html deleted file mode 100644 index 8f04302..0000000 --- a/app/views/admin/partials/media_header.html +++ /dev/null @@ -1,249 +0,0 @@ - -
- include('admin/partials/menu_btn.html') ?> -
-

t('media') ?>

-   - -
-
-
- - -
- - -
-
- -
- - $folder): ?> - - - include('icons/home.svg') : e($folder) ?> - / - - -
- -
-
-
-
-

t('rename') ?>

- - include('icons/x.svg') ?> - -
-
-
- - -
-
- - -
-
-
-
-
-
-
-

t('create_folder') ?>

- - include('icons/x.svg') ?> - -
-
-
- - -
-
- - -
-
-
-
-
-
-
-

t('move') ?>

- - include('icons/x.svg') ?> - -
-
-
- - -
-
- - -
-
-
-
-
-
-
-

t('duplicate') ?>

- - include('icons/x.svg') ?> - -
-
-
- - -
-
- - -
-
-
- diff --git a/app/views/admin/partials/menu_btn.html b/app/views/admin/partials/menu_btn.html deleted file mode 100755 index 76b75b3..0000000 --- a/app/views/admin/partials/menu_btn.html +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/app/views/admin/partials/snackbar.html b/app/views/admin/partials/snackbar.html deleted file mode 100644 index 77af6fa..0000000 --- a/app/views/admin/partials/snackbar.html +++ /dev/null @@ -1,12 +0,0 @@ -
-
- include('icons/x-circle.svg') ?> -
-
- include('icons/check-circle.svg') ?> -
- -
- include('icons/x.svg') ?> -
-
diff --git a/app/views/admin/password_restore.html b/app/views/admin/password_restore.html deleted file mode 100755 index aac691c..0000000 --- a/app/views/admin/password_restore.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - <?= $this->t('restore_your_password') . ' - ' . e(setting('title')) ?> - include('admin/partials/head.html') ?> - - - include('admin/partials/snackbar.html') ?> -
-
- - -
-
- - -
- - - -
- - - diff --git a/app/views/admin/post.html b/app/views/admin/post.html deleted file mode 100755 index ae489e4..0000000 --- a/app/views/admin/post.html +++ /dev/null @@ -1,172 +0,0 @@ -extend('admin/base.html') ?> - -sectionStart('title') ?> - t('post') ?> -sectionEnd() ?> - -sectionStart('content') ?> -
- -
-
- include('admin/partials/menu_btn.html') ?> -

t('post') ?>

-
-
- - - - - -
-
-
-
-
-
- - -
-
- -
-
-
-
- - <?= $this->t('post_image') ?> - -
-
- - - -
-
- - -
- -
- t('id') ?>: - - t('number_views') ?>: - -
- -
-
-
- - -
-
- - -
-
- -
- checked > - -
-
- -
- -
- - - - -
-
- -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -
-
-
-
-
-sectionEnd() ?> - -sectionStart('extra') ?> - - -sectionEnd() ?> diff --git a/app/views/admin/settings.html b/app/views/admin/settings.html deleted file mode 100755 index d63669e..0000000 --- a/app/views/admin/settings.html +++ /dev/null @@ -1,361 +0,0 @@ -extend('admin/base.html') ?> - -sectionStart('title') ?> - t('settings') ?> -sectionEnd() ?> - -sectionStart('content') ?> - -
-
-
- include('admin/partials/menu_btn.html') ?> -

t('settings') ?>

-
-
- -
-
-
- -
-
-
- - - -
-
- - -
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - t('system_language_description', false) ?> - -
-
- - t('date_format_description', false) ?> - -
-
-
-
- - -
-
- -
- checked > - -
-
-
-
-
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
-
-
- - t('session_lifetime_description') ?> - -
-
- - t('samesite_cookie_description') ?> - -
-
- -
- checked > - -
-
-
- -
- checked > - -
-
-
- - t('relative_system_root') ?> - -
-
- -
-
- - - -
- - - -
-
-
- -
-
-
-
- - -
-
- -
- - - -
- -
-
- -
- checked > - -
-
- -
-
-
-
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - t('file_size_upload_limit_description', false) ?> - -
-
-
-
-
-
- - t('site_header_description', false) ?> - -
-
- - t('site_footer_description', false) ?> - -
-
- - t('post_code_description', false) ?> - -
-
- - t('editor_code_description', false) ?> - -
-
-
-
-
-
- - t('update_description', false) ?> - - -
-
-
- -
-
-
-
-
-sectionEnd() ?> - -sectionStart('extra') ?> - -sectionEnd() ?> diff --git a/app/views/admin/tag.html b/app/views/admin/tag.html deleted file mode 100755 index 5b0888b..0000000 --- a/app/views/admin/tag.html +++ /dev/null @@ -1,97 +0,0 @@ -extend('admin/base.html') ?> - -sectionStart('title') ?> - t('tag') ?> -sectionEnd() ?> - -sectionStart('content') ?> -
-
-
- include('admin/partials/menu_btn.html') ?> -

t('tag') ?>

-
-
- - - - - -
-
-
-
-
- - -
-
- - -
-
- - -
- -
- t('id') ?>: - t('number_posts') ?>: -
- -
-
-
- - -
-
- - -
-
- -
-
-sectionEnd() ?> - -sectionStart('extra') ?> - -sectionEnd() ?> diff --git a/app/views/admin/user.html b/app/views/admin/user.html deleted file mode 100755 index 8c7544c..0000000 --- a/app/views/admin/user.html +++ /dev/null @@ -1,143 +0,0 @@ -extend('admin/base.html') ?> - - -t($current_user ? 'your_user' : 'user'); ?> - -sectionStart('title') ?> - -sectionEnd() ?> - -sectionStart('content') ?> -
- -
-
- include('admin/partials/menu_btn.html') ?> -

-
-
- - - - - - - - - - -
-
-
-
-
- - - - - -
- - -
-

t('id') ?>:

-

t('number_posts') ?>:

-

t('last_active') ?>: dateFormat($user['last_active'])) ?>

-
- -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- checked disabled > - -
-
-
-
-

t('password') ?>

-
- - -
-
- - -
-
-
- -
-
-
-
-
-sectionEnd() ?> - -sectionStart('extra') ?> - -sectionEnd() ?> diff --git a/app/views/admin/emails/password_restore.html b/app/views/emails/password_restore.html similarity index 72% rename from app/views/admin/emails/password_restore.html rename to app/views/emails/password_restore.html index ddb0367..e200985 100644 --- a/app/views/admin/emails/password_restore.html +++ b/app/views/emails/password_restore.html @@ -8,8 +8,8 @@

t('someone_requested_password_restore') ?>

- t('click_to_restore_password') ?> + " target="_blank">t('click_to_restore_password') ?>
- url()) ?> + diff --git a/app/views/themes/default/blog.html b/app/views/themes/default/blog.html index 53a6f11..5fd44ab 100755 --- a/app/views/themes/default/blog.html +++ b/app/views/themes/default/blog.html @@ -10,7 +10,7 @@ - + include('themes/default/partials/header.html') ?> @@ -28,7 +28,7 @@

t('tag') ?>:

- <?= $this->t('author') ?> + <?= $this->t('author') ?>

@@ -59,7 +59,7 @@

t('no_results') ?>

btn.classList.add('loading'); - fetch('/api/posts?page=' + next_page + '&' + args) + fetch('/api/blog/posts?page=' + next_page + '&' + args) .then(res => res.json()) .then(res => { window.history.replaceState(null, null, window.location.pathname + '?page=' + next_page); diff --git a/app/views/themes/default/information.html b/app/views/themes/default/information.html index ac72e60..1be93e3 100755 --- a/app/views/themes/default/information.html +++ b/app/views/themes/default/information.html @@ -4,7 +4,7 @@ <?= e(setting('title')) ?> include('themes/default/partials/head.html') ?> - + diff --git a/app/views/themes/default/page.html b/app/views/themes/default/page.html index 9d7e01c..365c3e9 100644 --- a/app/views/themes/default/page.html +++ b/app/views/themes/default/page.html @@ -6,7 +6,7 @@ - + include('themes/default/partials/header.html') ?> diff --git a/app/views/themes/default/partials/head.html b/app/views/themes/default/partials/head.html index 7ad3e5c..925544d 100755 --- a/app/views/themes/default/partials/head.html +++ b/app/views/themes/default/partials/head.html @@ -3,9 +3,9 @@ - + - + diff --git a/app/views/themes/default/partials/posts_page.html b/app/views/themes/default/partials/posts_page.html index 909bddd..a4ac13c 100755 --- a/app/views/themes/default/partials/posts_page.html +++ b/app/views/themes/default/partials/posts_page.html @@ -1,7 +1,7 @@
- <?= e($post['image_alt'] ?? '') ?> style="visibility: hidden" /> + <?= e($post['image_alt'] ?? '') ?> style="visibility: hidden" />
@@ -24,7 +24,7 @@

diff --git a/app/views/themes/default/post.html b/app/views/themes/default/post.html index 83b6bfd..dc9b04e 100755 --- a/app/views/themes/default/post.html +++ b/app/views/themes/default/post.html @@ -10,7 +10,7 @@ - + include('themes/default/partials/header.html') ?> @@ -26,14 +26,14 @@

- <?= e($post['image_alt'] ?? '') ?> + <?= e($post['image_alt'] ?? '') ?>
@@ -58,7 +58,7 @@

t('related_posts') ?>