commit ab0e4efbcbcf26effd833d5ac0011532aa8c6229 Author: Aaron van Geffen Date: Thu Sep 1 23:13:23 2016 +0200 Initial commit. This is to be the new HashRU website based on the Aaronweb.net/Kabuki CMS. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d01653a --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6425cd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +composer.lock +config.php +hashru.sublime-project +hashru.sublime-workspace diff --git a/README b/README new file mode 100644 index 0000000..50cb4c3 --- /dev/null +++ b/README @@ -0,0 +1 @@ +This marks the development repository for the HashRU website. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..fae32df --- /dev/null +++ b/TODO.md @@ -0,0 +1,10 @@ +TODO: + +* Draaiing van foto's bij importeren goedzetten +* Import asset ownership +* Import users +* Pagina om één foto te bekijken +* Taggen door gebruikers +* Uploaden door gebruikers +* Album management +* Password reset via e-mail diff --git a/app.php b/app.php new file mode 100644 index 0000000..3947ab9 --- /dev/null +++ b/app.php @@ -0,0 +1,32 @@ +updateAccessTime(); +Registry::set('user', $user); + +// Handle errors our own way. +//set_error_handler('ErrorHandler::handleError'); +ini_set("display_errors", DEBUG ? "On" : "Off"); + +// The real magic starts here! +ob_start(); +Dispatcher::dispatch(); +ob_end_flush(); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..52704a3 --- /dev/null +++ b/composer.json @@ -0,0 +1,18 @@ +{ + "name": "aaron/hashru", + "description": "HashRU", + "license": "Proprietary", + "authors": [ + { + "name": "Aaron van Geffen", + "email": "aaron@aaronweb.net" + } + ], + "autoload": { + "classmap": [ + "controllers/", + "models/", + "templates/" + ] + } +} diff --git a/config.php.dist b/config.php.dist new file mode 100644 index 0000000..d78de95 --- /dev/null +++ b/config.php.dist @@ -0,0 +1,33 @@ +isAdmin()) + throw new NotAllowedException(); + + if (empty($_GET['id'])) + throw new Exception('Invalid request.'); + + $asset = Asset::fromId($_GET['id']); + if (empty($asset)) + throw new NotFoundException('Asset not found'); + + if (isset($_REQUEST['delete'])) + throw new Exception('Not implemented.'); + + if (!empty($_POST)) + { + if (isset($_GET['updatethumb'])) + { + $image = $asset->getImage(); + return $this->updateThumb($image); + } + + // Key info + if (isset($_POST['title'], $_POST['date_captured'], $_POST['priority'])) + { + $date_captured = !empty($_POST['date_captured']) ? new DateTime($_POST['date_captured']) : null; + $asset->setKeyData(htmlentities($_POST['title']), $date_captured, intval($_POST['priority'])); + } + + // Handle tags + $new_tags = []; + if (isset($_POST['tag']) && is_array($_POST['tag'])) + foreach ($_POST['tag'] as $id_tag => $bool) + if (is_numeric($id_tag)) + $new_tags[] = $id_tag; + + $current_tags = array_keys($asset->getTags()); + + $tags_to_unlink = array_diff($current_tags, $new_tags); + $asset->unlinkTags($tags_to_unlink); + + $tags_to_link = array_diff($new_tags, $current_tags); + $asset->linkTags($tags_to_link); + + // Meta data + if (isset($_POST['meta_key'], $_POST['meta_value'])) + { + $new_meta = array_filter(array_combine($_POST['meta_key'], $_POST['meta_value']), function($e) { + return !empty($e); + }); + + $asset->setMetaData($new_meta); + } + + // A replacement file? + if (isset($_FILES['replacement'], $_POST['replacement_target']) && !empty($_FILES['replacement']['tmp_name'])) + { + if ($_POST['replacement_target'] === 'full') + { + $asset->replaceFile($_FILES['replacement']['tmp_name']); + if ($asset->isImage()) + { + $image = $asset->getImage(); + $image->removeAllThumbnails(); + } + } + elseif (preg_match('~^thumb_(\d+)x(\d+)(_c[best]?)?$~', $_POST['replacement_target'])) + { + $image = $asset->getImage(); + if (($replace_result = $image->replaceThumbnail($_POST['replacement_target'], $_FILES['replacement']['tmp_name'])) !== 0) + throw new Exception('Could not replace thumbnail \'' . $_POST['replacement_target'] . '\' with the uploaded file. Error code: ' . $replace_result); + } + } + + header('Location: ' . BASEURL . '/editasset/?id=' . $asset->getId()); + } + + // Get list of thumbnails + $thumbs = $this->getThumbs($asset); + + $page = new EditAssetForm($asset, $thumbs); + parent::__construct('Edit asset \'' . $asset->getTitle() . '\' (' . $asset->getFilename() . ') - ' . SITE_TITLE); + $this->page->adopt($page); + } + + private function getThumbs(Asset $asset) + { + $path = $asset->getPath(); + $thumbs = []; + $metadata = $asset->getMeta(); + foreach ($metadata as $key => $meta) + { + if (!preg_match('~^thumb_(?\d+)x(?\d+)(?_c(?[best]?))?$~', $key, $thumb)) + continue; + + $has_crop_boundary = isset($metadata['crop_' . $thumb['width'] . 'x' . $thumb['height']]); + $has_custom_image = isset($metadata['custom_' . $thumb['width'] . 'x' . $thumb['height']]); + $thumbs[] = [ + 'dimensions' => [(int) $thumb['width'], (int) $thumb['height']], + 'cropped' => !$has_custom_image && (!empty($thumb['suffix']) || $has_crop_boundary), + 'crop_method' => !$has_custom_image && !empty($thumb['method']) ? $thumb['method'] : (!empty($thumb['suffix']) ? 'c' : null), + 'crop_region' => $has_crop_boundary ? $metadata['crop_' . $thumb['width'] . 'x' . $thumb['height']] : null, + 'custom_image' => $has_custom_image, + 'filename' => $meta, + 'full_path' => THUMBSDIR . '/' . $path . '/' . $meta, + 'url' => THUMBSURL . '/' . $path . '/' . $meta, + 'status' => file_exists(THUMBSDIR . '/' . $path . '/' . $meta), + ]; + } + + return $thumbs; + } + + private function updateThumb(Image $image) + { + $data = json_decode($_POST['data']); + $meta = $image->getMeta(); + + // Set new crop boundary. + $crop_key = 'crop_' . $data->thumb_width . 'x' . $data->thumb_height; + $crop_value = $data->crop_width . ',' . $data->crop_height . ',' . $data->source_x . ',' . $data->source_y; + $meta[$crop_key] = $crop_value; + + // If we uploaded a custom thumbnail, stop considering it such. + $custom_key = 'custom_' . $data->thumb_width . 'x' . $data->thumb_height; + if (isset($meta[$custom_key])) + unset($meta[$custom_key]); + + // Force a rebuild of related thumbnails. + $thumb_key = 'thumb_' . $data->thumb_width . 'x' . $data->thumb_height; + foreach ($meta as $meta_key => $meta_value) + if ($meta_key === $thumb_key || strpos($meta_key, $thumb_key . '_') !== false) + unset($meta[$meta_key]); + + $image->setMetaData($meta); + + $payload = [ + 'key' => $crop_key, + 'value' => $crop_value, + 'url' => $image->getThumbnailUrl($data->thumb_width, $data->thumb_height, 'exact'), + ]; + + header('Content-Type: text/json; charset=utf-8'); + echo json_encode($payload); + exit; + } +} diff --git a/controllers/EditUser.php b/controllers/EditUser.php new file mode 100644 index 0000000..cbf031b --- /dev/null +++ b/controllers/EditUser.php @@ -0,0 +1,195 @@ +isAdmin()) + throw new NotAllowedException(); + + // Who are we, again? + $current_user = Registry::get('user'); + + $id_user = isset($_GET['id']) ? (int) $_GET['id'] : 0; + if (empty($id_user) && !isset($_GET['add'])) + throw new UnexpectedValueException('Requested user not found or not requesting a new user.'); + + // Adding a user? + if (isset($_GET['add'])) + { + parent::__construct('Add a new user'); + $view = new DummyBox('Add a new user'); + $this->page->adopt($view); + $this->page->addClass('edituser'); + } + // Deleting one? + elseif (isset($_GET['delete'])) + { + // Don't be stupid. + if ($current_user->getUserId() == $id_user) + trigger_error('Sorry, I cannot allow you to delete yourself.', E_USER_ERROR); + + // So far so good? + $user = Member::fromId($id_user); + if (Session::validateSession('get') && $user->delete()) + { + header('Location: ' . BASEURL . '/manageusers/'); + exit; + } + else + trigger_error('Cannot delete user: an error occured while processing the request.', E_USER_ERROR); + } + // Editing one, then, surely. + else + { + $user = Member::fromId($id_user); + parent::__construct('Edit user \'' . $user->getFullName() . '\''); + $view = new DummyBox('Edit user \'' . $user->getFullName() . '\''); + $this->page->adopt($view); + $this->page->addClass('edituser'); + } + + // Session checking! + if (empty($_POST)) + Session::resetSessionToken(); + else + Session::validateSession(); + + if ($id_user && !($current_user->isAdmin() && $current_user->getUserId() == $id_user)) + $after_form = 'Delete user'; + elseif (!$id_user) + $after_form = ''; + else + $after_form = ''; + + $form = new Form([ + 'request_url' => BASEURL . '/edituser/?' . ($id_user ? 'id=' . $id_user : 'add'), + 'content_below' => $after_form, + 'fields' => [ + 'first_name' => [ + 'type' => 'text', + 'label' => 'First name', + 'size' => 50, + 'maxlength' => 255, + ], + 'surname' => [ + 'type' => 'text', + 'label' => 'Surname', + 'size' => 50, + 'maxlength' => 255, + ], + 'slug' => [ + 'type' => 'text', + 'label' => 'URL slug', + 'size' => 50, + 'maxlength' => 255, + ], + 'emailaddress' => [ + 'type' => 'text', + 'label' => 'Email address', + 'size' => 50, + 'maxlength' => 255, + ], + 'password1' => [ + 'type' => 'password', + 'label' => 'Password', + 'size' => 50, + 'maxlength' => 255, + 'is_optional' => true, + ], + 'password2' => [ + 'type' => 'password', + 'label' => 'Password (repeat)', + 'size' => 50, + 'maxlength' => 255, + 'is_optional' => true, + ], + 'is_admin' => [ + 'header' => 'Privileges', + 'type' => 'checkbox', + 'label' => 'This user ' . ($id_user ? 'has' : 'should have') . ' administrative privileges.', + 'is_optional' => true, + ], + ], + ]); + + // Create the form, add in default values. + $form->setData($id_user ? $user->getProps() : $_POST); + $formview = new FormView($form); + $view->adopt($formview); + + if (!empty($_POST)) + { + $form->verify($_POST); + + // Anything missing? + if (!empty($form->getMissing())) + return $formview->adopt(new DummyBox('Some data missing', 'Please fill out the following fields: ' . implode(', ', $form->getMissing()))); + + $data = $form->getData(); + + // Just to be on the safe side. + $data['first_name'] = htmlentities(trim($data['first_name'])); + $data['surname'] = htmlentities(trim($data['surname'])); + $data['emailaddress'] = trim($data['emailaddress']); + + // Make sure there's a slug. + if (empty($data['slug'])) + $data['slug'] = $data['first_name']; + + // Quick stripping. + $data['slug'] = strtr(strtolower($data['slug']), [' ' => '-', '--' => '-', '&' => 'and', '=>' => '', "'" => "", ":"=> "", '/' => '-', '\\' => '-']); + + // Checkboxes, fun! + $data['is_admin'] = empty($data['is_admin']) ? 0 : 1; + + // If it looks like an e-mail address... + if (!empty($data['emailaddress']) && !preg_match('~^[^ ]+@[^ ]+\.[a-z]+$~', $data['emailaddress'])) + return $formview->adopt(new DummyBox('Email addresses invalid', 'The email address you entered is not a valid email address.')); + // Check whether email address is already linked to an account in the database -- just not to the account we happen to be editing, of course. + elseif (!empty($data['emailaddress']) && Member::exists($data['emailaddress']) && !($id_user && $user->getEmailAddress() == $data['emailaddress'])) + return $formview->adopt(new DummyBox('Email address already in use', 'Another account is already using the e-mail address you entered.')); + + // Setting passwords? We'll need two! + if (!$id_user || !empty($data['password1']) && !empty($data['password2'])) + { + if (strlen($data['password1']) < 6 || !preg_match('~[^A-z]~', $data['password1'])) + return $formview->adopt(new DummyBox('Password not acceptable', 'Please fill in a password that is at least six characters long and contains at least one non-alphabetic character (e.g. a number or symbol).')); + elseif ($data['password1'] !== $data['password2']) + return $formview->adopt(new DummyBox('Passwords do not match', 'The passwords you entered do not match. Please try again.')); + else + $data['password'] = $data['password1']; + + unset($data['password1'], $data['password2']); + } + + // Creating a new user? + if (!$id_user) + { + $return = Member::createNew($data); + if ($return === false) + return $formview->adopt(new DummyBox('Cannot create this user', 'Something went wrong while creating the user...')); + + if (isset($_POST['submit_and_new'])) + { + header('Location: ' . BASEURL . '/edituser/?add'); + exit; + } + } + // Just updating? + else + $user->update($data); + + // Redirect to the user management page. + header('Location: ' . BASEURL . '/manageusers/'); + exit; + } + } +} diff --git a/controllers/HTMLController.php b/controllers/HTMLController.php new file mode 100644 index 0000000..d03bc50 --- /dev/null +++ b/controllers/HTMLController.php @@ -0,0 +1,34 @@ +page = new MainTemplate($title); + + if (Registry::get('user')->isAdmin()) + { + $this->page->appendStylesheet(BASEURL . '/css/admin.css'); + $this->admin_bar = new AdminBar(); + $this->page->adopt($this->admin_bar); + } + } + + public function showContent() + { + $this->page->html_main(); + } +} diff --git a/controllers/JSONController.php b/controllers/JSONController.php new file mode 100644 index 0000000..2b787cc --- /dev/null +++ b/controllers/JSONController.php @@ -0,0 +1,21 @@ +payload); + } +} diff --git a/controllers/Login.php b/controllers/Login.php new file mode 100644 index 0000000..2272d95 --- /dev/null +++ b/controllers/Login.php @@ -0,0 +1,56 @@ +isLoggedIn()) + { + if (Registry::get('user')->isAdmin()) + header('Location: ' . BASEURL . '/admin/'); + else + header('Location: ' . BASEURL . '/'); + exit; + } + + // Sanity check + $login_error = false; + if (isset($_POST['emailaddress'], $_POST['password'])) + { + if (Authentication::checkPassword($_POST['emailaddress'], $_POST['password'])) + { + parent::__construct('Login'); + $_SESSION['user_id'] = Authentication::getUserId($_POST['emailaddress']); + + if (isset($_POST['redirect_url'])) + header('Location: ' . base64_decode($_POST['redirect_url'])); + elseif (isset($_SESSION['login_url'])) + header('Location: ' . $_SESSION['redirect_url']); + else + header('Location: ' . BASEURL . '/admin/'); + exit; + } + else + $login_error = true; + } + + parent::__construct('Log in'); + $this->page->appendStylesheet(BASEURL . '/css/admin.css'); + $form = new LogInForm('Log in'); + if ($login_error) + $form->setErrorMessage('Invalid email address or password.'); + + // Tried anything? Be helpful, at least. + if (isset($_POST['emailaddress'])) + $form->setEmail($_POST['emailaddress']); + + $this->page->adopt($form); + } +} diff --git a/controllers/Logout.php b/controllers/Logout.php new file mode 100644 index 0000000..1980518 --- /dev/null +++ b/controllers/Logout.php @@ -0,0 +1,20 @@ +isAdmin()) + throw new NotAllowedException(); + + // Flushing, are we? + if (isset($_POST['flush'])) + ErrorLog::flush(); + + $options = [ + 'title' => 'Error log', + 'form' => [ + 'action' => BASEURL . '/manageerrors/', + 'method' => 'post', + 'class' => 'floatright', + 'buttons' => [ + 'flush' => [ + 'type' => 'submit', + 'caption' => 'Delete all', + ], + ], + ], + 'columns' => [ + 'id' => [ + 'value' => 'id_entry', + 'header' => '#', + 'is_sortable' => true, + ], + 'message' => [ + 'parse' => [ + 'type' => 'function', + 'data' => function($row) { + return $row['message'] . '
Show debug info' . + '
' . $row['debug_info'] . '
' . + '' . $row['request_uri'] . ''; + } + ], + 'header' => 'Message / URL', + 'is_sortable' => false, + ], + 'file' => [ + 'value' => 'file', + 'header' => 'File', + 'is_sortable' => true, + ], + 'line' => [ + 'value' => 'line', + 'header' => 'Line', + 'is_sortable' => true, + ], + 'time' => [ + 'parse' => [ + 'type' => 'timestamp', + 'data' => [ + 'timestamp' => 'time', + 'pattern' => 'long', + ], + ], + 'header' => 'Time', + 'is_sortable' => true, + ], + 'ip' => [ + 'value' => 'ip_address', + 'header' => 'IP', + 'is_sortable' => true, + ], + 'uid' => [ + 'header' => 'UID', + 'is_sortable' => true, + 'parse' => [ + 'link' => BASEURL . '/member/?id={ID_USER}', + 'data' => 'id_user', + ], + ], + ], + 'start' => !empty($_GET['start']) ? (int) $_GET['start'] : 0, + 'sort_order' => !empty($_GET['order']) ? $_GET['order'] : '', + 'sort_direction' => !empty($_GET['dir']) ? $_GET['dir'] : '', + 'no_items_label' => "No errors to display -- we're all good!", + 'items_per_page' => 20, + 'index_class' => 'floatleft', + 'base_url' => BASEURL . '/manageerrors/', + 'get_count' => 'ErrorLog::getCount', + 'get_data' => function($offset = 0, $limit = 20, $order = '', $direction = 'down') { + if (!in_array($order, ['id_entry', 'file', 'line', 'time', 'ipaddress', 'id_user'])) + $order = 'id_entry'; + + $data = Registry::get('db')->queryAssocs(' + SELECT * + FROM log_errors + ORDER BY {raw:order} + LIMIT {int:offset}, {int:limit}', + [ + 'order' => $order . ($direction === 'up' ? ' ASC' : ' DESC'), + 'offset' => $offset, + 'limit' => $limit, + ]); + + return [ + 'rows' => $data, + 'order' => $order, + 'direction' => $direction, + ]; + }, + ]; + + $error_log = new GenericTable($options); + parent::__construct('Error log - Page ' . $error_log->getCurrentPage() .' - ' . SITE_TITLE); + $this->page->adopt(new TabularData($error_log)); + } +} diff --git a/controllers/ManageTags.php b/controllers/ManageTags.php new file mode 100644 index 0000000..b191a23 --- /dev/null +++ b/controllers/ManageTags.php @@ -0,0 +1,122 @@ +isAdmin()) + throw new NotAllowedException(); + + if (isset($_REQUEST['create']) && isset($_POST['tag'])) + $this->handleTagCreation(); + + $options = [ + 'columns' => [ + 'id_post' => [ + 'value' => 'id_tag', + 'header' => 'ID', + 'is_sortable' => true, + ], + 'tag' => [ + 'header' => 'Tag', + 'is_sortable' => true, + 'parse' => [ + 'link' => BASEURL . '/managetag/?id={ID_TAG}', + 'data' => 'tag', + ], + ], + 'slug' => [ + 'header' => 'Slug', + 'is_sortable' => true, + 'parse' => [ + 'link' => BASEURL . '/managetag/?id={ID_TAG}', + 'data' => 'slug', + ], + ], + 'count' => [ + 'header' => 'Cardinality', + 'is_sortable' => true, + 'value' => 'count', + ], + ], + 'start' => !empty($_GET['start']) ? (int) $_GET['start'] : 0, + 'sort_order' => !empty($_GET['order']) ? $_GET['order'] : null, + 'sort_direction' => !empty($_GET['dir']) ? $_GET['dir'] : null, + 'title' => 'Manage tags', + 'no_items_label' => 'No tags meet the requirements of the current filter.', + 'items_per_page' => 25, + 'base_url' => BASEURL . '/managetags/', + 'get_data' => function($offset = 0, $limit = 15, $order = '', $direction = 'up') { + if (!in_array($order, ['id_post', 'tag', 'slug', 'count'])) + $order = 'tag'; + if (!in_array($direction, ['up', 'down'])) + $direction = 'up'; + + $data = Registry::get('db')->queryAssocs(' + SELECT * + FROM tags + ORDER BY {raw:order} + LIMIT {int:offset}, {int:limit}', + [ + 'order' => $order . ($direction == 'up' ? ' ASC' : ' DESC'), + 'offset' => $offset, + 'limit' => $limit, + ]); + + return [ + 'rows' => $data, + 'order' => $order, + 'direction' => ($direction == 'up' ? 'up' : 'down'), + ]; + }, + 'get_count' => function() { + return Registry::get('db')->queryValue(' + SELECT COUNT(*) + FROM tags'); + } + ]; + + $table = new GenericTable($options); + parent::__construct('Tag management - Page ' . $table->getCurrentPage() .' - ' . SITE_TITLE); + $this->page->adopt(new TabularData($table)); + } + + private function handleTagCreation() + { + header('Content-Type: text/json; charset=utf-8'); + + // It better not already exist! + if (Tag::exactMatch($_POST['tag'])) + { + echo '{"error":"Tag already exists!"}'; + exit; + } + + $label = htmlentities(trim($_POST['tag'])); + $slug = strtr(strtolower($label), [' ' => '-']); + $tag = Tag::createNew([ + 'tag' => $label, + 'slug' => $slug, + ]); + + // Did we succeed? + if (!$tag) + { + echo '{"error":"Could not create tag."}'; + exit; + } + + echo json_encode([ + 'label' => $tag->tag, + 'id_tag' => $tag->id_tag, + ]); + exit; + } +} diff --git a/controllers/ManageUsers.php b/controllers/ManageUsers.php new file mode 100644 index 0000000..112d9f1 --- /dev/null +++ b/controllers/ManageUsers.php @@ -0,0 +1,131 @@ +isAdmin()) + throw new NotAllowedException(); + + $options = [ + 'form' => [ + 'action' => BASEURL . '/edituser/', + 'method' => 'get', + 'class' => 'floatright', + 'buttons' => [ + 'add' => [ + 'type' => 'submit', + 'caption' => 'Add new user', + ], + ], + ], + 'columns' => [ + 'id_user' => [ + 'value' => 'id_user', + 'header' => 'ID', + 'is_sortable' => true, + ], + 'surname' => [ + 'header' => 'Last name', + 'is_sortable' => true, + 'parse' => [ + 'link' => BASEURL . '/edituser/?id={ID_USER}', + 'data' => 'surname', + ], + ], + 'first_name' => [ + 'header' => 'First name', + 'is_sortable' => true, + 'parse' => [ + 'link' => BASEURL . '/edituser/?id={ID_USER}', + 'data' => 'first_name', + ], + ], + 'slug' => [ + 'header' => 'Slug', + 'is_sortable' => true, + 'parse' => [ + 'link' => BASEURL . '/edituser/?id={ID_USER}', + 'data' => 'slug', + ], + ], + 'emailaddress' => [ + 'value' => 'emailaddress', + 'header' => 'Email address', + 'is_sortable' => true, + ], + 'last_action_time' => [ + 'parse' => [ + 'type' => 'timestamp', + 'data' => [ + 'timestamp' => 'last_action_time', + 'pattern' => 'long', + ], + ], + 'header' => 'Last activity', + 'is_sortable' => true, + ], + 'ip_address' => [ + 'is_sortable' => true, + 'value' => 'ip_address', + 'header' => 'IP address', + ], + 'is_admin' => [ + 'is_sortable' => true, + 'header' => 'Admin?', + 'parse' => [ + 'type' => 'function', + 'data' => function($row) { + return $row['is_admin'] ? 'yes' : 'no'; + } + ], + ], + ], + 'start' => !empty($_GET['start']) ? (int) $_GET['start'] : 0, + 'sort_order' => !empty($_GET['order']) ? $_GET['order'] : '', + 'sort_direction' => !empty($_GET['dir']) ? $_GET['dir'] : '', + 'title' => 'Manage users', + 'no_items_label' => 'No users meet the requirements of the current filter.', + 'items_per_page' => 15, + 'index_class' => 'floatleft', + 'base_url' => BASEURL . '/manageusers/', + 'get_data' => function($offset = 0, $limit = 15, $order = '', $direction = 'down') { + if (!in_array($order, ['id_user', 'surname', 'first_name', 'slug', 'emailaddress', 'last_action_time', 'ip_address', 'is_admin'])) + $order = 'id_user'; + + $data = Registry::get('db')->queryAssocs(' + SELECT * + FROM users + ORDER BY {raw:order} + LIMIT {int:offset}, {int:limit}', + [ + 'order' => $order . ($direction == 'up' ? ' ASC' : ' DESC'), + 'offset' => $offset, + 'limit' => $limit, + ]); + + return [ + 'rows' => $data, + 'order' => $order, + 'direction' => $direction, + ]; + }, + 'get_count' => function() { + return Registry::get('db')->queryValue(' + SELECT COUNT(*) + FROM users'); + } + ]; + + $table = new GenericTable($options); + parent::__construct('User management - Page ' . $table->getCurrentPage() .' - ' . SITE_TITLE); + $this->page->adopt(new TabularData($table)); + } +} diff --git a/controllers/ProvideAutoSuggest.php b/controllers/ProvideAutoSuggest.php new file mode 100644 index 0000000..eb2a97e --- /dev/null +++ b/controllers/ProvideAutoSuggest.php @@ -0,0 +1,41 @@ +isAdmin()) + throw new NotAllowedException(); + + if (!isset($_GET['type'])) + throw new UnexpectedValueException('Unsupported autosuggest request.'); + + if ($_GET['type'] === 'tags' && isset($_GET['data'])) + { + $data = array_unique(explode(' ', urldecode($_GET['data']))); + $data = array_filter($data, function($item) { + return strlen($item) >= 3; + }); + + $this->payload = ['items' => []]; + + // Nothing to look for? + if (count($data) === 0) + return; + + $results = Tag::match($data); + foreach ($results as $id_tag => $tag) + $this->payload['items'][] = [ + 'label' => $tag, + 'id_tag' => $id_tag, + ]; + } + } +} diff --git a/controllers/UploadMedia.php b/controllers/UploadMedia.php new file mode 100644 index 0000000..5c1e3ef --- /dev/null +++ b/controllers/UploadMedia.php @@ -0,0 +1,58 @@ +isAdmin()) + throw new NotAllowedException(); + + $page = new MediaUploader(); + parent::__construct('Upload new media - ' . SITE_TITLE); + $this->page->adopt($page); + + // Are we saving something? + if (isset($_POST['save'])) + { + if (empty($_FILES) || empty($_FILES['new_asset'])) + return; + + // Any tags? + $new_tags = []; + if (isset($_POST['tag']) && is_array($_POST['tag'])) + { + foreach ($_POST['tag'] as $id_tag => $bool) + if (is_numeric($id_tag)) + $new_tags[] = $id_tag; + } + +var_dump($_FILES); +var_dump($_POST); + + foreach ($_FILES['new_asset']['tmp_name'] as $num => $uploaded_file) + { + if (empty($uploaded_file)) + continue; + + $asset = Asset::createNew([ + 'filename_to_copy' => $uploaded_file, + 'preferred_filename' => $_FILES['new_asset']['name'][$num], + 'title' => !empty($_POST['title'][$num]) ? $_POST['title'][$num] : null, + ]); + + $asset->linkTags($new_tags); + } + + // Prevent uploading twice. + header('Location: ' . BASEURL . '/uploadmedia/'); + exit; + } + } +} diff --git a/controllers/ViewPeople.php b/controllers/ViewPeople.php new file mode 100644 index 0000000..4b80365 --- /dev/null +++ b/controllers/ViewPeople.php @@ -0,0 +1,45 @@ + $album['id_tag'], + 'caption' => $album['tag'], + 'link' => BASEURL . '/' . $album['slug'] . '/', + 'thumbnail' => !empty($album['id_asset_thumb']) ? $assets[$album['id_asset_thumb']]->getImage() : null, + ]; + } + + $index = new AlbumIndex($albums); + + parent::__construct('People - ' . SITE_TITLE); + $this->page->adopt($index); + $this->page->setCanonicalUrl(BASEURL . '/people/'); + } +} diff --git a/controllers/ViewPhotoAlbum.php b/controllers/ViewPhotoAlbum.php new file mode 100644 index 0000000..afbb7ff --- /dev/null +++ b/controllers/ViewPhotoAlbum.php @@ -0,0 +1,130 @@ +id_tag; + $title = $tag->tag; + $description = !empty($tag->description) ? '

