commit a8056b66cc8d91ff01dd7669b320ee556c43616e Author: Riyyi Date: Wed Mar 3 17:55:48 2021 +0100 Initial commit Couldnt keep the history unfortunately. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd57ef9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Non-project +.idea/ +.directory + +# Files +*.mwb.bak +config.php +composer.lock +syncconfig.sh + +# Directories +/public/* +!/public/css/ +/public/css/* +!/public/fonts/ +!/public/img/ +/public/img/* +!/public/js/ +/public/js/* +!/public/media/ +/public/media/* +/vendor/* + +# Unignore +!/public/.htaccess +!/public/css/style.css +!/public/img/favicon.png +!/public/index.php +!/public/js/app.js +!/public/js/site.js +!/public/media/.gitinclude diff --git a/app/classes/Config.php b/app/classes/Config.php new file mode 100644 index 0000000..e1349dd --- /dev/null +++ b/app/classes/Config.php @@ -0,0 +1,29 @@ +getMessage(), (int)$e->getCode()); + } + } + + //-------------------------------------// + + /** + * Get the PDO connection object + * + * @return PDO + */ + public static function get(): \PDO { + return self::$db; + } + + /** + * Get all columns + * + * @return array + */ + public static function getColumns(): array + { + return self::$columns; + } + + /** + * Get all sections + * + * @return array + */ + public static function getSections(): array + { + return self::$sections; + } + + /** + * Get all pages + * + * @return array + */ + public static function getPages(): array + { + return self::$pages; + } + + /** + * Store columns + * + * @param array $columns + * + * @return void + */ + public static function setColumns(array $columns): void + { + self::$columns = $columns; + } + + /** + * Store sections + * + * @param array $sections + * + * @return void + */ + public static function setSections(array $sections): void + { + self::$sections = $sections; + } + + /** + * Store pages + * + * @param array $pages + * + * @return void + */ + public static function setPages(array $pages): void + { + self::$pages = $pages; + } + +} diff --git a/app/classes/Form.php b/app/classes/Form.php new file mode 100644 index 0000000..0174d4b --- /dev/null +++ b/app/classes/Form.php @@ -0,0 +1,188 @@ +router = $router; + $this->router->service()->csrfToken = Session::token(); + $this->router->service()->form = $this; + $this->router->service()->injectView = '../app/views/form.php'; + } + + //-------------------------------------// + + public function addField(string $name, array $field): void + { + // "name" => [ + // "label", + // "type", text, email, tel, password, radio, textarea, checkbox(?), comment + // "data", (for radio fields) [ 'value' => 'Label', 'value' => 'Label'] + // "rules", (server side rules) + // "message", (server side error message) + // "pattern", (client sided rule) + // "title", + // ], + + $this->data[$name] = [ + $field[0] ?? '', + $field[1] ?? '', + $field[2] ?? '', + $field[3] ?? '', + $field[4] ?? '', + $field[5] ?? '', + $field[6] ?? '', + ]; + } + + public function validated(array $submit = []): bool + { + $result = false; + + if ($_SERVER['REQUEST_METHOD'] == 'POST') { + + $result = true; + + if (empty($submit)) { + $submit = $_POST; + } + + if (!Session::validateToken($submit)) { + $result = false; + } + + // Only check fields if CSRF token is valid + if ($result) { + // Check field rules + foreach ($this->data as $ruleName => $ruleValue) { + + $found = false; + $value = ''; + foreach ($submit as $submitName => $submitValue) { + + if ($ruleName == $submitName) { + $found = true; + $value = $submitValue; + break; + } + + if ($ruleValue[1] == 'comment') { + $found = true; + break; + } + } + + if (!$found || !$this->matchRule($ruleName, $value)) { + $this->errorKey = $ruleName; + $result = false; + break; + } + } + } + + // If unsuccessful, remember the form fields + if (!$result) { + foreach (array_keys($this->data) as $name) { + if ($name == 'captcha') { + continue; + } + + $this->router->service()->{$name} = $submit[$name] ?? ''; + } + } + } + + Session::delete('captcha'); + + return $result; + } + + public function matchRule(string $key, string $value): bool + { + // Get the rule(s) + $rule = $this->data[$key]; + $rules = explode('|', $rule[3]); + + if (array_search('required', $rules) !== false) { + if (empty($value)) { + return false; + } + } + + if (array_search('email', $rules) !== false) { + if (!filter_var($value, FILTER_VALIDATE_EMAIL)) { + return false; + } + } + + if (array_search('tel', $rules) !== false) { + if (!filter_var($value, FILTER_VALIDATE_REGEXP, [ + 'options' => [ + 'regexp' => '/^([0-9]{2}-?[0-9]{8})|([0-9]{3}-?[0-9]{7})$/', + ] + ])) { + return false; + } + } + + if (array_search('captcha', $rules) !== false) { + if (!Session::exists('captcha')) { + return false; + } + else if ($value != Session::get('captcha')) { + return false; + } + } + + return true; + } + + public function errorMessage(): string + { + return $this->errorKey != '' ? $this->data[$this->errorKey][4] : ''; + } + + //-------------------------------------// + + public function getFields(): array + { + return $this->data; + } + + public function getReset(): string + { + return $this->resetLabel; + } + + public function getSubmit(): string + { + return $this->submitLabel; + } + + public function setData(array $data): void + { + $this->data = $data; + } + + public function setReset(string $resetLabel): void + { + $this->resetLabel = $resetLabel; + } + + public function setSubmit(string $submitLabel): void + { + $this->submitLabel = $submitLabel; + } + +} diff --git a/app/classes/Mail.php b/app/classes/Mail.php new file mode 100644 index 0000000..ec1a378 --- /dev/null +++ b/app/classes/Mail.php @@ -0,0 +1,72 @@ +'; + } + + $headers = + 'MIME-Version: 1.0' . "\r\n" . + 'Content-type: text/html; charset=utf-8' . "\r\n" . + 'From: ' . $from . "\r\n" . + 'Reply-To: ' . $from . "\r\n" . + 'X-Mailer: PHP/' . phpversion(); + + return mail($to, $subject, $message, $headers); + } + + public static function sendMail(string $subject, string $message, string $from = '', string $to = ''): bool + { + if ($to == '') { + $to = self::$to; + } + if ($from == '') { + $from = self::$name; + } + if (empty(self::$host) || empty(self::$port) || + empty(self::$username) || empty(self::$password) || empty($to)) { + return false; + } + + $result = (new Mailer()) + ->setServer(self::$host, self::$port, "tlsv1.2") + ->setAuth(self::$username, self::$password) + ->setFrom($from, self::$username) + ->addTo('', $to) + ->setSubject($subject) + ->setBody($message) + ->send(); + + return $result; + } + +} + +Mail::_init(); diff --git a/app/classes/Media.php b/app/classes/Media.php new file mode 100644 index 0000000..b8dabd8 --- /dev/null +++ b/app/classes/Media.php @@ -0,0 +1,282 @@ + 'Uploaded with success.', + 1 => 'Uploaded file exceeds the maximum upload size. (1)', // php.ini + 2 => 'Uploaded file exceeds the maximum upload size. (2)', // HTML Form + 3 => 'Uploaded file was only partially uploaded.', + 4 => 'No file was uploaded.', + 6 => 'Missing a temporary folder.', + 7 => 'Failed to write file to disk.', + 8 => 'A PHP extension stopped the file upload.', + 9 => 'User was not logged in.', + 10 => 'Missing media folder.', + 11 => 'Uploaded file has invalid MIME type.', + 12 => 'Uploaded file exceeds the maximum upload size. (3)', // Media.php + 13 => 'Uploaded file already exists.', + 14 => 'DB entry creation failed.', + 15 => 'Moving file from temporary location failed.', + ]; + + return $errorMessages[$errorCode]; + } + + public static function deleteMedia(int $id): bool + { + if (!User::check()) { + return false; + } + + $media = MediaModel::find($id); + + if (!$media->exists()) { + return false; + } + + // Delete file + $file = self::$directory . '/' . $media->filename . '.' . $media->extension; + if (file_exists($file)) { + unlink($file); + } + + return $media->delete(); + } + + public static function uploadMedia(bool $overwrite = false): int + { + // Check if User is logged in + if (!User::check()) { + return 9; + } + + // Check if "media" directory exists + if (!is_dir(self::$directory) && !mkdir(self::$directory, 0755)) { + return 10; + } + + $files = $_FILES['file']; + + // Check for file errors + foreach ($files['error'] as $error) { + if ($error != 0) { + return $error; + } + } + + if (!Media::checkMimeType($files['type'], $files['tmp_name'])) { + return 11; + } + + if (!Media::checkSize($files['size'])) { + return 12; + } + + // Append random string to filename that already exists + $nameExt = Media::duplicateName($files['name'], $overwrite); + + // Check if the file already exists + $hash = Media::hashExists($files['tmp_name']); + if (!$hash[0]) { + return 13; + } + + $count = count($files['name']); + for ($i = 0; $i < $count; $i++) { + $filename = $nameExt[0][$i]; + $extension = $nameExt[1][$i]; + $md5 = $hash[1][$i]; + $tmpName = $files['tmp_name'][$i]; + + // Store record + $media = MediaModel::create([ + 'filename' => $filename, + 'extension' => $extension, + 'md5' => $md5, + ]); + + if (!$media->exists()) { + return 14; + } + + // Store image + $name = self::$directory . '/'. $filename . '.' . $extension; + if (!move_uploaded_file($tmpName, $name)) { + return 15; + } + + // After storing successfully, remove old entries with duplicate names + if ($overwrite) { + Media::destroyDuplicates($filename, $extension); + } + } + + return 0; + } + + //-------------------------------------// + + private static function checkMimeType(array $fileTypes, array $fileTmpNames): bool + { + $allowedMimeType = [ + // .tar.gz + 'application/gzip', + 'application/json', + 'application/octet-stream', + 'application/pdf', + 'application/sql', + // .tar.xz + 'application/x-xz', + 'application/xml', + 'application/zip', + 'audio/mp3', + 'audio/mpeg', + 'audio/ogg', + 'audio/wav', + 'audio/webm', + 'image/jpg', + 'image/jpeg', + 'image/png', + 'image/gif', + 'text/plain', + 'text/csv', + 'text/xml', + 'video/mp4', + 'video/webm', + // .doc + 'application/msword', + // .xls + 'application/vnd.ms-excel', + // .ppt + 'application/vnd.ms-powerpoint', + // .docx + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + // .xlsx + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + // .pptx + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + ]; + + // Files type check + $count = count($fileTypes); + for ($i = 0; $i < $count; $i++) { + if ($fileTypes[$i] != mime_content_type($fileTmpNames[$i]) || + !in_array($fileTypes[$i], $allowedMimeType)) { + return false; + } + } + + return true; + } + + private static function checkSize(array $fileSizes): bool + { + // Files should not exceed 10MiB + foreach ($fileSizes as $fileSize) { + if ($fileSize > 10485760) { + return false; + } + } + + return true; + } + + private static function duplicateName(array $filenames, bool $overwrite): array + { + // Split name from extension + $names = []; + $extensions = []; + foreach ($filenames as $name) { + $dotPos = strrpos($name, '.'); + $names[] = substr($name, 0, $dotPos); + $extensions[] = substr($name, $dotPos + 1); + } + + // Early return if names are specified to be overwritten + if ($overwrite) { + return [$names, $extensions]; + } + + // Get duplicate filenames + $in1 = str_repeat('?, ', count($names) - 1) . '?'; + $in2 = str_repeat('?, ', count($extensions) - 1) . '?'; + $data = array_merge($names, $extensions); + $duplicates = MediaModel::selectAll( + '*', "WHERE filename IN ($in1) AND extension IN ($in2)", $data, '?' + ); + + foreach ($filenames as $key => $filename) { + $hasDuplicate = false; + foreach ($duplicates as $duplicate) { + if ($filename == $duplicate['filename'] . '.' . $duplicate['extension']) { + $hasDuplicate = true; + break; + } + } + + // Append to filename if there are duplicates + if ($hasDuplicate) { + $names[$key] = $names[$key] . '-' . _randomStr(5); + } + } + + return [$names, $extensions]; + } + + private static function hashExists(array $fileTmpNames): array + { + $md5 = []; + foreach ($fileTmpNames as $tmpName) { + $md5[] = md5_file($tmpName); + } + + // If exact file already exists + $in = str_repeat('?, ', count($md5) - 1) . '?'; + $exists = MediaModel::selectAll( + '*', "WHERE md5 IN ($in)", $md5, '?' + ); + + if (!empty($exists)) { + return [false]; + } + + return [true, $md5]; + } + + private static function destroyDuplicates(string $filename, string $extension): void + { + $media = MediaModel::selectAll( + '*', 'WHERE filename = ? AND extension = ? ORDER BY id ASC', + [$filename, $extension], '?' + ); + + if (!_exists($media)) { + return; + } + + foreach ($media as $key => $value) { + + // Dont delete the new entry + if ($key === array_key_last($media)) { + return; + } + + MediaModel::destroy($value['id']); + } + } + +} + +// @Todo +// - If a file fails to store in the loop, destruct all files of that request, by tracking all IDs diff --git a/app/classes/Router.php b/app/classes/Router.php new file mode 100644 index 0000000..408ae53 --- /dev/null +++ b/app/classes/Router.php @@ -0,0 +1,352 @@ +dispatch(); + } + } + + /** + * Add CRUD routes + * Example usage: Router::resource('/example', 'CrudController'); + * + * @param string $route The URL location + * @param string $controller Controller to handle this route + * + * @return void + */ + public static function resource(string $route, string $controller): void + { + /* + * HTTP Verb Part (URL) Action (Method) + * + * GET /route indexAction + * GET /route/create createAction + * POST /route storeAction + * GET /route/{id} showAction + * GET /route/{id}/edit editAction + * PUT/PATCH /route/{id} updateAction + * DELETE /route/{id} destroyAction + */ + + self::addRoute(['GET'], [$route, $controller, 'indexAction']); + self::addRoute(['GET'], [$route . '/create', $controller, 'createAction']); + self::addRoute(['POST'], [$route, $controller, 'storeAction']); + self::addRoute(['GET'], [$route . '/[i:id]', $controller, 'showAction', ['id']]); + self::addRoute(['GET'], [$route . '/[i:id]/edit', $controller, 'editAction', ['id']]); + self::addRoute(['PUT', 'PATCH'], [$route . '/[i:id]', $controller, 'updateAction', ['id']]); + self::addRoute(['DELETE'], [$route . '/[i:id]', $controller, 'destroyAction', ['id']]); + } + + //-------------------------------------// + + protected static function setDefaultLayout(): void + { + self::$router->respond(function ($request, $response, $service) { + $service->layout('../app/views/layouts/default.php'); + }); + } + + protected static function loadConfigRoutes(): void + { + if (file_exists('../route.php')) { + self::$routes = require_once '../route.php'; + } + } + + /** + * Add all pages in the Db to self::$routes + * + * @return void + */ + protected static function loadDbRoutes(): void + { + // Load all sections from Db + $sections = SectionModel::selectAll('*', 'WHERE `active` = 1 ORDER BY `order` ASC'); + + // Return if no sections + if (!_exists($sections)) { + return; + } + + // Load all pages from Db + $pages = PageModel::selectAll('DISTINCT page.*', ' + LEFT JOIN page_has_content ON page_has_content.page_id = page.id + LEFT JOIN content ON content.id = page_has_content.content_id + WHERE page.active = 1 AND content.active = 1 + ORDER BY page.order ASC; + '); + + // Return if no pages + if (!_exists($pages)) { + return; + } + + // Select id column + $section = array_column($sections, 'section', 'id'); + + // Loop through all pages + foreach ($pages as $pageKey => $page) { + // Skip if section isn't created / active + if (!_exists($section, $page['section_id'])) { continue; } + + // url = /section/page + $url = '/' . $section[$page['section_id']] . '/' . $page['page']; + + // Add route + self::$routes[] = [$url, 'PageController', 'route', $page['id']]; + } + + // Cache sections and pages + Db::setSections($sections); + Db::setPages($pages); + } + + protected static function addRoute(array $method = [], array $data = []): void + { + if (!_exists($method) || !_exists($data)) { + return; + } + + $route = $data[0] ?? ''; + $controller = $data[1] ?? ''; + $action = $data[2] ?? ''; + $param = $data[3] ?? []; + if ($route == '' || $controller == '' || $action == '') { + return; + } + + // Create Klein route + self::$router->respond($method, $route, function($request, $response, $service) + use($controller, $action, $param) { + + // Create new Controller object + $controller = '\App\Controllers\\' . $controller; + $controller = new $controller(self::$router); + + $stillValid = true; + + // If method does not exist in object + if (!method_exists($controller, $action)) { + $stillValid = false; + } + + // If no valid permissions + if ($controller->getAdminSection() && + $controller->getLoggedIn() == false) { + $stillValid = false; + } + + // Call Controller action + if ($stillValid) { + + // Loop through params + $params = []; + foreach ($param as $name) { + $params[] = $request->param($name); + } + + return $controller->{$action}(...$params); + } + else { + $controller->throw404(); + } + }); + } + + protected static function addBasicRoute(array $method = [], array $route = []): void + { + if (!_exists($method) || !_exists($route)) { + return; + } + + // Create Klein route + self::$router->respond($method, $route[0], function() use($route) { + + // Create new Controller object + $controller = '\App\Controllers\\' . $route[1]; + $controller = new $controller(self::$router); + + // Complete action variable + if ($route[2] == '') { + $route[2] = 'indexAction'; + } + else { + $route[2] .= 'Action'; + } + + $stillValid = true; + + // If method does not exist in object + if (!method_exists($controller, $route[2])) { + $stillValid = false; + } + + // If no valid permissions + if ($controller->getAdminSection() && + $controller->getLoggedIn() == false) { + $stillValid = false; + } + + // Call Controller action + if ($stillValid) { + if (is_array($route[3])) { + return $controller->{$route[2]}( + $route[3][0] ?? '', + $route[3][1] ?? '', + $route[3][2] ?? '' + ); + } + else if ($route[3] != '') { + return $controller->{$route[2]}($route[3]); + } + else { + return $controller->{$route[2]}(); + } + } + else { + $controller->throw404(); + } + }); + } + + public static function createNavigation(): void + { + // Pull from cache + $sections = Db::getSections(); + $pages = Db::getPages(); + + // [ + // [ + // 'section url', + // 'title', + // ['page url', 'title'], + // ['page url', 'titleOfPage2'], + // ], + // [], + // [], + // ] + + $navigation = []; + + // Generate sections + foreach ($sections as $section) { + // Skip hidden sections + if ($section['hide_navigation'] == '1') { + continue; + } + + // Add URL, title to ID + $navigation[$section['id']] = [ + $section['section'], $section['title'] + ]; + } + + // Generate pages + foreach ($pages as $page) { + // Skip hidden sections + if (!_exists($navigation, $page['section_id'])) { + continue; + } + + // Skip hidden pages + if ($page['hide_navigation'] == '1') { + continue; + } + + // Add [URL, title] to ID + $navigation[$page['section_id']][] = [ + $page['page'], $page['title'] + ]; + } + + self::$router->service()->navigation = $navigation; + } + + //-------------------------------------// + + protected static function setHttpError(): void + { + self::$router->onHttpError(function($code) { + $service = self::$router->service(); + + switch ($code) { + case 404: + + $service->escape = function (?string $string) { + return htmlentities($string, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + }; + + self::$router->response()->sendHeaders(true, true); + $service->pageTitle = 'Error 404 (Not Found)'; + $service->render('../app/views/errors/404.php'); + break; + } + }); + } + + //-------------------------------------// + + public static function getRoutes(): array + { + return self::$routes; + } + +} + +Router::_init(); + +// @Todo +// - combine addRoute and addBasicroute functionality diff --git a/app/classes/Session.php b/app/classes/Session.php new file mode 100644 index 0000000..675550d --- /dev/null +++ b/app/classes/Session.php @@ -0,0 +1,300 @@ + true, 'httponly' => true, 'samesite' => 'Strict']); + session_start(); + + self::$attributes = &$_SESSION; + + if (!self::exists('_token')) { + self::regenerateToken(); + } + + self::$started = true; + } + + //-------------------------------------// + + /** + * Get all of the session data. + * + * @return array + */ + public static function all(): array + { + return self::$attributes; + } + + /** + * Checks if a key exists. + * + * @param string|array $key + * + * @return bool + */ + public static function isset($key): bool + { + $placeholder = new stdClass; + return self::get($key, $placeholder) !== $placeholder; + } + + /** + * Check if a key is present and not null. + * + * @param string|array $key + * + * @return bool + */ + public static function exists($key): bool + { + return !is_null(self::get($key)); + } + + /** + * Get an item from the session. + * + * @param string $key + * @param mixed $default + * + * @return mixed + */ + public static function get(string $key, $default = null) + { + if (array_key_exists($key, self::$attributes)) { + return self::$attributes[$key]; + } + + if (strpos($key, '.') === false) { + return $default; + } + + // Get item using the "dot" notation + $array = self::$attributes; + foreach (explode('.', $key) as $segment) { + if (is_array($array) && array_key_exists($segment, $array)) { + $array = $array[$segment]; + } + else { + return $default; + } + } + + return $array; + } + + /** + * Put a key / value pair or array of key / value pairs in the session. + * + * @param string|array $key + * @param mixed $value + * @return void + */ + public static function put($key, $value = null) + { + if (!is_array($key)) { + $key = [$key => $value]; + } + + foreach ($key as $arrayKey => $arrayValue) { + + // Set array item to a given value using the "dot" notation + + $keys = explode('.', $arrayKey); + + $array = &self::$attributes; + foreach ($keys as $i => $key) { + if (count($keys) === 1) { + break; + } + + unset($keys[$i]); + + // If key doesnt exist at this depth, create empty array holder + if (!isset($array[$key]) || !is_array($array[$key])) { + $array[$key] = []; + } + + $array = &$array[$key]; + } + + $array[array_shift($keys)] = $arrayValue; + } + } + + /** + * Get an item from the session, or store the default value. + * + * @param string $key + * + * @return mixed + */ + public static function emplace(string $key) + { + $value = self::get($key); + if (!is_null($value)) { + return $value; + } + + self::put($key, $value); + return $value; + } + + /** + * Push a value onto a session array. + * + * @param string $key + * @param mixed $value + * + * @return void + */ + public static function push(string $key, $value): void + { + $array = self::get($key, []); + $array[] = $value; + self::put($key, $array); + } + + /** + * Get the value of a given key and then remove it. + * + * @param string $keys + * + * @return mixed + */ + public static function pull(string $key, $default = null) + { + $result = self::get($key, $default); + self::delete($key); + + return $result; + } + + /** + * Delete one or many items from the session. + * + * @param string|array $keys + * + * @return void + */ + public static function delete($keys): void + { + $start = &self::$attributes; + + $keys = (array)$keys; + + if (count($keys) === 0) { + return; + } + + foreach ($keys as $key) { + // Delete top-level if non-"dot" notation key + if (self::exists($key)) { + unset(self::$attributes[$key]); + + continue; + } + + // Delete using "dot" notation key + + $parts = explode('.', $key); + + // Move to the start of the array each pass + $array = &$start; + + // Traverse into the associative array + while (count($parts) > 1) { + $part = array_shift($parts); + + if (isset($array[$part]) && is_array($array[$part])) { + $array = &$array[$part]; + } + else { + continue 2; + } + } + + unset($array[array_shift($parts)]); + } + } + + /** + * Remove all of the items from the session. + * + * @return void + */ + public static function flush(): void + { + self::$attributes = []; + } + + //-------------------------------------// + + /** + * Get the CSRF token value. + * + * @return string + */ + public static function token() + { + return self::get('_token'); + } + + /** + * Regenerate the CSRF token value. + * + * @return void + */ + public static function regenerateToken() + { + self::put('_token', _randomStr(40)); + } + + /** + * Validate the CSRF token value to the given value. + * + * @param array $array + * + * @return bool + */ + public static function validateToken(array $array = null): bool + { + if (is_array($array) && array_key_exists('_token', $array) && $array['_token'] == self::token()) { + return true; + } + + return false; + } + +} diff --git a/app/classes/User.php b/app/classes/User.php new file mode 100644 index 0000000..20432f3 --- /dev/null +++ b/app/classes/User.php @@ -0,0 +1,174 @@ +exists() && + $_COOKIE['username'] == $user->username && + $_COOKIE['salt'] == $user->salt) { + $success = true; + + self::setSession($_COOKIE['id'], $_COOKIE['username'], + $_COOKIE['salt'], $_COOKIE['toggle']); + } + } + + return $success; + } + + public static function login(string $username, string $password, string $rememberMe): bool + { + $user = UserModel::search(['username' => $username]); + + $success = false; + if ($user->exists() && $user->failed_login_attempt <= 2) { + $saltPassword = $user->salt . $password; + if (password_verify($saltPassword, $user->password)) { + $success = true; + + // On successful login, set failed_login_attempt to 0 + if ($user->failed_login_attempt > 0) { + $user->failed_login_attempt = 0; + $user->save(); + } + } + else { + $user->failed_login_attempt++; + $user->save(); + } + } + + if (!$success) { + self::logout(); + + return false; + } + + // Set session + self::setSession($user->id, $user->username, $user->salt, 1); + + // Set cookie + if ($rememberMe == '1') { + $time = time() + (3600 * 24 * 7); + self::setCookie($time, $user->id, $user->username, $user->salt, 1); + } + + return true; + } + + public static function logout(): void + { + Session::delete('user'); + + // Destroy user login cookie + $time = time() - 3600; + self::setCookie($time, 0, '', '', 0); + } + + public static function getUser(string $id = '', string $username = '', string $email = ''): UserModel + { + if ($id == '' && $username == '' && $email == '' && self::check()) { + $id = Session::get('user.id'); + $username = Session::get('user.username'); + } + + return UserModel::search([ + 'id' => $id, + 'username' => $username, + 'email' => $email, + ], 'OR'); + } + + public static function toggle(): void + { + if (self::check()) { + // Toggle session + Session::put('user.toggle', !Session::get('user.toggle')); + // Toggle cookie + self::setCookieToggle(Session::get('user.toggle')); + } + } + + //-------------------------------------// + + protected static function setSession( + int $id, string $username, string $salt, int $toggle): void + { + Session::put('user', [ + 'id' => $id, + 'username' => $username, + 'salt' => $salt, + 'toggle' => $toggle, + ]); + } + + protected static function setCookie( + int $time, int $id, string $username, string $salt, int $toggle): void + { + if (_exists($_SERVER, 'HTTPS') && $_SERVER['HTTPS'] == 'on') { + $domain = Config::c('APP_NAME'); + $options = [ + 'expires' => $time, + 'path' => '/', + 'domain' => $domain, + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Strict' + ]; + setcookie('id', $id, $options); + setcookie('username', $username, $options); + setcookie('salt', $salt, $options); + setcookie('toggle', $toggle, $options); + } + } + + protected static function setCookieToggle(int $toggle): void + { + if (_exists($_SERVER, 'HTTPS') && $_SERVER['HTTPS'] == 'on') { + $domain = Config::c('APP_NAME'); + $options = [ + 'expires' => time() + (3600 * 24 * 7), + 'path' => '/', + 'domain' => $domain, + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Strict' + ]; + setcookie('toggle', $toggle, $options); + } + } + + //-------------------------------------// + + public static function getToggle(): int + { + return self::check() ? Session::get('user.toggle') : 0; + } + + public static function getSession(): array + { + return self::check() ? Session::get('user') : []; + } + +} diff --git a/app/controllers/AdminController.php b/app/controllers/AdminController.php new file mode 100644 index 0000000..2a4c0f7 --- /dev/null +++ b/app/controllers/AdminController.php @@ -0,0 +1,24 @@ +router->service()->user = User::getUser(); + + parent::view('', 'Admin'); + } + + public function toggleAction(): void { + User::toggle(); + echo User::getToggle() ? '1' : '0'; + } + + public function syntaxAction(): void { + parent::view(); + } + +} diff --git a/app/controllers/BaseController.php b/app/controllers/BaseController.php new file mode 100644 index 0000000..242478c --- /dev/null +++ b/app/controllers/BaseController.php @@ -0,0 +1,133 @@ +router = $router; + + $request = $this->router->request()->uri(); + $request = parse_url($request)['path']; + $request = explode("/", $request); + + if (array_key_exists(1, $request)) { + $this->section = $request[1]; + } + if (array_key_exists(2, $request)) { + $this->page = $request[2]; + } + + // Set login status + $this->loggedIn = User::check(); + $this->router->service()->loggedIn = $this->loggedIn; + + // Set url https://site.com/section/page + $this->url = Config::c('APP_URL'); + $this->url .= _exists([$this->section]) ? '/' . $this->section : ''; + $this->url .= _exists([$this->page]) ? '/' . $this->page : ''; + $this->router->service()->url = $this->url; + + // If Admin section + $this->adminSection = $this->section == 'admin'; + $this->router->service()->adminSection = $this->adminSection; + + // Clear alert + $this->setAlert('', ''); + // Load alert set on the previous page + $this->loadAlert(); + + // View helper method + $this->router->service()->escape = function (?string $string) { + return htmlentities($string, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + }; + } + + //-------------------------------------// + + public function throw404(): void + { + $this->router->response()->sendHeaders(true, true); + $service = $this->router->service(); + $service->pageTitle = 'Error 404 (Not Found)'; + $service->render('../app/views/errors/404.php'); + exit(); + } + + /** + * Set alert for the current page + * + * @param string $type Color of the message (success/danger/warning/info) + * @param string $message The message to display + * + * @return void + */ + public function setAlert(string $type, string $message): void + { + $this->router->service()->type = $type; + $this->router->service()->message = $message; + } + + /** + * Set alert for the next page + * + * @param string $type Color of the message (success/danger/warning/info) + * @param string $message The message to display + * + * @return void + */ + public function setAlertNext(string $type, string $message): void + { + Session::put('type', $type); + Session::put('message', $message); + } + + /** + * Load alert set on the previous page + * + * @return void + */ + public function loadAlert(): void + { + if (Session::exists('type') && Session::exists('message')) { + $this->setAlert(Session::get('type'), Session::get('message')); + Session::delete(['type', 'message']); + } + } + + //-------------------------------------// + + public function getSection(): string + { + return $this->section; + } + + public function getLoggedIn(): bool + { + return $this->loggedIn; + } + + public function getAdminSection(): bool + { + return $this->adminSection; + } + +} + +// @Todo +// - Image lazy loading diff --git a/app/controllers/CrudController.php b/app/controllers/CrudController.php new file mode 100644 index 0000000..1a55311 --- /dev/null +++ b/app/controllers/CrudController.php @@ -0,0 +1,249 @@ +getModelName(); + $model = "\App\Model\\{$modelName}Model"; + $model = new $model; + + // ?page=x + $page = 1; + if (_exists($_GET, 'page') && is_numeric($_GET['page'])) { + $page = $_GET['page']; + } + + $rows = $model->all($page, self::$pagination); + + // Set empty value + if (!_exists($rows)) { + $rows = []; + } + + $this->router->service()->attributes = $model->getAttributesRules(); + $this->router->service()->csrfToken = Session::token(); + $this->router->service()->rows = $rows; + $this->router->service()->title = $modelName; + + // Set page variables + $this->router->service()->page = $page; + $pages = ceil($model->count() / self::$pagination); + $pages > 1 + ? $this->router->service()->pages = $pages + : $this->router->service()->pages = 1; + + parent::view("{$this->section}/crud/index"); + } + + /** + * Show the form for creating a new resource. + * + * @return void + */ + public function createAction(): void + { + $model = "\App\Model\\{$this->getModelName()}Model"; + $model = new $model; + + $attributes = $model->getAttributesRules(); + + // Get dropdown data + $dropdownData = []; + foreach ($attributes as $key => $attribute) { + if ($attribute[1] == 'dropdown') { + $dropdownData[$key] = $model->getDropdownData($attribute[0]); + } + } + + $this->router->service()->attributes = $attributes; + $this->router->service()->csrfToken = Session::token(); + $this->router->service()->dropdownData = $dropdownData; + parent::view("{$this->section}/crud/create"); + } + + /** + * Store a newly created resource in storage. + * + * @return void + */ + public function storeAction(): void + { + $modelName = $this->getModelName(); + $model = "\App\Model\\{$modelName}Model"; + $model = new $model; + + $token = Session::validateToken($_POST); + + $token && $model->fill($_POST) && $model->save() + ? $this->setAlertNext('success', "$modelName successfully created.") + : $this->setAlertNext('danger', "$modelName could not be created!"); + + $this->router->response()->redirect($this->url); + } + + /** + * Display the specified resource. + * + * @param int $id + * + * @return void + */ + public function showAction(int $id): void + { + $model = "\App\Model\\{$this->getModelName()}Model"; + $model = $model::find($id); + + if (!$model->exists()) { + parent::throw404(); + } + + $this->router->service()->model = $model; + $this->router->service()->attributes = $model->getAttributesRules(); + parent::view("{$this->section}/crud/show"); + } + + /** + * Show the form for editing the specified resource. + * + * @param int $id + * + * @return void + */ + public function editAction(int $id): void + { + $model = "\App\Model\\{$this->getModelName()}Model"; + $model = $model::find($id); + + if (!$model->exists()) { + parent::throw404(); + } + + $attributes = $model->getAttributesRules(); + + // Get dropdown data + $dropdownData = []; + foreach ($attributes as $key => $attribute) { + if ($attribute[1] == 'dropdown') { + $dropdownData[$key] = $model->getDropdownData($attribute[0]); + } + } + + $this->router->service()->attributes = $attributes; + $this->router->service()->csrfToken = Session::token(); + $this->router->service()->dropdownData = $dropdownData; + $this->router->service()->model = $model; + parent::view("{$this->section}/crud/edit"); + } + + /** + * Update the specified resource in storage. + * + * @param int $id + * + * @return void + */ + public function updateAction(int $id): void + { + $modelName = $this->getModelName(); + $model = "\App\Model\\{$modelName}Model"; + $model = $model::find($id); + + if (!$model->exists()) { + $this->setAlertNext('danger', "$modelName does not exist!"); + } + else { + // Read PUT request + $this->parsePhpInput($_PUT); + + $token = Session::validateToken($_PUT); + + $token && $model->fill($_PUT) && $model->save() + ? $this->setAlertNext('success', "$modelName successfully updated.") + : $this->setAlertNext('danger', "$modelName could not be updated!"); + } + + echo $this->url; + } + + /** + * Remove the specified resource from storage. + * + * @param int $id + * + * @return void + */ + public function destroyAction(int $id): void + { + // Read DELETE request + $this->parsePhpInput($_DELETE); + + $token = Session::validateToken($_DELETE); + + $modelName = $this->getModelName(); + $model = "\App\Model\\{$modelName}Model"; + + $token && $model::destroy($id) + ? $this->setAlertNext('success', "$modelName successfully deleted.") + : $this->setAlertNext('danger', "$modelName could not be deleted!"); + } + + //-------------------------------------// + + /** + * Generate the model name from the /section/page url + * + * @return string The model name + */ + private function getModelName(): string + { + $model = $this->page; + $model = str_replace('-', ' ', $model); + $model = str_replace('_', ' ', $model); + $model = ucwords($model); + $model = str_replace(' ', '', $model); + + return $model; + } + + /** + * PUT/DELETE requests aren't handled by PHP automatically yet. + * Parse them from php://input instead. + * + * @param ?array &$result Array filled with input data + * + * @return void + */ + private function parsePhpInput(?array &$result): void + { + // Parse json or form-encoded formatted data + if (strpos($_SERVER['CONTENT_TYPE'], "application/json") !== false) { + $result = json_decode(file_get_contents("php://input"), true); + } + else if (strpos($_SERVER['CONTENT_TYPE'], "application/x-www-form-urlencoded") !== false) { + parse_str(file_get_contents("php://input"), $result); + + // Cleanup & HTML entities + foreach ($result as $key => $value) { + unset($result[$key]); + $result[str_replace('amp;', '', $key)] = $value; + } + } + + $_REQUEST = array_merge($_REQUEST, $result); + } + +} diff --git a/app/controllers/IndexController.php b/app/controllers/IndexController.php new file mode 100644 index 0000000..c88935d --- /dev/null +++ b/app/controllers/IndexController.php @@ -0,0 +1,98 @@ +'); + + // Config routes + $routes = array_column(Router::getRoutes(), '0'); + + // Remove /admin and /test pages + foreach ($routes as $key => $route) { + if (strpos($route, '/admin') !== false || + strpos($route, '/test') !== false) { + + unset($routes[$key]); + } + } + + foreach ($routes as $route) { + $url = $xml->addChild('url'); + $loc = Config::c('APP_URL') . $route; + + $url->addChild('loc', $loc); + } + + Header('Content-type: text/xml'); + print($xml->asXML()); + } + +} diff --git a/app/controllers/LoginController.php b/app/controllers/LoginController.php new file mode 100644 index 0000000..b2d9e43 --- /dev/null +++ b/app/controllers/LoginController.php @@ -0,0 +1,232 @@ +router); + $form->addField('username', [ + 'Username*', + 'text', + '', + 'required', + 'Username is required.', + ]); + $form->addField('password', [ + 'Password*', + 'password', + '', + 'required', + 'Password is required.', + ]); + $form->addField('rememberMe', [ + '', + 'checkbox', + ['1' => 'Remember me'], + ]); + + $form->setSubmit('Sign in'); + + if ($form->validated()) { + if (User::login($_POST['username'], $_POST['password'], $_POST['rememberMe'])) { + $this->setAlert('success', 'Successfully signed in, redirecting...'); + + // Set delayed redirect URL + $this->router->service()->redirectURL = Config::c('APP_URL') . '/admin'; + } + else { + $user = User::getUser('', $_POST['username']); + if ($user->exists() && $user->failed_login_attempt >= 5) { + $this->setAlert('danger', 'User has been blocked.'); + } + else { + $this->setAlert('danger', 'Invalid username and password combination.'); + } + } + } + else if ($_SERVER['REQUEST_METHOD'] == 'POST') { + if(!_exists($_POST, 'username') || !_exists($_POST, 'password')) { + $this->setAlert('danger', 'Please fill out both fields.'); + } + else { + $this->setAlert('danger', 'Could not sign in.'); + } + } + + $this->router->service()->password = ""; + parent::view($view, $title); + } + + public function resetAction(string $view, string $title): void { + + // Send request + if (!_exists($_GET, 'uid') || !_exists($_GET, 'reset-key')) { + $this->requestResetForm(); + } + // Reset password + else { + $this->resetPasswordForm(); + + if ($_SERVER['REQUEST_METHOD'] != 'POST') { + $this->router->service()->newPassword = true; + $user = User::getUser($_GET['uid']); + if (!$user->exists() || $_GET['reset-key'] != $user->reset_key) { + $this->setAlert('danger', 'Link expired.'); + } + } + } + + parent::view($view, $title); + } + + public function logoutAction(): void { + User::logout(); + + $this->router->response()->redirect('/'); + } + + //-------------------------------------// + + private function requestResetForm(): void { + // Only have required on 1 field + $usernameRule = !_exists($_POST, 'reset-password-email') ? 'required' : ''; + $emailRule = !_exists($_POST, 'reset-password-username') ? 'required|email' : ''; + + $form = new Form($this->router); + $form->addField('reset-password-username', [ + 'Username', + 'text', + '', + "$usernameRule", + 'Username is required.', + ]); + $form->addField('reset-password-email', [ + 'Email', + 'email', + '', + "$emailRule", + 'Please enter a valid email address.', + '[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$', + 'Valid email address' + ]); + + $form->setSubmit('Send request'); + + if ($form->validated()) { + $this->requestResetSend(); + } + else if ($_SERVER['REQUEST_METHOD'] == 'POST') { + if (!_exists($_POST, 'reset-password-username') && + !_exists($_POST, 'reset-password-email')) { + $this->setAlert('danger', 'Please fill out one of the fields.'); + } + else { + $error = $form->errorMessage(); + $this->setAlert('danger', $error ? $error : 'Could not send request.'); + } + } + } + + private function requestResetSend(): void { + $user = User::getUser('', $_POST['reset-password-username'], $_POST['reset-password-email']); + if ($user->exists()) { + + // Generate new reset key + $user->reset_key = _randomStr(25); + $user->save(); + + // Send reset mail + $subject = 'Password reset request - ' . Config::c('APP_NAME'); + + $resetUrl = Config::c('APP_URL') . "/reset-password?uid={$user->id}&reset-key={$user->reset_key}"; + $message = " + Click the link below to reset your password:
+ $resetUrl + "; + + if (Mail::send($subject, $message, $user->email)) { + $this->setAlert('success', 'Successfully requested password reset.'); + } + else { + $this->setAlert('danger', 'Password reset failed.'); + } + } + else { + $this->setAlert('danger', 'User was not found.'); + } + } + + private function resetPasswordForm(): void { + // Add _GETs to form post url + $uid = $_GET['uid']; + $resetKey = $_GET['reset-key']; + $this->router->service()->url .= "?uid=$uid&reset-key=$resetKey"; + + $form = new Form($this->router); + $form->addField('password', [ + 'New password*', + 'password', + '', + 'required', + 'New password is required.', + ]); + $form->addField('password-again', [ + 'New password again*', + 'password', + '', + 'required', + 'New password again is required.', + ]); + + $form->setSubmit('Reset password'); + + if ($form->validated()) { + $this->resetPasswordSend(); + } + else if ($_SERVER['REQUEST_METHOD'] == 'POST') { + if (!_exists($_POST, 'password') || + !_exists($_POST, 'password-again')) { + $this->setAlert('danger', 'Please fill out all fields.'); + } + else { + $error = $form->errorMessage(); + $this->setAlert('danger', $error ? $error : 'Could not reset password.'); + } + } + } + + private function resetPasswordSend(): void + { + $user = User::getUser($_GET['uid']); + if ($user->exists()) { + (bool)$notEmptyKey = $user->reset_key != ''; + (bool)$correctKey = $user->reset_key == $_GET['reset-key']; + (bool)$identicalPass = $_POST['password'] == $_POST['password-again']; + } + if ($notEmptyKey && $correctKey && $identicalPass) { + + $user->salt = _randomStr(15); + $user->password = password_hash($user->salt . $_POST['password'], PASSWORD_BCRYPT, ['cost', 12]); + $user->reset_key = ''; + $user->save(); + + $this->setAlert('success', 'Successfully changed password.'); + } + + // Display error message + if (!$user || !$notEmptyKey || !$correctKey) { + $this->setAlert('danger', 'Invalid password reset link.'); + } + else if (!$identicalPass) { + $this->setAlert('danger', 'Fields did not match.'); + } + } + +} diff --git a/app/controllers/MediaController.php b/app/controllers/MediaController.php new file mode 100644 index 0000000..681a33a --- /dev/null +++ b/app/controllers/MediaController.php @@ -0,0 +1,117 @@ +mediaPage(); + parent::view(); + } + + /** + * Store a newly created resource in storage. + * + * @return void + */ + public function storeAction(): void + { + $name = ucfirst($this->page); + + $overwrite = _exists($_POST, 'overwrite'); + + $error = Media::uploadMedia($overwrite); + if (!$error) { + $this->setAlert('success', "$name successfully created."); + } + else { + $this->setAlert('danger', Media::errorMessage($error)); + } + + $this->mediaPage(); + parent::view(); + } + + /** + * Remove the specified resource from storage. + * + * @param int $id + * + * @return void + */ + public function destroyAction(int $id): void + { + $name = ucfirst($this->page); + + if (Media::deleteMedia($id)) { + $this->setAlertNext('success', "$name successfully deleted."); + } + else { + $this->setAlertNext('danger', "$name could not be deleted!"); + } + } + + //-------------------------------------// + + protected function mediaPage(): void { + // ?page=x + $page = 1; + if (_exists($_GET, 'page') && is_numeric($_GET['page'])) { + $page = $_GET['page']; + } + + $mediaModel = new MediaModel; + + // Get all Media of the page + $media = $mediaModel->all($page, Media::$pagination); + + // Get all the connected Logs + $log = null; + if (_exists($media)) { + $logId = array_column($media, 'log_id'); + $log = LogModel::findAll($logId); + } + + // Get all the connected Users + $user = null; + if (_exists($log)) { + $uploaderId = array_column($log, 'user_id'); + $user = UserModel::findAll($uploaderId); + } + + // Set empty values + if (!_exists($media) || !_exists($log) || !_exists($user)) { + $media = []; + $log = [["user_id" => "0"]]; + $user = [["username" => "Could not load users.."]]; + } + + // Set view Media variables + $this->router->service()->media = $media; + $this->router->service()->log = $log; + $this->router->service()->user = $user; + + // Set view Page variables + $this->router->service()->page = $page; + $pages = ceil($mediaModel->count() / Media::$pagination); + $pages > 1 + ? $this->router->service()->pages = $pages + : $this->router->service()->pages = 1; + + $this->router->service()->fileUrl = Config::c('APP_URL') . '/' . Media::$directory . '/'; + } + +} diff --git a/app/controllers/PageController.php b/app/controllers/PageController.php new file mode 100644 index 0000000..1c091e1 --- /dev/null +++ b/app/controllers/PageController.php @@ -0,0 +1,186 @@ +loadLinkedContent($pageHasContent, 'page', $id), + (array)$this->loadLinkedContent($sectionHasContent, 'section', $page['section_id'])); + + // Exit if nothing was found + if (!_exists($contents)) { + parent::throw404(); + } + + $sideContent = in_array('2', array_column($contents, 'type')); + + $this->router->service()->contents = $contents; + $this->router->service()->sideContent = $sideContent; + $this->view('content', $title, $metaDescription); + } + + //-------------------------------------// + + /** + * Load all content blocks linked to the provided Model. + * + * @param Model $model + * @param string $column + * @param int $id + * + * @return null|array + */ + protected function loadLinkedContent(Model $model, string $column, int $id): ?array + { + // Load all the Model <-> Content link data + $hasContent = $model::selectAll('*', " + WHERE {$column}_id = :id + ORDER BY {$model->getSort()} ASC", [ + [':id', $id, \PDO::PARAM_INT], + ] + ); + + // Exit if nothing was found + if (!_exists($hasContent)) { + return null; + } + + // Get all the content + $contentIds = array_column($hasContent, 'content_id'); + $contents = ContentModel::findAll($contentIds); + + // Exit if nothing was found + if (!_exists($contents)) { + return null; + } + + // Remove inactive content + foreach ($contents as $key => $content) { + if ($content['active'] == "0") { + unset($contents[$key]); + } + } + $contents = array_values($contents); + + return $contents; + } + + /** + * Render page view with title and meta description. + * + * @param string $view + * @param string $pageTitle + * @param string $metaDescription + * + * @return void + */ + protected function view( + string $view = '', string $pageTitle = '', string $metaDescription = ''): void + { + if ($view != '') { + $view = $this->fileExists($this->views . $view . '.php'); + } + + if ($this->page == null) { + if ($view == '' && $this->section == '') { + // / + $view = $this->fileExists($this->views . 'home.php'); + } + + if ($view == '') { + // /example.php + $view = $this->fileExists($this->views . $this->section . '.php'); + } + + if ($view == '') { + // /example/index.php + $view = $this->fileExists($this->views . $this->section . '/index.php'); + } + } + else if ($view == '') { + // /example/my-page.php + $view = $this->fileExists($this->views . $this->section . '/' . $this->page . '.php'); + } + + if ($view != '') { + $pageTitle != '' + ? $this->router->service()->pageTitle = $pageTitle + : $this->router->service()->pageTitle = ucfirst(str_replace('-', ' ', $this->page)); + $this->router->service()->metaDescription = $metaDescription; + $this->router->service()->render($view); + } + else { + parent::throw404(); + } + } + + //-------------------------------------// + + /** + * Loop back filename if it exists, empty string otherwise. + * + * @param string $file + * + * @return string + */ + private function fileExists(string $file): string + { + return file_exists($file) ? $file : ''; + } + +} + +// @Todo +// - Fix line 32, breaks if no DB content! +// - Implement page.description (meta) +// - Use page.title instead of content.title (?) diff --git a/app/controllers/TestController.php b/app/controllers/TestController.php new file mode 100644 index 0000000..69c3047 --- /dev/null +++ b/app/controllers/TestController.php @@ -0,0 +1,25 @@ + and exit + * + * @param mixed[] $output The variable (single/array) to print + * + * @return void Nothing + */ +function _log($output): void { + echo '
';
+	var_dump($output);
+	echo '
'; + die(); +} + +//-------------------------------------// + +if (!function_exists('session')) { + /** + * Get / set the specified session value. + * If key is an array, treat as a setter. + * + * @param string|array $key + * @param mixed $default + * + * @return mixed + */ + function session($key = null, $default = null) + { + $session = "\App\Classes\Session"; + + if (is_null($key)) { + return $session; + } + + if (is_array($key)) { + return $session::put($key); + } + + return $session::get($key, $default); + } +} diff --git a/app/model/ContentModel.php b/app/model/ContentModel.php new file mode 100644 index 0000000..6f82784 --- /dev/null +++ b/app/model/ContentModel.php @@ -0,0 +1,50 @@ + 'Select type', 1 => 'Page content', 2 => 'Side block']; + } + + return []; + } + + public function delete(): bool + { + if (self::query( + "DELETE FROM `page_has_{$this->table}` WHERE `{$this->table}_$this->primaryKey` = :id", [ + [':id', $this->{$this->primaryKey}], + ] + ) === null) { + return false; + } + + return $this->deleteLog(); + } +} diff --git a/app/model/LogModel.php b/app/model/LogModel.php new file mode 100644 index 0000000..88065ae --- /dev/null +++ b/app/model/LogModel.php @@ -0,0 +1,7 @@ +table)) { + $class = strtolower(get_class($this)); + $pos = strrpos($class, '\\'); + $this->table = substr($class, $pos + 1); + } + + // Pull columns from cache + $columnsCache = Db::getColumns(); + + // If exists in cache + if (array_key_exists($this->table, $columnsCache)) { + $columns = $columnsCache[$this->table]; + } + // Otherwise query Db and add to cache + else { + $columns = self::query("SHOW COLUMNS FROM `$this->table`"); + $columns = array_column($columns, 'Field'); + $columnsCache[$this->table] = $columns; + Db::setColumns($columnsCache); + } + + // Create attribute placeholders + if (_exists($columns)) { + foreach ($columns as $column) { + if ($column != $this->primaryKey) { + $this->attributes[] = $column; + } + $this->{$column} = null; + } + } + } + + //-------------------------------------// + + protected static function query(string $query, array $parameters = [], + $type = ':'): ?array + { + // Example + // $parameters = [ + // [':id', 1], + // [':number', 7, \PDO::PARAM_INT], + // [':string', 'A random string', \PDO::PARAM_STR], + // ]; + + // PDO::PARAM_BOOL + // PDO::PARAM_NULL + // PDO::PARAM_INT + // PDO::PARAM_STR + + if (substr_count($query, $type) != count($parameters)) { + return null; + } + + $query = Db::get()->prepare($query); + + $success = false; + if ($type == '?') { + $success = $query->execute($parameters); + } + else { + foreach ($parameters as $key => $parameter) { + if (count($parameter) == 2) { + $query->bindParam($parameter[0], $parameter[1]); + } + else if (count($parameter) == 3) { + $query->bindParam($parameter[0], $parameter[1], $parameter[2]); + } + } + $success = $query->execute(); + } + + if (!$success) { + return null; + } + + return $query->fetchAll(); + } + + //-------------------------------------// + + public function exists(): bool + { + if ($this->exists == true) { + return $this->exists; + } + + $exists = self::query( + "SELECT id FROM `$this->table` WHERE `$this->primaryKey` = :id", [ + [':id', $this->{$this->primaryKey}], + ] + ); + + if (_exists($exists)) { + $this->exists = true; + } + + return $this->exists; + } + + public function save(): bool + { + $parameters = []; + + $exists = $this->exists(); + // Insert new Model + if (!$exists) { + $query = "INSERT INTO `$this->table` "; + for ($i = 0; $i < 2; $i++) { + if ($i == 0) { + $query .= '('; + } + else { + $query .= 'VALUES ('; + } + + foreach ($this->attributes as $key => $attribute) { + if ($key != 0) { + $query .= ', '; + } + + if ($i == 0) { + $query .= "`$attribute`"; + } + else { + $query .= ":$attribute"; + $parameters[] = [":$attribute", $this->{$attribute}]; + } + } + $query .= ') '; + } + } + // Update existing Model + else { + $query = "UPDATE `$this->table` SET "; + foreach ($this->attributes as $key => $attribute) { + if ($key != 0) { + $query .= ', '; + } + + $query .= "`$attribute` = :$attribute"; + $parameters[] = [":$attribute", $this->{$attribute}]; + } + $query .= " WHERE `$this->primaryKey` = :id"; + $parameters[] = [':id', $this->{$this->primaryKey}]; + } + + if (self::query($query, $parameters) === null) { + return false; + } + + // Fill in primary key and exists for newly created Models + if (!$exists) { + $id = self::query("SELECT LAST_INSERT_ID() as `$this->primaryKey`"); + if (_exists($id)) { + $this->{$this->primaryKey} = $id[0][$this->primaryKey]; + $this->exists = true; + } + } + + return true; + } + + public function delete(): bool + { + return self::query( + "DELETE FROM `$this->table` WHERE `$this->primaryKey` = :id", [ + [':id', $this->{$this->primaryKey}], + ] + ) !== null; + } + + public function count(): int + { + $total = self::query( + "SELECT COUNT({$this->primaryKey}) as total FROM `$this->table`"); + return $total[0]['total'] ?? 0; + } + + //-------------------------------------// + + public function fill(array $fill): bool + { + if (!_exists([$fill])) { + return false; + } + + // Set primary key + if (_exists($fill, $this->primaryKey)) { + $this->{$this->primaryKey} = $fill[$this->primaryKey]; + } + + // Set other attributes + foreach ($this->getAttributes() as $attribute) { + if (_exists($fill, $attribute) || + (isset($fill[$attribute]) && $fill[$attribute] === '0')) { + // Escape sequences are only interpreted with double quotes! + $this->{$attribute} = preg_replace('/\r\n?/', "\n", $fill[$attribute]); + } + } + + return true; + } + + public function getPrimaryKey(): string + { + return $this->primaryKey; + } + + public function getAttributes(): array + { + return $this->attributes; + } + + public function getAttributesRules(): array + { + + // Check if model has set rules + if (!_exists($this->rules) || !is_array($this->rules)) { + $rules = []; + } + else { + $rules = $this->rules; + } + + // Get first column (name) of every rule + $rulesSearch = array_column($rules, 0); + + // Loop through attributes + foreach ($this->attributes as $attribute) { + $found = array_search($attribute, $rulesSearch); + + if ($found === false) { + // Define default ruleset + $rules[] = [$attribute, "text", 0, 0]; + // Name | Type | Required | Filtered + // Type can be: + // - text + // - textarea + // - checkbox + // - dropdown // @Todo: store dropdown data + } + } + + return $rules; + } + + // Placeholder for generating the dropdown data + public function getDropdownData(string $type): array + { + return []; + } + + public function getSort(): string + { + return is_array($this->sort) + ? '`' . implode('`, `', $this->sort) . '`' + : '`' . $this->sort . '`'; + } + + //-------------------------------------// + + public static function select(string $select = '*', string $filter = '', + array $parameters = [], $type = ':'): Model + { + $class = get_called_class(); + $model = new $class; + + $select = self::query("SELECT $select FROM `$model->table` $filter", + $parameters, $type); + if (_exists($select)) { + $model->fill($select[0]); + } + + return $model; + } + + // $media = MediaModel::selectAll( + // '*', 'ORDER BY id DESC LIMIT :offset, :limit', [ + // [':offset', $offset, \PDO::PARAM_INT], + // [':limit', $limit, \PDO::PARAM_INT], + // ] + // ); + // + // $contents = ContentModel::selectAll( + // '*', 'WHERE id IN (?, ?, ?)', [1, 2, 3], '?' + // ); + public static function selectAll(string $select = '*', string $filter = '', + array $parameters = [], $type = ':'): ?array + { + $class = get_called_class(); + $model = new $class; + + return self::query("SELECT $select FROM `$model->table` $filter", + $parameters, $type); + } + + public static function find(int $id): Model + { + $class = get_called_class(); + $model = new $class; + + $find = self::query("SELECT * FROM `$model->table` WHERE `$model->primaryKey` = :id", [ + [':id', $id], + ]); + if (_exists($find)) { + $model->fill($find[0]); + } + + return $model; + } + + // @Todo: add field name to query -> update Media.php md5 + public static function findAll(array $id = []): ?array + { + if (_exists($id)) { + $id = array_values(array_unique($id)); + $in = str_repeat('?, ', count($id) - 1) . '?'; + array_push($id, ...$id); + return self::selectAll( + '*', "WHERE id IN ($in) ORDER BY FIELD(id, $in)", $id, '?'); + } + else { + return self::selectAll(); + } + } + + public static function findOrFail(int $id): Model + { + $model = self::find($id); + if (!$model->exists()) { + throw new \Exception('Could not find Model!'); + } + + return $model; + } + + public static function search(array $constraints, string $delimiter = 'AND'): Model + { + $parameters = []; + + $filter = "WHERE "; + foreach (array_keys($constraints) as $key => $constraint) { + if ($key != 0) { + $filter .= " $delimiter "; + } + + $filter .= "`$constraint` = :$constraint"; + $parameters[] = [":$constraint", $constraints[$constraint]]; + } + + return self::select('*', $filter, $parameters); + } + + public static function destroy(int $id): bool + { + $model = self::find($id); + if ($model->exists()) { + return $model->delete(); + } + + return false; + } + + /** + * Load Model data: all, with a limit or pagination + * + * @param int $limitOrPage Treated as page if $limit is provided, limit otherwise + * @param int $limit The amount to limit by + * + * @return array|null The found model data, or null + */ + public static function all(int $limitOrPage = -1, int $limit = -1): ?array { + $class = get_called_class(); + $model = new $class; + + $filter = ''; + $parameters = []; + + // If the user wants to paginate + if ($limitOrPage >= 1 && $limit >= 0) { + // Pagination calculation + $page = ($limitOrPage - 1) * $limit; + + $filter = 'LIMIT :page, :limit'; + $parameters[] = [':page', $page, \PDO::PARAM_INT]; + $parameters[] = [':limit', $limit, \PDO::PARAM_INT]; + } + // If the user wants an offset + else if ($limitOrPage >= 0) { + $filter = 'LIMIT :limit'; + $parameters[] = [':limit', $limitOrPage, \PDO::PARAM_INT]; + } + + return $model->selectAll( + '*', "ORDER BY {$model->getSort()} ASC $filter", + $parameters + ); + } + + + /** + * Retreive Model, or instantiate + * Usage: $model = \App\Model\Example::firstOrNew(['name' => 'Example name']); + * + * @param $search Retrieve by + * @param $data Instantiate with search plus data + * + * @return Model The Model + */ + public static function firstOrNew(array $search, array $data = []): Model + { + $model = self::search($search); + + if (!$model->exists()) { + $class = get_called_class(); + $model = new $class; + $model->fill($search); + $model->fill($data); + } + + return $model; + } + + /** + * Create new Model + * Usage: $model = \App\Model\Example::create(['name' => 'Example name']); + * + * @param $data Create with this data + * + * @return Model The Model + */ + public static function create(array $data): Model { + $class = get_called_class(); + $model = new $class; + $model->fill($data); + $model->save(); + return $model; + } + + /** + * Retreive Model, or create + * Usage: $model = \App\Model\Example::firstOrCreate(['name' => 'Example name']); + * + * @param $search Retrieve by + * @param $data Instantiate with search plus data + * + * @return Model The Model + */ + public static function firstOrCreate(array $search, array $data = []): Model { + $model = self::firstOrNew($search, $data); + + if (!$model->exists()) { + $model->save(); + } + + return $model; + } + + // // Update existing Model, or create it if doesn't exist + // public static function updateOrCreate(array $data, array $data): Model { + // // $flight = App\Flight::updateOrCreate( + // // ['departure' => 'Oakland', 'destination' => 'San Diego'], + // // ['price' => 99] + // // ); + // return new Model; + // } + +} + +// @Todo +// - Generate rules from database table +// - Make count work without 'this' context diff --git a/app/model/PageHasContentModel.php b/app/model/PageHasContentModel.php new file mode 100644 index 0000000..b61642c --- /dev/null +++ b/app/model/PageHasContentModel.php @@ -0,0 +1,68 @@ +dropdownPage(); + } + else if ($type == 'content_id') { + return $this->dropdownContent(); + } + + return []; + } + + //-------------------------------------// + + protected function dropdownPage(): array + { + $pages = PageModel::selectAll( + '*', "WHERE `active` = ? ORDER BY `title` ASC", [1], '?'); + + return [0 => 'Select page'] + array_combine( + array_column($pages, 'id'), + array_column($pages, 'title') + ); + } + + protected function dropdownContent(): array + { + $contents = ContentModel::selectAll( + '*', "WHERE `active` = ? ORDER BY `title` ASC", [1], '?'); + + // Exit if nothing was found + if (!_exists($contents)) { + return []; + } + + return [0 => 'Select content'] + array_combine( + array_column($contents, 'id'), + array_column($contents, 'title') + ); + } + +} diff --git a/app/model/PageModel.php b/app/model/PageModel.php new file mode 100644 index 0000000..a9e7bb3 --- /dev/null +++ b/app/model/PageModel.php @@ -0,0 +1,76 @@ +type = 0; + } + + // Generate the dropdown data + public function getDropdownData(string $type): array + { + if ($type == 'section_id') { + return $this->dropdownSection(); + } + + return []; + } + + public function delete(): bool + { + if (self::query( + "DELETE FROM `{$this->table}_has_content` WHERE `{$this->table}_$this->primaryKey` = :id", [ + [':id', $this->{$this->primaryKey}], + ] + ) === null) { + return false; + } + + return $this->deleteLog(); + } + + //-------------------------------------// + + protected function dropdownSection(): array + { + // Pull sections from cache + $sections = Db::getSections(); + + return [0 => 'Select section'] + array_combine( + array_column($sections, 'id'), + array_column($sections, 'title') + ); + } + +} diff --git a/app/model/SectionHasContentModel.php b/app/model/SectionHasContentModel.php new file mode 100644 index 0000000..a8df747 --- /dev/null +++ b/app/model/SectionHasContentModel.php @@ -0,0 +1,68 @@ +dropdownSection(); + } + else if ($type == 'content_id') { + return $this->dropdownContent(); + } + + return []; + } + + //-------------------------------------// + + protected function dropdownSection(): array + { + $sections = SectionModel::selectAll( + '*', "WHERE `active` = ? ORDER BY `title` ASC", [1], '?'); + + return [0 => 'Select section'] + array_combine( + array_column($sections, 'id'), + array_column($sections, 'title') + ); + } + + protected function dropdownContent(): array + { + $contents = ContentModel::selectAll( + '*', "WHERE `active` = ? ORDER BY `title` ASC", [1], '?'); + + // Exit if nothing was found + if (!_exists($contents)) { + return []; + } + + return [0 => 'Select content'] + array_combine( + array_column($contents, 'id'), + array_column($contents, 'title') + ); + } + +} diff --git a/app/model/SectionModel.php b/app/model/SectionModel.php new file mode 100644 index 0000000..a188024 --- /dev/null +++ b/app/model/SectionModel.php @@ -0,0 +1,24 @@ += 2) { + $query = Db::get()->prepare($query); + $query->execute(); + die(); +} + +// Users +//-------------------------------------// + +$users = [ + ['', '', '', '', '', '', '0', ''], // 1 + // ['', '', '', '', '', '', '', ''], +]; + +foreach ($users as $user) { + UserModel::firstOrCreate( + ['username' => $user[0]], + [ + 'email' => $user[1], + 'first_name' => $user[2], + 'last_name' => $user[3], + 'salt' => $user[4], + 'password' => $user[5], + 'failed_login_attempt' => $user[6], + 'reset_key' => $user[7], + ] + ); +} + +User::login($users[0][0], 'password', $users[0][6]); + +// Sections +//-------------------------------------// + +$sections = [ + ['home', 'Homepage', '1', '1', '1'], // 1 + // ['', '', '', '', ''], +]; + +foreach ($sections as $section) { + SectionModel::firstOrCreate( + ['section' => $section[0]], + [ + 'title' => $section[1], + 'order' => $section[2], + 'hide_navigation' => $section[3], + 'active' => $section[4], + ], + ); +} + +// Pages +//-------------------------------------// + +$pages = [ + ['home', 'Homepage', '', '', '1', '1', '0', '1', '1'], // 1 + // ['', '', '', '', '', '', '', '', ''], +]; + +foreach ($pages as $page) { + PageModel::firstOrCreate( + ['page' => $page[0]], + [ + 'title' => $page[1], + 'title_url' => $page[2], + 'meta_description' => $page[3], + 'type' => $page[4], + 'order' => $page[5], + 'hide_navigation' => $page[6], + 'active' => $page[7], + 'section_id' => $page[8], + ], + ); +} + +// Content +//-------------------------------------// + +$contents = [ + ['Homepage', 'home', '1', '1', '0', '1'], // 1 + // ['', '', '', '', '', ''], +]; + +foreach ($contents as $content) { + ContentModel::firstOrCreate( + ['title' => $content[0]], + [ + 'content' => $content[1], + 'type' => $content[2], + 'hide_title' => $content[3], + 'hide_background' => $content[4], + 'active' => $content[5], + ], + ); +} + +// PageHasContent +//-------------------------------------// + +$pageLinks = [ + // id, order, page_id, content_id + [ '1', '1', '1', '1'], + // ['', '', '', ''], +]; + +foreach ($pageLinks as $pageLink) { + PageHasContentModel::firstOrCreate( + ['id' => $pageLink[0]], + [ + 'order' => $pageLink[1], + 'page_id' => $pageLink[2], + 'content_id' => $pageLink[3], + ], + ); +} + +// SectionHasContent +//-------------------------------------// + +$sectionLinks = [ + // id, order, section_id, content_id + // ['1', '1', '1' '1'], + // ['', '', '', ''], +]; + +foreach ($sectionLinks as $sectionLink) { + SectionHasContentModel::firstOrCreate( + ['id' => $sectionLink[0]], + [ + 'order' => $sectionLink[1], + 'section_id' => $sectionLink[2], + 'content_id' => $sectionLink[3], + ], + ); +} diff --git a/app/traits/Log.php b/app/traits/Log.php new file mode 100644 index 0000000..99de001 --- /dev/null +++ b/app/traits/Log.php @@ -0,0 +1,68 @@ +log_id])) { + $log = new LogModel; + $log->{self::CREATED_AT} = $date; + $log->{self::UPDATED_AT} = $date; + $log->user_id = User::getSession()['id']; + if (!$log->save()) { + return false; + } + + // Add log to new model + $this->log_id = $log->{$log->primaryKey}; + } + // Update log + else { + $log = LogModel::findOrFail($this->log_id); + $log->{self::UPDATED_AT} = $date; + } + + // Save model + if (!parent::save()) { + // Clean up the log if model creation failed + if (!_exists([$this->{$this->primaryKey}])) { + $log->delete(); + } + + return false; + } + + // Update table and user ID + $log->table_name = $this->table; + $log->table_id = $this->{$this->primaryKey}; + $log->user_id = User::getSession()['id']; + return $log->save(); + } + + public function delete(): bool + { + // Exit if there is no log + if (!_exists([$this->log_id])) { + return false; + } + + $log = $this->log_id; + + // Delete model + if (!parent::delete()) { + return false; + } + + // Delete log + return LogModel::destroy($log); + } +} diff --git a/app/views/admin/crud/create.php b/app/views/admin/crud/create.php new file mode 100644 index 0000000..ac45fb1 --- /dev/null +++ b/app/views/admin/crud/create.php @@ -0,0 +1,62 @@ +
+
+
+ partial('../app/views/partials/message.php'); ?> + +

Create

+ +
+attributes as $key => $attribute) { ?> + +
+
+ + + + + name="" + placeholder=""> + + + + + + + + + + + + + + + +
+ + + + +
+ +
+
+
+
diff --git a/app/views/admin/crud/edit.php b/app/views/admin/crud/edit.php new file mode 100644 index 0000000..2f40bf0 --- /dev/null +++ b/app/views/admin/crud/edit.php @@ -0,0 +1,66 @@ +
+
+
+ partial('../app/views/partials/message.php'); ?> + +

Edit

+ +
+attributes as $key => $attribute) { ?> + +
+
+ + + + + name="" + placeholder="" + value="escape)($this->model->$name); ?>"> + + + + + + + + + model->$name == '1' ? 'checked' : ''; ?>> + + + + + + +
+ + +
+ + +
+
+
+
diff --git a/app/views/admin/crud/index.php b/app/views/admin/crud/index.php new file mode 100644 index 0000000..3c6074c --- /dev/null +++ b/app/views/admin/crud/index.php @@ -0,0 +1,71 @@ +
+
+
+ partial('../app/views/partials/message.php'); ?> + +

title; ?>

+ + + + + +attributes as $attribute) { ?> + + + + + + + +rows as $key => $row) { ?> + + + attributes as $attribute) { ?> + + + + + + + +
#
+ + + + + + + + + escape)(substr($value, 0, 47)); ?> + 47 ? '...' : ''; ?> + + + + + + + + +
+ + partial('../app/views/partials/pagination.php'); ?> + +
+ +
+ +
+
+
+
diff --git a/app/views/admin/crud/show.php b/app/views/admin/crud/show.php new file mode 100644 index 0000000..8ff0ed2 --- /dev/null +++ b/app/views/admin/crud/show.php @@ -0,0 +1,40 @@ +
+
+
+

