From ab0e4efbcbcf26effd833d5ac0011532aa8c6229 Mon Sep 17 00:00:00 2001 From: Aaron van Geffen Date: Thu, 1 Sep 2016 23:13:23 +0200 Subject: [PATCH] Initial commit. This is to be the new HashRU website based on the Aaronweb.net/Kabuki CMS. --- .gitattributes | 1 + .gitignore | 5 + README | 1 + TODO.md | 10 + app.php | 32 ++ composer.json | 18 + config.php.dist | 33 ++ controllers/EditAsset.php | 159 +++++++++ controllers/EditUser.php | 195 +++++++++++ controllers/HTMLController.php | 34 ++ controllers/JSONController.php | 21 ++ controllers/Login.php | 56 +++ controllers/Logout.php | 20 ++ controllers/ManageErrors.php | 122 +++++++ controllers/ManageTags.php | 122 +++++++ controllers/ManageUsers.php | 131 +++++++ controllers/ProvideAutoSuggest.php | 41 +++ controllers/UploadMedia.php | 58 ++++ controllers/ViewPeople.php | 45 +++ controllers/ViewPhotoAlbum.php | 130 +++++++ controllers/ViewTimeline.php | 58 ++++ import_albums.php | 256 ++++++++++++++ models/Asset.php | 478 ++++++++++++++++++++++++++ models/AssetIterator.php | 154 +++++++++ models/Authentication.php | 114 ++++++ models/BestColor.php | 149 ++++++++ models/Cache.php | 61 ++++ models/Database.php | 535 +++++++++++++++++++++++++++++ models/Dispatcher.php | 140 ++++++++ models/EXIF.php | 135 ++++++++ models/ErrorHandler.php | 159 +++++++++ models/ErrorLog.php | 36 ++ models/Form.php | 165 +++++++++ models/GenericTable.php | 246 +++++++++++++ models/Guest.php | 31 ++ models/Image.php | 382 ++++++++++++++++++++ models/Member.php | 192 +++++++++++ models/NotAllowedException.php | 12 + models/NotFoundException.php | 12 + models/PageIndex.php | 189 ++++++++++ models/PhotoMosaic.php | 166 +++++++++ models/Registry.php | 39 +++ models/Session.php | 87 +++++ models/Setting.php | 80 +++++ models/Tag.php | 337 ++++++++++++++++++ models/User.php | 98 ++++++ public/css/admin.css | 342 ++++++++++++++++++ public/css/default.css | 422 +++++++++++++++++++++++ public/images/nothumb.png | Bin 0 -> 3694 bytes public/index.php | 10 + public/js/ajax.js | 38 ++ public/js/autosuggest.js | 178 ++++++++++ public/js/crop_editor.js | 218 ++++++++++++ public/robots.txt | 3 + server | 2 + templates/AdminBar.php | 36 ++ templates/AlbumIndex.php | 71 ++++ templates/DummyBox.php | 31 ++ templates/EditAssetForm.php | 292 ++++++++++++++++ templates/FormView.php | 161 +++++++++ templates/LogInForm.php | 60 ++++ templates/MainTemplate.php | 115 +++++++ templates/MediaUploader.php | 77 +++++ templates/Pagination.php | 44 +++ templates/PhotosIndex.php | 244 +++++++++++++ templates/SubTemplate.php | 17 + templates/TabularData.php | 114 ++++++ templates/Template.php | 34 ++ 68 files changed, 8054 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 README create mode 100644 TODO.md create mode 100644 app.php create mode 100644 composer.json create mode 100644 config.php.dist create mode 100644 controllers/EditAsset.php create mode 100644 controllers/EditUser.php create mode 100644 controllers/HTMLController.php create mode 100644 controllers/JSONController.php create mode 100644 controllers/Login.php create mode 100644 controllers/Logout.php create mode 100644 controllers/ManageErrors.php create mode 100644 controllers/ManageTags.php create mode 100644 controllers/ManageUsers.php create mode 100644 controllers/ProvideAutoSuggest.php create mode 100644 controllers/UploadMedia.php create mode 100644 controllers/ViewPeople.php create mode 100644 controllers/ViewPhotoAlbum.php create mode 100644 controllers/ViewTimeline.php create mode 100644 import_albums.php create mode 100644 models/Asset.php create mode 100644 models/AssetIterator.php create mode 100644 models/Authentication.php create mode 100644 models/BestColor.php create mode 100644 models/Cache.php create mode 100644 models/Database.php create mode 100644 models/Dispatcher.php create mode 100644 models/EXIF.php create mode 100644 models/ErrorHandler.php create mode 100644 models/ErrorLog.php create mode 100644 models/Form.php create mode 100644 models/GenericTable.php create mode 100644 models/Guest.php create mode 100644 models/Image.php create mode 100644 models/Member.php create mode 100644 models/NotAllowedException.php create mode 100644 models/NotFoundException.php create mode 100644 models/PageIndex.php create mode 100644 models/PhotoMosaic.php create mode 100644 models/Registry.php create mode 100644 models/Session.php create mode 100644 models/Setting.php create mode 100644 models/Tag.php create mode 100644 models/User.php create mode 100644 public/css/admin.css create mode 100644 public/css/default.css create mode 100644 public/images/nothumb.png create mode 100644 public/index.php create mode 100644 public/js/ajax.js create mode 100644 public/js/autosuggest.js create mode 100644 public/js/crop_editor.js create mode 100644 public/robots.txt create mode 100644 server create mode 100644 templates/AdminBar.php create mode 100644 templates/AlbumIndex.php create mode 100644 templates/DummyBox.php create mode 100644 templates/EditAssetForm.php create mode 100644 templates/FormView.php create mode 100644 templates/LogInForm.php create mode 100644 templates/MainTemplate.php create mode 100644 templates/MediaUploader.php create mode 100644 templates/Pagination.php create mode 100644 templates/PhotosIndex.php create mode 100644 templates/SubTemplate.php create mode 100644 templates/TabularData.php create mode 100644 templates/Template.php 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 0000000000000000000000000000000000000000..ce6a2f2d8c7db907ef15baf54f579481e35a5e7a GIT binary patch literal 3694 zcma)93pmqzA766I<>)wBa(NqalE&OG3mt8WG?&6g$7Rhw)@)-NyCAQlbWZE-2oVlm zbkyt8mP^WQ#n~YfMNK85NEsQqgz)~SQ|Eo2_w79Io;~0F{=eV%^Z8!?zvsK3e7p{* ztLUgeAP{vAcl2QhWJ4ObZdaBAr3VvM3;t|lxZxN_sK*&hJPm-jhEtCLFb^_50yqrd z!`U%+0VfD#;|`)9j)B7*LK3KCbG%H)oJEcX(GZB!URE@oKmr)BV?YFv;%qq8ATWdx z!<`MWwipXcGzy3$x^rm25ssH1fkPrVh8ym6fjO~|AORU*;9)Fs6ormtIUBC?BEhw6 z8(|1rS7DHx4L>@C!}!2ZR2l%YHMce+SXf%Z?Ci`fZEWrAEa5OK3rj161^Bfyv$R3l z+99p%VSjuKL2I<|NZ5&6(EbR9XbW($UdT#$jb;25Okm z*%StzWk#VJePTcZbOMbS%^*@KFc~BM7&VsRYzTV#F$8k-XIcvVk2HY^L$L7C2upJd zSxBE0F_^zrC6hmE(;0_>KmGlm#B@J)G=Mk^(5bOB0$8}?Mlw^;NE8jgGpIB_DmCg; z7JVYA3@Sa68Vy4ov4fdl@B|`7wy|p+fx#d>D0Bv%LI6C_&W0d|IguESw6sE5Ik?)R z?H%`9TUxqVyY92Mb#y>G*t()n_Et9gK4H;RLM$1eFg{_!|HQg|5i4s3ax`ce4bX^j zK=^(dl?+=C8cF=JFZ;g8_Xjro%f6t#h(&3CkDtz`o&O1AAx(7dU6~xV|guUU?siZP9aLfX=;lbhq%gV z_NPmOT5Ap!ol;)L7fzN`w}tj3$Xm#NlMUI7gUG3_i|@iV$f<%t8NMzc$qLGFP{8;f zg*+5o$wU8sTW(!!#(}HPU~a~8eGtMv(AU3~87^j10U-`u$`M%S7(cn^?`0{0g} z_$C1Blz-M-9-1FWt?JvmQ`FkuJfT7ovh_@Pkw;m#5|rUxrDc*t&8AvCRa>k}Q0Wu* zR@G!-jutTYj^v?^kcW8!r@RgvhhY1NK0m#9bw z%woW~nPWMW{cCoOEmfA8!8#$z@If<#s8nNRtS(RbVD@>?8yxBPJu=R=U=8zhR^F}{ zhIOHTY<{-?kCpK5pZ(7hSq6Mo;iSH!`qBNUAM^$_wC=-mQ|o-RPaB5^Wac?(ti8P6 z;wf`b(ouFoC7(Sz(@H>YL!_HDK5EeA*csfYY8;g`)=l(Ci|@Yo%xzG~j=FVswK23g zEg)ugu`aLf47y2jnq1{W*RLF&n^?}FW?x;-pKI|vEp$8aG+C>uI(v(YV~$sbaQ1l2 zja2NG@&x2Z}aJf_9ua3}yZudE|araLp87`ti z`^K{;@(XVa+y1O)Y{1v9$}$3=F$p)QgnsGSE5jHtT(^?OMfV(5KVs@TC3W6pc~KR~ zVwP{-*XPfv3ljK*7*CJ&d#^0INI02Tosg@1^T(9^UV~Ek^YL?)scP(}Qd6cnZ(plt zSj?`nS4O~bkEHEd4PbPG;{&0?YU(^b&5yYWjubOm>8#@4dx>j;wQ06)2>u?c;Zpo# zWqAFA*`=Y)p)R^q>W*A=?+7RBm*Qcq7754Y-KDQ_jFLgGeAeRqX11or5^ug@_@WlR zmUSw%4?c4UJRO-%8yoBHeE-sE$G0!1S0OE-QLM=`795Zlc@&-4+(Zo0Jo%g8EyA`j z-K2*xCHlF?1#ZqHeyAL-O$7MHxAxscXbPVCX*{jVeMTH=9|}4vS=km8!vTL^k%i5dd;~h)kZ``e;RD5_q6P8UFeShyUjMFAlT= z=WqXTlBj2#chl$q=xS!Gp7Cz}kfEqN%eU$qb$GHuVvpUyfVG2Ru1N_)zEyJEBA1;c zY>{ifNg#7%_EO=!Gx4SYbF^v?X3Bh=5%)^rtLmI!H*a#TE-uwc72Xw|Ke`~9_1P2k z{4N)$zZKk>)DZHzQ}6Qm%sJuSf=vq}scjlSN{g zqxD*tP-^p`omZA(&IpHwW9y_|g$qf5>0{Ec z{86_jZQ^)IT7<5S;%%=54KPJM?wIU1ucS3zRGnz7D0KNsKKgzRMM-^-UCUU6-+b5 z?MeC8dV23Um3?W|))LobAq;LGxSW&JZ6yij-f8nnj2C7jRH% zp+miCW50F!CA$@h$_q?7W+umSZ>Vlq>1#FpQ2j>rh8kOZTgODi8hvyNtb)@T<8ts+ zQAmGw)VqO)LMha{^vpeptSp&W!T6TSg@I1)v6}Cvy7x5a8V^7JcyK^D+h@7ciyk$& zYI|osUNCUM`g;|^sJ~LZvm_C^>|EJWtP1!~#@nOLJ@pD$f79xh7ct+OMYKyx{+!-+8SYRw?cLN9(O!M~2fPIs!<{f~Fk@j3T3PxyIn~q_+0D9f7f)}Ob;3M; zZh(^k2!WN5e|gi>tLFZB`^CsxMb;Q5bT>R1@K8oAj!w95OIAo3lF8Oh6*;okMeVm| z=k&lw_uaBSR67k;N>#B{wv~ckH+!k=!EgGGpfTSN;q{xEI14o$(6nM}S_G)Fi}&n; z5kE}(m-{gOKera&9eK3+5PrRAn6Pp`ST1$xalV(!^OI%c^(N&u-Mw({cQ1%+K*kzU z`StJMz?E9pFKnzBuP_k@hGYWD2$>7BSmmpHcQxu6*}7uXYR8_2(h_B`{4z&CC9%Rx{!K>i<7= 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; + } +}