' . $tag->description . '

' : ''; + + // Can we go up a level? + if ($tag->id_parent != 0) + { + $ptag = Tag::fromId($tag->id_parent); + $description .= '

« Go back to "' . $ptag->tag . '"

'; + } + elseif ($tag->kind === 'Person') + $description .= '

« Go back to "People"

'; + } + // View the album root. + else + { + $id_tag = 1; + $title = 'Albums'; + $description = ''; + } + + // What page are we at? + $page = isset($_GET['page']) ? (int) $_GET['page'] : 1; + + parent::__construct($title . ' - Page ' . $page . ' - ' . SITE_TITLE); + + if ($id_tag !== 1) + $this->page->adopt(new DummyBox($title, $description, 'page_title_box')); + + // Fetch subalbums, but only if we're on the first page. + if ($page === 1) + { + $albums = $this->getAlbums($id_tag); + $index = new AlbumIndex($albums); + $this->page->adopt($index); + } + + // Load a photo mosaic for the current tag. + list($mosaic, $total_count) = $this->getPhotoMosaic($id_tag, $page); + if (isset($mosaic)) + $this->page->adopt(new PhotosIndex($mosaic, Registry::get('user')->isAdmin())); + + // Make a page index as needed, while we're at it. + if ($total_count > self::PER_PAGE) + { + $index = new PageIndex([ + 'recordCount' => $total_count, + 'items_per_page' => self::PER_PAGE, + 'start' => (isset($_GET['page']) ? $_GET['page'] - 1 : 0) * self::PER_PAGE, + 'base_url' => BASEURL . '/' . (isset($_GET['tag']) ? $_GET['tag'] . '/' : ''), + 'page_slug' => 'page/%PAGE%/', + ]); + $this->page->adopt(new Pagination($index)); + } + + // Set the canonical url. + $this->page->setCanonicalUrl(BASEURL . '/' . (isset($_GET['tag']) ? $_GET['tag'] . '/' : '')); + } + + public function getPhotoMosaic($id_tag, $page) + { + // Create an iterator. + list($this->iterator, $total_count) = AssetIterator::getByOptions([ + 'id_tag' => $id_tag, + 'order' => 'date_captured', + 'direction' => 'desc', + 'limit' => self::PER_PAGE, + 'page' => $page, + ], true); + + $mosaic = $total_count > 0 ? new PhotoMosaic($this->iterator) : null; + return [$mosaic, $total_count]; + } + + private function getAlbums($id_tag) + { + // Fetch subalbums. + $subalbums = Tag::getAlbums($id_tag); + + // What assets are we using? + $id_assets = array_map(function($album) { + return (int) $album['id_asset_thumb']; + }, $subalbums); + + // Fetch assets for thumbnails. + $assets = Asset::fromIds($id_assets, 'object'); + + // Build album list. + $albums = []; + foreach ($subalbums as $album) + { + $albums[$album['id_tag']] = [ + 'id_tag' => $album['id_tag'], + 'caption' => $album['tag'], + 'link' => BASEURL . '/' . $album['slug'] . '/', + 'thumbnail' => !empty($album['id_asset_thumb']) ? $assets[$album['id_asset_thumb']]->getImage() : null, + ]; + } + + return $albums; + } + + public function __destruct() + { + if (isset($this->iterator)) + $this->iterator->clean(); + } +} diff --git a/controllers/ViewTimeline.php b/controllers/ViewTimeline.php new file mode 100644 index 0000000..1a5c8f1 --- /dev/null +++ b/controllers/ViewTimeline.php @@ -0,0 +1,58 @@ +iterator, $total_count) = AssetIterator::getByOptions([ + 'order' => 'date_captured', + 'direction' => 'desc', + 'limit' => self::PER_PAGE, + 'page' => $page, + ], true); + + $mosaic = $total_count > 0 ? new PhotoMosaic($this->iterator) : null; + if (isset($mosaic)) + $this->page->adopt(new PhotosIndex($mosaic, Registry::get('user')->isAdmin())); + + // Make a page index as needed, while we're at it. + if ($total_count > self::PER_PAGE) + { + $index = new PageIndex([ + 'recordCount' => $total_count, + 'items_per_page' => self::PER_PAGE, + 'start' => (isset($_GET['page']) ? $_GET['page'] - 1 : 0) * self::PER_PAGE, + 'base_url' => BASEURL . '/timeline/', + 'page_slug' => 'page/%PAGE%/', + ]); + $this->page->adopt(new Pagination($index)); + } + + // Set the canonical url. + $this->page->setCanonicalUrl(BASEURL . '/timeline/'); + } + + public function __destruct() + { + if (isset($this->iterator)) + $this->iterator->clean(); + } +} diff --git a/import_albums.php b/import_albums.php new file mode 100644 index 0000000..770d9cb --- /dev/null +++ b/import_albums.php @@ -0,0 +1,256 @@ +queryValue(' + SELECT COUNT(*) + FROM items + WHERE type = {string:album} + ORDER BY id ASC', + ['album' => 'album']); + +echo $num_albums, ' albums to import.', "\n"; + +$albums = $pdb->query(' + SELECT id, album_cover_item_id, parent_id, title, description, relative_path_cache, relative_url_cache + FROM items + WHERE type = {string:album} + ORDER BY id ASC', + ['album' => 'album']); + +$tags = []; +$old_album_id_to_new_tag_id = []; +$dirnames_by_old_album_id = []; +$old_thumb_id_by_tag_id = []; + +while ($album = $pdb->fetch_assoc($albums)) +{ + $tag = Tag::createNew([ + 'tag' => $album['title'], + 'slug' => $album['relative_url_cache'], + 'kind' => 'Album', + 'description' => $album['description'], + ]); + + if (!empty($album['parent_id'])) + $parent_to_set[$tag->id_tag] = $album['parent_id']; + + $tags[$tag->id_tag] = $tag; + $old_album_id_to_new_tag_id[$album['id']] = $tag->id_tag; + $dirnames_by_old_album_id[$album['id']] = str_replace('#', '', urldecode($album['relative_path_cache'])); + $old_thumb_id_by_tag_id[$tag->id_tag] = $album['album_cover_item_id']; +} + +$pdb->free_result($albums); + +foreach ($parent_to_set as $id_tag => $old_album_id) +{ + $id_parent = $old_album_id_to_new_tag_id[$old_album_id]; + $db->query(' + UPDATE tags + SET id_parent = ' . $id_parent . ' + WHERE id_tag = ' . $id_tag); +} + +unset($parent_to_set); + +/******************************* + * STEP 2: PHOTOS + *******************************/ + +$num_photos = $pdb->queryValue(' + SELECT COUNT(*) + FROM items + WHERE type = {string:photo}', + ['photo' => "photo"]); + +echo $num_photos, " photos to import.\n"; + +$old_photo_id_to_asset_id = []; +for ($i = 0; $i < $num_photos; $i += 50) +{ + echo 'Offset ' . $i . "...\n"; + + $photos = $pdb->query(' + SELECT id, parent_id, captured, created, name, title, description, relative_url_cache, width, height, mime_type, weight + FROM items + WHERE type = {string:photo} + ORDER BY id ASC + LIMIT ' . $i . ', 50', + ['photo' => 'photo']); + + while ($photo = $pdb->fetch_assoc($photos)) + { + $res = $db->query(' + INSERT INTO assets + (subdir, filename, title, slug, mimetype, image_width, image_height, date_captured, priority) + VALUES + ({string:subdir}, {string:filename}, {string:title}, {string:slug}, {string:mimetype}, + {int:image_width}, {int:image_height}, + IF({int:date_captured} > 0, FROM_UNIXTIME({int:date_captured}), NULL), + {int:priority})', + [ + 'subdir' => $dirnames_by_old_album_id[$photo['parent_id']], + 'filename' => $photo['name'], + 'title' => $photo['title'], + 'slug' => $photo['relative_url_cache'], + 'mimetype' => $photo['mime_type'], + 'image_width' => !empty($photo['width']) ? $photo['width'] : 'NULL', + 'image_height' => !empty($photo['height']) ? $photo['height'] : 'NULL', + 'date_captured' => !empty($photo['captured']) ? $photo['captured'] : $photo['created'], + 'priority' => !empty($photo['weight']) ? (int) $photo['weight'] : 0, + ]); + + $id_asset = $db->insert_id(); + $old_photo_id_to_asset_id[$photo['id']] = $id_asset; + + // Link to album. + $db->query(' + INSERT INTO assets_tags + (id_asset, id_tag) + VALUES + ({int:id_asset}, {int:id_tag})', + [ + 'id_asset' => $id_asset, + 'id_tag' => $old_album_id_to_new_tag_id[$photo['parent_id']], + ]); + } +} + +/******************************* + * STEP 3: TAGS + *******************************/ + +$num_tags = $pdb->queryValue(' + SELECT COUNT(*) + FROM tags'); + +echo $num_tags, " tags to import.\n"; + +$rs_tags = $pdb->query(' + SELECT id, name, count + FROM tags'); + +$old_tag_id_to_new_tag_id = []; +while ($person = $pdb->fetch_assoc($rs_tags)) +{ + $tag = Tag::createNew([ + 'tag' => $person['name'], + 'slug' => $person['name'], + 'kind' => 'Person', + 'description' => '', + 'count' => $person['count'], + ]); + + $tags[$tag->id_tag] = $tag; + $old_tag_id_to_new_tag_id[$person['id']] = $tag->id_tag; +} + +$pdb->free_result($rs_tags); + +/******************************* + * STEP 4: TAGGED PHOTOS + *******************************/ + +$num_tagged = $pdb->queryValue(' + SELECT COUNT(*) + FROM items_tags + WHERE item_id IN( + SELECT id + FROM items + WHERE type = {string:photo} + )', + ['photo' => 'photo']); + +echo $num_tagged, " photo tags to import.\n"; + +$rs_tags = $pdb->query(' + SELECT item_id, tag_id + FROM items_tags + WHERE item_id IN( + SELECT id + FROM items + WHERE type = {string:photo} + )', + ['photo' => 'photo']); + +while ($tag = $pdb->fetch_assoc($rs_tags)) +{ + if (!isset($old_tag_id_to_new_tag_id[$tag['tag_id']], $old_photo_id_to_asset_id[$tag['item_id']])) + continue; + + $id_asset = $old_photo_id_to_asset_id[$tag['item_id']]; + $id_tag = $old_tag_id_to_new_tag_id[$tag['tag_id']]; + + // Link up. + $db->query(' + INSERT IGNORE INTO assets_tags + (id_asset, id_tag) + VALUES + ({int:id_asset}, {int:id_tag})', + [ + 'id_asset' => $id_asset, + 'id_tag' => $id_tag, + ]); +} + +$pdb->free_result($rs_tags); + +/******************************* + * STEP 5: THUMBNAIL IDS + *******************************/ + +foreach ($old_thumb_id_by_tag_id as $id_tag => $old_thumb_id) +{ + if (!isset($old_photo_id_to_asset_id[$old_thumb_id])) + continue; + + $id_asset = $old_photo_id_to_asset_id[$old_thumb_id]; + $db->query(' + UPDATE tags + SET id_asset_thumb = ' . $id_asset . ' + WHERE id_tag = ' . $id_tag); +} + +/******************************* + * STEP 6: THUMBNAILS FOR PEOPLE + *******************************/ + +$db->query(' + UPDATE tags AS t + SET id_asset_thumb = ( + SELECT id_asset + FROM assets_tags AS a + WHERE a.id_tag = t.id_tag + ORDER BY RAND() + LIMIT 1 + ) + WHERE kind = {string:person}', + ['person' => 'Person']); diff --git a/models/Asset.php b/models/Asset.php new file mode 100644 index 0000000..5aec230 --- /dev/null +++ b/models/Asset.php @@ -0,0 +1,478 @@ + $value) + $this->$attribute = $value; + + if (!empty($data['date_captured']) && $data['date_captured'] !== 'NULL') + $this->date_captured = new DateTime($data['date_captured']); + } + + public static function fromId($id_asset, $return_format = 'object') + { + $db = Registry::get('db'); + + $row = $db->queryAssoc(' + SELECT * + FROM assets + WHERE id_asset = {int:id_asset}', + [ + 'id_asset' => $id_asset, + ]); + + // Asset not found? + if (empty($row)) + return false; + + $row['meta'] = $db->queryPair(' + SELECT variable, value + FROM assets_meta + WHERE id_asset = {int:id_asset}', + [ + 'id_asset' => $id_asset, + ]); + + return $return_format == 'object' ? new Asset($row) : $row; + } + + public static function fromIds(array $id_assets, $return_format = 'array') + { + if (empty($id_assets)) + return []; + + $db = Registry::get('db'); + + $res = $db->query(' + SELECT * + FROM assets + WHERE id_asset IN ({array_int:id_assets}) + ORDER BY id_asset', + [ + 'id_assets' => $id_assets, + ]); + + $assets = []; + while ($asset = $db->fetch_assoc($res)) + { + $assets[$asset['id_asset']] = $asset; + $assets[$asset['id_asset']]['meta'] = []; + } + + $metas = $db->queryRows(' + SELECT id_asset, variable, value + FROM assets_meta + WHERE id_asset IN ({array_int:id_assets}) + ORDER BY id_asset', + [ + 'id_assets' => $id_assets, + ]); + + foreach ($metas as $meta) + $assets[$meta[0]]['meta'][$meta[1]] = $meta[2]; + + if ($return_format == 'array') + return $assets; + else + { + $objects = []; + foreach ($assets as $id => $asset) + $objects[$id] = new Asset($asset); + return $objects; + } + } + + public static function byPostId($id_post, $return_format = 'object') + { + $db = Registry::get('db'); + + // !!! TODO + } + + public static function createNew(array $data, $return_format = 'object') + { + // Extract the data array. + extract($data); + + // No filename? Abort! + if (!isset($filename_to_copy) || !is_file($filename_to_copy)) + return false; + + // No subdir? Use YYYY/MM + if (!isset($preferred_subdir)) + $preferred_subdir = date('Y') . '/' . date('m'); + + // Does this dir exist yet? If not, create it. + if (!is_dir(ASSETSDIR . '/' . $preferred_subdir)) + mkdir(ASSETSDIR . '/' . $preferred_subdir, 0755, true); + + // Construct the destination filename. Make sure we don't accidentally overwrite anything. + if (!isset($preferred_filename)) + $preferred_filename = basename($filename_to_copy); + + $new_filename = $preferred_filename; + $destination = ASSETSDIR . '/' . $preferred_subdir . '/' . $preferred_filename; + while (file_exists($destination)) + { + $filename = pathinfo($preferred_filename, PATHINFO_FILENAME) . '_' . mt_rand(10, 99); + $extension = pathinfo($preferred_filename, PATHINFO_EXTENSION); + $new_filename = $filename . '.' . $extension; + $destination = dirname($destination) . '/' . $new_filename; + } + + // Can we write to the target directory? Then copy the file. + if (is_writable(ASSETSDIR . '/' . $preferred_subdir)) + copy($filename_to_copy, $destination); + else + return false; + + // Figure out the mime type for the file. + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mimetype = finfo_file($finfo, $destination); + finfo_close($finfo); + + // Do we have a title yet? Otherwise, use the filename. + $title = isset($data['title']) ? $data['title'] : pathinfo($preferred_filename, PATHINFO_FILENAME); + + // Same with the slug. + $slug = isset($data['slug']) ? $data['slug'] : $preferred_subdir . '/' . pathinfo($preferred_filename, PATHINFO_FILENAME); + + // Detected an image? + if (substr($mimetype, 0, 5) == 'image') + { + $image = new Imagick($destination); + $d = $image->getImageGeometry(); + + // Get image dimensions, bearing orientation in mind. + switch ($image->getImageOrientation()) + { + case Imagick::ORIENTATION_LEFTBOTTOM: + case Imagick::ORIENTATION_RIGHTTOP: + $image_width = $d['height']; + $image_height = $d['width']; + break; + + default: + $image_width = $d['width']; + $image_height = $d['height']; + } + + unset($image); + + $exif = EXIF::fromFile($destination); + $date_captured = intval($exif->created_timestamp); + } + + $db = Registry::get('db'); + $res = $db->query(' + INSERT INTO assets + (subdir, filename, title, slug, mimetype, image_width, image_height, date_captured, priority) + VALUES + ({string:subdir}, {string:filename}, {string:title}, {string:slug}, {string:mimetype}, + {int:image_width}, {int:image_height}, + IF({int:date_captured} > 0, FROM_UNIXTIME({int:date_captured}), NULL), + {int:priority})', + [ + 'subdir' => $preferred_subdir, + 'filename' => $new_filename, + 'title' => $title, + 'slug' => $slug, + 'mimetype' => $mimetype, + 'image_width' => isset($image_width) ? $image_width : 'NULL', + 'image_height' => isset($image_height) ? $image_height : 'NULL', + 'date_captured' => isset($date_captured) ? $date_captured : 'NULL', + 'priority' => isset($priority) ? (int) $priority : 0, + ]); + + if (!$res) + { + unlink($destination); + return false; + } + + $data['id_asset'] = $db->insert_id(); + return $return_format == 'object' ? new self($data) : $data; + } + + public function getId() + { + return $this->id_asset; + } + + public function getDateCaptured() + { + return $this->date_captured; + } + + public function getFilename() + { + return $this->filename; + } + + public function getLinkedPosts() + { + $posts = Registry::get('db')->queryValues(' + SELECT id_post + FROM posts_assets + WHERE id_asset = {int:id_asset}', + ['id_asset' => $this->id_asset]); + + // TODO: fix empty post iterator. + if (empty($posts)) + return []; + + return PostIterator::getByOptions([ + 'ids' => $posts, + 'type' => '', + ]); + } + + public function getMeta() + { + return $this->meta; + } + + public function getPath() + { + return $this->subdir; + } + + public function getPriority() + { + return $this->priority; + } + + public function getTags() + { + if (!isset($this->tags)) + $this->tags = Tag::byAssetId($this->id_asset); + + return $this->tags; + } + + public function getTitle() + { + return $this->title; + } + + public function getUrl() + { + return BASEURL . '/assets/' . $this->subdir . '/' . $this->filename; + } + + public function getType() + { + return substr($this->mimetype, 0, strpos($this->mimetype, '/')); + } + + public function getDimensions($as_type = 'string') + { + return $as_type === 'string' ? $this->image_width . 'x' . $this->image_height : [$this->image_width, $this->image_height]; + } + + public function isImage() + { + return substr($this->mimetype, 0, 5) === 'image'; + } + + public function getImage() + { + if (!$this->isImage()) + throw new Exception('Trying to upgrade an Asset to an Image while the Asset is not an image!'); + + return new Image(get_object_vars($this)); + } + + public function replaceFile($filename) + { + // No filename? Abort! + if (!isset($filename) || !is_readable($filename)) + return false; + + // Can we write to the target file? + $destination = ASSETSDIR . '/' . $this->subdir . '/' . $this->filename; + if (!is_writable($destination)) + return false; + + copy($filename, $destination); + + // Figure out the mime type for the file. + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $this->mimetype = finfo_file($finfo, $destination); + finfo_close($finfo); + + // Detected an image? + if (substr($this->mimetype, 0, 5) == 'image') + { + $image = new Imagick($destination); + $d = $image->getImageGeometry(); + $this->image_width = $d['width']; + $this->image_height = $d['height']; + unset($image); + + $exif = EXIF::fromFile($destination); + if (!empty($exif->created_timestamp)) + $this->date_captured = new DateTime(date('r', $exif->created_timestamp)); + else + $this->date_captured = null; + } + else + { + $this->image_width = null; + $this->image_height = null; + $this->date_captured = null; + } + + return Registry::get('db')->query(' + UPDATE assets + SET + mimetype = {string:mimetype}, + image_width = {int:image_width}, + image_height = {int:image_height}, + date_captured = {datetime:date_captured}, + priority = {int:priority} + WHERE id_asset = {int:id_asset}', + [ + 'id_asset' => $this->id_asset, + 'mimetype' => $this->mimetype, + 'image_width' => isset($this->image_width) ? $this->image_width : 'NULL', + 'image_height' => isset($this->image_height) ? $this->image_height : 'NULL', + 'date_captured' => isset($this->date_captured) ? $this->date_captured : 'NULL', + 'priority' => $this->priority, + ]); + } + + protected function saveMetaData() + { + $this->setMetaData($this->meta); + } + + public function setMetaData(array $new_meta, $mode = 'replace') + { + $db = Registry::get('db'); + + // If we're replacing, delete current data first. + if ($mode === 'replace') + { + $to_remove = array_diff_key($this->meta, $new_meta); + if (!empty($to_remove)) + $db->query(' + DELETE FROM assets_meta + WHERE id_asset = {int:id_asset} AND + variable IN({array_string:variables})', + [ + 'id_asset' => $this->id_asset, + 'variables' => array_keys($to_remove), + ]); + } + + // Build rows + $to_insert = []; + foreach ($new_meta as $key => $value) + $to_insert[] = [ + 'id_asset' => $this->id_asset, + 'variable' => $key, + 'value' => $value, + ]; + + // Do the insertion + $res = Registry::get('db')->insert('replace', 'assets_meta', [ + 'id_asset' => 'int', + 'variable' => 'string', + 'value' => 'string', + ], $to_insert, ['id_asset', 'variable']); + + if ($res) + $this->meta = $new_meta; + } + + public function delete() + { + return Registry::get('db')->query(' + DELETE FROM assets + WHERE id_asset = {int:id_asset}', + [ + 'id_asset' => $this->id_asset, + ]); + } + + public function linkTags(array $id_tags) + { + if (empty($id_tags)) + return true; + + $pairs = []; + foreach ($id_tags as $id_tag) + $pairs[] = ['id_asset' => $this->id_asset, 'id_tag' => $id_tag]; + + Registry::get('db')->insert('ignore', 'assets_tags', [ + 'id_asset' => 'int', + 'id_tag' => 'int', + ], $pairs, ['id_asset', 'id_tag']); + + Tag::recount($id_tags); + } + + public function unlinkTags(array $id_tags) + { + if (empty($id_tags)) + return true; + + Registry::get('db')->query(' + DELETE FROM assets_tags + WHERE id_asset = {int:id_asset} AND id_tag IN ({array_int:id_tags})', + [ + 'id_asset' => $this->id_asset, + 'id_tags' => $id_tags, + ]); + + Tag::recount($id_tags); + } + + public static function getCount() + { + return $db->queryValue(' + SELECT COUNT(*) + FROM assets'); + } + + public function setKeyData($title, DateTime $date_captured = null, $priority) + { + $params = [ + 'id_asset' => $this->id_asset, + 'title' => $title, + 'priority' => $priority, + ]; + + if (isset($date_captured)) + $params['date_captured'] = $date_captured->format('Y-m-d H:i:s'); + + return Registry::get('db')->query(' + UPDATE assets + SET title = {string:title},' . (isset($date_captured) ? ' + date_captured = {datetime:date_captured},' : '') . ' + priority = {int:priority} + WHERE id_asset = {int:id_asset}', + $params); + } +} diff --git a/models/AssetIterator.php b/models/AssetIterator.php new file mode 100644 index 0000000..0a1d7fd --- /dev/null +++ b/models/AssetIterator.php @@ -0,0 +1,154 @@ +db = Registry::get('db'); + $this->res_assets = $res_assets; + $this->res_meta = $res_meta; + $this->return_format = $return_format; + } + + public function next() + { + $row = $this->db->fetch_assoc($this->res_assets); + + // No more rows? + if (!$row) + return false; + + // Looks up metadata. + $row['meta'] = []; + while ($meta = $this->db->fetch_assoc($this->res_meta)) + { + if ($meta['id_asset'] != $row['id_asset']) + continue; + + $row['meta'][$meta['variable']] = $meta['value']; + } + + // Reset internal pointer for next asset. + $this->db->data_seek($this->res_meta, 0); + + if ($this->return_format == 'object') + return new Asset($row); + else + return $row; + } + + public function reset() + { + $this->db->data_seek($this->res_assets, 0); + $this->db->data_seek($this->res_meta, 0); + } + + public function clean() + { + if (!$this->res_assets) + return; + + $this->db->free_result($this->res_assets); + $this->res_assets = null; + } + + public function num() + { + return $this->db->num_rows($this->res_assets); + } + + public static function all() + { + return self::getByOptions(); + } + + public static function getByOptions(array $options = [], $return_count = false, $return_format = 'object') + { + $params = [ + 'limit' => isset($options['limit']) ? $options['limit'] : 0, + 'order' => isset($options['order']) && in_array($options['order'], ['id_asset', 'subdir', 'filename', 'title', + 'mime_type', 'image_width', 'image_height', 'date_captured']) ? $options['order'] : 'id_asset', + 'direction' => isset($options['direction']) ? $options['direction'] : 'asc', + ]; + + if (isset($options['offset'])) + $params['offset'] = $options['offset']; + elseif (isset($options['page']) && $options['page'] >= 1) + $params['offset'] = $params['limit'] * ($options['page'] - 1); + else + $params['offset'] = 0; + + $where = []; + + if (isset($options['mime_type'])) + { + $params['mime_type'] = $options['mime_type']; + if (is_array($options['mime_type'])) + $where[] = 'a.mimetype IN({array_string:mime_type})'; + else + $where[] = 'a.mimetype = {string:mime_type}'; + } + if (isset($options['id_tag'])) + { + $params['id_tag'] = $options['id_tag']; + $where[] = 'id_asset IN( + SELECT l.id_asset + FROM assets_tags AS l + WHERE l.id_tag = {int:id_tag})'; + } + + // Make it valid SQL. + $order = 'a.' . $params['order'] . ' ' . ($params['direction'] == 'desc' ? 'DESC' : 'ASC'); + $where = empty($where) ? '1' : implode(' AND ', $where); + + // And ... go! + $db = Registry::get('db'); + + // Get a resource object for the assets. + $res_assets = $db->query(' + SELECT a.* + FROM assets AS a + WHERE ' . $where . ' + ORDER BY ' . $order . (!empty($params['limit']) ? ' + LIMIT {int:offset}, {int:limit}' : ''), + $params); + + // Get a resource object for the asset meta. + $res_meta = $db->query(' + SELECT id_asset, variable, value + FROM assets_meta + WHERE id_asset IN( + SELECT id_asset + FROM assets AS a + WHERE ' . $where . ' + ) + ORDER BY id_asset', + $params); + + $iterator = new self($res_assets, $res_meta, $return_format); + + // Returning total count, too? + if ($return_count) + { + $count = $db->queryValue(' + SELECT COUNT(*) + FROM assets AS a + WHERE ' . $where, + $params); + + return [$iterator, $count]; + } + else + return $iterator; + } +} diff --git a/models/Authentication.php b/models/Authentication.php new file mode 100644 index 0000000..1f0f505 --- /dev/null +++ b/models/Authentication.php @@ -0,0 +1,114 @@ +queryValue(' + SELECT id_user + FROM users + WHERE id_user = {int:id}', + [ + 'id' => $id_user, + ]); + + return $res !== null; + } + + /** + * Finds the user id belonging to a certain emailaddress. + */ + public static function getUserId($emailaddress) + { + $res = Registry::get('db')->queryValue(' + SELECT id_user + FROM users + WHERE emailaddress = {string:emailaddress}', + [ + 'emailaddress' => $emailaddress, + ]); + + return empty($res) ? false : $res; + } + + /** + * Verifies whether the user is currently logged in. + */ + public static function isLoggedIn() + { + // Check whether the active session matches the current user's environment. + if (isset($_SESSION['ip_address'], $_SESSION['user_agent']) && ( + (isset($_SERVER['REMOTE_ADDR']) && $_SESSION['ip_address'] != $_SERVER['REMOTE_ADDR']) || + (isset($_SERVER['HTTP_USER_AGENT']) && $_SESSION['user_agent'] != $_SERVER['HTTP_USER_AGENT']))) + { + session_destroy(); + return false; + } + + // A user is logged in if a user id exists in the session and this id is (still) in the database. + return isset($_SESSION['user_id']) && self::checkExists($_SESSION['user_id']); + } + + /** + * Checks a password for a given username against the database. + */ + public static function checkPassword($emailaddress, $password) + { + // Retrieve password hash for user matching the provided emailaddress. + $password_hash = Registry::get('db')->queryValue(' + SELECT password_hash + FROM users + WHERE emailaddress = {string:emailaddress}', + [ + 'emailaddress' => $emailaddress, + ]); + + // If there's no hash, the user likely does not exist. + if (!$password_hash) + return false; + + return password_verify($password, $password_hash); + } + + /** + * Computes a password hash. + */ + public static function computeHash($password) + { + $hash = password_hash($password, PASSWORD_DEFAULT); + if (!$hash) + throw new Exception('Hash creation failed!'); + return $hash; + } + + /** + * Resets a password for a certain user. + */ + public static function updatePassword($id_user, $hash) + { + return Registry::get('db')->query(' + UPDATE users + SET + password_hash = {string:hash}, + reset_key = {string:blank} + WHERE id_user = {int:id_user}', + [ + 'id_user' => $id_user, + 'hash' => $hash, + 'blank' => '', + ]); + } +} diff --git a/models/BestColor.php b/models/BestColor.php new file mode 100644 index 0000000..ff30783 --- /dev/null +++ b/models/BestColor.php @@ -0,0 +1,149 @@ +best = ['r' => 204, 'g' => 204, 'b' => 204]; // #cccccc + + // We will be needing to read this... + if (!file_exists($asset->getPath())) + return; + + // Try the arcane stuff again. + try + { + $image = new Imagick($asset->getPath()); + $width = $image->getImageWidth(); + $height = $image->getImageHeight(); + + // Sample six points in the image: four based on the rule of thirds, as well as the horizontal and vertical centre. + $topy = round($height / 3); + $bottomy = round(($height / 3) * 2); + $leftx = round($width / 3); + $rightx = round(($width / 3) * 2); + $centery = round($height / 2); + $centerx = round($width / 2); + + // Grab their colours. + $rgb = [ + $image->getImagePixelColor($leftx, $topy)->getColor(), + $image->getImagePixelColor($rightx, $topy)->getColor(), + $image->getImagePixelColor($leftx, $bottomy)->getColor(), + $image->getImagePixelColor($rightx, $bottomy)->getColor(), + $image->getImagePixelColor($centerx, $centery)->getColor(), + ]; + + // We won't be needing this anymore, so save us some memory. + $image->clear(); + $image->destroy(); + } + // In case something does go wrong... + catch (ImagickException $e) + { + // Fall back to default color. + return; + } + + // Process rgb values into hsv values + foreach ($rgb as $i => $color) + { + $colors[$i] = $color; + list($colors[$i]['h'], $colors[$i]['s'], $colors[$i]['v']) = self::rgb2hsv($color['r'], $color['g'], $color['b']); + } + + // Figure out which color is the best saturated. + $best_saturation = $best_brightness = 0; + $the_best_s = $the_best_v = ['v' => 0]; + foreach ($colors as $color) + { + if ($color['s'] > $best_saturation) + { + $best_saturation = $color['s']; + $the_best_s = $color; + } + if ($color['v'] > $best_brightness) + { + $best_brightness = $color['v']; + $the_best_v = $color; + } + } + + // Is brightest the same as most saturated? + $this->best = ($the_best_s['v'] >= ($the_best_v['v'] - ($the_best_v['v'] / 2))) ? $the_best_s : $the_best_v; + } + + public static function hex2rgb($hex) + { + return sscanf($hex, '%2X%2X%2X'); + } + + public static function rgb2hex($red, $green, $blue) + { + return sprintf('%02X%02X%02X', $red, $green, $blue); + } + + public static function rgb2hsv($r, $g, $b) + { + $max = max($r, $g, $b); + $min = min($r, $g, $b); + $delta = $max - $min; + $v = round(($max / 255) * 100); + $s = ($max != 0) ? (round($delta / $max * 100)) : 0; + if ($s == 0) + { + $h = false; + } + else + { + if ($r == $max) + $h = ($g - $b) / $delta; + elseif ($g == $max) + $h = 2 + ($b - $r) / $delta; + elseif ($b == $max) + $h = 4 + ($r - $g) / $delta; + + $h = round($h * 60); + + if ($h > 360) + $h = 360; + + if ($h < 0) + $h += 360; + } + + return [$h, $s, $v]; + } + + /** + * Get a normal (light) background color as hexadecimal value (without hash prefix). + * @return color string + */ + public function hex() + { + $c = $this->best; + return self::rgb2hex($c['r'], $c['g'], $c['b']); + } + + /** + * Get a 50% darker version of the best color as string. + * @param factor, defaults to 0.5 + * @param alpha, defaults to 0.7 + * @return rgba(r * factor, g * factor, b * factor, alpha) + */ + public function rgba($factor = 0.5, $alpha = 0.7) + { + $c = $this->best; + return 'rgba(' . round($c['r'] * $factor) . ', ' . round($c['g'] * $factor) . ', ' . round($c['b'] * $factor) . ', ' . $alpha . ')'; + } + +} diff --git a/models/Cache.php b/models/Cache.php new file mode 100644 index 0000000..33e650f --- /dev/null +++ b/models/Cache.php @@ -0,0 +1,61 @@ +connection = @mysqli_connect($server, $user, $password, $name); + + // Give up if we have a connection error. + if (mysqli_connect_error()) + { + header('HTTP/1.1 503 Service Temporarily Unavailable'); + echo '