model->title]) ? ($this->escape)($this->model->title) : 'Show'; ?>

+ + + + + + + + + +attributes as $attribute) { ?> + + + + + + + +
ColumnValue
+ model->{$attribute[0]}; ?> + + + + escape)($value); ?> + +
+ +
+
+
+
diff --git a/app/views/admin/index.php b/app/views/admin/index.php new file mode 100644 index 0000000..adde72f --- /dev/null +++ b/app/views/admin/index.php @@ -0,0 +1,13 @@ +
+
+

+ Welcome back, +

+

+ user->first_name; ?> + user->last_name) ? ' ' . $this->user->last_name : ''; ?> +

+
+ +
+
diff --git a/app/views/admin/media.php b/app/views/admin/media.php new file mode 100644 index 0000000..3ae08b2 --- /dev/null +++ b/app/views/admin/media.php @@ -0,0 +1,90 @@ +
+
+
+ partial('../app/views/partials/message.php'); ?> + +

Media file manager

+ + + + + + + + + + +media as $media) { ?> + + + + + + + + + + + + + + + + + +
ThumbnailURLUploaded byModifier
+ + <?= $filename; ?> + + No thumbnail available. + + + + fileUrl . $filename; ?> + + + log, 'id')); + $idx = array_search($this->log[$idx]['user_id'], array_column($this->user, 'id')); + echo $this->user[$idx]['username']; + ?> + + + + +
+ + + + + + + + + + +
+ + Drag files here + + +
+
+ + Overwrite + + +
+ + partial('../app/views/partials/pagination.php'); ?> + +
+
+
+
diff --git a/app/views/admin/syntax-highlighting.php b/app/views/admin/syntax-highlighting.php new file mode 100644 index 0000000..d0838e3 --- /dev/null +++ b/app/views/admin/syntax-highlighting.php @@ -0,0 +1,33 @@ +
+

