summaryrefslogtreecommitdiff
path: root/public/app/controllers
diff options
context:
space:
mode:
Diffstat (limited to 'public/app/controllers')
-rw-r--r--public/app/controllers/.gitkeep0
-rw-r--r--public/app/controllers/Admin.php58
-rw-r--r--public/app/controllers/Auth.php382
-rw-r--r--public/app/controllers/admin/.gitkeep0
-rw-r--r--public/app/controllers/admin/Course_admin.php676
-rw-r--r--public/app/controllers/admin/Users_admin.php42
-rw-r--r--public/app/controllers/api/.gitkeep0
-rw-r--r--public/app/controllers/api/Common_api.php281
-rw-r--r--public/app/controllers/api/Doc_api.php34
-rw-r--r--public/app/controllers/cli/CliContoller.php55
-rw-r--r--public/app/controllers/cms/Course.php252
11 files changed, 1780 insertions, 0 deletions
diff --git a/public/app/controllers/.gitkeep b/public/app/controllers/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/public/app/controllers/.gitkeep
diff --git a/public/app/controllers/Admin.php b/public/app/controllers/Admin.php
new file mode 100644
index 0000000..6f6858c
--- /dev/null
+++ b/public/app/controllers/Admin.php
@@ -0,0 +1,58 @@
+<?php
+namespace app\controllers;
+
+use Registry;
+use Render;
+
+// user classes and models
+use app\extends\Classroom_user;
+use app\extends\Classroom_manager;
+use app\models\admin\User_model;
+
+use app\extends\Send_mail;
+use app\extends\Mail_jet;
+
+
+/** class Auth
+ *
+ * handles user's Authentication and Authorizarion
+ *
+ */
+class Admin {
+
+ /** admin cateogories
+ *
+ */
+ public static function categories()
+ {
+ Render::view('admin/categories');
+ }
+
+
+ /** admin lessons
+ *
+ */
+ public static function lessons()
+ {
+
+ }
+
+
+ /** admin pages
+ *
+ */
+ public static function pages()
+ {
+
+ }
+
+ /** admin users
+ *
+ */
+ public static function users()
+ {
+
+ }
+
+
+} \ No newline at end of file
diff --git a/public/app/controllers/Auth.php b/public/app/controllers/Auth.php
new file mode 100644
index 0000000..4a6b9ed
--- /dev/null
+++ b/public/app/controllers/Auth.php
@@ -0,0 +1,382 @@
+<?php
+namespace app\controllers;
+
+use Registry;
+use Render;
+
+// user classes and models
+use app\extends\Classroom_user;
+use app\extends\Classroom_manager;
+use app\models\admin\User_model;
+
+use app\extends\Send_mail;
+use app\extends\Mail_jet;
+
+
+/** class Auth
+ *
+ * handles user's Authentication and Authorizarion
+ *
+ */
+class Auth {
+
+ /** login
+ *
+ * checks visitor's credentials;
+ * if valid, authenticates user
+ *
+ */
+ public static function login()
+ {
+ $req = Registry::get('REQUEST');
+
+ // get the record of the target user
+ $record = User_model::checkUser($req->POST['email']);
+
+ // if no user exists, return false
+ if ($record === false) return false;
+
+ // user is valid; check user password
+ // create a user object
+ $user = (new Classroom_user())
+ ->setID($record['id'])
+ ->setUserName($record['email'])
+ ->setName($record['first_name'] .' '. $record['last_name'])
+ ->setPassword($record['password'])
+ ->setEnabled($record['active']);
+
+ // let user manager to validate user credentials
+ $userManager = new Classroom_manager();
+
+ if ($userManager->isPasswordValid($user, $req->POST['password'])) {
+
+ // get user's security attributes
+ $attributes = User_Model::getUser($record['id']);
+ $roles = json_decode($attributes['Roles_json']);
+ $user
+ ->setRoles($roles)
+ ->setPrivileges(
+ array_merge(
+ json_decode($attributes['RootPrivileges_json']),
+ self::merge_lists_array(
+ json_decode($attributes['SubPrivileges_json'])
+ )
+ )
+ );
+
+ // regeneration session ID (prevent session fixation)
+ session_regenerate_id();
+ // set cookie for connected user
+ setcookie(
+ 'cluser',
+ 'connected;'. $user->getName(),
+ time()+60*60*8, // 8 hours
+ '/'
+ );
+
+ // check if admin (and redirect differently)
+ $is_admin = (!empty(array_intersect([1,2,3], $roles)));
+
+ // login OK, set Token in session
+ $userManager->createUserToken($user);
+ return [
+ 'success' => true,
+ 'goto' => $is_admin ? '/admin/lessons' : '/user/profile',
+ ];
+
+ } else {
+ return false;
+ }
+ }
+
+
+ /**
+ * merges an array of lists to one list
+ */
+ private static function merge_lists_array( $list )
+ {
+ $current = [];
+ foreach($list as $sublist) {
+ $current = array_merge($current, $sublist);
+ }
+ return $current;
+ }
+
+
+ /** activate
+ * resolves a call like: /account/activate?ticket=ca42d68cfba5fbbafeacc010b8e3a551
+ */
+ public static function activate()
+ {
+ $req = Registry::get('REQUEST');
+
+ // get the record of the target user
+ $check = User_model::activate($req->GET['ticket']);
+
+ if ($check == true) {
+ Render::view('/error/general', [
+ 'title' => ACCOUNT_ACTIVATED_TITLE,
+ 'message' => ACCOUNT_ACTIVATED_MESSAGE
+ ]);
+
+ } else {
+ Render::view('/error/general', [
+ 'title' => NOT_VALID_ACTIVATION_TITLE,
+ 'message' => NOT_VALID_ACTIVATION_MESSAGE
+ ]);
+ }
+
+ }
+
+ /** register
+ *
+ * Method for new user registration
+ *
+ */
+ public static function register()
+ {
+ $userManager = new Classroom_manager();
+ $req = Registry::get('REQUEST');
+
+ // create a salted password hash
+ $password = $userManager->cryptPassword($req->POST['password']);
+
+ // echo $password; print_r($req->POST); die(); // OK!
+
+ $user = (new Classroom_user())
+ ->setUserName($req->POST['email'])
+ ->setName($req->POST['name'] .' '. $req->POST['surname'])
+ ->setPassword($password)
+ ->setRoles([ READER ]) // Role: authorized reader
+ ->setPrivileges([]); // none privilege until acount confirmation
+
+ // create user record
+ $activation_code = User_model::registerUser($req->POST, $password);
+
+ // TODO:
+ // handle error on user registration
+ // ...
+ //
+ // if ($activatopn_code[] == -1) {
+ // return [
+ // 'success' => false,
+ // 'message' => REGISTRATION_USER_EXISTS
+ // ];
+ // }
+
+ $send_mail = Send_mail::send_activation_code([
+ 'email' => $req->POST['email'],
+ 'name' => $req->POST['name'] .' '. $req->POST['surname'],
+ 'code' => $activation_code['activation']
+ ]);
+
+ // Send replies
+ if ($send_mail) {
+ return [
+ 'success' => true,
+ 'message' => REGISTRATION_SUCCESS
+ ];
+
+ } else {
+ return [
+ 'success' => false,
+ 'message' => 'error on sending email'
+ ];
+ }
+
+ }
+
+ /** is_connected
+ * checks if the user is connected
+ *
+ * @return true|false
+ */
+ public static function is_connected()
+ {
+ $manager = new Classroom_manager();
+ if ($manager->hasUserToken()) {
+
+ // user is connected;
+ $token = $manager->getUserToken();
+ $user = $token->getUser();
+
+ return $user;
+
+ } else {
+ // user is not connected;
+ return false;
+ }
+ }
+
+
+ /** logout
+ *
+ * performs a secure logout;
+ * regenerates session; deletes cookies;
+ */
+ public static function logout()
+ {
+ $userManager = new Classroom_manager();
+ $userManager->logout();
+
+ // regeneration session ID (prevent session fixation)
+ session_regenerate_id();
+
+ // remove user-conected cookie
+ if (isset($_COOKIE['cluser'])) {
+ unset($_COOKIE['cluser']);
+ setcookie('cluser', '', -1, '/');
+ return true;
+
+ } else {
+ return false;
+ }
+ }
+
+
+
+ /** hasPermition( PERMIT )
+ *
+ * checks if the user owns the specified permition
+ * to access the source
+ *
+ */
+ public static function hasPermition($permit = [0])
+ {
+ if (in_array(0, $permit)) { // permision 0 means public
+ return true; // permision 0 is always granted
+ }
+
+ if ($user = self::is_connected() === false) { // if not connected
+ return false; // then no other permition is granted
+ }
+
+ if ($user instanceof UserInterface) {
+ return ( !empty( array_intersect($permit, $user->getPrivileges()) ) );
+ }
+ }
+
+
+ /** isAuthenticated()
+ *
+ * chechs if the user's roles and permitions
+ * satisfy the specified requirements
+ * to access the source
+ *
+ * @param $requirements (array of rules-array)
+ *
+ * example:
+ * [
+ * [
+ * role => [2, 3]
+ * permition => ['10', '12', '18']
+ * ],
+ * [
+ * role => [1 , 4]
+ * ],
+ * [
+ * permition => [ 3 ]
+ * ]
+ * ]
+ *
+ * defines (and parses to) a requirements rule of:
+ * [
+ * user should be creator or editor
+ * _AND_ have permition 10 or 12 or 18
+ * ]
+ * OR
+ * [
+ * user should be an administrator or developer
+ * ]
+ * OR
+ * [
+ * user should have permition #3
+ * ]
+ *
+ *
+ */
+ public static function isAuthorized($requirements)
+ {
+ $authorized = false;
+ foreach($requirements as $required) {
+
+ if (isset($required['role'])) { // if a role is required
+ if ( (self::isGranted($required['role'])) // authorize both role
+ && (self::hasPermition($required['permition'] ?? [ 0 ])) ) { // and permition
+ // $authorized = true;
+ return true;
+ }
+
+ } else { // else, if not is not required
+ if (self::hasPermition($required['permition'] ?? [ 0 ])) { // authorize permition
+ // $authorized = true;
+ return true;
+ }
+ }
+ }
+ return $authorized;
+ }
+
+
+ public static function forgot_pass()
+ {
+ }
+
+
+
+ public static function validate_otp()
+ {
+ }
+
+
+ /** allowRoles
+ *
+ * method filters access for certain roles
+ * if user is not grented acces, a forbiden message is sent and app ends._
+ * otherwise the method returns true (app will continue)
+ *
+ * @param $allowed (array) : array of allowed roles
+ * @return true or die();
+ */
+ public static function allowRoles($allowed)
+ {
+ $manager = new Classroom_manager();
+ if ($manager->isGranted($allowed)) { // if valid, return true (continue)
+ return true;
+
+ } else { // no user, no access; die._
+ Render::view('error/general', [
+ 'title' => 'Forbidden',
+ 'message' => 'Access is forbidden'
+ ]);
+ die();
+ return false; // this line will never run
+ }
+ }
+
+
+ /** hasValidRole
+ * like allowRoles() but dowes not stop execution
+ *
+ * @return true|false
+ */
+ public static function hasValidRole($allowed)
+ {
+ $manager = new Classroom_manager();
+ return ($manager->isGranted($allowed));
+ }
+
+
+ public static function in_admin_group()
+ {
+ $manager = new Classroom_manager();
+ return ($manager->isGranted([1, 2, 3]));
+ }
+
+
+
+}
+
+
+// NOTE:
+// check: https://netcorecloud.com/tutorials/send-an-email-via-gmail-smtp-server-using-php/ \ No newline at end of file
diff --git a/public/app/controllers/admin/.gitkeep b/public/app/controllers/admin/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/public/app/controllers/admin/.gitkeep
diff --git a/public/app/controllers/admin/Course_admin.php b/public/app/controllers/admin/Course_admin.php
new file mode 100644
index 0000000..fcf38c9
--- /dev/null
+++ b/public/app/controllers/admin/Course_admin.php
@@ -0,0 +1,676 @@
+<?php
+
+namespace app\controllers\admin;
+
+use Registry;
+use Render;
+use app\models\cms\Course_model;
+use app\extends\Cache_service;
+
+class Course_admin {
+
+
+ ## -------------------------------------------------------------------------
+ ##
+ ## COURSE (category) METHODS
+ ##
+ ## -------------------------------------------------------------------------
+
+
+ /** breadcrumbs
+ * ---
+ * responds to ajax GET: /admin/api/categories
+ */
+ public static function breadcrumbs()
+ {
+ Render::json( Cache_service::courses_struct()['breadcrumbs'] );
+ }
+
+ /** add course
+ * ---
+ * @param void (get data from POST)
+ */
+ public static function add_course()
+ {
+ // insert new category; id = new category id
+ $id = Registry::use('database')->query(
+ "INSERT INTO course (parent_id, label) VALUES (:parent, :label)",
+ [
+ ':parent' => Registry::get('REQUEST')->POST['parent_id'],
+ ':label' => Registry::get('REQUEST')->POST['label']
+ ]
+ )->lastInsertID();
+
+ $cache = self::update_courses_cache(); // update category caches
+ return $cache['breadcrumbs']; // return
+ }
+
+
+ /** update course
+ * ---
+ * @param void (get data from POST)
+ */
+ public static function update_course()
+ {
+ Registry::use('database')->query(
+ "UPDATE course SET parent_id = :parent, label = :label
+ WHERE id = :id",
+ [
+ ':id' => Registry::get('REQUEST')->POST['id'],
+ ':parent' => Registry::get('REQUEST')->POST['parent_id'],
+ ':label' => Registry::get('REQUEST')->POST['label']
+ ]
+ );
+ $cache = self::update_courses_cache();
+ return $cache['breadcrumbs'];
+ }
+
+
+ /** update courses cache
+ * --- -- -- - - -
+ * Forces re-caching of courses tree
+ * Shall run if anything changes to courses
+ *
+ * @param void
+ * @return (array): ['tree' => ..., 'breadcrumbs' => ... ]
+ */
+ public static function update_courses_cache()
+ {
+ return Cache_service::courses_struct(PROXY_IGNORE_CACHE);
+ }
+
+
+ ## -------------------------------------------------------------------------
+ ##
+ ## LESSON METHODS
+ ##
+ ## -------------------------------------------------------------------------
+
+
+ /** all lessons
+ *
+ */
+ public static function all_lessons()
+ {
+ Render::json(Registry::use('database')->runQuery(
+ "SELECT lesson.*, lesson_privilege.privilege_id FROM lesson
+ LEFT JOIN lesson_privilege ON lesson_privilege.lesson_id = lesson.id",
+ []
+ ));
+ }
+
+
+ /** get_lesson
+ *
+ * @param $id (int) : lesson's id
+ */
+ public static function get_lesson($id)
+ {
+ // get (first) lesson with id = $id; render as json
+ Render::json(Registry::use('database')->query(
+ "SELECT lesson.*,
+ ( SELECT
+ CONCAT('[', GROUP_CONCAT(JSON_OBJECT(
+ 'id', media.id,
+ 'label', media.label,
+ 'type', media.type,
+ 'path', media.path )),
+ ']')
+ FROM media
+ LEFT JOIN lesson_media ON lesson_media.media_id = media.id
+ WHERE lesson_media.lesson_id = :id
+ ) AS medias_json,
+ lesson_privilege.privilege_id
+ FROM lesson
+ LEFT JOIN lesson_privilege ON lesson_privilege.lesson_id = lesson.id
+ WHERE lesson.id = :id",
+ [ ':id' => $id]
+ )->getFirst());
+ }
+
+
+ /** add_lesson
+ * insert a new lesson
+ *
+ * all parametres are passed via $_POST array
+ *
+ * POST @param title
+ * POST @param course_id
+ * POST @param intro
+ * POST @param body
+ * POST @param published
+ */
+ public static function add_lesson()
+ {
+ $post = Registry::get('REQUEST')->POST;
+
+ // insert lesson content into daabase
+ $id = Registry::use('database')->query(
+ "INSERT INTO lesson (title, course_id, intro, `body`, `status`)
+ VALUES (:title, :courseid, :intro, :body, :status)",
+ [
+ ':title' => $post['title'],
+ ':courseid' => $post['course_id'],
+ ':intro' => $post['intro'],
+ ':body' => $post['body'],
+ 'status' => $post['status']
+ ]
+ )->lastInsertID();
+
+ // set lesson privileges to database
+ Registry::use('database')->runQuery(
+ "INSERT INTO lesson_privilege (lesson_id, privilege_id)
+ VALUES (:lesson_id, :privilege_id)",
+ [
+ ':lesson_id' => $id,
+ ':privilege_id' => $post['privilege_id']
+ ]
+ );
+
+ if (isset($post['media'])) {
+ // update media's privileges; NOTE: media table
+ self::update_medias_privileges($post['media'], $post['privilege_id']);
+
+ // set lesson's media files; NOTE: lesson_media table
+ // ERROR: self::create_medias_for_lesson($post['media'], $id, $post['privilege_id']);
+ self::create_medias_for_lesson($post['media'], $id);
+
+ // recache media
+ Cache_service::files_attributes(PROXY_IGNORE_CACHE);
+ }
+
+ return true;
+ }
+
+
+ public static function update_lesson()
+ {
+ $post = Registry::get('REQUEST')->POST;
+
+ // print_r($post); die();
+
+ // update lesson's content
+ Registry::use('database')->query(
+ "UPDATE lesson
+ SET title = :title,
+ course_id = :courseid,
+ intro = :intro, `body` = :body,
+ `status` = :status
+ WHERE id = :id",
+ [
+ ':id' => $post['id'],
+ ':title' => $post['title'],
+ ':courseid' => $post['course_id'],
+ ':intro' => $post['intro'],
+ ':body' => $post['body'],
+ ':status' => $post['status']
+ ]
+ );
+
+ // update lesson's privileges
+ Registry::use('database')->runQuery(
+ "UPDATE lesson_privilege SET privilege_id = :privilege_id
+ WHERE lesson_id = :lesson_id",
+ [
+ ':lesson_id' => $post['id'],
+ ':privilege_id' => $post['privilege_id']
+ ]
+ );
+
+ if (isset($post['media'])) {
+ // update media's privileges
+ self::update_medias_privileges($post['media'], $post['privilege_id']);
+ }
+
+ // remove all old lesson's links to media files
+ Registry::use('database')->runQuery(
+ "DELETE from lesson_media WHERE lesson_id = :lesson",
+ [ ':lesson' => $post['id'] ]
+ );
+
+ if (isset($post['media'])) {
+ // update lesson's media files
+ // ERROR: self::create_medias_for_lesson($post['media'], $post['id'], $post['privilege_id']);
+ self::create_medias_for_lesson($post['media'], $post['id']);
+
+ // recache media
+ Cache_service::files_attributes(PROXY_IGNORE_CACHE);
+ }
+
+ return true;
+ }
+
+
+ ## -------------------------------------------------------------------------
+ ##
+ ## FILE METHODS
+ ##
+ ## -------------------------------------------------------------------------
+
+
+ /** files
+ * echo all files
+ *
+ * @return (array)
+ */
+ public static function files()
+ {
+ return Cache_service::files_attributes();
+ }
+
+
+ /** upload_file
+ * upload the file to the file system
+ *
+ * the method reads the POST and FILES array
+ * to retrieve all needed parametres
+ *
+ * FILES @param file
+ * POST @param folder : lesson's ID or somthing random
+ * POST @param reference : reference type
+ * POST @param
+ */
+ public static function upload_file()
+ {
+ $request = Registry::get('REQUEST');
+
+ $uploaded = self::upload_to_fs(); // upload file to file-system
+
+ if ($uploaded['success']) {
+
+ $media_id = self::define_media([ // define media in database; get id
+ 'title' => $request->POST['title'],
+ 'type' => $uploaded['type'],
+ 'path' => $uploaded['path']
+ ]);
+
+ Render::json([ // render results as json
+ 'success' => true,
+ 'id' => $media_id,
+ 'title' => $request->POST['title'],
+ 'path' => $uploaded['path'],
+ 'type' => $uploaded['type']
+ ]);
+
+ } else {
+ Render::json(['success' => false ]);
+ }
+ }
+
+
+ /** upload to fs
+ * upload file to File-System
+ *
+ * POST @param folder
+ * FILES @param file
+ */
+ private static function upload_to_fs()
+ {
+ $post = Registry::get('REQUEST')->POST;
+ $files = Registry::get('REQUEST')->FILES;
+
+
+ // Checks before uploading the file
+ ////////////////////////////////////////////////////////////////////////
+
+ // ** 1: file is upladed to temporary folder ---------------------------
+ if (! is_uploaded_file($files['file']['tmp_name'])) {
+ return ['success' => false]; // bye!
+ }
+
+ // ** 2: File belongs to the allowed MIME types ------------------------
+ $allowed_file_types = array(
+ 'application/pdf',
+ 'image/png', 'image/jpeg',
+ 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+ );
+ // Recomended MIME type checking via mime_content_type():
+ $mime_type = mime_content_type($files['file']['tmp_name']);
+ if (! in_array($mime_type, $allowed_file_types)) { // File type NOT allowed ...
+ return ['success' => false]; // bye!
+ }
+
+ $file_name = $files['file']['name'];
+ $file_type = $files['file']['type']; // do not take it for granted
+ $file_size = $files['file']['size'];
+ $file_tmp = $files['file']['tmp_name'];
+
+ $bare_name = pathinfo($file_name, PATHINFO_FILENAME);
+ $file_ext = pathinfo($file_name, PATHINFO_EXTENSION);
+
+
+ // ** 3: filename or size checks may be added --------------------------
+ if ($file_name == "") {
+ return ['success' => false]; // bye!
+ }
+
+
+ // READY to finaly save/upload the file to CDN /////////////////////////
+
+ $folder = MEDIA_STORAGE_ROOT . $post['folder'];
+ if (!file_exists($folder)) { // create folder if not exists
+ mkdir($folder, 0757, true);
+ }
+
+ // print_r([
+ // 'dir' => $folder,
+ // 'file' => $bare_name,
+ // 'ext' => $file_ext,
+ // 'type' => $file_type
+ // ]); die();
+
+ $relative_filename = $post['folder']
+ .'/'
+ . strtolower(self::clear_file_name($bare_name) .'.'. $file_ext);
+ $store_filename = MEDIA_STORAGE_ROOT . $relative_filename;
+
+ if (move_uploaded_file($files["file"]["tmp_name"], $store_filename)) {
+ return [
+ 'success' => true,
+ 'path' => $relative_filename,
+ 'type' => $mime_type
+ ];
+
+ } else { return ['success' => false]; }
+
+ }
+
+
+ /** clear_file_name
+ * replace greek characters and strip symbols
+ */
+ private static function clear_file_name($str)
+ {
+ $el = mb_split( "ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩαβγδεζηθικλμνξοπρστυφχψωάέήίόύώϊϋς ", "");
+ $en = str_split("ABGDEZHUIKLMNJOPRSTYFXCVabgdezhuiklmnjoprstyfxcvaehioyviys-");
+ $strip = str_split("!@#$%^&*()+~`[]{};'/<>?=\"");
+
+ return str_replace($strip, '', str_replace($el, $en, $str));
+ }
+
+
+ /** define_media
+ *
+ * create a record in media table
+ *
+ * @param $data (array): [title => , path => , type => mime-type]
+ * @return id (int): id of created media record
+ */
+ private static function define_media($data)
+ {
+ $request = Registry::get('REQUEST');
+
+ $media_id = Registry::use('database')->query(
+ "INSERT INTO media (label, `type`, `path`)
+ VALUES (:label, :mimetype, :filepath)",
+ [
+ 'label' => $data['title'],
+ 'mimetype' => $data['type'],
+ 'filepath' => $data['path']
+ ]
+ )->lastInsertID();
+ return $media_id;
+ }
+
+
+ /** create media for lesson
+ *
+ * links lesson to each media-file of the media `id`s array
+ *
+ * @param $media (array): a list of media-file `id`s
+ * @param $lesson_id (int)
+ */
+ private static function create_medias_for_lesson($medias, $lesson_id)
+ {
+ foreach($medias as $key => $medi) {
+ self::link_media_to_lesson($medi, $lesson_id); // link to lesson
+ }
+ return true;
+ }
+
+
+ /** create media for page
+ *
+ * links page to each media-file of the media `id`s array
+ *
+ * @param $media (array): a list of media-file `id`s
+ * @param $page_id (int)
+ */
+ private static function create_medias_for_page($medias, $page_id)
+ {
+ foreach($medias as $key => $medi) {
+ self::link_media_to_page($medi, $page_id); // link to page
+ }
+ return true;
+ }
+
+
+ /** link one media-file to a specific post
+ *
+ * NOTE:
+ * the method does not check if media is linked already
+ * so be sure that the pair of (media_id,post_id) not exist
+ *
+ * @param $media_id (int)
+ * @param $lesson_id (int)
+ */
+ private static function link_media_to_lesson( $media_id, $lesson_id )
+ {
+ Registry::use('database')->runQuery(
+ "INSERT INTO lesson_media (lesson_id, media_id)
+ VALUES (:lesson, :media)",
+ [
+ 'lesson' => $lesson_id,
+ 'media' => $media_id,
+ ]
+ );
+ return true;
+ }
+
+ /** link one media-file to a specific page
+ *
+ * NOTE:
+ * the method does not check if media is linked already
+ * so be sure that the pair of (media_id, page_id) not exist
+ *
+ * @param $media_id (int)
+ * @param $page_id (int)
+ */
+ private static function link_media_to_page( $media_id, $page_id )
+ {
+ Registry::use('database')->runQuery(
+ "INSERT INTO page_media (page_id, media_id)
+ VALUES (:page, :media)",
+ [
+ 'page' => $page_id,
+ 'media' => $media_id,
+ ]
+ );
+ return true;
+ }
+
+
+ /** update medias privileges
+ *
+ * UPDATE media SET privige WHERE IN {list}
+ *
+ * @param $medias (array) : array of media id(s)
+ * @param $privilege (int) : privilege id
+ * @return true (always)
+ */
+ private static function update_medias_privileges($medias, $privilege)
+ {
+ // create WHERE IN (LIST) holders and params for prepare statement
+ $params = [ ':privilege' => $privilege];
+ $holders = [];
+ foreach($medias as $key => $media) {
+ $params[':id'.$key] = $media;
+ $holders[] = ':id'.$key;
+ }
+ $list = '('. implode(',', $holders) .')';
+
+ // print_r(['params' => $params, 'list' => $list]); die();
+
+ Registry::use('database')->runQuery(
+ "UPDATE media SET privilege_id = :privilege
+ WHERE id IN {$list}",
+ $params
+ );
+
+ return true;
+ }
+
+
+
+
+
+
+ ## -------------------------------------------------------------------------
+ ##
+ ## PAGE METHODS
+ ##
+ ## -------------------------------------------------------------------------
+
+
+ /** all pages
+ *
+ */
+ public static function all_pages()
+ {
+ Render::json(Registry::use('database')->runQuery(
+ "SELECT * FROM page", []
+ ));
+ }
+
+
+ /** get_page
+ *
+ * @param $id (int) : page's id
+ */
+ public static function get_page($id)
+ {
+ // get (first) page with id = $id; render as json
+ Render::json(Registry::use('database')->query(
+ "SELECT page.*,
+ ( SELECT
+ CONCAT('[', GROUP_CONCAT(JSON_OBJECT(
+ 'id', media.id,
+ 'label', media.label,
+ 'type', media.type,
+ 'path', media.path )),
+ ']')
+ FROM media
+ LEFT JOIN page_media ON page_media.media_id = media.id
+ WHERE page_media.page_id = :id
+ ) AS medias_json
+ FROM page
+ WHERE page.id = :id",
+ [ ':id' => $id]
+ )->getFirst());
+ }
+
+
+ /** add_page
+ * insert a new page
+ *
+ * all parametres are passed via $_POST array
+ *
+ * POST @param title
+ * POST @param body
+ * POST @param status (int): 0 = draft , 1 = published
+ */
+ public static function add_page()
+ {
+ $post = Registry::get('REQUEST')->POST;
+
+ // insert page content into daabase
+ $id = Registry::use('database')->query(
+ "INSERT INTO page (title, `body`, `status`)
+ VALUES (:title, :body, :status)",
+ [
+ ':title' => $post['title'],
+ ':body' => $post['body'],
+ 'status' => $post['status']
+ ]
+ )->lastInsertID();
+
+
+ if (isset($post['media'])) {
+ // update media's privileges;
+ // NOTE: media table; pages have public files (privilege_id=0)
+ self::update_medias_privileges($post['media'], 0);
+
+ // set page's media files
+ // NOTE: page_media table
+ self::create_medias_for_page($post['media'], $id);
+ }
+
+ self::update_pages_cache(); // update pages cache
+
+ return true;
+ }
+
+
+ /** update_page
+ *
+ * POST @param id (int)
+ * POST @param title
+ * POST @param body
+ * POST @param status (int): 0 = draft , 1 = published
+ */
+ public static function update_page()
+ {
+ $post = Registry::get('REQUEST')->POST;
+
+ // print_r($post); die();
+
+ // update page's content
+ Registry::use('database')->query(
+ "UPDATE page
+ SET title = :title, `body` = :body, `status` = :status
+ WHERE id = :id",
+ [
+ ':id' => $post['id'],
+ ':title' => $post['title'],
+ ':body' => $post['body'],
+ ':status' => $post['status']
+ ]
+ );
+
+ if (isset($post['media'])) {
+ // update media's privileges
+ self::update_medias_privileges($post['media'], 0);
+ }
+
+ // remove all old page's links to media files
+ Registry::use('database')->runQuery(
+ "DELETE from page_media WHERE page_id = :page",
+ [ ':page' => $post['id'] ]
+ );
+
+ if (isset($post['media'])) {
+ // update page's media files
+ self::create_medias_for_page($post['media'], $post['id']);
+ }
+
+ self::update_pages_cache(); // update pages cache
+
+ return true;
+ }
+
+
+ /** update pages cache
+ * --- -- -- - - -
+ * Forces re-caching of pages tree
+ * Shall run if anything changes to courses
+ *
+ * @param void
+ * @return (array): ['tree' => ..., 'breadcrumbs' => ... ]
+ */
+ public static function update_pages_cache()
+ {
+ return Cache_service::pages_list(PROXY_IGNORE_CACHE);
+ }
+
+} \ No newline at end of file
diff --git a/public/app/controllers/admin/Users_admin.php b/public/app/controllers/admin/Users_admin.php
new file mode 100644
index 0000000..4e335c1
--- /dev/null
+++ b/public/app/controllers/admin/Users_admin.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace app\controllers\admin;
+
+use Registry;
+use Render;
+
+
+class Users_admin {
+
+ /** privileges
+ * ---
+ * get all privileges
+ *
+ * @param void
+ * @return privileges (array)
+ */
+ public static function privileges()
+ {
+ return Registry::use('database')->runQuery(
+ "SELECT * FROM privilege",[]
+ );
+ }
+
+ /** edit privileges
+ * ---
+ */
+ public static function edit_privileges()
+ {
+ // TODO: ...
+ }
+
+
+ /** update course
+ * ---
+ */
+ public static function update_course()
+ {
+ // TODO: ...
+ }
+
+} \ No newline at end of file
diff --git a/public/app/controllers/api/.gitkeep b/public/app/controllers/api/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/public/app/controllers/api/.gitkeep
diff --git a/public/app/controllers/api/Common_api.php b/public/app/controllers/api/Common_api.php
new file mode 100644
index 0000000..a8bd75f
--- /dev/null
+++ b/public/app/controllers/api/Common_api.php
@@ -0,0 +1,281 @@
+<?php
+
+namespace app\controllers\api;
+
+use \Registry;
+use app\models\market\Product_model;
+use app\models\market\ProductCategories_model;
+
+/** Common_api
+ * ---
+ * Controller for handling common API calls
+ * about main tables of the project;
+ *
+ * includes aglo for passing special filters and sorting
+ * (check parse_Where_OrderBy() method)
+ */
+class Common_api
+{
+
+ /** APITables
+ * Method returns allowed tables to accept API calls
+ * The return is an array of [label => data-source] pairs
+ * where 'label' is a friendly name of the datasource
+ * (and 'datasource' the actual table/data-source)
+ */
+ public static function APITables()
+ {
+ /** allowed tables for api call
+ * @return: (array) an of api-call arrays
+ * each api-call array has the folloing form:
+ * [ label => [
+ * 'table' => (string) actual table in database,
+ * (optional) 'filter' => (array) of allowed filtering fields,
+ * (optional) 'sort' => (array) of allowed sorting fields,
+ * ]
+ * ]
+ */
+ return [
+ 'page' => [
+ 'table' => 'pages',
+ 'filter' => ['Title']
+ ],
+ 'product' => [
+ 'table' => 'products',
+ 'filter' => [ 'ID', 'Title' ],
+ 'sort' => ['ID']
+ ],
+ 'category' => [
+ 'table' => 'product_categories',
+ 'filter' => ['Title', 'Hierarchy', 'Level']
+ ]
+ ];
+ }
+
+
+ /** select anything on any (allowed table)
+ * + supports filtering and sorting
+ * check self::parse_Where_OrderBy() for options;
+ * sets a limti of 1000 records
+ */
+ public static function table($table)
+ {
+ $sources = self::APITables();
+ if (array_key_exists($table, $sources)) {
+
+ $query = Registry::get('REQUEST')->QUERY;
+
+ // Get parametres? => parse filters
+ if ($query !== false) {
+
+ $parsed = self::parse_Where_OrderBy(
+ $query,
+ ($sources[$table]['filter'] ?? []),
+ ($sources[$table][ 'sort' ] ?? [])
+ );
+ $where = $parsed['filter'] ? (' WHERE ' . $parsed['filter']) : '';
+ $orderBy = $parsed['sort'] ? (' ORDER BY '. $parsed['sort']) : '' ;
+ $bindArguments = $parsed['bind'];
+
+ } else {
+ $where = '';
+ $orderBy = '';
+ $bindArguments = [];
+ }
+
+ $db = Registry::use('database');
+ $result = $db->runQuery('SELECT *
+ FROM '. $sources[$table]['table']
+ . $where
+ . $orderBy
+ .' LIMIT 1000',
+ $bindArguments
+ );
+
+ reply_json([
+ 'success' => true,
+ 'client' => Registry::get('REQUEST')->SIGNATURE,
+ 'result' => $result
+ ]);
+
+ } else {
+ reply_json(['success' => false]);
+ }
+ die();
+ }
+
+
+ /** get record of {table} by ID
+ * (if table has no ID then return false)
+ */
+ public static function record($table, $id)
+ {
+ $sources = self::APITables();
+ if (array_key_exists($table, $sources)) {
+
+ $db = Registry::use('database');
+ $result = $db->runQuery("SELECT *
+ FROM ". $sources[$table]['table'] ."
+ WHERE ID = :id",
+ [':id' => $id]
+ );
+ reply_json([
+ 'success' => true,
+ 'data' => $result[0]
+ ]);
+
+ } else {
+ reply_json(['status' => false]);
+
+ }
+ die();
+ }
+
+
+ /** category_by_url
+ * product categorie by full-friendly-URL
+ * @param $url (string): full friendly url
+ */
+ public static function category_by_url($url)
+ {
+ $category = proxy(
+ [\app\models\market\ProductCategories_model::class, 'categoryFromUrl'],
+ [ $url ], CACHE_CATEGORY_TTL
+ );
+ if ($category !== false) {
+ reply_json([
+ 'success' => true,
+ 'result' => $category
+ ]);
+
+ } else {
+ reply_json(['status' => false]);
+
+ }
+ die();
+ }
+
+ /** parse Where & OrderBy
+ * -------------------------------------------------------------------------
+ * parses SAFELY the query string to Where {CONDITIONS} and ORDER BY clauses
+ * according to the specified rules.
+ *
+ * The rules:
+ * ** filters: ?fieldname=[operator]:value &...
+ * where operators:[ like | startlike | endlike | eq | gt | gteq | lt |t leq ]
+ *
+ * ** Order by: ?_sort=fieldname[:[asc|desc]][,field[,]]
+ *
+ * for example: ?id=gt:4&active=1&_sort:reputation:desc,category
+ * parses to WHERE id > 4 AND active = 4 ORDER BY reputation desc, catetory asc
+ *
+ * -------------------------------------------------------------------------
+ * arguments:
+ * @param $query (string): string to be parsed
+ * @param $filters (array): the allowed fields to apply filters
+ * @param $sortings (array): the allowed fields to sort the result
+ * @return array('filter'=>(string) , 'sort'=>(string) , 'bind'=>(array))
+ */
+ private static function parse_Where_OrderBy($query, $filters=[], $sortings=[])
+ {
+ // break query to [key => value] pairs
+ parse_str($query, $queryArray);
+
+ $filterClause = []; // array to hold filter/WHERE clauses
+ $sortClause = []; // array to hold sort/ORDER-BY caluses
+ $bindings = []; // array to hold variable bindigs
+
+ // parse filers
+ // ---------------------------------------------------------------------
+ foreach($queryArray as $filter => $value) {
+
+ if (in_array($filter, $filters)) {
+
+ $parts = explode(':', $value ); // that is => [operator], value
+ if (count($parts) == 0) {
+ // forget it
+
+ } else if (count($parts) == 1) {
+ $filterClause[] = "{$filter} = :{$filter}";
+ $bindings[$filter] = $parts[0];
+
+ } else {
+
+ switch ($parts[0]) {
+ case 'like':
+ $filterClause[] = "{$filter} LIKE :{$filter}";
+ $bindings[':'.$filter] = '%'.$parts[1].'%';
+ break;
+
+ case 'startlike':
+ $filterClause[] = "{$filter} LIKE :{$filter}";
+ $bindings[':'.$filter] = $parts[1].'%';
+ break;
+
+ case 'endlike':
+ $filterClause[] = "{$filter} LIKE :{$filter}";
+ $bindings[':'.$filter] = '%'.$parts[1];
+ break;
+
+ case 'gt':
+ $filterClause[] = "{$filter} > :{$filter}";
+ $bindings[':'.$filter] = $parts[1];
+ break;
+
+ case 'gteq':
+ $filterClause[] = "{$filter} >= :{$filter}";
+ $bindings[':'.$filter] = $parts[1];
+ break;
+
+ case 'lt':
+ $filterClause[] = "{$filter} < :{$filter}";
+ $bindings[':'.$filter] = $parts[1];
+ break;
+
+ case 'lteq':
+ $filterClause[] = "{$filter} <= :{$filter}";
+ $bindings[':'.$filter] = $parts[1];
+ break;
+
+ case 'eq':
+ default:
+ $filterClause[] = "{$filter} = :{$filter}";
+ $bindings[':'.$filter] = $parts[1];
+ }
+ }
+ }
+ }
+ $whereSQL = ($filterClause == [])
+ ? false
+ : implode(' AND ', $filterClause);
+
+
+ // parse sort options
+ // ---------------------------------------------------------------------
+ if (isset($queryArray['_sort'])) {
+ $sortTerms = explode(',', $queryArray['_sort']);
+
+ foreach($sortTerms as $term) {
+
+ $parts = explode(':', $term);
+ if ($parts != [] && in_array($parts[0], $sortings) ) {
+ $sortClause[] = (count($parts)==1)
+ ? $parts[0]
+ : $parts[0] .' '. (($parts[1] == 'desc') ? 'desc' : 'asc');
+ }
+ }
+
+ }
+ $sortSQL = ($sortClause == [])
+ ? false
+ : implode(', ', $sortClause);
+
+ return ([
+ 'filter' => $whereSQL,
+ 'sort' => $sortSQL,
+ 'bind' => $bindings
+ ]);
+
+ }
+
+} \ No newline at end of file
diff --git a/public/app/controllers/api/Doc_api.php b/public/app/controllers/api/Doc_api.php
new file mode 100644
index 0000000..e893552
--- /dev/null
+++ b/public/app/controllers/api/Doc_api.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace app\controllers\api;
+
+use app\models\market\ProductCategories_model;
+use app\models\Jorge;
+
+class Doc_api
+{
+
+ /** */
+ public static function tree($store = 904)
+ {
+ $tree = proxy(
+ [ProductCategories_model::class,'tree'],
+ [], CACHE_ROOT_TTL,
+ PROXY_IGNORE_CACHE
+ );
+ reply_json([ 'success' => true, 'result' => $tree ]);
+ die();
+ }
+
+
+ public static function dbDoc()
+ {
+ $j = new Jorge();
+ reply_json([
+ 'success' => true,
+ 'result' => $j->databaseDocumentation()
+ ]);
+ die();
+
+ }
+} \ No newline at end of file
diff --git a/public/app/controllers/cli/CliContoller.php b/public/app/controllers/cli/CliContoller.php
new file mode 100644
index 0000000..4549b50
--- /dev/null
+++ b/public/app/controllers/cli/CliContoller.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace app\controllers\cli;
+
+/** CliContoller
+ *
+ * A typical cli constoller; this one is used to
+ * re-generate cashes of commonly used entities
+ * with expensive queries like (ex:) categories;
+ *
+ * Proxy calls make use of PROXY_IGNORE_CACHE
+ *
+ * CliController calls Model methods who create
+ * cashed data with the exact same arguments as
+ * when called from the web interface; this way
+ * the Model::method(arguments) triplete creates
+ * the same keys as via the web interface.
+ *
+ * NOTE: Only Cli interface is allowed¨
+ * `if (php_sapi_name() != 'cli') return false;`
+ *
+ * The Cli-interfaced script can run manualy or
+ * (most usual scenario) scheduled via a cron job
+ * in order to refresh cashes befor expiration.
+ *
+ * ---------------------------------------------
+ */
+class CliContoller
+{
+
+
+ /** (re-) cacheCategoriesTree
+ * regenerate cache for product_categories tree
+ * @param (void)
+ */
+ public static function cacheCategoriesTree()
+ {
+ // Only Cli interface is allowed
+ if (php_sapi_name() != 'cli') return false;
+
+ # PROXY call:
+ # CMS \ Course_model::courses_struct():
+ # + Refresh Cache
+ # --------------------------------------
+ echo textColor("Creating category_products tree Cache ... ", \NORMAL);
+ $reply = proxy(
+ [\app\models\cms\Course_model::class, 'courses_struct'],
+ [], \CACHE_ROOT_TTL,
+ \PROXY_IGNORE_CACHE
+ );
+ echo ($reply == false) ? textColor("Failed\n", \FAIL) : textColor("Done!\n", \SUCCESS);
+ }
+
+
+} \ No newline at end of file
diff --git a/public/app/controllers/cms/Course.php b/public/app/controllers/cms/Course.php
new file mode 100644
index 0000000..47eac5f
--- /dev/null
+++ b/public/app/controllers/cms/Course.php
@@ -0,0 +1,252 @@
+<?php
+
+namespace app\controllers\cms;
+
+use Registry;
+use Render;
+use app\controllers\Auth;
+use app\models\cms\Course_model;
+use app\extends\Cache_service;
+
+class Course {
+
+
+ /** PERMITION
+ * -------------------------------------------------------------------------
+ */
+
+
+ /** is admin or permited
+ *
+ * shortcut method for checking access authorization
+ *
+ * returns true if user belogns to the admin group
+ * OR has the specified permition/privilege
+ *
+ * @param $privilege_id (int)
+ *
+ * NOTE:
+ * unlike the other authorization methods ...
+ * $privilege_id is NOT an array but a single privilege id
+ *
+ * @return true|false
+ */
+ private static function is_admin_or_permited( $privilege_id )
+ {
+ return (
+ Auth::in_admin_group()
+ || Auth::hasPermition([ $privilege_id ])
+ );
+ }
+
+
+
+ /** FILES
+ * -------------------------------------------------------------------------
+ */
+
+ /** serve file by file_path
+ * (request is valid only for admin users)
+ *
+ * @param $file_path (string): relative file path
+ * GET @param type (string) : media-type of file
+ */
+ public static function serve_file($file_path)
+ {
+ $file = self::get_file_attributes($file_path);
+
+ // if user is authorized
+ if (self::is_admin_or_permited($file['privilege_id'])) {
+
+ $media_type = Registry::get('REQUEST')->GET['type']; // get media-type
+ $real_path = MEDIA_STORAGE_ROOT . $file_path; // construct real path
+
+ if (!file_exists($real_path)) {
+ Render::view('error/404');
+
+ } else {
+ Render::file($real_path, $media_type);
+ }
+
+ } else {
+ Render::view('error/404', [
+ 'error_code' => 403,
+ 'moto' => 'Forbidden',
+ 'message' => ''
+ ]);
+ }
+ }
+
+
+ /** get_file_attributes
+ *
+ * returns attributes of a file
+ * (medias are proxied for speed optimization)
+ *
+ * @param $path (string) : file path
+ * @return $file attributes --or-- false
+ */
+ private static function get_file_attributes($path)
+ {
+ $medias = Cache_service::files_attributes();
+
+ foreach($medias as $key => $medi) {
+
+ if ($medi['path'] == $path) {
+
+ return $medi;
+ }
+ }
+
+ return false;
+ }
+
+
+
+ /** COURSES
+ * -------------------------------------------------------------------------
+ */
+
+ /** course
+ * prepare and render the course view
+ *
+ * @param $id : course id
+ */
+ public static function course($id)
+ {
+ $id = intval($id); // course id
+
+ // collect all data needed for course view
+ // --- -- -- - - -
+ $cache = Cache_service::courses_struct();
+ $tree = $cache['tree'];
+ $breadcrumbs = $cache['breadcrumbs'];
+ $lessons = Course_model::lessons_of_course(intval($id));
+
+ // find parent_ids of current category (to open the menu tree)
+ // ---
+ $course_path = [];
+ foreach($breadcrumbs[$id]['parents'] as $key => $parent) {
+ $course_path[] = intval($parent['id']);
+ }
+ $course_path[] = $id;
+
+ // then Render
+ // --- -- -- - - -
+ Render::view('templates/course', [
+ 'id' => $id,
+ 'title' => $breadcrumbs[ $id ]['rec']['label'],
+ 'categories' => $tree,
+ 'breadcrumbs' => $breadcrumbs,
+ 'lessons' => $lessons,
+ 'course_path' => $course_path,
+ // needed by footer
+ 'entity' => Cache_service::pages_list()
+ ]);
+ }
+
+
+
+ /** LESSONS
+ * ------------------------------------------------------------------------
+ */
+
+
+ /** lesson
+ *
+ * check if user is authorized to view the content;
+ * if so, prepare and render the lesson view
+ *
+ * @param $id : lesson id
+ */
+ public static function lesson($id)
+ {
+ $id = intval($id); // lesson id
+
+ $lesson = Course_model::lesson($id); // get lesson
+ $course_id = $lesson['course_id']; // get course id
+ $pri_id = $lesson['privilege_id']; // privilege needed
+
+
+ if (self::is_admin_or_permited($pri_id)) {
+
+ // collect all data needed for course view
+ // --- -- -- - - -
+ $cache = Cache_service::courses_struct();
+ $tree = $cache['tree'];
+ $breadcrumbs = $cache['breadcrumbs'];
+
+ // find parent_ids of current category (to open the menu tree)
+ // ---
+ $course_path = [];
+ foreach($breadcrumbs[$course_id]['parents'] as $key => $parent) {
+ $course_path[] = intval($parent['id']);
+ }
+ $course_path[] = intval($course_id);
+
+ // NOTE: CRITICAL:
+ // do not pass Class::method directly to the eported variables
+ // $entity = Cache_service::pages_list();
+
+ // var_dump($entity); die();
+
+ // then Render
+ // --- -- -- - - -
+ Render::view('templates/lesson', [
+ // main content
+ 'id' => $id,
+ 'course_id' => $course_id,
+ 'title' => $lesson['title'],
+ 'lesson' => $lesson,
+ // needed for side panel and breadcrunbs
+ 'categories' => $tree,
+ 'breadcrumbs' => $breadcrumbs,
+ 'course_path' => $course_path,
+ // needed by footer
+ 'entity' => Cache_service::pages_list()
+ ]);
+
+ } else { // user is not authorized
+ Render::view('error/general', [
+ 'title' => 'Δεν έχετε πρόσβαση',
+ 'message' => 'Θα πρέπει να αγοράσετε το πακέτο πρόσβασης '
+ . $pri_id
+ .' για να δείτε το περιεχόμενο!
+ <br><br>
+ Αγόρασε τώρα το <button class="btn">πακέτο πρόσβασης '. $pri_id .'</button>'
+ ]);
+ }
+
+ }
+
+
+ /** lesson
+ *
+ * check if user is authorized to view the content;
+ * if so, prepare and render the lesson view
+ *
+ * @param $id : lesson id
+ */
+ public static function page($id)
+ {
+ $id = intval($id); // lesson id
+
+ $pages = Cache_service::pages_list();
+ $page = $pages[$id];
+
+ // then Render
+ // --- -- -- - - -
+ Render::view('templates/page', [
+ // main content
+ 'id' => $id,
+ 'page' => $page,
+ // needed by footer
+ 'entity' => $pages
+ ]);
+
+ }
+
+
+
+
+} \ No newline at end of file