Database Connection Problems

Our software could not connect to the database. We apologise for any inconvenience and ask you to check back later.

'; + exit; + } + + $this->query('SET NAMES {string:utf8}', array('utf8' => 'utf8')); + } + + public function getQueryCount() + { + return $this->query_count; + } + + public function getLoggedQueries() + { + return $this->logged_queries; + } + + /** + * Fetches a row from a given recordset, using field names as keys. + */ + public function fetch_assoc($resource) + { + return mysqli_fetch_assoc($resource); + } + + /** + * Fetches a row from a given recordset, using numeric keys. + */ + public function fetch_row($resource) + { + return mysqli_fetch_row($resource); + } + + /** + * Destroys a given recordset. + */ + public function free_result($resource) + { + return mysqli_free_result($resource); + } + + public function data_seek($result, $row_num) + { + return mysqli_data_seek($result, $row_num); + } + + /** + * Returns the amount of rows in a given recordset. + */ + public function num_rows($resource) + { + return mysqli_num_rows($resource); + } + + /** + * Returns the amount of fields in a given recordset. + */ + public function num_fields($resource) + { + return mysqli_num_fields($resource); + } + + /** + * Escapes a string. + */ + public function escape_string($string) + { + return mysqli_real_escape_string($this->connection, $string); + } + + /** + * Unescapes a string. + */ + public function unescape_string($string) + { + return stripslashes($string); + } + + /** + * Returns the last MySQL error. + */ + public function error() + { + return mysqli_error($this->connection); + } + + public function server_info() + { + return mysqli_get_server_info($this->connection); + } + + /** + * Selects a database on a given connection. + */ + public function select_db($database) + { + return mysqli_select_db($database, $this->connection); + } + + /** + * Returns the amount of rows affected by the previous query. + */ + public function affected_rows() + { + return mysqli_affected_rows($this->connection); + } + + /** + * Returns the id of the row created by a previous query. + */ + public function insert_id() + { + return mysqli_insert_id($this->connection); + } + + /** + * Do a MySQL transaction. + */ + public function transaction($operation = 'commit') + { + switch ($operation) + { + case 'begin': + case 'rollback': + case 'commit': + return @mysqli_query($this->connection, strtoupper($operation)); + default: + return false; + } + } + + /** + * Function used as a callback for the preg_match function that parses variables into database queries. + */ + private function replacement_callback($matches) + { + list ($values, $connection) = $this->db_callback; + + if (!isset($matches[2])) + trigger_error('Invalid value inserted or no type specified.', E_USER_ERROR); + + if (!isset($values[$matches[2]])) + trigger_error('The database value you\'re trying to insert does not exist: ' . htmlspecialchars($matches[2]), E_USER_ERROR); + + $replacement = $values[$matches[2]]; + + switch ($matches[1]) + { + case 'int': + if ((!is_numeric($replacement) || (string) $replacement !== (string) (int) $replacement) && $replacement !== 'NULL') + trigger_error('Wrong value type sent to the database. Integer expected.', E_USER_ERROR); + return $replacement !== 'NULL' ? (string) (int) $replacement : 'NULL'; + break; + + case 'string': + case 'text': + return $replacement !== 'NULL' ? sprintf('\'%1$s\'', mysqli_real_escape_string($connection, $replacement)) : 'NULL'; + break; + + case 'array_int': + if (is_array($replacement)) + { + if (empty($replacement)) + trigger_error('Database error, given array of integer values is empty.', E_USER_ERROR); + + foreach ($replacement as $key => $value) + { + if (!is_numeric($value) || (string) $value !== (string) (int) $value) + trigger_error('Wrong value type sent to the database. Array of integers expected.', E_USER_ERROR); + + $replacement[$key] = (string) (int) $value; + } + + return implode(', ', $replacement); + } + else + trigger_error('Wrong value type sent to the database. Array of integers expected.', E_USER_ERROR); + + break; + + case 'array_string': + if (is_array($replacement)) + { + if (empty($replacement)) + trigger_error('Database error, given array of string values is empty.', E_USER_ERROR); + + foreach ($replacement as $key => $value) + $replacement[$key] = sprintf('\'%1$s\'', mysqli_real_escape_string($connection, $value)); + + return implode(', ', $replacement); + } + else + trigger_error('Wrong value type sent to the database. Array of strings expected.', E_USER_ERROR); + break; + + case 'date': + if (preg_match('~^(\d{4})-([0-1]?\d)-([0-3]?\d)$~', $replacement, $date_matches) === 1) + return sprintf('\'%04d-%02d-%02d\'', $date_matches[1], $date_matches[2], $date_matches[3]); + elseif ($replacement === 'NULL') + return 'NULL'; + else + trigger_error('Wrong value type sent to the database. Date expected.', E_USER_ERROR); + break; + + case 'datetime': + if (is_a($replacement, 'DateTime')) + return $replacement->format('\'Y-m-d H:i:s\''); + elseif (preg_match('~^(\d{4})-([0-1]?\d)-([0-3]?\d) (\d{2}):(\d{2}):(\d{2})$~', $replacement, $date_matches) === 1) + return sprintf('\'%04d-%02d-%02d %02d:%02d:%02d\'', $date_matches[1], $date_matches[2], $date_matches[3], $date_matches[4], $date_matches[5], $date_matches[6]); + elseif ($replacement === 'NULL') + return 'NULL'; + else + trigger_error('Wrong value type sent to the database. DateTime expected.', E_USER_ERROR); + break; + + case 'float': + if (!is_numeric($replacement) && $replacement !== 'NULL') + trigger_error('Wrong value type sent to the database. Floating point number expected.', E_USER_ERROR); + return $replacement !== 'NULL' ? (string) (float) $replacement : 'NULL'; + break; + + case 'identifier': + // Backticks inside identifiers are supported as of MySQL 4.1. We don't need them here. + return '`' . strtr($replacement, array('`' => '', '.' => '')) . '`'; + break; + + case 'raw': + return $replacement; + break; + + case 'bool': + case 'boolean': + // In mysql this is a synonym for tinyint(1) + return (bool)$replacement ? 1 : 0; + break; + + default: + trigger_error('Undefined type ' . $matches[1] . ' used in the database query', E_USER_ERROR); + break; + } + } + + /** + * Escapes and quotes a string using values passed, and executes the query. + */ + public function query($db_string, $db_values = array()) + { + // One more query.... + $this->query_count ++; + + // Overriding security? This is evil! + $security_override = $db_values === 'security_override' || !empty($db_values['security_override']); + + // Please, just use new style queries. + if (strpos($db_string, '\'') !== false && !$security_override) + trigger_error('Hack attempt!', 'Illegal character (\') used in query.', E_USER_ERROR); + + if (!$security_override && !empty($db_values)) + { + // Set some values for use in the callback function. + $this->db_callback = array($db_values, $this->connection); + + // Insert the values passed to this function. + $db_string = preg_replace_callback('~{([a-z_]+)(?::([a-zA-Z0-9_-]+))?}~', array(&$this, 'replacement_callback'), $db_string); + + // Save some memory. + $this->db_callback = []; + } + + if (defined("DB_LOG_QUERIES") && DB_LOG_QUERIES) + $this->logged_queries[] = $db_string; + + $return = @mysqli_query($this->connection, $db_string, empty($this->unbuffered) ? MYSQLI_STORE_RESULT : MYSQLI_USE_RESULT); + + if (!$return) + { + $clean_sql = implode("\n", array_map('trim', explode("\n", $db_string))); + trigger_error($this->error() . '
' . $clean_sql, E_USER_ERROR); + } + + return $return; + } + + /** + * Escapes and quotes a string just like db_query, but does not execute the query. + * Useful for debugging purposes. + */ + public function quote($db_string, $db_values = array()) + { + // Please, just use new style queries. + if (strpos($db_string, '\'') !== false) + trigger_error('Hack attempt!', 'Illegal character (\') used in query.', E_USER_ERROR); + + // Save some values for use in the callback function. + $this->db_callback = array($db_values, $this->connection); + + // Insert the values passed to this function. + $db_string = preg_replace_callback('~{([a-z_]+)(?::([a-zA-Z0-9_-]+))?}~', array(&$this, 'replacement_callback'), $db_string); + + // Save some memory. + $this->db_callback = array(); + + return $db_string; + } + + /** + * Executes a query, returning an array of all the rows it returns. + */ + public function queryRow($db_string, $db_values = array()) + { + $res = $this->query($db_string, $db_values); + + if (!$res || $this->num_rows($res) == 0) + return array(); + + $row = $this->fetch_row($res); + $this->free_result($res); + + return $row; + } + + /** + * Executes a query, returning an array of all the rows it returns. + */ + public function queryRows($db_string, $db_values = array()) + { + $res = $this->query($db_string, $db_values); + + if (!$res || $this->num_rows($res) == 0) + return array(); + + $rows = array(); + while ($row = $this->fetch_row($res)) + $rows[] = $row; + + $this->free_result($res); + + return $rows; + } + + /** + * Executes a query, returning an array of all the rows it returns. + */ + public function queryPair($db_string, $db_values = array()) + { + $res = $this->query($db_string, $db_values); + + if (!$res || $this->num_rows($res) == 0) + return array(); + + $rows = array(); + while ($row = $this->fetch_row($res)) + $rows[$row[0]] = $row[1]; + + $this->free_result($res); + + return $rows; + } + + /** + * Executes a query, returning an array of all the rows it returns. + */ + public function queryPairs($db_string, $db_values = array()) + { + $res = $this->query($db_string, $db_values); + + if (!$res || $this->num_rows($res) == 0) + return array(); + + $rows = array(); + while ($row = $this->fetch_assoc($res)) + { + $key_value = reset($row); + $rows[$key_value] = $row; + } + + $this->free_result($res); + + return $rows; + } + + /** + * Executes a query, returning an associative array of all the rows it returns. + */ + public function queryAssoc($db_string, $db_values = array()) + { + $res = $this->query($db_string, $db_values); + + if (!$res || $this->num_rows($res) == 0) + return array(); + + $row = $this->fetch_assoc($res); + $this->free_result($res); + + return $row; + } + + /** + * Executes a query, returning an associative array of all the rows it returns. + */ + public function queryAssocs($db_string, $db_values = array(), $connection = null) + { + $res = $this->query($db_string, $db_values); + + if (!$res || $this->num_rows($res) == 0) + return array(); + + $rows = array(); + while ($row = $this->fetch_assoc($res)) + $rows[] = $row; + + $this->free_result($res); + + return $rows; + } + + /** + * Executes a query, returning the first value of the first row. + */ + public function queryValue($db_string, $db_values = array()) + { + $res = $this->query($db_string, $db_values); + + // If this happens, you're doing it wrong. + if (!$res || $this->num_rows($res) == 0) + return null; + + list($value) = $this->fetch_row($res); + $this->free_result($res); + + return $value; + } + + /** + * Executes a query, returning an array of the first value of each row. + */ + public function queryValues($db_string, $db_values = array()) + { + $res = $this->query($db_string, $db_values); + + if (!$res || $this->num_rows($res) == 0) + return array(); + + $rows = array(); + while ($row = $this->fetch_row($res)) + $rows[] = $row[0]; + + $this->free_result($res); + + return $rows; + } + + /** + * This function can be used to insert data into the database in a secure way. + */ + public function insert($method = 'replace', $table, $columns, $data) + { + // With nothing to insert, simply return. + if (empty($data)) + return; + + // Inserting data as a single row can be done as a single array. + if (!is_array($data[array_rand($data)])) + $data = array($data); + + // Create the mold for a single row insert. + $insertData = '('; + foreach ($columns as $columnName => $type) + { + // Are we restricting the length? + if (strpos($type, 'string-') !== false) + $insertData .= sprintf('SUBSTRING({string:%1$s}, 1, ' . substr($type, 7) . '), ', $columnName); + else + $insertData .= sprintf('{%1$s:%2$s}, ', $type, $columnName); + } + $insertData = substr($insertData, 0, -2) . ')'; + + // Create an array consisting of only the columns. + $indexed_columns = array_keys($columns); + + // Here's where the variables are injected to the query. + $insertRows = array(); + foreach ($data as $dataRow) + $insertRows[] = $this->quote($insertData, array_combine($indexed_columns, $dataRow)); + + // Determine the method of insertion. + $queryTitle = $method == 'replace' ? 'REPLACE' : ($method == 'ignore' ? 'INSERT IGNORE' : 'INSERT'); + + // Do the insert. + return $this->query(' + ' . $queryTitle . ' INTO ' . $table . ' (`' . implode('`, `', $indexed_columns) . '`) + VALUES + ' . implode(', + ', $insertRows), + array( + 'security_override' => true, + ) + ); + } +} diff --git a/models/Dispatcher.php b/models/Dispatcher.php new file mode 100644 index 0000000..9336d44 --- /dev/null +++ b/models/Dispatcher.php @@ -0,0 +1,140 @@ + 'ViewPhotoAlbums', + 'editasset' => 'EditAsset', + 'edituser' => 'EditUser', + 'login' => 'Login', + 'logout' => 'Logout', + 'managecomments' => 'ManageComments', + 'manageerrors' => 'ManageErrors', + 'managetags' => 'ManageTags', + 'manageusers' => 'ManageUsers', + 'people' => 'ViewPeople', + 'suggest' => 'ProvideAutoSuggest', + 'timeline' => 'ViewTimeline', + 'uploadmedia' => 'UploadMedia', + ]; + + // Work around PHP's FPM not always providing PATH_INFO. + if (empty($_SERVER['PATH_INFO']) && isset($_SERVER['REQUEST_URI'])) + { + if (strpos($_SERVER['REQUEST_URI'], '?') === false) + $_SERVER['PATH_INFO'] = $_SERVER['REQUEST_URI']; + else + $_SERVER['PATH_INFO'] = substr($_SERVER['REQUEST_URI'], 0, strpos($_SERVER['REQUEST_URI'], '?')); + } + + // Nothing in particular? Just show the root of the album list. + if (empty($_SERVER['PATH_INFO']) || $_SERVER['PATH_INFO'] == '/') + { + return new ViewPhotoAlbum(); + } + // An album, person, or any other tag? + elseif (preg_match('~^/(?.+?)(?:/page/(?\d+))?/?$~', $_SERVER['PATH_INFO'], $path) && Tag::matchSlug($path['tag'])) + { + $_GET = array_merge($_GET, $path); + return new ViewPhotoAlbum(); + } + // Look for an action... + elseif (preg_match('~^/(?[a-z]+)(?:/page/(?\d+))?/?~', $_SERVER['PATH_INFO'], $path) && isset($possibleActions[$path['action']])) + { + $_GET = array_merge($_GET, $path); + return new $possibleActions[$path['action']](); + } + else + throw new NotFoundException(); + } + + public static function dispatch() + { + // Let's try to find our bearings! + try + { + $page = self::route(); + $page->showContent(); + } + // Something wasn't found? + catch (NotFoundException $e) + { + self::trigger404(); + } + // Or are they just sneaking into areas they don't belong? + catch (NotAllowedException $e) + { + if (Registry::get('user')->isGuest()) + self::kickGuest(); + else + self::trigger403(); + } + catch (Exception $e) + { + ErrorHandler::handleError(E_USER_ERROR, 'Unspecified exception: ' . $e->getMessage(), $e->getFile(), $e->getLine()); + } + catch (Error $e) + { + ErrorHandler::handleError(E_USER_ERROR, 'Fatal error: ' . $e->getMessage(), $e->getFile(), $e->getLine()); + } + } + + /** + * Kicks a guest to a login form, redirecting them back to this page upon login. + */ + public static function kickGuest() + { + $form = new LogInForm('Log in'); + $form->setErrorMessage('Admin access required. Please log in.'); + $form->setRedirectUrl($_SERVER['REQUEST_URI']); + + $page = new MainTemplate('Login required'); + $page->appendStylesheet(BASEURL . '/css/admin.css'); + $page->adopt($form); + $page->html_main(); + exit; + } + + public static function trigger400() + { + header('HTTP/1.1 400 Bad Request'); + $page = new MainTemplate('Bad request'); + $page->adopt(new DummyBox('Bad request', '