Syntax Highlighting

+ +
+
+ + +
+ +
+
+ +
+ +
+ Highlight + Copy +
+ +
+ + +
+
diff --git a/app/views/content.php b/app/views/content.php new file mode 100644 index 0000000..a237d0c --- /dev/null +++ b/app/views/content.php @@ -0,0 +1,44 @@ +
+sideContent ? '8' : '12'; ?> +
+contents as $key => $content) { ?> + + +
+ contents)) { ?> + partial('../app/views/partials/message.php'); ?> + + + +

escape)($content['title']); ?>

+ + + + injectView != '') { ?> + partial($this->injectView); ?> + + + contents)) { ?> +
+ +
+ + + +
+ +sideContent) { ?> +
+ contents as $content) { ?> + + +
+

escape)($content['title']); ?>

+ +
+ + + +
+ +
diff --git a/app/views/errors/404.php b/app/views/errors/404.php new file mode 100644 index 0000000..65699bc --- /dev/null +++ b/app/views/errors/404.php @@ -0,0 +1,8 @@ +
+ + +
+
diff --git a/app/views/form.php b/app/views/form.php new file mode 100644 index 0000000..781781f --- /dev/null +++ b/app/views/form.php @@ -0,0 +1,96 @@ +
+ + + form->getFields() as $name => $field) { ?> + + + +
+ +
+ + + +
+ + + +
+ + + + $label) { ?> + + + {$name} == $value || (!isset($this->{$name}) && $radioCount == 0 && $field[0] == '') ? 'checked' : ''; ?> + + value=""> + + ' : ''; ?> + + + +
+ + + +
+ + + + + + + value="{$name}; ?>"> + + + + +
+ + + +
+ + +
+ + + +
+ + + + $label) { ?> + + + {$name} == $value ? 'checked' : ''; ?> + + value=""> +
+ + + +
+ + + + + + +

+ form->getReset()])) { ?> + + + +

+ + +
diff --git a/app/views/layouts/default.php b/app/views/layouts/default.php new file mode 100644 index 0000000..6674815 --- /dev/null +++ b/app/views/layouts/default.php @@ -0,0 +1,49 @@ + + + + + + + + + + + +adminSection) { ?> + + + + + + + + + + + + <?= ($this->escape)($this->pageTitle); ?><?= $this->pageTitle != '' ? ' - ' : '' ?>Rick van Vonderen + + + + partial('../app/views/partials/header.php'); ?> + +
+
+
+ yieldView(); ?> + partial('../app/views/partials/footer.php'); ?> +
+ loggedIn) { ?> + partial('../app/views/partials/admin.php'); ?> + +
+ +
+
+ + partial('../app/views/partials/script.php'); ?> + + diff --git a/app/views/login.php b/app/views/login.php new file mode 100644 index 0000000..ba59e29 --- /dev/null +++ b/app/views/login.php @@ -0,0 +1,27 @@ + + +
+ partial('../app/views/partials/message.php'); ?> + + + You're already logged in. Click to log out. + + redirectURL) { ?> + + + +

Sign in

+ partial($this->injectView); ?> +
+ Forgot password? + + +
+
diff --git a/app/views/partials/admin.php b/app/views/partials/admin.php new file mode 100644 index 0000000..533ee19 --- /dev/null +++ b/app/views/partials/admin.php @@ -0,0 +1,31 @@ +
+
+

Admin panel

+ +
+
Navigation
+ - Section +
+ - Page + +
+
Link
+ - SectionHasContent +
+ - PageHasContent + +
+
Content
+ - Content +
+ - Media +
+ - Syntax Highlighting + +
+ - Log out +
+
+
+ +
diff --git a/app/views/partials/footer.php b/app/views/partials/footer.php new file mode 100644 index 0000000..c32d852 --- /dev/null +++ b/app/views/partials/footer.php @@ -0,0 +1,11 @@ + + + diff --git a/app/views/partials/header.php b/app/views/partials/header.php new file mode 100644 index 0000000..5fe2033 --- /dev/null +++ b/app/views/partials/header.php @@ -0,0 +1,54 @@ + + + diff --git a/app/views/partials/message.php b/app/views/partials/message.php new file mode 100644 index 0000000..1752d3f --- /dev/null +++ b/app/views/partials/message.php @@ -0,0 +1,8 @@ +type != '') { ?> + + diff --git a/app/views/partials/pagination.php b/app/views/partials/pagination.php new file mode 100644 index 0000000..a23f195 --- /dev/null +++ b/app/views/partials/pagination.php @@ -0,0 +1,25 @@ +
+
+ +
+
diff --git a/app/views/partials/script.php b/app/views/partials/script.php new file mode 100644 index 0000000..78e5aeb --- /dev/null +++ b/app/views/partials/script.php @@ -0,0 +1,28 @@ + + + + +adminSection) { ?> + + + + + + + + + + + + + + + + + + + + + diff --git a/app/views/reset-password.php b/app/views/reset-password.php new file mode 100644 index 0000000..e749612 --- /dev/null +++ b/app/views/reset-password.php @@ -0,0 +1,14 @@ +
+ partial('../app/views/partials/message.php'); ?> + +newPassword) { ?> +

Reset password

+

Please fill in one of the fields.

+ +

Set new password

+ + + partial($this->injectView); ?> + +
+
diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..fc0f4cd --- /dev/null +++ b/composer.json @@ -0,0 +1,20 @@ +{ + "name": "riyyi/website", + "description": "Riyyi's website.", + "type": "project", + "autoload": { + "files": [ + "app/helper.php" + ], + "psr-4": { + "App\\Classes\\": "app/classes/", + "App\\Controllers\\": "app/controllers/", + "App\\Model\\": "app/model/", + "App\\Traits\\": "app/traits/" + } + }, + "require": { + "klein/klein": "^2.1", + "txthinking/mailer": "^2.0" + } +} diff --git a/config.php.example b/config.php.example new file mode 100644 index 0000000..12e6609 --- /dev/null +++ b/config.php.example @@ -0,0 +1,17 @@ + '', + 'APP_URL' => '', + + 'DB_HOST' => '', + 'DB_NAME' => '', + 'DB_USERNAME' => '', + 'DB_PASSWORD' => '', + + 'MAIL_HOST' => '', + 'MAIL_PORT' => '', + 'MAIL_NAME' => '', + 'MAIL_USERNAME' => '', + 'MAIL_PASSWORD' => '', +]; diff --git a/doc/db.mwb b/doc/db.mwb new file mode 100644 index 0000000..a385640 Binary files /dev/null and b/doc/db.mwb differ diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..49810a4 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,9 @@ +Options -MultiViews -Indexes +FollowSymLinks + +RewriteEngine On +RewriteBase / + +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d + +RewriteRule ^(.*)$ index.php/$1 [NC,L] diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..a342752 --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,117 @@ +/* General */ +/*----------------------------------------*/ + +html { + font-family: "Segoe UI", "DejaVu Sans", sans-serif; +} + +body { + background-color: #f4f4f4; +} + +table td.middle { + vertical-align: middle; +} + +/* Navigation */ +/*----------------------------------------*/ + +nav.shadow { + box-shadow: 0 .25rem .5rem rgba(0,0,0,.28) !important; +} + +/* Main content */ +/*----------------------------------------*/ + +.container { + margin: 56px auto 0 auto; +} + +.content { + position: relative; + /* height: calc(100% - 56px); */ + min-height: calc(100vh - 80px); + padding: 20px 20px 50px 20px; + + background-color: #fff; +} + +#home { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + text-align: center; +} + +.padding-border { + padding: 10px 15px 10px 15px; +} + +.padding-border-col { + padding: 10px 15px 0px 0px; +} + +.pointer { + cursor: pointer; +} + +/* Admin content */ +/*----------------------------------------*/ + +.admin-toggle { + left: auto; + margin: 10px 15px; + padding: 5px 10px; + border-radius: 5px; + + color: #ffffff; +} + +.admin-menu { + position: fixed; + top: 0; + right: 0; + padding-right: 0px; + z-index: 1030; +} + +.admin-content { + min-height: 100vh; +} + +.admin-hidden { + position: absolute; + top: 0; + left: -9999px; +} + +.admin-upload-dragover { + background-color: rgba(0,0,0,0.1); +} + +/* Cursor color in normal mode */ +.CodeMirror.cm-fat-cursor .CodeMirror-cursor { + background: white; +} + +/* Cursor color in visual mode */ +.CodeMirror .cm-animate-fat-cursor { + background-color: white; + animation: none !important; +} + +/* Footer */ +/*----------------------------------------*/ + +footer { + position: absolute; + bottom: 0; + width: calc(100% - 30px); + height: 50px; + padding-top: 10px; + + text-align: center; + color: #909090; +} diff --git a/public/fonts/captcha.otf b/public/fonts/captcha.otf new file mode 100644 index 0000000..bd60081 Binary files /dev/null and b/public/fonts/captcha.otf differ diff --git a/public/fonts/fontawesome-webfont.eot b/public/fonts/fontawesome-webfont.eot new file mode 100644 index 0000000..e9f60ca Binary files /dev/null and b/public/fonts/fontawesome-webfont.eot differ diff --git a/public/fonts/fontawesome-webfont.svg b/public/fonts/fontawesome-webfont.svg new file mode 100644 index 0000000..855c845 --- /dev/null +++ b/public/fonts/fontawesome-webfont.svg @@ -0,0 +1,2671 @@ + + + + +Created by FontForge 20120731 at Mon Oct 24 17:37:40 2016 + By ,,, +Copyright Dave Gandy 2016. All rights reserved. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/fonts/fontawesome-webfont.ttf b/public/fonts/fontawesome-webfont.ttf new file mode 100644 index 0000000..35acda2 Binary files /dev/null and b/public/fonts/fontawesome-webfont.ttf differ diff --git a/public/fonts/fontawesome-webfont.woff b/public/fonts/fontawesome-webfont.woff new file mode 100644 index 0000000..400014a Binary files /dev/null and b/public/fonts/fontawesome-webfont.woff differ diff --git a/public/fonts/fontawesome-webfont.woff2 b/public/fonts/fontawesome-webfont.woff2 new file mode 100644 index 0000000..4d13fc6 Binary files /dev/null and b/public/fonts/fontawesome-webfont.woff2 differ diff --git a/public/img/favicon.png b/public/img/favicon.png new file mode 100644 index 0000000..25b4247 Binary files /dev/null and b/public/img/favicon.png differ diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..b622e2c --- /dev/null +++ b/public/index.php @@ -0,0 +1,10 @@ + { fileNames += file.name + '\n'; }); + element.prop('title', fileNames); + } + } + +//------------------------------------------ + + // CodeMirror editor + var editor; + var editorOptions = { + lineSeparator: '\n', + theme: 'tomorrow-night-eighties', + mode: 'text/html', + indentUnit: 4, + lineNumbers: true, + lineWrapping: true, + cursorBlinkRate: 0, + keyMap: 'vim', + }; + + // Copy editor selection to clipboard on + function registerEditorCopy() { + editor.on('vim-keypress', function(e) { + if (e === '' && editor.state.vim.visualMode === true) { + document.execCommand("copy"); + } + }); + } + + // Enable CodeMirror Editor + $.fn.codeMirror = function() { + editor = CodeMirror.fromTextArea($(this)[0], editorOptions); + editor.setSize('100%', '500'); + registerEditorCopy(); + + return editor; + } + $('textarea#syntax-code').codeMirror().setOption('mode', 'text/x-csrc'); + + // Enable WYSIWYG Editor + $('textarea#summernote').summernote({ + tabDisable: true, + tabsize: 4, + minHeight: 450, + maxHeight: null, + focus: true, + fontNames: [ + 'Helvetica', 'Arial', 'Verdana', 'Trebuchet MS', 'sans-serif', + 'Georgia', 'Times New Roman', 'Courier New', 'monospace' + ], + fontNamesIgnoreCheck: [], + toolbar: [ + ['style', ['style']], + ['font', ['bold', 'underline', 'italic', 'strikethrough', 'clear']], + ['fontname', ['fontname']], + ['color', ['color']], + ['para', ['ul', 'ol', 'paragraph']], + ['table', ['table']], + ['insert', ['link', 'picture', 'video']], + ['view', ['fullscreen', 'codeview', 'help']], + ], + callbacks: { + onInit: function() { + $("#summernote").summernote('codeview.activate'); + + editor = $('.CodeMirror')[0].CodeMirror; + registerEditorCopy(); + } + }, + prettifyHtml: false, + codemirror: editorOptions, + }); + + var syntaxLanguages = { + 'c': 'text/x-csrc', + 'cpp': 'text/x-c++src', + 'css': 'text/css', + 'html': 'text/html', + 'javascript': 'text/javascript', + 'php': 'application/x-httpd-php', + 'python': 'text/x-python', + 'shell': 'text/x-sh', + }; + + // Syntax Language selection + $('#syntax-language').on('change', function() { + // Set the editor language mode + editor.setOption('mode', syntaxLanguages[this.value]); + + // Set the language class + var parse = $('code'); + parse.removeClass().addClass('language-' + this.value); + }) + .trigger('change'); + + // Syntax Highlight + $('#syntax-highlight').on('click', function() { + // Set the code + var parse = $('code'); + parse.text(editor.getValue()); + + // Highlight the DOM element + Prism.highlightAll(); + }); + + // Copy highlighted syntax to the clipboard + $('#syntax-copy').on('click', function() { + // Copy text into hidden textarea + var parse = $('div#syntax-parse').html(); + var copy = $('textarea#syntax-parse-copy'); + copy.val(parse); + + // Select the text field + copy = copy[0]; + copy.select(); + copy.setSelectionRange(0, copy.value.length); + + // Copy the text inside the field to the clipboard + document.execCommand("copy"); + + alert("Copied to the clipboard"); + }); + +//------------------------------------------ + + // Hotkeys + $(window).keydown(function(e) { + + // Save form + if (e.ctrlKey && e.keyCode == 83) { // Ctrl + S + e.preventDefault(); + + // Codeview needs to be deactived before saving + $('#summernote').summernote('codeview.deactivate'); + + $('.js-edit').click(); + } + + // Toggle codeview + if (e.ctrlKey && e.keyCode == 71) { // Ctrl + G + e.preventDefault(); + + $('#summernote').summernote('codeview.toggle'); + } + + }); + +}); + +// @Todo +// - Look at converting .ajax() into the JS fetch API (native) diff --git a/public/js/site.js b/public/js/site.js new file mode 100644 index 0000000..68f6991 --- /dev/null +++ b/public/js/site.js @@ -0,0 +1,83 @@ +$(document).ready(function() { + + //------------------------------------------// + + $.fn.inViewport = function() { + var elementTop = $(this).offset().top; + var elementBottom = elementTop + $(this).outerHeight(); + + var viewportTop = $(window).scrollTop(); + var viewportBottom = viewportTop + $(window).height(); + + return elementBottom > viewportTop && elementTop < viewportBottom; + } + + //------------------------------------------// + + // Image hover mouseenter + $(".js-img-hover").mouseenter(function(event) { + var width = $(window).width() - event.clientX - 1 + "px"; + if (!$("#isMobile").is(":hidden")) { + width = "100%"; + } + var url = $(this).data("img-hover"); + var divImg = $("
" + + "
"); + divImg.appendTo("body"); + }); + + // Image hover mouseleave + $(".js-img-hover").mouseleave(function() { + $("#img-hover").remove(); + }); + + // Lazy load video's + $(window).on('resize scroll', function() { + $('.js-video').each(function(index) { + if (!$(this).inViewport()) { + return true; + } + + $(this).find('source').each(function() { + var source = $(this).attr('data-src'); + if (source === undefined) { + return true; + } + + $(this).attr("src", source); + var video = this.parentElement; + video.load(); + video.play(); + $(this).removeAttr('data-src'); + }); + }); + }); + $(window).trigger('scroll'); + + // Only 1 field is required + var $inputs = $('input[name=reset-password-username],input[name=reset-password-email]'); + $inputs.on('input', function() { + // Set the required property of the other input to false if this input is not empty. + $inputs.not(this).prop('required', !$(this).val().length); + }); + + // List toggle + $('.js-toggle').click(function() { + $(this).next("ul").toggle(400); + }); + + // Admin panel toggle + $('.js-admin-toggle').click(function() { + $.get('/admin/toggle').done(function(data) { + if (data == '1') { + $('.js-admin-menu').removeClass('d-none').addClass('d-block'); + $('.js-main-content').removeClass('col-12').addClass('col-9'); + } + else if (data == '0') { + $('.js-admin-menu').removeClass('d-block').addClass('d-none'); + $('.js-main-content').removeClass('col-9').addClass('col-12'); + } + }); + }); + +}); diff --git a/public/media/.gitinclude b/public/media/.gitinclude new file mode 100644 index 0000000..e69de29 diff --git a/route.php b/route.php new file mode 100644 index 0000000..c8163a9 --- /dev/null +++ b/route.php @@ -0,0 +1,26 @@ +