The server does not understand your request.

')); + $page->html_main(); + exit; + } + + public static function trigger403() + { + header('HTTP/1.1 403 Forbidden'); + $page = new MainTemplate('Access denied'); + $page->adopt(new DummyBox('Forbidden', '

You do not have access to the page you requested.

')); + $page->html_main(); + exit; + } + + public static function trigger404() + { + header('HTTP/1.1 404 Not Found'); + $page = new MainTemplate('Page not found'); + + if (Registry::has('user') && Registry::get('user')->isAdmin()) + { + $page->appendStylesheet(BASEURL . '/css/admin.css'); + $page->adopt(new AdminBar()); + } + + $page->adopt(new DummyBox('Well, this is a bit embarrassing!', '

The page you requested could not be found. Don\'t worry, it\'s probably not your fault. You\'re welcome to browse the website, though!

', 'errormsg')); + $page->addClass('errorpage'); + $page->html_main(); + exit; + } +} diff --git a/models/EXIF.php b/models/EXIF.php new file mode 100644 index 0000000..7d7460b --- /dev/null +++ b/models/EXIF.php @@ -0,0 +1,135 @@ + $value) + $this->$key = $value; + } + + public static function fromFile($file, $as_array = false, $override_cache = false) + { + if (!file_exists($file)) + return false; + + $meta = [ + 'aperture' => 0, + 'credit' => '', + 'camera' => '', + 'caption' => '', + 'created_timestamp' => 0, + 'copyright' => '', + 'focal_length' => 0, + 'iso' => 0, + 'shutter_speed' => 0, + 'title' => '', + ]; + + if (!function_exists('exif_read_data')) + throw new Exception("The PHP module 'exif' appears to be disabled. Please enable it to use EXIF functions."); + + list(, , $image_type) = getimagesize($file); + if (!in_array($image_type, [IMAGETYPE_JPEG, IMAGETYPE_TIFF_II, IMAGETYPE_TIFF_MM])) + return new self($meta); + + $exif = @exif_read_data($file); + + if (!empty($exif['Title'])) + $meta['title'] = trim($exif['Title']); + + if (!empty($exif['ImageDescription'])) + { + if (empty($meta['title']) && strlen($exif['ImageDescription']) < 80) + { + $meta['title'] = trim($exif['ImageDescription']); + if (!empty($exif['COMPUTED']['UserComment']) && trim($exif['COMPUTED']['UserComment']) != $meta['title']) + $meta['caption'] = trim($exif['COMPUTED']['UserComment']); + } + elseif (trim($exif['ImageDescription']) != $meta['title']) + $meta['caption'] = trim($exif['ImageDescription']); + } + elseif (!empty($exif['Comments']) && trim($exif['Comments']) != $meta['title']) + $meta['caption'] = trim($exif['Comments']); + + if (!empty($exif['Artist'])) + $meta['credit'] = trim($exif['Artist']); + elseif (!empty($exif['Author'])) + $meta['credit'] = trim($exif['Author']); + + if (!empty($exif['Copyright'])) + $meta['copyright'] = trim($exif['Copyright']); + + if (!empty($exif['ExposureTime'])) + $meta['shutter_speed'] = (string) self::frac2dec($exif['ExposureTime']); + + if (!empty($exif['FNumber'])) + $meta['aperture'] = round(self::frac2dec($exif['FNumber']), 2); + + if (!empty($exif['FocalLength'])) + $meta['focal_length'] = (string) self::frac2dec($exif['FocalLength']); + + if (!empty($exif['ISOSpeedRatings'])) + { + $meta['iso'] = is_array($exif['ISOSpeedRatings']) ? reset($exif['ISOSpeedRatings']) : $exif['ISOSpeedRatings']; + $meta['iso'] = trim($meta['iso']); + } + + if (!empty($exif['Model'])) + { + if (!empty($exif['Make']) && strpos($exif['Model'], $exif['Make']) === false) + $meta['camera'] = trim($exif['Make']) . ' ' . trim($exif['Model']); + else + $meta['camera'] = trim($exif['Model']); + } + elseif (!empty($exif['Make'])) + $meta['camera'] = trim($exif['Make']); + + if (!empty($exif['DateTimeDigitized'])) + $meta['created_timestamp'] = self::toUnixTime($exif['DateTimeDigitized']); + + return new self($meta); + } + + public function shutterSpeedFraction() + { + $speed = $this->shutter_speed; + if (empty($this->shutter_speed)) + return ''; + + if (1 / $this->shutter_speed <= 1) + return $this->shutter_speed . ' seconds'; + + $speed = (float) 1 / $this->shutter_speed; + if (in_array($speed, [1.3, 1.5, 1.6, 2.5])) + return "1/" . number_format($speed, 1, '.', '') . ' second'; + else + return "1/" . number_format($speed, 0, '.', '') . ' second'; + } + + // Convert a fraction string to a decimal. + private static function frac2dec($str) + { + @list($n, $d) = explode('/', $str); + return !empty($d) ? $n / $d : $str; + } + + // Convert an EXIF formatted date to a UNIX timestamp. + private static function toUnixTime($str) + { + @list($date, $time) = explode(' ', trim($str)); + @list($y, $m, $d) = explode(':', $date); + return strtotime("{$y}-{$m}-{$d} {$time}"); + } +} diff --git a/models/ErrorHandler.php b/models/ErrorHandler.php new file mode 100644 index 0000000..214f6ec --- /dev/null +++ b/models/ErrorHandler.php @@ -0,0 +1,159 @@ + '<', '>' => '>', '"' => '"', "\t" => ' ']); + $error_message = strtr($error_message, ['<br>' => "
", '<br />' => "
", '<b>' => '', '</b>' => '', '<pre>' => '
', '</pre>' => '
']); + + // Generate a bunch of useful information to ease debugging later. + $debug_info = self::getDebugInfo(debug_backtrace()); + + // Log the error in the database. + self::logError($error_message, $debug_info, $file, $line); + + // Are we considering this fatal? Then display and exit. + // !!! TODO: should we consider warnings fatal? + if (true) // DEBUG || (!DEBUG && $error_level === E_WARNING || $error_level === E_USER_WARNING)) + self::display($file . ' (' . $line . ')
' . $error_message, $debug_info); + + // If it wasn't a fatal error, well... + self::$handling_error = false; + } + + public static function getDebugInfo(array $trace) + { + $debug_info = "Backtrace:\n"; + $debug_info .= self::formatBacktrace($trace); + + // Include info on the contents of superglobals. + if (!empty($_SESSION)) + $debug_info .= "\nSESSION: " . print_r($_SESSION, true); + if (!empty($_POST)) + $debug_info .= "\nPOST: " . print_r($_POST, true); + if (!empty($_GET)) + $debug_info .= "\nGET: " . print_r($_GET, true); + + return $debug_info; + } + + private static function formatBacktrace(array $trace) + { + $buffer = ''; + $skipping = true; + + foreach ($trace as $i => $call) + { + if (isset($call['class']) && ($call['class'] === 'ErrorHandler' || $call['class'] === 'Database') || + isset($call['function']) && $call['function'] === 'preg_replace_callback') + { + if (!$skipping) + { + $buffer .= "[...]\n"; + $skipping = true; + } + continue; + } + else + $skipping = false; + + $file = isset($call['file']) ? str_replace(BASEDIR, '', $call['file']) : 'Unknown'; + $object = isset($call['class']) ? $call['class'] . $call['type'] : ''; + + $args = []; + foreach ($call['args'] as $j => $arg) + { + if (is_array($arg)) + $args[$j] = print_r($arg, true); + elseif (is_object($arg)) + $args[$j] = var_dump($arg); + } + + $buffer .= '#' . str_pad($i, 3, ' ') + . $object . $call['function'] . '(' . implode(', ', $args) . ')' + . ' called at [' . $file . ':' . $call['line'] . "]\n"; + } + + return $buffer; + } + + // Logs an error into the database. + private static function logError($error_message = '', $debug_info = '', $file = '', $line = 0) + { + if (!ErrorLog::log([ + 'message' => $error_message, + 'debug_info' => $debug_info, + 'file' => str_replace(BASEDIR, '', $file), + 'line' => $line, + 'id_user' => Registry::has('user') ? Registry::get('user')->getUserId() : 0, + 'ip_address' => isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '', + 'request_uri' => isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '', + ])) + { + header('HTTP/1.1 503 Service Temporarily Unavailable'); + echo '

An Error Occured

Our software could not connect to the database. We apologise for any inconvenience and ask you to check back later.

'; + exit; + } + + return $error_message; + } + + public static function display($message, $debug_info) + { + // Just show the message if we're running in a console. + if (empty($_SERVER['HTTP_HOST'])) + { + echo $message; + exit; + } + + // Initialise the main template to present a nice message to the user. + $page = new MainTemplate('An error occured!'); + + // Show the error. + $is_admin = Registry::has('user') && Registry::get('user')->isAdmin(); + if (DEBUG || $is_admin) + { + $page->adopt(new DummyBox('An error occured!', '

' . $message . '

' . $debug_info . '
')); + + // Let's provide the admin navigation despite it all! + if ($is_admin) + { + $page->appendStylesheet(BASEURL . '/css/admin.css'); + $page->adopt(new AdminBar()); + } + } + else + $page->adopt(new DummyBox('An error occured!', '

Our apologies, an error occured while we were processing your request. Please try again later, or contact us if the problem persists.

')); + + // If we got this far, make sure we're not showing stuff twice. + ob_end_clean(); + + // Render the page. + $page->html_main(); + exit; + } +} diff --git a/models/ErrorLog.php b/models/ErrorLog.php new file mode 100644 index 0000000..0f13d5b --- /dev/null +++ b/models/ErrorLog.php @@ -0,0 +1,36 @@ +query(' + INSERT INTO log_errors + (id_user, message, debug_info, file, line, request_uri, time, ip_address) + VALUES + ({int:id_user}, {string:message}, {string:debug_info}, {string:file}, {int:line}, + {string:request_uri}, CURRENT_TIMESTAMP, {string:ip_address})', + $data); + } + + public static function flush() + { + return Registry::get('db')->query('TRUNCATE log_errors'); + } + + public static function getCount() + { + return Registry::get('db')->queryValue(' + SELECT COUNT(*) + FROM log_errors'); + } +} diff --git a/models/Form.php b/models/Form.php new file mode 100644 index 0000000..64cfe04 --- /dev/null +++ b/models/Form.php @@ -0,0 +1,165 @@ +request_method = !empty($options['request_method']) ? $options['request_method'] : 'POST'; + $this->request_url = !empty($options['request_url']) ? $options['request_url'] : BASEURL; + $this->fields = !empty($options['fields']) ? $options['fields'] : []; + $this->content_below = !empty($options['content_below']) ? $options['content_below'] : null; + $this->content_above = !empty($options['content_above']) ? $options['content_above'] : null; + } + + public function verify($post) + { + $this->data = []; + $this->missing = []; + + foreach ($this->fields as $field_id => $field) + { + // Field disabled? + if (!empty($field['disabled'])) + { + $this->data[$field_id] = ''; + continue; + } + + // No data present at all for this field? + if ((!isset($post[$field_id]) || $post[$field_id] == '') && empty($field['is_optional'])) + { + $this->missing[] = $field_id; + $this->data[$field_id] = ''; + continue; + } + + // Verify data for all fields + switch ($field['type']) + { + case 'select': + case 'radio': + // Skip validation? Dangerous territory! + if (isset($field['verify_options']) && $field['verify_options'] === false) + $this->data[$field_id] = $post[$field_id]; + // Check whether selected option is valid. + elseif (isset($post[$field_id]) && !isset($field['options'][$post[$field_id]])) + { + $this->missing[] = $field_id; + $this->data[$field_id] = ''; + continue; + } + else + $this->data[$field_id] = $post[$field_id]; + break; + + case 'checkbox': + // Just give us a 'boolean' int for this one + $this->data[$field_id] = empty($post[$field_id]) ? 0 : 1; + break; + + case 'color': + // Colors are stored as a string of length 3 or 6 (hex) + if (!isset($post[$field_id]) || (strlen($post[$field_id]) != 3 && strlen($post[$field_id]) != 6)) + { + $this->missing[] = $field_id; + $this->data[$field_id] = ''; + continue; + } + else + $this->data[$field_id] = $post[$field_id]; + break; + + case 'file': + // Needs to be verified elsewhere! + break; + + case 'numeric': + $data = isset($post[$field_id]) ? $post[$field_id] : ''; + // Do we need to check bounds? + if (isset($field['min_value']) && is_numeric($data)) + { + if (is_float($field['min_value']) && (float) $data < $field['min_value']) + { + $this->missing[] = $field_id; + $this->data[$field_id] = 0.0; + } + elseif (is_int($field['min_value']) && (int) $data < $field['min_value']) + { + $this->missing[] = $field_id; + $this->data[$field_id] = 0; + } + else + $this->data[$field_id] = $data; + } + elseif (isset($field['max_value']) && is_numeric($data)) + { + if (is_float($field['max_value']) && (float) $data > $field['max_value']) + { + $this->missing[] = $field_id; + $this->data[$field_id] = 0.0; + } + elseif (is_int($field['max_value']) && (int) $data > $field['max_value']) + { + $this->missing[] = $field_id; + $this->data[$field_id] = 0; + } + else + $this->data[$field_id] = $data; + } + // Does it look numeric? + elseif (is_numeric($data)) + { + $this->data[$field_id] = $data; + } + // Let's consider it missing, then. + else + { + $this->missing[] = $field_id; + $this->data[$field_id] = 0; + } + break; + + case 'text': + case 'textarea': + default: + $this->data[$field_id] = isset($post[$field_id]) ? $post[$field_id] : ''; + } + } + } + + public function setData($data) + { + $this->verify($data); + $this->missing = []; + } + + public function getFields() + { + return $this->fields; + } + + public function getData() + { + return $this->data; + } + + public function getMissing() + { + return $this->missing; + } +} diff --git a/models/GenericTable.php b/models/GenericTable.php new file mode 100644 index 0000000..fb09bec --- /dev/null +++ b/models/GenericTable.php @@ -0,0 +1,246 @@ +tableIsSortable = !empty($options['base_url']); + + // How much stuff do we have? + $this->recordCount = call_user_func_array($options['get_count'], !empty($options['get_count_params']) ? $options['get_count_params'] : array()); + + // Should we create a page index? + $this->items_per_page = !empty($options['items_per_page']) ? $options['items_per_page'] : 30; + $this->needsPageIndex = !empty($this->items_per_page) && $this->recordCount > $this->items_per_page; + $this->index_class = isset($options['index_class']) ? $options['index_class'] : ''; + + // Figure out where to start. + $this->start = empty($options['start']) || !is_numeric($options['start']) || $options['start'] < 0 || $options['start'] > $this->recordCount ? 0 : $options['start']; + + // Let's bear a few things in mind... + $this->base_url = $options['base_url']; + + // This should be set at all times, too. + if (empty($options['no_items_label'])) + $options['no_items_label'] = ''; + + // Gather parameters for the data gather function first. + $parameters = array($this->start, $this->items_per_page, $options['sort_order'], $options['sort_direction']); + if (!empty($options['get_data_params']) && is_array($options['get_data_params'])) + $parameters = array_merge($parameters, $options['get_data_params']); + + // Okay, let's fetch the data! + $data = call_user_func_array($options['get_data'], $parameters); + + // Clean up a bit. + $rows = $data['rows']; + $this->sort_order = $data['order']; + $this->sort_direction = $data['direction']; + unset($data); + + // Okay, now for the column headers... + $this->generateColumnHeaders($options); + + // Generate a pagination if requested + if ($this->needsPageIndex) + $this->generatePageIndex(); + + // Not a single row in sight? + if (empty($rows)) + $this->body = $options['no_items_label']; + // Otherwise, parse it all! + else + $this->parseAllRows($rows, $options); + + // Got a title? + $this->title = isset($options['title']) ? htmlentities($options['title']) : ''; + $this->title_class = isset($options['title_class']) ? $options['title_class'] : ''; + + // Maybe even a form or two? + $this->form_above = isset($options['form_above']) ? $options['form_above'] : (isset($options['form']) ? $options['form'] : null); + $this->form_below = isset($options['form_below']) ? $options['form_below'] : (isset($options['form']) ? $options['form'] : null); + } + + private function generateColumnHeaders($options) + { + foreach ($options['columns'] as $key => $column) + { + if (empty($column['header'])) + continue; + + $header = array( + 'class' => isset($column['class']) ? $column['class'] : '', + 'colspan' => !empty($column['header_colspan']) ? $column['header_colspan'] : 1, + 'href' => !$this->tableIsSortable || empty($column['is_sortable']) ? '' : $this->getLink($this->start, $key, $key == $this->sort_order && $this->sort_direction == 'up' ? 'down' : 'up'), + 'label' => $column['header'], + 'scope' => 'col', + 'sort_mode' => $key == $this->sort_order ? $this->sort_direction : null, + 'width' => !empty($column['header_width']) && is_int($column['header_width']) ? $column['header_width'] : null, + ); + + $this->header[] = $header; + } + } + + private function parseAllRows($rows, $options) + { + // Parse all rows... + $i = 0; + foreach ($rows as $row) + { + $i ++; + $newRow = array( + 'class' => $i %2 == 1 ? 'odd' : 'even', + 'cells' => array(), + ); + + foreach ($options['columns'] as $column) + { + if (isset($column['enabled']) && $column['enabled'] == false) + continue; + + // The hard way? + if (isset($column['parse'])) + { + if (!isset($column['parse']['type'])) + $column['parse']['type'] = 'value'; + + // Parse the basic value first. + switch ($column['parse']['type']) + { + // value: easy as pie. + default: + case 'value': + $value = $row[$column['parse']['data']]; + break; + + // sprintf: filling the gaps! + case 'sprintf': + $parameters = array($column['parse']['data']['pattern']); + foreach ($column['parse']['data']['arguments'] as $identifier) + $parameters[] = $row[$identifier]; + $value = call_user_func_array('sprintf', $parameters); + break; + + // timestamps: let's make them readable! + case 'timestamp': + $pattern = !empty($column['parse']['data']['pattern']) && $column['parse']['data']['pattern'] == 'long' ? '%F %H:%M' : '%H:%M'; + + if (!is_numeric($row[$column['parse']['data']['timestamp']])) + $timestamp = strtotime($row[$column['parse']['data']['timestamp']]); + else + $timestamp = (int) $row[$column['parse']['data']['timestamp']]; + + if (isset($column['parse']['data']['if_null']) && $timestamp == 0) + $value = $column['parse']['data']['if_null']; + else + $value = strftime($pattern, $timestamp); + break; + + // function: the flexible way! + case 'function': + $value = $column['parse']['data']($row); + break; + } + + // Generate a link, if requested. + if (!empty($column['parse']['link'])) + { + // First, generate the replacement variables. + $keys = array_keys($row); + $values = array_values($row); + foreach ($keys as $keyKey => $keyValue) + $keys[$keyKey] = '{' . strtoupper($keyValue) . '}'; + + $value = '' . $value . ''; + } + } + // The easy way! + else + $value = $row[$column['value']]; + + // Append the cell to the row. + $newRow['cells'][] = array( + 'width' => !empty($column['cell_width']) && is_int($column['cell_width']) ? $column['cell_width'] : null, + 'value' => $value, + ); + } + + // Append the new row in the body. + $this->body[] = $newRow; + } + } + + protected function getLink($start = null, $order = null, $dir = null) + { + if ($start === null) + $start = $this->start; + if ($order === null) + $order = $this->sort_order; + if ($dir === null) + $dir = $this->sort_direction; + + return $this->base_url . (strpos($this->base_url, '?') ? '&' : '?') . 'start=' . $start . '&order=' . $order. '&dir=' . $dir; + } + + public function getOffset() + { + return $this->start; + } + + public function getArray() + { + // Makes no sense to call it for a table, but inherits from PageIndex due to poor design, sorry. + throw new Exception('Function call is ambiguous.'); + } + + public function getHeader() + { + return $this->header; + } + + public function getBody() + { + return $this->body; + } + + public function getTitle() + { + return $this->title; + } + + public function getTitleClass() + { + return $this->title_class; + } +} diff --git a/models/Guest.php b/models/Guest.php new file mode 100644 index 0000000..cbd717e --- /dev/null +++ b/models/Guest.php @@ -0,0 +1,31 @@ +id_user = 0; + $this->is_logged = false; + $this->is_guest = true; + $this->is_admin = false; + $this->first_name = 'Guest'; + $this->last_name = ''; + } + + public function updateAccessTime() + { + return true; + } +} diff --git a/models/Image.php b/models/Image.php new file mode 100644 index 0000000..3f69d45 --- /dev/null +++ b/models/Image.php @@ -0,0 +1,382 @@ + $value) + $this->$attribute = $value; + } + + public static function fromId($id_asset, $return_format = 'object') + { + $asset = parent::fromId($id_asset, 'array'); + if ($asset) + return $return_format == 'object' ? new Image($asset) : $asset; + else + return false; + } + + public static function fromIds(array $id_assets, $return_format = 'object') + { + if (empty($id_assets)) + return []; + + $assets = parent::fromIds($id_assets, 'array'); + + if ($return_format == 'array') + return $assets; + else + { + $objects = []; + foreach ($assets as $id => $asset) + $objects[$id] = new Image($asset); + return $objects; + } + } + + public function save() + { + $data = []; + foreach ($this->meta as $key => $value) + $data[] = [ + 'id_asset' => $this->id_asset, + 'variable' => $key, + 'value' => $value, + ]; + + return Registry::get('db')->insert('replace', 'assets_meta', [ + 'id_asset' => 'int', + 'variable' => 'string', + 'value' => 'string', + ], $data); + } + + public function getExif() + { + return EXIF::fromFile($this->getPath()); + } + + public function getPath() + { + return ASSETSDIR . '/' . $this->subdir . '/' . $this->filename; + } + + public function getUrl() + { + return ASSETSURL . '/' . $this->subdir . '/' . $this->filename; + } + + /** + * @param width: width of the thumbnail. + * @param height: height of the thumbnail. + * @param crop: whether and how to crop original image to fit. [false|true|'top'|'center'|'bottom'] + * @param fit: whether to fit the image to given boundaries [true], or use them merely as an estimation [false]. + */ + public function getThumbnailUrl($width, $height, $crop = true, $fit = true) + { + // First, assert the image's dimensions are properly known in the database. + if (!isset($this->image_height, $this->image_width)) + throw new UnexpectedValueException('Image width or height is undefined -- inconsistent database?'); + + // Inferring width or height? + if (!$height) + $height = ceil($width / $this->image_width * $this->image_height); + elseif (!$width) + $width = ceil($height / $this->image_height * $this->image_width); + + // Inferring the height from the original image's ratio? + if (!$fit) + $height = floor($width / ($this->image_width / $this->image_height)); + + // Assert we have both, now... + if (empty($width) || empty($height)) + throw new InvalidArgumentException('Expecting at least either width or height as argument.'); + + // If we're cropping, verify we're in the right mode. + if ($crop) + { + // If the original image's aspect ratio is much wider, take a slice instead. + if ($this->image_width / $this->image_height > $width / $height) + $crop = 'slice'; + + // We won't be cropping if the thumbnail is proportional to its original. + if (abs($width / $height - $this->image_width / $this->image_height) <= 0.05) + $crop = false; + } + + // Do we have an exact crop boundary for these dimensions? + $crop_selector = "crop_{$width}x{$height}"; + if (isset($this->meta[$crop_selector])) + $crop = 'exact'; + + // Now, do we need to suffix the filename? + if ($crop) + $suffix = '_c' . (is_string($crop) && $crop !== 'center' ? substr($crop, 0, 1) : ''); + else + $suffix = ''; + + // Check whether we already resized this earlier. + $thumb_selector = "thumb_{$width}x{$height}{$suffix}"; + if (isset($this->meta[$thumb_selector]) && file_exists(THUMBSDIR . '/' . $this->subdir . '/' . $this->meta[$thumb_selector])) + return THUMBSURL . '/' . $this->subdir . '/' . $this->meta[$thumb_selector]; + + // Do we have a custom thumbnail on file? + $custom_selector = "custom_{$width}x{$height}"; + if (isset($this->meta[$custom_selector])) + { + if (file_exists(ASSETSDIR . '/' . $this->subdir . '/' . $this->meta[$custom_selector])) + { + // Copy the custom thumbail to the general thumbnail directory. + copy(ASSETSDIR . '/' . $this->subdir . '/' . $this->meta[$custom_selector], + THUMBSDIR . '/' . $this->subdir . '/' . $this->meta[$custom_selector]); + + // Let's remember this for future reference. + $this->meta[$thumb_selector] = $this->meta[$custom_selector]; + $this->save(); + + return THUMBSURL . '/' . $this->subdir . '/' . $this->meta[$custom_selector]; + } + else + throw new UnexpectedValueException('Custom thumbnail expected, but missing in file system!'); + } + + // Let's try some arcane stuff... + try + { + if (!class_exists('Imagick')) + throw new Exception("The PHP module 'imagick' appears to be disabled. Please enable it to use image resampling functions."); + + $thumb = new Imagick(ASSETSDIR . '/' . $this->subdir . '/' . $this->filename); + + // The image might have some orientation set through EXIF. Let's apply this first. + self::applyRotation($thumb); + + // Just resizing? Easy peasy. + if (!$crop) + $thumb->resizeImage($width, $height, Imagick::FILTER_LANCZOS, 1); + // Cropping in the center? + elseif ($crop === true || $crop === 'center') + $thumb->cropThumbnailImage($width, $height); + // Exact cropping? We can do that. + elseif ($crop === 'exact') + { + list($crop_width, $crop_height, $crop_x_pos, $crop_y_pos) = explode(',', $this->meta[$crop_selector]); + $thumb->cropImage($crop_width, $crop_height, $crop_x_pos, $crop_y_pos); + $thumb->resizeImage($width, $height, Imagick::FILTER_LANCZOS, 1); + } + // Advanced cropping? Fun! + else + { + $size = $thumb->getImageGeometry(); + + // Taking a horizontal slice from the top or bottom of the original image? + if ($crop === 'top' || $crop === 'bottom') + { + $crop_width = $size['width']; + $crop_height = floor($size['width'] / $width * $height); + $target_x = 0; + $target_y = $crop === 'top' ? 0 : $size['height'] - $crop_height; + } + // Otherwise, we're taking a vertical slice from the centre. + else + { + $crop_width = floor($size['height'] / $height * $width); + $crop_height = $size['height']; + $target_x = floor(($size['width'] - $crop_width) / 2); + $target_y = 0; + } + + $thumb->cropImage($crop_width, $crop_height, $target_x, $target_y); + $thumb->resizeImage($width, $height, Imagick::FILTER_LANCZOS, 1); + } + + // What sort of image is this? Fall back to PNG if we must. + switch ($thumb->getImageFormat()) + { + case 'JPEG': + $ext = 'jpg'; + break; + + case 'GIF': + $ext = 'gif'; + break; + + case 'PNG': + default: + $thumb->setFormat('PNG'); + $ext = 'png'; + break; + } + + // So, how do we name this? + $thumbfilename = substr($this->filename, 0, strrpos($this->filename, '.')) . "_{$width}x{$height}{$suffix}.$ext"; + + // Ensure the thumbnail subdirectory exists. + if (!is_dir(THUMBSDIR . '/' . $this->subdir)) + mkdir(THUMBSDIR . '/' . $this->subdir, 0755, true); + + // Save it in a public spot. + $thumb->writeImage(THUMBSDIR . '/' . $this->subdir . '/' . $thumbfilename); + $thumb->clear(); + $thumb->destroy(); + } + // Blast! Curse your sudden but inevitable betrayal! + catch (ImagickException $e) + { + throw new Exception('ImageMagick error occurred while generating thumbnail. Output: ' . $e->getMessage()); + } + + // Let's remember this for future reference. + $this->meta[$thumb_selector] = $thumbfilename; + $this->save(); + + // Ah yes, you wanted a URL, didn't you... + return THUMBSURL . '/' . $this->subdir . '/' . $this->meta[$thumb_selector]; + } + + private static function applyRotation(Imagick $image) + { + switch ($image->getImageOrientation()) + { + // Clockwise rotation + case Imagick::ORIENTATION_RIGHTTOP: + $image->rotateImage("#000", 90); + break; + + // Counter-clockwise rotation + case Imagick::ORIENTATION_LEFTBOTTOM: + $image->rotateImage("#000", 270); + break; + + // Upside down? + case Imagick::ORIENTATION_BOTTOMRIGHT: + $image->rotateImage("#000", 180); + } + + // Having rotated the image, make sure the EXIF data is set properly. + $image->setImageOrientation(Imagick::ORIENTATION_TOPLEFT); + } + + public function bestColor() + { + // Save some computations if we can. + if (isset($this->meta['best_color'])) + return $this->meta['best_color']; + + // Find out what colour is most prominent. + $color = new BestColor($this); + $this->meta['best_color'] = $color->hex(); + $this->save(); + + // There's your colour. + return $this->meta['best_color']; + } + + public function bestLabelColor() + { + // Save some computations if we can. + if (isset($this->meta['best_color_label'])) + return $this->meta['best_color_label']; + + // Find out what colour is most prominent. + $color = new BestColor($this); + $this->meta['best_color_label'] = $color->rgba(); + $this->save(); + + // There's your colour. + return $this->meta['best_color_label']; + } + + public function width() + { + return $this->image_width; + } + + public function height() + { + return $this->image_height; + } + + public function isPanorama() + { + return $this->image_width / $this->image_height > 2; + } + + public function isPortrait() + { + return $this->image_width / $this->image_height < 1; + } + + public function isLandscape() + { + $ratio = $this->image_width / $this->image_height; + return $ratio >= 1 && $ratio <= 2; + } + + public function removeAllThumbnails() + { + foreach ($this->meta as $key => $value) + { + if (substr($key, 0, 6) !== 'thumb_') + continue; + + $thumb_path = THUMBSDIR . '/' . $this->subdir . '/' . $value; + if (is_file($thumb_path)) + unlink($thumb_path); + + unset($this->meta[$key]); + } + + $this->saveMetaData(); + } + + public function replaceThumbnail($descriptor, $tmp_file) + { + if (!is_file($tmp_file)) + return -1; + + if (!isset($this->meta[$descriptor])) + return -2; + + $image = new Imagick($tmp_file); + $d = $image->getImageGeometry(); + unset($image); + + // Check whether dimensions match. + $test_descriptor = 'thumb_' . $d['width'] . 'x' . $d['height']; + if ($descriptor !== $test_descriptor && strpos($descriptor, $test_descriptor . '_') === false) + return -3; + + // Save the custom thumbnail in the assets directory. + $destination = ASSETSDIR . '/' . $this->subdir . '/' . $this->meta[$descriptor]; + if (file_exists($destination) && !is_writable($destination)) + return -4; + + if (!copy($tmp_file, $destination)) + return -5; + + // Copy it to the thumbnail directory, overwriting the automatically generated one, too. + $destination = THUMBSDIR . '/' . $this->subdir . '/' . $this->meta[$descriptor]; + if (file_exists($destination) && !is_writable($destination)) + return -6; + + if (!copy($tmp_file, $destination)) + return -7; + + // A little bookkeeping + $this->meta['custom_' . $d['width'] . 'x' . $d['height']] = $this->meta[$descriptor]; + $this->saveMetaData(); + return 0; + } +} diff --git a/models/Member.php b/models/Member.php new file mode 100644 index 0000000..d163a55 --- /dev/null +++ b/models/Member.php @@ -0,0 +1,192 @@ + $value) + $this->$key = $value; + + $this->is_logged = true; + $this->is_guest = false; + $this->is_admin = $this->is_admin == 1; + } + + public static function fromId($id_user) + { + $row = Registry::get('db')->queryAssoc(' + SELECT * + FROM users + WHERE id_user = {int:id_user}', + [ + 'id_user' => $id_user, + ]); + + // This should never happen. + if (empty($row)) + throw new NotFoundException('Cannot create Member object; user not found in db!'); + + return new Member($row); + } + + public static function fromSlug($slug) + { + $row = Registry::get('db')->queryAssoc(' + SELECT * + FROM users + WHERE slug = {string:slug}', + [ + 'slug' => $slug, + ]); + + // This shouldn't happen. + if (empty($row)) + throw new NotFoundException('Cannot create Member object; user not found in db!'); + + return new Member($row); + } + + /** + * Creates a new member from the data provided. + * @param data + */ + public static function createNew(array $data) + { + $error = false; + $new_user = [ + 'first_name' => !empty($data['first_name']) ? $data['first_name'] : $error |= true, + 'surname' => !empty($data['surname']) ? $data['surname'] : $error |= true, + 'slug' => !empty($data['slug']) ? $data['slug'] : $error |= true, + 'emailaddress' => !empty($data['emailaddress']) ? $data['emailaddress'] : $error |= true, + 'password_hash' => !empty($data['password']) ? Authentication::computeHash($data['password']) : $error |= true, + 'creation_time' => time(), + 'ip_address' => isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '', + 'is_admin' => empty($data['is_admin']) ? 0 : 1, + ]; + + if ($error) + return false; + + $db = Registry::get('db'); + $bool = $db->insert('insert', 'users', [ + 'first_name' => 'string-30', + 'surname' => 'string-60', + 'slug' => 'string-90', + 'emailaddress' => 'string-255', + 'password_hash' => 'string-255', + 'creation_time' => 'int', + 'ip_address' => 'string-15', + 'is_admin' => 'int', + ], $new_user, ['id_user']); + + if (!$bool) + return false; + + $new_user['id_user'] = $db->insert_id(); + $member = new Member($new_user); + + return $member; + } + + /** + * Updates the member using the data provided. + * @param data + */ + public function update(array $new_data) + { + foreach ($new_data as $key => $value) + { + if (in_array($key, ['first_name', 'surname', 'slug', 'emailaddress'])) + $this->$key = $value; + elseif ($key === 'password') + $this->password_hash = Authentication::computeHash($value); + elseif ($key === 'is_admin') + $this->is_admin = $value == 1 ? 1 : 0; + } + + return Registry::get('db')->query(' + UPDATE users + SET + first_name = {string:first_name}, + surname = {string:surname}, + slug = {string:slug}, + emailaddress = {string:emailaddress}, + password_hash = {string:password_hash}, + is_admin = {int:is_admin} + WHERE id_user = {int:id_user}', + get_object_vars($this)); + } + + /** + * Deletes the member. + * @param data + */ + public function delete() + { + return Registry::get('db')->query(' + DELETE FROM users + WHERE id_user = {int:id_user}', + ['id_user' => $this->id_user]); + } + + /** + * Checks whether an email address is already linked to an account. + * @param emailaddress to check + * @return false if account does not exist + * @return user id if user does exist + */ + public static function exists($emailaddress) + { + $res = Registry::get('db')->queryValue(' + SELECT id_user + FROM users + WHERE emailaddress = {string:emailaddress}', + [ + 'emailaddress' => $emailaddress, + ]); + + if (empty($res)) + return false; + + return $res; + } + + public function updateAccessTime() + { + return Registry::get('db')->query(' + UPDATE users + SET + last_action_time = {int:now}, + ip_address = {string:ip} + WHERE id_user = {int:id}', + [ + 'now' => time(), + 'id' => $this->id_user, + 'ip' => isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '', + ]); + } + + public function getUrl() + { + return BASEURL . '/author/' . $this->slug . '/'; + } + + public static function getCount() + { + return Registry::get('db')->queryValue(' + SELECT COUNT(*) + FROM users'); + } + + public function getProps() + { + // We should probably phase out the use of this function, or refactor the access levels of member properties... + return get_object_vars($this); + } +} diff --git a/models/NotAllowedException.php b/models/NotAllowedException.php new file mode 100644 index 0000000..2631b6d --- /dev/null +++ b/models/NotAllowedException.php @@ -0,0 +1,12 @@ + $value) + $this->$key = $value; + $this->generatePageIndex(); + } + + protected function generatePageIndex() + { + /* + Example 1: + [1] [2] [3] [...] [c-2] [c-1] [c] [c+1] [c+2] [...] [n-2] [n-1] [n] + \---------/ \-------------------------/ \-------------/ + lower current/contiguous pages upper + + Example 2: + [1] [2] [3] [4] [5] [c] [6] [7] [...] [n/2] [...] [n-2] [n-1] [n] + \---------/ \-----------------/ \---/ \-------------/ + lower current/cont. pgs. center upper + */ + + $this->num_pages = ceil($this->recordCount / $this->items_per_page); + $this->current_page = min(ceil($this->start / $this->items_per_page) + 1, $this->num_pages); + if ($this->num_pages == 0) + { + $this->needsPageIndex = false; + return; + } + + $lowerLower = 1; + $lowerUpper = min($this->num_pages, 3); + + $contigLower = max($lowerUpper + 1, max($this->current_page - 2, 1)); + $contigUpper = min($this->current_page + 2, $this->num_pages); + + $center = floor($this->num_pages / 2); + + $upperLower = max($contigUpper + 1, max(0, $this->num_pages - 2)); + $upperUpper = $this->num_pages; + + $this->page_index = []; + + // Lower pages + for ($p = $lowerLower; $p <= $lowerUpper; $p++) + $this->page_index[$p] = [ + 'index' => $p, + 'is_selected' => $this->current_page == $p, + 'href'=> $this->getLink(($p - 1) * $this->items_per_page, $this->sort_order, $this->sort_direction), + ]; + + // The center of the page index. + if ($center > $lowerUpper && $center < $contigLower) + { + // Gap? + if ($lowerUpper != $center) + $this->page_index[] = '...'; + + $this->page_index[$center] = [ + 'index' => $center, + 'is_selected' => $this->current_page == $center, + 'href'=> $this->getLink(($center - 1) * $this->items_per_page, $this->sort_order, $this->sort_direction), + ]; + } + + // Gap? + if ($contigLower != $p) + $this->page_index[] = '...'; + + // contig pages + for ($p = $contigLower; $p <= $contigUpper; $p++) + $this->page_index[$p] = [ + 'index' => $p, + 'is_selected' => $this->current_page == $p, + 'href'=> $this->getLink(($p - 1) * $this->items_per_page, $this->sort_order, $this->sort_direction), + ]; + + // The center of the page index. + if ($center > $contigUpper && $center < $upperLower) + { + // Gap? + if ($contigUpper != $center) + $this->page_index[] = '...'; + + $this->page_index[$center] = [ + 'index' => $center, + 'is_selected' => $this->current_page == $center, + 'href'=> $this->getLink(($center - 1) * $this->items_per_page, $this->sort_order, $this->sort_direction), + ]; + } + + // Gap? + if ($upperLower != $p) + $this->page_index[] = '...'; + + // Upper pages + for ($p = $upperLower; $p <= $upperUpper; $p++) + $this->page_index[$p] = [ + 'index' => $p, + 'is_selected' => $this->current_page == $p, + 'href'=> $this->getLink(($p - 1) * $this->items_per_page, $this->sort_order, $this->sort_direction), + ]; + + // Previous page? + if ($this->current_page > 1) + $this->page_index['previous'] = $this->page_index[$this->current_page - 1]; + + // Next page? + if ($this->current_page < $this->num_pages) + $this->page_index['next'] = $this->page_index[$this->current_page + 1]; + } + + protected function getLink($start = null, $order = null, $dir = null) + { + $url = $this->base_url; + $amp = strpos($this->base_url, '?') ? '&' : '?'; + + if (!empty($start)) + { + $page = ($start / $this->items_per_page) + 1; + $url .= strtr($this->page_slug, ['%PAGE%' => $page, '%AMP%' => $amp]); + $amp = '&'; + } + if (!empty($order)) + { + $url .= $amp . 'order=' . $order; + $amp = '&'; + } + if (!empty($dir)) + { + $url .= $amp . 'dir=' . $dir; + $amp = '&'; + } + + return $url; + } + + public function getArray() + { + return $this->page_index; + } + + public function getPageIndex() + { + return $this->page_index; + } + + public function getPageIndexClass() + { + return $this->index_class; + } + + public function getCurrentPage() + { + return $this->current_page; + } + + public function getNumberOfPages() + { + return $this->num_pages; + } + + public function getRecordCount() + { + return $this->recordCount; + } +} diff --git a/models/PhotoMosaic.php b/models/PhotoMosaic.php new file mode 100644 index 0000000..2365836 --- /dev/null +++ b/models/PhotoMosaic.php @@ -0,0 +1,166 @@ +iterator = $iterator; + } + + public function __destruct() + { + $this->iterator->clean(); + } + + public static function getRecentPhotos() + { + return new self(AssetIterator::getByOptions([ + 'tag' => 'photo', + 'order' => 'date_captured', + 'direction' => 'desc', + 'limit' => 15, // worst case: 3 rows * (portrait + 4 thumbs) + ])); + } + + private static function matchTypeMask(Image $image, $type_mask) + { + return ($type_mask & Image::TYPE_PANORAMA) && $image->isPanorama() || + ($type_mask & Image::TYPE_LANDSCAPE) && $image->isLandscape() || + ($type_mask & Image::TYPE_PORTRAIT) && $image->isPortrait(); + } + + private function fetchImage($desired_type = Image::TYPE_PORTRAIT | Image::TYPE_LANDSCAPE | Image::TYPE_PANORAMA, Image $refDateImage = null) + { + // First, check if we have what we're looking for in the queue. + foreach ($this->queue as $i => $image) + { + // Image has to match the desired type and be taken within a week of the reference image. + if (self::matchTypeMask($image, $desired_type) && !(isset($refDateImage) && abs(self::daysApart($image, $refDateImage)) > self::NUM_DAYS_CUTOFF)) + { + unset($this->queue[$i]); + return $image; + } + } + + // Check whatever's next up! + while (($asset = $this->iterator->next()) && ($image = $asset->getImage())) + { + // Image has to match the desired type and be taken within a week of the reference image. + if (self::matchTypeMask($image, $desired_type) && !(isset($refDateImage) && abs(self::daysApart($image, $refDateImage)) > self::NUM_DAYS_CUTOFF)) + return $image; + else + $this->pushToQueue($image); + } + + return false; + } + + private function pushToQueue(Image $image) + { + $this->queue[] = $image; + } + + private static function orderPhotos(Image $a, Image $b) + { + // Show images of highest priority first. + $priority_diff = $a->getPriority() - $b->getPriority(); + if ($priority_diff !== 0) + return -$priority_diff; + + // In other cases, we'll just show the newest first. + return $a->getDateCaptured() > $b->getDateCaptured() ? -1 : 1; + } + + private static function daysApart(Image $a, Image $b) + { + return $a->getDateCaptured()->diff($b->getDateCaptured())->days; + } + + public function getRow() + { + // Fetch the first image... + $image = $this->fetchImage(); + + // No image at all? + if (!$image) + return false; + + // Is it a panorama? Then we've got our row! + elseif ($image->isPanorama()) + return [[$image], 'panorama']; + + // Alright, let's initalise a proper row, then. + $photos = [$image]; + $num_portrait = $image->isPortrait() ? 1 : 0; + $num_landscape = $image->isLandscape() ? 1 : 0; + + // Get an initial batch of non-panorama images to work with. + for ($i = 1; $i < 3 && ($image = $this->fetchImage(Image::TYPE_LANDSCAPE | Image::TYPE_PORTRAIT, $image)); $i++) + { + $num_portrait += $image->isPortrait() ? 1 : 0; + $num_landscape += $image->isLandscape() ? 1 : 0; + $photos[] = $image; + } + + // Sort photos by priority and date captured. + usort($photos, 'self::orderPhotos'); + + // At least one portrait? + if ($num_portrait >= 1) + { + // Grab two more landscapes, so we can put a total of four tiles on the side. + for ($i = 0; $image && $i < 2 && ($image = $this->fetchImage(Image::TYPE_LANDSCAPE, $image)); $i++) + $photos[] = $image; + + // We prefer to have the portrait on the side, so prepare to process that first. + usort($photos, function($a, $b) { + if ($a->isPortrait() && !$b->isPortrait()) + return -1; + elseif ($b->isPortrait() && !$a->isPortrait()) + return 1; + else + return self::orderPhotos($a, $b); + }); + + // We might not have a full set of photos, but only bother if we have at least three. + if (count($photos) > 3) + return [$photos, 'portrait']; + } + + // One landscape at least, hopefully? + if ($num_landscape >= 1) + { + if (count($photos) === 3) + { + // We prefer to have the landscape on the side, so prepare to process that first. + usort($photos, function($a, $b) { + if ($a->isLandscape() && !$b->isLandscape()) + return -1; + elseif ($b->isLandscape() && !$a->isLandscape()) + return 1; + else + return self::orderPhotos($a, $b); + }); + + return [$photos, 'landscape']; + } + elseif (count($photos) === 2) + return [$photos, 'duo']; + else + return [$photos, 'single']; + } + + // A boring set it is, then. + return [$photos, 'row']; + } +} diff --git a/models/Registry.php b/models/Registry.php new file mode 100644 index 0000000..849bd16 --- /dev/null +++ b/models/Registry.php @@ -0,0 +1,39 @@ + isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '', + 'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '', + ]; + + return true; + } + + public static function resetSessionToken() + { + $_SESSION['session_token'] = sha1(session_id() . mt_rand()); + $_SESSION['session_token_key'] = substr(preg_replace('~^\d+~', '', sha1(mt_rand() . session_id() . mt_rand())), 0, rand(7, 12)); + return true; + } + + public static function validateSession($method = 'post') + { + // First, check whether the submitted token and key match the ones in storage. + if (($method === 'post' && (!isset($_POST[$_SESSION['session_token_key']]) || $_POST[$_SESSION['session_token_key']] !== $_SESSION['session_token'])) || + ($method === 'get' && (!isset($_GET[$_SESSION['session_token_key']]) || $_GET[$_SESSION['session_token_key']] !== $_SESSION['session_token']))) + trigger_error('Session failed to verify (' . $method . '). Please reload the page and try again.', E_USER_ERROR); + + // Check the referring site, too -- should be the same site! + $referring_host = isset($_SERVER['HTTP_REFERER']) ? parse_url($_SERVER['HTTP_REFERER'], PHP_URL_HOST) : ''; + if (!empty($referring_host)) + { + if (strpos($_SERVER['HTTP_HOST'], ':') !== false) + $current_host = substr($_SERVER['HTTP_HOST'], 0, strpos($_SERVER['HTTP_HOST'], ':')); + else + $current_host = $_SERVER['HTTP_HOST']; + + $base_url_host = parse_url(BASEURL, PHP_URL_HOST); + + // The referring_host must match either the base_url_host or the current_host. + if (strtolower($referring_host) !== strtolower($base_url_host) && strtolower($referring_host) !== strtolower($current_host)) + trigger_error('Invalid referring URL. Please reload the page and try again.', E_USER_ERROR); + } + + // All looks good from here! But you can only use this token once, so... + return self::resetSessionToken(); + } + + public static function getSessionToken() + { + if (empty($_SESSION['session_token'])) + trigger_error('Call to getSessionToken without a session token being set!', E_USER_ERROR); + + return $_SESSION['session_token']; + } + + public static function getSessionTokenKey() + { + if (empty($_SESSION['session_token_key'])) + trigger_error('Call to getSessionTokenKey without a session token key being set!', E_USER_ERROR); + + return $_SESSION['session_token_key']; + } +} diff --git a/models/Setting.php b/models/Setting.php new file mode 100644 index 0000000..6171c6f --- /dev/null +++ b/models/Setting.php @@ -0,0 +1,80 @@ +getUserId(); + + if (isset(self::$cache[$id_user], self::$cache[$id_user][$key])) + unset(self::$cache[$id_user][$key]); + + if (Registry::get('db')->query(' + REPLACE INTO settings + (id_user, variable, value, time_set) + VALUES + ({int:id_user}, {string:key}, {string:value}, CURRENT_TIMESTAMP())', + [ + 'id_user' => $id_user, + 'key' => $key, + 'value' => $value, + ])) + { + if (!isset(self::$cache[$id_user])) + self::$cache[$id_user] = []; + + self::$cache[$id_user][$key] = $value; + } + } + + public static function get($key, $id_user = null) + { + $id_user = Registry::get('user')->getUserId(); + + if (isset(self::$cache[$id_user], self::$cache[$id_user][$key])) + return self::$cache[$id_user][$key]; + + $value = Registry::get('db')->queryValue(' + SELECT value + FROM settings + WHERE id_user = {int:id_user} AND variable = {string:key}', + [ + 'id_user' => $id_user, + 'key' => $key, + ]); + + if (!$value) + return false; + + if (!isset(self::$cache[$id_user])) + self::$cache[$id_user] = []; + + self::$cache[$id_user][$key] = $value; + return $value; + } + + public static function remove($key, $id_user = null) + { + $id_user = Registry::get('user')->getUserId(); + + if (Registry::get('db')->query(' + DELETE FROM settings + WHERE id_user = {int:id_user} AND variable = {string:key}', + [ + 'id_user' => $id_user, + 'key' => $key, + ])) + { + if (isset(self::$cache[$id_user], self::$cache[$id_user][$key])) + unset(self::$cache[$id_user][$key]); + } + } +} diff --git a/models/Tag.php b/models/Tag.php new file mode 100644 index 0000000..572e077 --- /dev/null +++ b/models/Tag.php @@ -0,0 +1,337 @@ + $value) + $this->$attribute = $value; + } + + public static function fromId($id_tag, $return_format = 'object') + { + $db = Registry::get('db'); + + $row = $db->queryAssoc(' + SELECT * + FROM tags + WHERE id_tag = {int:id_tag}', + [ + 'id_tag' => $id_tag, + ]); + + // Tag not found? + if (empty($row)) + throw new NotFoundException(); + + return $return_format == 'object' ? new Tag($row) : $row; + } + + public static function fromSlug($slug, $return_format = 'object') + { + $db = Registry::get('db'); + + $row = $db->queryAssoc(' + SELECT * + FROM tags + WHERE slug = {string:slug}', + [ + 'slug' => $slug, + ]); + + // Tag not found? + if (empty($row)) + throw new NotFoundException(); + + return $return_format == 'object' ? new Tag($row) : $row; + } + + public static function getAll($limit = 0, $return_format = 'array') + { + $rows = Registry::get('db')->queryAssocs(' + SELECT * + FROM tags + ORDER BY ' . ($limit > 0 ? 'count + LIMIT {int:limit}' : 'tag'), + [ + 'limit' => $limit, + ]); + + // No tags found? + if (empty($rows)) + return []; + + // Limited? Make sure the lot is sorted alphabetically. + if (!empty($limit)) + { + usort($rows, function($a, $b) { + return strcmp($a['tag'], $b['tag']); + }); + } + + if ($return_format == 'object') + { + $return = []; + foreach ($rows as $row) + $return[$row['id_tag']] = new Tag($row); + return $return; + } + else + return $rows; + } + + public static function getAlbums($id_parent = 0, $return_format = 'array') + { + $rows = Registry::get('db')->queryAssocs(' + SELECT * + FROM tags + WHERE id_parent = {int:id_parent} AND kind = {string:kind} + ORDER BY tag ASC', + [ + 'id_parent' => $id_parent, + 'kind' => 'Album', + ]); + + if ($return_format == 'object') + { + $return = []; + foreach ($rows as $row) + $return[$row['id_tag']] = new Tag($row); + return $return; + } + else + return $rows; + } + + public static function getPeople($id_parent = 0, $return_format = 'array') + { + $rows = Registry::get('db')->queryAssocs(' + SELECT * + FROM tags + WHERE id_parent = {int:id_parent} AND kind = {string:kind} + ORDER BY tag ASC', + [ + 'id_parent' => $id_parent, + 'kind' => 'Person', + ]); + + if ($return_format == 'object') + { + $return = []; + foreach ($rows as $row) + $return[$row['id_tag']] = new Tag($row); + return $return; + } + else + return $rows; + } + + public static function byAssetId($id_asset, $return_format = 'object') + { + $rows = Registry::get('db')->queryAssocs(' + SELECT * + FROM tags + WHERE id_tag IN( + SELECT id_tag + FROM assets_tags + WHERE id_asset = {int:id_asset} + ) + ORDER BY count DESC', + [ + 'id_asset' => $id_asset, + ]); + + // No tags found? + if (empty($rows)) + return []; + + if ($return_format == 'object') + { + $return = []; + foreach ($rows as $row) + $return[$row['id_tag']] = new Tag($row); + return $return; + } + else + return $rows; + } + + public static function byPostId($id_post, $return_format = 'object') + { + $rows = Registry::get('db')->queryAssocs(' + SELECT * + FROM tags + WHERE id_tag IN( + SELECT id_tag + FROM posts_tags + WHERE id_post = {int:id_post} + ) + ORDER BY count DESC', + [ + 'id_post' => $id_post, + ]); + + // No tags found? + if (empty($rows)) + return []; + + if ($return_format == 'object') + { + $return = []; + foreach ($rows as $row) + $return[$row['id_tag']] = new Tag($row); + return $return; + } + else + return $rows; + } + + public static function recount(array $id_tags = []) + { + return Registry::get('db')->query(' + UPDATE tags AS t SET count = ( + SELECT COUNT(*) + FROM `assets_tags` AS at + WHERE at.id_tag = t.id_tag + )' . (!empty($id_tags) ? ' + WHERE t.id_tag IN({array_int:id_tags})' : ''), + ['id_tags' => $id_tags]); + } + + public static function createNew(array $data, $return_format = 'object') + { + $db = Registry::get('db'); + + if (!isset($data['id_parent'])) + $data['id_parent'] = 0; + + if (!isset($data['count'])) + $data['count'] = 0; + + $res = $db->query(' + INSERT IGNORE INTO tags + (id_parent, tag, slug, kind, description, count) + VALUES + ({int:id_parent}, {string:tag}, {string:slug}, {string:kind}, {string:description}, {int:count}) + ON DUPLICATE KEY UPDATE count = count + 1', + $data); + + if (!$res) + trigger_error('Could not create the requested tag.', E_USER_ERROR); + + $data['id_tag'] = $db->insert_id(); + return $return_format == 'object' ? new Tag($data) : $data; + } + + public function getUrl() + { + return BASEURL . '/tag/' . $this->slug . '/'; + } + + public function save() + { + return Registry::get('db')->query(' + UPDATE tags + SET + tag = {string:tag}, + count = {int:count} + WHERE id_tag = {int:id_tag}', + get_object_vars($this)); + } + + public function delete() + { + $db = Registry::get('db'); + + $res = $db->query(' + DELETE FROM posts_tags + WHERE id_tag = {int:id_tag}', + [ + 'id_tag' => $this->id_tag, + ]); + + if (!$res) + return false; + + return $db->query(' + DELETE FROM tags + WHERE id_tag = {int:id_tag}', + [ + 'id_tag' => $this->id_tag, + ]); + } + + public static function match($tokens) + { + if (!is_array($tokens)) + $tokens = explode(' ', $tokens); + + return Registry::get('db')->queryPair(' + SELECT id_tag, tag + FROM tags + WHERE LOWER(tag) LIKE {string:tokens} + ORDER BY tag ASC', + ['tokens' => '%' . strtolower(implode('%', $tokens)) . '%']); + } + + public static function exactMatch($tag) + { + if (!is_string($tag)) + throw new InvalidArgumentException('Expecting a string!'); + + return Registry::get('db')->queryPair(' + SELECT id_tag, tag + FROM tags + WHERE tag = {string:tag}', + ['tag' => $tag]); + } + + public static function matchSlug($slug) + { + if (!is_string($slug)) + throw new InvalidArgumentException('Expecting a string!'); + + return Registry::get('db')->queryValue(' + SELECT id_tag + FROM tags + WHERE slug = {string:slug}', + ['slug' => $slug]); + } + + public static function matchAll(array $tags) + { + return Registry::get('db')->queryPair(' + SELECT tag, id_tag + FROM tags + WHERE tag IN ({array_string:tags})', + ['tags' => $tags]); + } + + public static function getCount($only_active = 1) + { + return $db->queryValue(' + SELECT COUNT(*) + FROM tags' . ($only_active ? ' + WHERE count > 0' : '')); + } + + public function __toString() + { + return $this->tag; + } +} diff --git a/models/User.php b/models/User.php new file mode 100644 index 0000000..dd802ef --- /dev/null +++ b/models/User.php @@ -0,0 +1,98 @@ +id_user; + } + + /** + * Returns first name. + */ + public function getFirstName() + { + return $this->first_name; + } + + /** + * Returns surname. + */ + public function getSurname() + { + return $this->surname; + } + + /** + * Returns full name. + */ + public function getFullName() + { + return trim($this->first_name . ' ' . $this->surname); + } + + /** + * Returns email address. + */ + public function getEmailAddress() + { + return $this->emailaddress; + } + + /** + * Have a guess! + */ + public function getIPAddress() + { + return $this->ip_address; + } + + /** + * Returns whether user is logged in. + */ + public function isLoggedIn() + { + return $this->is_logged; + } + + /** + * Returns whether user is a guest. + */ + public function isGuest() + { + return $this->is_guest; + } + + /** + * Returns whether user is an administrator. + */ + public function isAdmin() + { + return $this->is_admin; + } +} diff --git a/public/css/admin.css b/public/css/admin.css new file mode 100644 index 0000000..852a9f5 --- /dev/null +++ b/public/css/admin.css @@ -0,0 +1,342 @@ +.admin_box { + margin: 0; + padding: 20px; + background: #fff; + box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); + overflow: auto; +} + +.admin_box h2 { + font: 700 24px "Open Sans", sans-serif; + margin: 0 0 0.2em; +} + +.floatleft { + float: left; +} +.floatright { + float: right; +} + +/* Admin bar styles +---------------------*/ +body { + padding-top: 30px; +} +#admin_bar { + background: #333; + color: #ccc; + left: 0; + position: fixed; + top: 0; + width: 100%; + z-index: 100; +} +#admin_bar ul { + list-style: none; + margin: 0 auto; + max-width: 1280px; + min-width: 900px; + padding: 2px; + width: 95%; +} +#admin_bar ul > li { + display: inline; + border-right: 1px solid #aaa; +} +#admin_bar ul > li:last-child { + border-right: none; +} +#admin_bar li > a { + color: inherit; + display: inline-block; + padding: 4px 6px; +} +#admin_bar li a:hover { + text-decoration: underline; +} + +/* Login box styles +---------------------*/ + +#login { + background: #fff; + border: 1px solid #aaa; + border-radius: 10px; + box-shadow: 2px 2px 4px rgba(0,0,0,0.1); + margin: 0 auto; + padding: 15px; + width: 275px; +} +#login * { + font-size: 15px; + line-height: 35px; +} +#login h3 { + font: 700 24px/36px "Open Sans", sans-serif; + margin: 0; +} +#login dd { + width: 96%; + margin: 0 0 10px; +} +#login input { + background: #eee; + border: 1px solid #aaa; + border-radius: 3px; + padding: 4px 5px; + width: 100%; +} +#login div { + text-align: right; +} +#login button { + line-height: 20px; +} + + +/* (Tag) autosuggest +----------------------*/ +#new_tag_container { + display: block; + position: relative; +} +.autosuggest { + background: #fff; + border: 1px solid #ccc; + position: absolute; + top: 29px; + margin: 0; + padding: 0; +} +.autosuggest li { + display: block !important; + padding: 3px; +} +.autosuggest li:hover, .autosuggest li.selected { + background: #CFECF7; + cursor: pointer; +} + + +/* Edit user screen +---------------------*/ +.edituser dt { + clear: left; + float: left; + width: 150px; +} +.edituser dd { + float: left; + margin-bottom: 5px; +} +.edituser form div:last-child { + padding: 1em 0 0; +} + + +/* Admin widgets +------------------*/ +.widget { + background: #fff; + padding: 25px; + box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); +} +.widget h3 { + margin: 0 0 1em; + font: 400 18px "Raleway", sans-serif; +} +.widget p, .errormsg p { + margin: 0; +} +.widget ul { + margin: 0; + list-style: none; + padding: 0; +} +.widget li { + line-height: 1.7em; +} + + +/* Edit icon on tiled grids +-----------------------------*/ +.tiled_grid div.landscape, .tiled_grid div.portrait, .tiled_grid div.panorama { + position: relative; +} +.tiled_grid div > a.edit { + background: #fff; + border-radius: 3px; + box-shadow: 1px 1px 2px rgba(0,0,0,0.3); + display: none; + left: 20px; + line-height: 1.5; + padding: 5px 10px; + position: absolute; + top: 20px; +} +.tiled_grid div:hover > a.edit { + display: block; +} + + +/* Crop editor +----------------*/ +#crop_editor { + position: fixed; + top: 0; + left: 0; + height: 100%; + width: 100%; + background: #000; + z-index: 100; + color: #fff; +} +#crop_editor input { + width: 50px; + background: #555; + color: #fff; +} +.crop_image_container { + position: relative; +} +.crop_position { + padding: 5px; + text-align: center; +} +.crop_position input, .crop_position .btn { + margin: 0 5px; +} +.crop_image_container img { + height: auto; + width: auto; + max-width: 100%; + max-height: 700px; +} +#crop_boundary { + border: 1px solid rgba(255, 255, 255, 0.75); + background: rgba(255, 255, 255, 0.75); + position: absolute; + z-index: 200; + width: 500px; + height: 300px; + top: 400px; + left: 300px; + filter: invert(100%); /* temp */ +} + + +/* The pagination styles below are based on Bootstrap 2.3.2 +-------------------------------------------------------------*/ + +.table_pagination, .table_form { + margin: 20px 0; +} + +.table_pagination ul { + display: inline-block; + margin: 0; + padding: 0; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); +} + +.table_pagination ul > li { + display: inline; +} + +.table_pagination ul > li > a, +.table_pagination ul > li > span { + float: left; + padding: 4px 12px; + line-height: 20px; + text-decoration: none; + background-color: #ffffff; + border: 1px solid #dddddd; + border-left-width: 0; +} + +.table_pagination ul > li > a:hover, +.table_pagination ul > li > a:focus, +.table_pagination ul > .active > a, +.table_pagination ul > .active > span { + background-color: #f5f5f5; +} + +.table_pagination ul > .active > a, +.table_pagination ul > .active > span { + color: #999999; + cursor: default; +} + +.table_pagination ul > .disabled > span, +.table_pagination ul > .disabled > a, +.table_pagination ul > .disabled > a:hover, +.table_pagination ul > .disabled > a:focus { + color: #999999; + cursor: default; + background-color: transparent; +} + +.table_pagination ul > li:first-child > a, +.table_pagination ul > li:first-child > span { + border-left-width: 1px; +} + + +/* The table styles below were taken from Bootstrap 2.3.2 +-----------------------------------------------------------*/ +table { + max-width: 100%; + background-color: transparent; + border-collapse: collapse; + border-spacing: 0; +} + +.table { + width: 100%; + margin-bottom: 20px; +} + +.table th, +.table td { + border-top: 1px solid #dddddd; + line-height: 20px; + padding: 8px; + text-align: left; + vertical-align: top; +} + +.table th { + font-weight: bold; +} + +.table thead th { + vertical-align: bottom; +} + +.table caption + thead tr:first-child th, +.table caption + thead tr:first-child td, +.table colgroup + thead tr:first-child th, +.table colgroup + thead tr:first-child td, +.table thead:first-child tr:first-child th, +.table thead:first-child tr:first-child td { + border-top: 0; +} + +.table tbody + tbody { + border-top: 2px solid #dddddd; +} + +.table .table { + background-color: #ffffff; +} + +.table-striped tbody > tr:nth-child(odd) > td, +.table-striped tbody > tr:nth-child(odd) > th { + background-color: #f9f9f9; +} + +.table-hover tbody tr:hover > td, +.table-hover tbody tr:hover > th { + background-color: #f5f5f5; +} diff --git a/public/css/default.css b/public/css/default.css new file mode 100644 index 0000000..5f38a87 --- /dev/null +++ b/public/css/default.css @@ -0,0 +1,422 @@ +/** + * Styles for the fifth version of Aaronweb.net. + * (C) Aaron van Geffen 2013, 2014 + * DO NOT COPY OR RE-USE WITHOUT EXPLICIT WRITTEN PERMISSION. THANK YOU. + */ + +@import url(//fonts.googleapis.com/css?family=Press+Start+2P); +@import url(//fonts.googleapis.com/css?family=Open+Sans:400,400italic,700,700italic); + +body { + font: 13px/1.7 "Open Sans", "Helvetica", sans-serif; + padding: 0 0 3em; + margin: 0; + background: #99BFCE 0 -50% fixed; + background-image: radial-gradient(ellipse at top, #c3dee5 0%,#92b9ca 55%,#365e77 100%); /* W3C */ +} + +#wrapper, header { + width: 95%; + min-width: 900px; + max-width: 1280px; + margin: 0 auto; +} + +header { + overflow: auto; +} + +a { + color: #487C96; + text-decoration: none; +} +a:hover { + color: #222; +} + +/* Logo +---------*/ +h1#logo { + color: #fff; + float: left; + font: 200 50px 'Press Start 2P', "Helvetica Neue", sans-serif; + margin: 40px 0 50px 10px; + padding: 0; + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.6); +} +h1#logo a { + text-decoration: none; + color: inherit; +} +h1#logo span.name { + padding-right: 7px; +} +h1#logo span.area:before { + content: '|'; + font-weight: 400; + letter-spacing: 7px; +} + +/* Navigation +---------------*/ +ul#nav { + margin: 55px 10px 0 0; + padding: 0; + float: right; + list-style: none; +} +ul#nav li { + float: left; +} +ul#nav li a { + color: #fff; + display: block; + float: left; + font: 200 20px 'Press Start 2P', "Helvetica Neue", sans-serif; + margin: 0 0 0 32px; + padding: 10px 0; + text-decoration: none; + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7); +} +ul#nav li a:hover { + border-bottom: 3px solid rgba(255, 255, 255, 0.5); +} + + +/* Pagination +---------------*/ +.pagination { + clear: both; + text-align: center; +} +.pagination ul { + display: inline-block; + margin: 0; + padding: 0; + box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); +} +.pagination ul > li { + display: inline; +} +.pagination ul > li > a, .pagination ul > li > span { + float: left; + font: 300 18px/2.2 "Open Sans", "Helvetica", sans-serif; + padding: 6px 22px; + text-decoration: none; + background-color: #fff; + border-right: 1px solid #ddd; +} + +.pagination ul > li > a:hover, .pagination ul > li > a:focus, +.pagination ul > .active > a, .pagination ul > .active > span { + background-color: #eee; +} + +.pagination ul > .active > a, .pagination ul > .active > span { + cursor: default; +} + +.pagination ul > .disabled > span, .pagination ul > .disabled > a, +.pagination ul > .disabled > a:hover, .pagination ul > .disabled > a:focus { + color: #999; + cursor: default; + background-color: transparent; +} + + +/* Tiled grid +---------------*/ +.tiled_header, .page_title_box { + background: #fff; + box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); + color: #000; + float: left; + margin: 0 0 1.5% 0; + font: 400 18px/2.2 "Open Sans", "Helvetica Neue", sans-serif; + padding: 6px 22px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} +.page_title_box { + padding: 6px 22px !important; + clear: both; + display: inline-block; + float: none; + font: inherit; +} +.page_title_box h2 { + font: 400 18px/2.2 "Open Sans", "Helvetica Neue", sans-serif !important; +} +.page_title_box p { + margin: 0 0 10px; +} + +.tiled_grid { + margin: 0; + padding: 0; + list-style: none; +} +.tiled_grid div.landscape, .tiled_grid div.portrait, .tiled_grid div.panorama, +.tiled_grid div.duo, .tiled_grid div.single { + background: #fff; + border-bottom-style: none !important; + box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); + float: left; + line-height: 0; + margin: 0 3.5% 3.5% 0; + overflow: none; + position: relative; + padding-bottom: 5px; + width: 31%; +} +.tiled_grid div img { + border: none; + width: 100%; +} +.tiled_grid div h4 { + color: #000; + margin: 0; + font: 400 18px "Open Sans", "Helvetica Neue", sans-serif; + padding: 20px 5px 15px; + text-overflow: ellipsis; + text-align: center; + white-space: nowrap; + overflow: hidden; +} +.tiled_grid div a { + text-decoration: none; +} +.tiled_grid div.landscape:hover, .tiled_grid div.portrait:hover, .tiled_grid div.panorama:hover, +.tiled_grid div.duo:hover, .tiled_grid div.single:hover { + padding-bottom: 0; + border-bottom-width: 5px; + border-bottom-style: solid !important; +} + +/* Panoramas */ +.tiled_grid div.panorama, .tiled_grid .tiled_row { + clear: both; + float: left; + width: 100%; + margin-right: 0; +} + +/* Tiling: one landscape, two tiles */ +.tiled_row .column_landscape { + float: left; + width: 65.5%; + margin: 0 3.5% 3.5% 0; +} +.tiled_row .column_landscape div { + width: auto; + margin: 0; +} + +.tiled_row .column_tiles_two { + float: left; + width: 31%; +} +.tiled_row .column_tiles_two div { + float: left; + width: 100%; + margin: 0 0 10% 0; +} + +/* Tiling: big portrait, four tiles */ +.tiled_row .column_portrait { + float: left; + width: 31%; + margin: 0 3.5% 3.5% 0; +} +.tiled_row .column_portrait div { + width: auto; + margin: 0; +} + +.tiled_row .column_tiles_four { + float: left; + width: 65.5%; +} +.tiled_row .column_tiles_four div { + float: left; + width: 47.45%; + margin: 0 5% 5% 0; +} + +/* Tiling: two tiles */ +.tiled_row .duo { + width: 48.25% !important; +} + +/* Tiling: one tile */ +.tiled_row .single { + width: 48.25% !important; +} + +/* Tiling: remove horizontal margin at end of row. */ +.tiled_row > div:nth-child(3n), +.tiled_row > .duo:nth-child(2) { + margin-right: 0 !important; +} +.tiled_row .column_tiles_four > div:nth-child(2n) { + margin-right: 0; +} + +/* Tiling: switch places for odd rows */ +.tiled_row:nth-child(odd) .column_landscape, +.tiled_row:nth-child(odd) .column_portrait { + float: right; + margin: 0 0 3.5% 3.5%; +} +.tiled_row:nth-child(odd) .column_tiles_four { + float: right; +} +.tiled_row:nth-child(odd) .column_tiles_two { + float: right; +} + +.boxed_content { + background: #fff; + padding: 25px; + box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); +} +.boxed_content h2 { + font: 300 24px "Open Sans", "Helvetica Neue", sans-serif; + margin: 0 0 0.2em; +} + + +/* Error pages +----------------*/ +.errormsg p { + font-size: 1.1em; + margin: 1em 0 0; +} +.errorpage .widget { + margin-top: 3%; +} +.errorpage .widget_recentposts { + margin-right: 0; +} + + +/* Footer +-----------*/ +footer { + clear: both; + color: #fff; + font: 400 12px/1.7 "Open Sans", "Helvetica Neue", sans-serif; + padding: 40px 0 0; + text-align: center; + overflow: auto; + text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.4); +} +footer a { + color: #eee; +} + + +/* Input +----------*/ + +input, select, .btn { + background: #fff; + border: 1px solid #ccc; + color: #000; + font: 13px/1.7 "Open Sans", "Helvetica", sans-serif; + padding: 3px; +} +input[type=submit], button, .btn { + background: #C5E2EA; + border-radius: 3px; + border: 1px solid #8BBFCE; + display: inline-block; + font: inherit; + padding: 4px 5px; +} +input[type=submit]:hover, button:hover, .btn:hover { + background-color: #bddce5; + border-color: #88b7c6; +} +textarea { + border: 1px solid #ccc; + font: 12px/1.4 'Monaco', 'Inconsolata', 'DejaVu Sans Mono', monospace; + padding: 0.75%; + width: 98.5%; +} +.btn-red { + background: #F3B076; + border-color: #C98245; + color: #000; +} + + +/* Responsive: smartphone in portrait +---------------------------------------*/ +@media only screen and (max-width: 895px) { + #wrapper, header { + width: 100% !important; + min-width: 100% !important; + max-width: 100% !important; + } + + h1#logo { + font-size: 42px; + float: none; + margin: 1em 0 0.5em; + text-align: center; + } + + ul#nav { + float: none; + padding: 0; + margin: 2em 0 2.5em; + text-align: center; + overflow: none; + } + + ul#nav li, ul#nav li a { + display: inline; + float: none; + } + + ul#nav li a { + float: none; + font-size: 16px; + margin-left: 6px; + padding: 15px 4px; + } + + .grid li { + margin: 0 0 5%; + width: 47.5%; + } + .grid li:nth-child(2n) { + margin-right: 0 !important; + } + .grid li:nth-child(2n+1) { + margin-right: 5% !important; + } + + .tiled_header { + font-size: 14px; + margin: 0 0 3.5% 0; + } + .tiled_grid div h4 { + font-size: 14px; + padding: 15px 5px 10px; + } + + .tiled_row > div, .tiled_row .single, .tiled_row .duo { + float: none !important; + width: 100% !important; + margin: 0 0 5% !important; + } + + .tiled_row > div > div { + float: none !important; + width: 100% !important; + margin: 0 0 5% !important; + } +} diff --git a/public/images/nothumb.png b/public/images/nothumb.png new file mode 100644 index 0000000..ce6a2f2 Binary files /dev/null and b/public/images/nothumb.png differ diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..96e7ae2 --- /dev/null +++ b/public/index.php @@ -0,0 +1,10 @@ += 3; + }); + + if (tokens.length === 0) { + if (typeof this.container !== "undefined") { + this.clearContainer(); + } + return false; + } + + var request_uri = this.baseurl + '/suggest/?type=tags&data=' + window.encodeURIComponent(tokens.join(" ")); + var request = new HttpRequest('get', request_uri, {}, this.onReceive, this); +}; + +AutoSuggest.prototype.onReceive = function(response, self) { + self.openContainer(); + self.clearContainer(); + self.fillContainer(response); +}; + +AutoSuggest.prototype.openContainer = function() { + if (this.container) { + if (!this.container.parentNode) { + this.input.parentNode.appendChild(this.container); + } + return this.container; + } + + this.container = document.createElement('ul'); + this.container.className = 'autosuggest'; + this.input.parentNode.appendChild(this.container); + return this.container; +}; + +AutoSuggest.prototype.clearContainer = function() { + while (this.container.children.length > 0) { + this.container.removeChild(this.container.children[0]); + } +}; + +AutoSuggest.prototype.clearInput = function() { + this.input.value = ""; + this.input.focus(); +}; + +AutoSuggest.prototype.closeContainer = function() { + this.container.parentNode.removeChild(this.container); +}; + +AutoSuggest.prototype.fillContainer = function(response) { + var self = this; + this.selectedIndex = 0; + response.items.forEach(function(item, i) { + var node = document.createElement('li'); + var text = document.createTextNode(item.label); + node.jsondata = item; + node.addEventListener('click', function(event) { + self.appendCallback(this.jsondata); + self.closeContainer(); + self.clearInput(); + }); + node.appendChild(text); + self.container.appendChild(node); + if (self.container.children.length === 1) { + node.className = 'selected'; + } + }); +}; + + +function TagAutoSuggest(opt) { + AutoSuggest.prototype.constructor.call(this, opt); + this.type = "tags"; +} + +TagAutoSuggest.prototype = Object.create(AutoSuggest.prototype); + +TagAutoSuggest.prototype.constructor = TagAutoSuggest; + +TagAutoSuggest.prototype.fillContainer = function(response) { + if (response.items.length > 0) { + AutoSuggest.prototype.fillContainer.call(this, response); + } else { + var node = document.createElement('li') + node.innerHTML = "Tag does not exist yet. Create it?"; + + var self = this; + node.addEventListener('click', function(event) { + self.createNewTag(function(response) { + console.log('Nieuwe tag!!'); + console.log(response); + self.appendCallback(response); + }); + self.closeContainer(); + self.clearInput(); + }); + + self.container.appendChild(node); + this.selectedIndex = 0; + node.className = 'selected'; + } +}; + +TagAutoSuggest.prototype.createNewTag = function(callback) { + var request_uri = this.baseurl + '/managetags/?create'; + var request = new HttpRequest('post', request_uri, 'tag=' + encodeURIComponent(this.input.value), callback, this); +} diff --git a/public/js/crop_editor.js b/public/js/crop_editor.js new file mode 100644 index 0000000..11b26e5 --- /dev/null +++ b/public/js/crop_editor.js @@ -0,0 +1,218 @@ +function CropEditor(opt) { + this.opt = opt; + + this.edit_crop_button = document.createElement("span"); + this.edit_crop_button.className = "btn"; + this.edit_crop_button.innerHTML = "Edit crop"; + this.edit_crop_button.addEventListener('click', this.show.bind(this)); + + this.thumbnail_select = document.getElementById(opt.thumbnail_select_id); + this.thumbnail_select.addEventListener('change', this.toggleCropButton.bind(this)); + this.thumbnail_select.parentNode.insertBefore(this.edit_crop_button, this.thumbnail_select.nextSibling); + + this.toggleCropButton(); +} + +CropEditor.prototype.buildContainer = function() { + this.container = document.createElement("div"); + this.container.id = "crop_editor"; + + this.position = document.createElement("div"); + this.position.className = "crop_position"; + this.container.appendChild(this.position); + + var source_x_label = document.createTextNode("Source X:"); + this.position.appendChild(source_x_label); + + this.source_x = document.createElement("input"); + this.source_x.addEventListener("keyup", this.positionBoundary.bind(this)); + this.position.appendChild(this.source_x); + + var source_y_label = document.createTextNode("Source Y:"); + this.position.appendChild(source_y_label); + + this.source_y = document.createElement("input"); + this.source_y.addEventListener("keyup", this.positionBoundary.bind(this)); + this.position.appendChild(this.source_y); + + var crop_width_label = document.createTextNode("Crop width:"); + this.position.appendChild(crop_width_label); + + this.crop_width = document.createElement("input"); + this.crop_width.addEventListener("keyup", this.positionBoundary.bind(this)); + this.position.appendChild(this.crop_width); + + var crop_height_label = document.createTextNode("Crop height:"); + this.position.appendChild(crop_height_label); + + this.crop_height = document.createElement("input"); + this.crop_height.addEventListener("keyup", this.positionBoundary.bind(this)); + this.position.appendChild(this.crop_height); + + this.save_button = document.createElement("span"); + this.save_button.className = "btn"; + this.save_button.innerHTML = "Save"; + this.save_button.addEventListener('click', this.save.bind(this)); + this.position.appendChild(this.save_button); + + this.abort_button = document.createElement("span"); + this.abort_button.className = "btn btn-red"; + this.abort_button.innerHTML = "Abort"; + this.abort_button.addEventListener('click', this.hide.bind(this)); + this.position.appendChild(this.abort_button); + + this.image_container = document.createElement("div"); + this.image_container.className = "crop_image_container"; + this.container.appendChild(this.image_container); + + this.crop_boundary = document.createElement("div"); + this.crop_boundary.id = "crop_boundary"; + this.image_container.appendChild(this.crop_boundary); + + this.original_image = document.createElement("img"); + this.original_image.id = "original_image"; + this.original_image.src = this.opt.original_image_src; + this.image_container.appendChild(this.original_image); + + this.parent = document.getElementById(this.opt.editor_container_parent_id); + this.parent.appendChild(this.container); +}; + +CropEditor.prototype.setInputValues = function() { + var current = this.thumbnail_select.options[this.thumbnail_select.selectedIndex].dataset; + + if (typeof current.crop_region === "undefined") { + var source_ratio = this.original_image.naturalWidth / this.original_image.naturalHeight, + crop_ratio = current.crop_width / current.crop_height, + min_dim = Math.min(this.original_image.naturalWidth, this.original_image.naturalHeight); + + // Cropping from the centre? + if (current.crop_method === "c") { + // Crop vertically from the centre, using the entire width. + if (source_ratio < crop_ratio) { + this.crop_width.value = this.original_image.naturalWidth; + this.crop_height.value = Math.ceil(this.original_image.naturalWidth / crop_ratio); + this.source_x.value = 0; + this.source_y.value = Math.ceil((this.original_image.naturalHeight - this.crop_height.value) / 2); + } + // Crop horizontally from the centre, using the entire height. + else { + this.crop_width.value = Math.ceil(current.crop_width * this.original_image.naturalHeight / current.crop_height); + this.crop_height.value = this.original_image.naturalHeight; + this.source_x.value = Math.ceil((this.original_image.naturalWidth - this.crop_width.value) / 2); + this.source_y.value = 0; + } + } + // Cropping a top or bottom slice? + else { + // Can we actually take a top or bottom slice from the original image? + if (source_ratio < crop_ratio) { + this.crop_width.value = this.original_image.naturalWidth; + this.crop_height.value = Math.floor(this.original_image.naturalHeight / crop_ratio); + this.source_x.value = "0"; + this.source_y.value = current.crop_method.indexOf("t") !== -1 ? "0" : this.original_image.naturalHeight - this.crop_height.value; + } + // Otherwise, take a vertical slice from the centre. + else { + this.crop_width.value = Math.floor(this.original_image.naturalHeight * crop_ratio); + this.crop_height.value = this.original_image.naturalHeight; + this.source_x.value = Math.floor((this.original_image.naturalWidth - this.crop_width.value) / 2); + this.source_y.value = "0"; + } + } + } else { + var region = current.crop_region.split(','); + this.crop_width.value = region[0]; + this.crop_height.value = region[1]; + this.source_x.value = region[2]; + this.source_y.value = region[3]; + } +}; + +CropEditor.prototype.showContainer = function() { + this.container.style.display = "block"; + this.setInputValues(); + this.positionBoundary(); +} + +CropEditor.prototype.save = function() { + var current = this.thumbnail_select.options[this.thumbnail_select.selectedIndex].dataset; + var payload = { + thumb_width: current.crop_width, + thumb_height: current.crop_height, + crop_method: current.crop_method, + crop_width: this.crop_width.value, + crop_height: this.crop_height.value, + source_x: this.source_x.value, + source_y: this.source_y.value + }; + var req = HttpRequest("post", this.parent.action + "?id=" + this.opt.asset_id + "&updatethumb", + "data=" + encodeURIComponent(JSON.stringify(payload)), function(response) { + this.opt.after_save(response); + this.hide(); + }.bind(this)); +}; + +CropEditor.prototype.show = function() { + if (typeof this.container === "undefined") { + this.buildContainer(); + } + + // Defer showing and positioning until image is loaded. + // !!! TODO: add a spinner in the mean time? + if (this.original_image.naturalWidth > 0) { + this.showContainer(); + } else { + this.original_image.addEventListener("load", function() { + this.showContainer(); + }.bind(this)); + } +}; + +CropEditor.prototype.hide = function() { + this.container.style.display = "none"; +}; + +CropEditor.prototype.addEvents = function(event) { + var drag_target = document.getElementById(opt.drag_target); + drag_target.addEventListener('dragstart', this.dragStart); + drag_target.addEventListener('drag', this.drag); + drag_target.addEventListener('dragend', this.dragEnd); +}; + +CropEditor.prototype.dragStart = function(event) { + console.log(event); + event.preventDefault(); +}; + +CropEditor.prototype.dragEnd = function(event) { + console.log(event); +}; + +CropEditor.prototype.drag = function(event) { + console.log(event); +}; + +CropEditor.prototype.toggleCropButton = function() { + var current = this.thumbnail_select.options[this.thumbnail_select.selectedIndex].dataset; + this.edit_crop_button.style.display = typeof current.crop_method === "undefined" ? "none" : ""; +}; + +CropEditor.prototype.positionBoundary = function(event) { + var source_x = parseInt(this.source_x.value), + source_y = parseInt(this.source_y.value), + crop_width = parseInt(this.crop_width.value), + crop_height = parseInt(this.crop_height.value), + real_width = this.original_image.naturalWidth, + real_height = this.original_image.naturalHeight, + scaled_width = this.original_image.clientWidth, + scaled_height = this.original_image.clientHeight; + + var width_scale = scaled_width / real_width, + height_scale = scaled_height / real_height; + + crop_boundary.style.left = (this.source_x.value) * width_scale + "px"; + crop_boundary.style.top = (this.source_y.value) * height_scale + "px"; + crop_boundary.style.width = (this.crop_width.value) * width_scale + "px"; + crop_boundary.style.height = (this.crop_height.value) * height_scale + "px"; +}; diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..2d10b69 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +Disallow: /login + diff --git a/server b/server new file mode 100644 index 0000000..285e1db --- /dev/null +++ b/server @@ -0,0 +1,2 @@ +#!/bin/bash +php -S hashru.local:8080 -t public diff --git a/templates/AdminBar.php b/templates/AdminBar.php new file mode 100644 index 0000000..cc1e447 --- /dev/null +++ b/templates/AdminBar.php @@ -0,0 +1,36 @@ + + + '; + } + + public function appendItem($url, $caption) + { + $this->extra_items[] = [$url, $caption]; + } +} diff --git a/templates/AlbumIndex.php b/templates/AlbumIndex.php new file mode 100644 index 0000000..4307fb7 --- /dev/null +++ b/templates/AlbumIndex.php @@ -0,0 +1,71 @@ +albums = $albums; + $this->show_edit_buttons = $show_edit_buttons; + $this->show_labels = $show_labels; + } + + protected function html_content() + { + echo ' +
'; + + foreach (array_chunk($this->albums, 3) as $photos) + { + echo ' +
'; + + foreach ($photos as $album) + { + echo ' + '; + } + + echo ' +
'; + } + + echo ' +
'; + } +} diff --git a/templates/DummyBox.php b/templates/DummyBox.php new file mode 100644 index 0000000..cc88114 --- /dev/null +++ b/templates/DummyBox.php @@ -0,0 +1,31 @@ +_title = $title; + $this->_content = $content; + $this->_class = $class; + } + + protected function html_content() + { + echo ' +
', $this->_title ? ' +

' . $this->_title . '

' : '', ' + ', $this->_content; + + foreach ($this->_subtemplates as $template) + $template->html_main(); + + echo ' +
'; + } +} diff --git a/templates/EditAssetForm.php b/templates/EditAssetForm.php new file mode 100644 index 0000000..7ba33cf --- /dev/null +++ b/templates/EditAssetForm.php @@ -0,0 +1,292 @@ +asset = $asset; + $this->thumbs = $thumbs; + } + + protected function html_content() + { + echo ' +
+
+
+ Delete asset + +
+

Edit asset \'', $this->asset->getTitle(), '\' (', $this->asset->getFilename(), ')

+
'; + + $this->section_replace(); + + echo ' +
'; + + $this->section_key_info(); + $this->section_asset_meta(); + + echo ' +
+
'; + + if (!empty($this->thumbs)) + $this->section_thumbnails(); + + $this->section_linked_tags(); + + echo ' +
'; + + $this->section_crop_editor(); + + echo ' +
'; + } + + protected function section_key_info() + { + $date_captured = $this->asset->getDateCaptured(); + echo ' +
+

Key info

+
+
Title
+
+ +
Date captured
+
+ +
Display priority
+
+
+
'; + } + + protected function section_linked_tags() + { + echo ' +
+

Linked tags

+
    '; + + foreach ($this->asset->getTags() as $tag) + echo ' +
  • + + ', $tag->tag, ' +
  • '; + + echo ' +
  • +
+
+ + + '; + } + + protected function section_thumbnails() + { + echo ' +
+

Thumbnails

+ View: + + Thumbnail + +
+ '; + } + + protected function section_crop_editor() + { + if (!$this->asset->isImage()) + return; + + echo ' + + '; + } + + protected function section_asset_meta() + { + echo ' +
+

Asset meta data

+
    '; + + $i = -1; + foreach ($this->asset->getMeta() as $key => $meta) + { + $i++; + echo ' +
  • + + +
  • '; + } + + echo ' +
  • + + +
  • +
+

+
'; + } + + protected function section_replace() + { + echo ' +
+

Replace asset

+ File: + Target: + +
'; + } +} diff --git a/templates/FormView.php b/templates/FormView.php new file mode 100644 index 0000000..3bad1e5 --- /dev/null +++ b/templates/FormView.php @@ -0,0 +1,161 @@ +title = $title; + $this->request_url = $form->request_url; + $this->request_method = $form->request_method; + $this->fields = $form->getFields(); + $this->missing = $form->getMissing(); + $this->data = $form->getData(); + $this->content_above = $form->content_above; + $this->content_below = $form->content_below; + } + + protected function html_content($exclude = [], $include = []) + { + if (!empty($this->title)) + echo ' +
+

', $this->title, '

+
+
'; + + foreach ($this->_subtemplates as $template) + $template->html_main(); + + echo ' +
'; + + if (isset($this->content_above)) + echo $this->content_above; + + echo ' +
'; + + foreach ($this->fields as $field_id => $field) + { + // Either we have a blacklist + if (!empty($exclude) && in_array($field_id, $exclude)) + continue; + // ... or a whitelist + elseif (!empty($include) && !in_array($field_id, $include)) + continue; + // ... or neither (ha) + + $this->renderField($field_id, $field); + } + + echo ' +
+ +
+ '; + + if (isset($this->content_below)) + echo ' + ', $this->content_below; + + echo ' +
+
'; + + if (!empty($this->title)) + echo ' +
'; + } + + protected function renderField($field_id, $field) + { + if (isset($field['before_html'])) + echo ' + ', $field['before_html'], ' +
'; + + if ($field['type'] != 'checkbox' && isset($field['label'])) + echo ' +
missing) ? ' style="color: red"' : '', '>', $field['label'], '
'; + elseif ($field['type'] == 'checkbox' && isset($field['header'])) + echo ' +
missing) ? ' style="color: red"' : '', '>', $field['header'], '
'; + + echo ' +
'; + + if (isset($field['before'])) + echo $field['before']; + + switch ($field['type']) + { + case 'select': + echo ' + '; + break; + + case 'radio': + foreach ($field['options'] as $value => $option) + echo ' + data[$field_id] == $value ? ' checked' : '', !empty($field['disabled']) ? ' disabled' : '', '> ', htmlentities($option); + break; + + case 'checkbox': + echo ' + '; + break; + + case 'textarea': + echo ' + '; + break; + + case 'color': + echo ' + '; + break; + + case 'numeric': + echo ' + '; + break; + + case 'file': + if (!empty($this->data[$field_id])) + echo '
'; + + echo ' + '; + break; + + case 'text': + case 'password': + default: + echo ' + '; + } + + if (isset($field['after'])) + echo ' ', $field['after']; + + echo ' +
'; + } +} diff --git a/templates/LogInForm.php b/templates/LogInForm.php new file mode 100644 index 0000000..c051b4d --- /dev/null +++ b/templates/LogInForm.php @@ -0,0 +1,60 @@ +error_message = $message; + } + + public function setRedirectUrl($url) + { + $_SESSION['login_url'] = $url; + $this->redirect_url = $url; + } + + public function setEmail($addr) + { + $this->emailaddress = htmlentities($addr); + } + + protected function html_content() + { + echo ' +
+

Admin login

'; + + // Invalid login? Show a message. + if (!empty($this->error_message)) + echo ' +

', $this->error_message, '

'; + + echo ' +
+
+
+ +
+
+
'; + + // Throw in a redirect url if asked for. + if (!empty($this->redirect_url)) + echo ' + '; + + echo ' +
+
'; + } +} diff --git a/templates/MainTemplate.php b/templates/MainTemplate.php new file mode 100644 index 0000000..9ade1e4 --- /dev/null +++ b/templates/MainTemplate.php @@ -0,0 +1,115 @@ +title = $title; + } + + public function html_main() + { + echo ' + + + ', $this->title, '', !empty($this->canonical_url) ? ' + ' : '', ' + + + ', !empty($this->css) ? ' + ' : '', $this->header_html, ' + + classes) ? ' class="' . implode(' ', $this->classes) . '"' : '', '> +
+

#pics

+ +
+
'; + + foreach ($this->_subtemplates as $template) + $template->html_main(); + + echo ' +
'; + + if (Registry::has('user') && Registry::get('user')->isAdmin()) + { + if (class_exists('Cache')) + echo ' + Cache info: ', Cache::$hits, ' hits, ', Cache::$misses, ' misses, ', Cache::$puts, ' puts, ', Cache::$removals, ' removals'; + + if (Registry::has('start')) + echo '
+ Page creation time: ', sprintf('%1.4f', microtime(true) - Registry::get('start')), ' seconds'; + + if (Registry::has('db')) + echo '
+ Database interaction: ', Registry::get('db')->getQueryCount(), ' queries'; + } + else + echo ' + Powered by Kabuki CMS | Admin'; + + echo ' +
+
'; + + if (Registry::has('db') && defined("DB_LOG_QUERIES") && DB_LOG_QUERIES) + foreach (Registry::get('db')->getLoggedQueries() as $query) + echo '
', strtr($query, "\t", " "), '
'; + + echo ' + +'; + } + + public function appendCss($css) + { + $this->css .= $css; + } + + public function appendHeaderHtml($html) + { + $this->header_html .= "\n\t\t" . $html; + } + + public function appendStylesheet($uri) + { + $this->appendHeaderHtml(''); + } + + public function setCanonicalUrl($url) + { + $this->canonical_url = $url; + } + + public function addClass($class) + { + if (!in_array($class, $this->classes)) + $this->classes[] = $class; + } + + public function removeClass($class) + { + if ($key = array_search($class, $this->classes) !== false) + unset($this->classes[$key]); + } +} diff --git a/templates/MediaUploader.php b/templates/MediaUploader.php new file mode 100644 index 0000000..dbab5a7 --- /dev/null +++ b/templates/MediaUploader.php @@ -0,0 +1,77 @@ + +

Upload new media

+
+

Select files

+
    +
  • +
    + +
  • +
  • +
    + +
  • +
  • +
    + +
  • +
+
+
+

Link tags

+
    +
  • +
+
+ + + +
+ +
+ '; + } +} diff --git a/templates/Pagination.php b/templates/Pagination.php new file mode 100644 index 0000000..73a7c7e --- /dev/null +++ b/templates/Pagination.php @@ -0,0 +1,44 @@ +index = $index->getPageIndex(); + $this->class = $index->getPageIndexClass(); + } + + protected function html_content() + { + echo ' +
+
    +
  • <', !empty($this->index['previous']) ? 'a href="' . $this->index['previous']['href'] . '"' : 'span', '>« previousindex['previous']) ? 'a' : 'span', '>
  • '; + + foreach ($this->index as $key => $page) + { + if (!is_numeric($key)) + continue; + + if (!is_array($page)) + echo ' +
  • ...
  • '; + else + echo ' +
  • ', $page['index'], '
  • '; + } + + echo ' +
  • <', !empty($this->index['next']) ? 'a href="' . $this->index['next']['href'] . '"' : 'span', '>next »index['next']) ? 'a' : 'span', '>
  • +
+
'; + } +} diff --git a/templates/PhotosIndex.php b/templates/PhotosIndex.php new file mode 100644 index 0000000..da7b8c4 --- /dev/null +++ b/templates/PhotosIndex.php @@ -0,0 +1,244 @@ +mosaic = $mosaic; + $this->show_edit_buttons = $show_edit_buttons; + $this->show_headers = $show_headers; + $this->show_labels = $show_labels; + } + + protected function html_content() + { + echo ' +
'; + + for ($i = $this->row_limit; $i > 0 && $row = $this->mosaic->getRow(); $i--) + { + list($photos, $what) = $row; + $this->header($photos); + $this->$what($photos); + } + + echo ' +
'; + } + + protected function header($photos) + { + if (!$this->show_headers) + return; + + $date = $photos[0]->getDateCaptured(); + if (!$date) + return; + + $header = $date->format('F Y'); + if ($header === $this->previous_header) + return; + + $name = str_replace(' ', '', strtolower($header)); + echo ' + '; + + $this->previous_header = $header; + } + + protected function color(Image $image) + { + $color = $image->bestColor(); + if ($color == 'FFFFFF') + $color = 'ccc'; + + return $color; + } + + protected function photo(Image $image, $width, $height, $crop = true, $fit = true) + { + if ($this->show_edit_buttons) + echo ' + Edit'; + + echo ' + + '; + + if ($this->show_labels) + echo ' +

', $image->getTitle(), '

'; + + echo ' +
'; + } + + protected function panorama(array $photos) + { + foreach ($photos as $image) + { + echo ' +
'; + + $this->photo($image, static::PANORAMA_WIDTH, static::PANORAMA_HEIGHT, false, false); + + echo ' +
'; + } + } + + protected function portrait(array $photos) + { + $image = array_shift($photos); + + echo ' +
+
+
'; + + $this->photo($image, static::PORTRAIT_WIDTH, static::PORTRAIT_HEIGHT, 'top'); + + echo ' +
+
+
'; + + foreach ($photos as $image) + { + $color = $image->bestColor(); + if ($color == 'FFFFFF') + $color = 'ccc'; + + echo ' +
'; + + $this->photo($image, static::TILE_WIDTH, static::TILE_HEIGHT, 'top'); + + echo ' +
'; + } + + echo ' +
+
'; + } + + protected function landscape(array $photos) + { + $image = array_shift($photos); + + echo ' +
+
+
'; + + $this->photo($image, static::LANDSCAPE_WIDTH, static::LANDSCAPE_HEIGHT, 'top'); + + echo ' +
+
+
'; + + foreach ($photos as $image) + { + echo ' +
'; + + $this->photo($image, static::TILE_WIDTH, static::TILE_HEIGHT, 'top'); + + echo ' +
'; + } + + echo ' +
+
'; + } + + protected function duo(array $photos) + { + echo ' +
'; + + foreach ($photos as $image) + { + echo ' +
'; + + $this->photo($image, static::DUO_WIDTH, static::DUO_HEIGHT, true); + + echo ' +
'; + } + + echo ' +
'; + } + + protected function single(array $photos) + { + $image = array_shift($photos); + + echo ' +
+
'; + + $this->photo($image, static::SINGLE_WIDTH, static::SINGLE_HEIGHT, 'top'); + + echo ' +
+
'; + } + + protected function row(array $photos) + { + echo ' +
'; + + foreach ($photos as $image) + { + echo ' +
'; + + $this->photo($image, static::TILE_WIDTH, static::TILE_HEIGHT, true); + + echo ' +
'; + } + + echo ' +
'; + } +} diff --git a/templates/SubTemplate.php b/templates/SubTemplate.php new file mode 100644 index 0000000..75030de --- /dev/null +++ b/templates/SubTemplate.php @@ -0,0 +1,17 @@ +html_content(); + } + + abstract protected function html_content(); +} diff --git a/templates/TabularData.php b/templates/TabularData.php new file mode 100644 index 0000000..8460158 --- /dev/null +++ b/templates/TabularData.php @@ -0,0 +1,114 @@ +_t = $table; + parent::__construct($table); + } + + protected function html_content() + { + echo ' +
'; + + $title = $this->_t->getTitle(); + if (!empty($title)) + echo ' +

', $title, '

'; + + // Showing a page index? + parent::html_content(); + + // Maybe even a small form? + if (isset($this->_t->form_above)) + $this->showForm($this->_t->form_above); + + // Build the table! + echo ' + + + '; + + // Show the table's headers. + foreach ($this->_t->getHeader() as $th) + { + echo ' + 1 ? ' colspan="' . $th['colspan'] . '"' : ''), ' scope="', $th['scope'], '">', + $th['href'] ? '' . $th['label'] . '' : $th['label']; + + if ($th['sort_mode'] ) + echo ' ', $th['sort_mode'] == 'up' ? '↑' : '↓'; + + echo ''; + } + + echo ' + + + '; + + // Show the table's body. + $body = $this->_t->getBody(); + if (is_array($body)) + { + foreach ($body as $tr) + { + echo ' + '; + + foreach ($tr['cells'] as $td) + echo ' + ', $td['value'], ''; + + echo ' + '; + } + } + else + echo ' + + + '; + + echo ' + +
', $body, '
'; + + // Maybe another small form? + if (isset($this->_t->form_below)) + $this->showForm($this->_t->form_below); + + // Showing a page index? + parent::html_content(); + + echo ' +
'; + } + + protected function showForm($form) + { + echo ' +
'; + + if (!empty($form['fields'])) + foreach ($form['fields'] as $name => $field) + echo ' + '; + + if (!empty($form['buttons'])) + foreach ($form['buttons'] as $name => $button) + echo ' + '; + + echo ' +
'; + } +} diff --git a/templates/Template.php b/templates/Template.php new file mode 100644 index 0000000..6719de6 --- /dev/null +++ b/templates/Template.php @@ -0,0 +1,34 @@ +_subtemplates[] = $template; + // We can also add it to the beginning of the list, though. + else + array_unshift($this->_subtemplates, $template); + } + + public function clear() + { + $this->_subtemplates = []; + } + + public function pass($id, $data) + { + $this->{$id} = $data; + } +}