162 Commits

Author SHA1 Message Date
ea4983e967 FeaturedThumbnailManager: add pager widget; show only 20 thumbs per page 2025-09-24 12:44:05 +02:00
b48c8ea820 EditAlbum: reorder asset loading 2025-09-24 12:32:29 +02:00
c9da46b36f EditAlbum: drop old thumbnail id field entirely 2025-09-24 12:30:22 +02:00
2b8b12e065 Merge branch 'inline-forms' 2025-09-24 12:23:50 +02:00
2af4e865e0 TabularData: take control of juxtapositing pager and form 2025-09-23 15:04:57 +02:00
77fa33730a InlineFormView: combine fields and buttons into one 'controls' array 2025-09-23 14:48:08 +02:00
0274ff5bf4 InlineFormView: remove support for unused 'html_after' property 2025-09-23 14:44:07 +02:00
2dea80b58e InlineFormView: split rendering into smaller methods 2025-09-23 14:42:47 +02:00
2bf78b9f5d InlineFormView: split off from TabularData template 2025-09-23 14:35:40 +02:00
913fb974c7 Fix two more stray queries 2025-09-18 11:10:04 +02:00
92b2cfa391 Merge pull request 'Simplify and clarify Forms and FormViews' (#54) from form-views into master
Reviewed-on: #54
2025-09-18 11:08:42 +02:00
48377ec823 Update stray queries to PDO-style parameters 2025-09-18 11:07:55 +02:00
8373c5d2d5 Form: reorder class properties and rework constructor 2025-09-11 20:01:36 +02:00
e69139e591 Form: introduce 'after_fields' content as well 2025-09-11 20:00:22 +02:00
f88d1885a2 Form: rename 'content_above' to 'before_fields' 2025-09-11 19:59:53 +02:00
be51946436 Form: rename 'content_below' to 'buttons_extra' 2025-09-11 19:59:30 +02:00
094fa16e78 FormView: add 'after_html' equivalent to 'before_html' 2025-09-11 19:58:35 +02:00
12352c0d71 FormView: remove unused 'before' and 'after' properties 2025-09-11 19:57:45 +02:00
416cb73069 FormView: remove unused $exclude and $include field lists 2025-09-11 19:57:12 +02:00
f82e952247 Asset: fix createNew query 2025-08-21 21:59:22 +02:00
609edf3332 Merge pull request 'Rework DBA to use PDO' (#53) from pdo into master
Reviewed-on: #53
Reviewed-by: Roflin <d.brentjes@gmail.com>
2025-05-17 15:31:38 +02:00
26d8063c45 Asset/Thumbnail: replace 'NULL' placeholder strings with actual null values 2025-05-16 11:57:07 +02:00
3dfda45681 GenericTable: better handling of null values for timestamps 2025-05-16 11:54:05 +02:00
219260c57f Member: set empty reset key for new users 2025-05-16 11:53:59 +02:00
4b26c677bb AssetIterator: rewrite to standard Iterator interface 2025-05-13 23:29:43 +02:00
9989ba1fa7 CachedPDOIterator: introduce rewindable PDOStatement iterator 2025-05-13 22:51:12 +02:00
8dbf1dce7b Database: start reworking the DBA to work with PDO 2025-05-13 20:51:43 +02:00
7faa59562d Database: address PHP 8.5 mysqli deprecation warning 2025-04-18 19:26:50 +02:00
d6a319b886 Merge pull request 'Add time-out to password resets; prevent repeated mails' (#50) from password-reset into master
Reviewed-on: #50
2025-03-02 15:01:08 +01:00
fc9de822d8 Merge branch 'master' into password-reset 2025-03-02 15:00:34 +01:00
b775cffc0c EditAlbum: address refactor mistake 2025-02-26 15:44:30 +01:00
041b56ff8c ErrorPage: display debug info in separate box 2025-02-26 15:33:18 +01:00
13cbe08219 Merge pull request 'Replace deprecated trigger_error calls with exceptions' (#52) from trigger-error into master
Reviewed-on: #52
2025-02-26 15:29:13 +01:00
afd9811616 Merge pull request 'Refactor the GenericTables class' (#51) from generic-tables into master
Reviewed-on: #51
2025-02-26 15:29:02 +01:00
85ed6ba8d3 Replace deprecated trigger_error calls with exceptions 2025-02-13 11:38:45 +01:00
00ca931cf3 GenericTable: rework timestamp formatting 2025-01-08 19:11:10 +01:00
7c25d628e1 GenericTable: remove unused formatting types 2025-01-08 19:11:10 +01:00
9740416cb2 Management controllers: make format functions first-level 2025-01-08 19:11:10 +01:00
6ca3ee6d9d GenericTable: move link generation out of from formatting options 2025-01-08 19:11:10 +01:00
77809faada GenericTable: rename 'parse' option to 'format' 2025-01-08 19:11:10 +01:00
cc0ff71ef7 Management controllers: move table queries into models 2025-01-08 19:11:10 +01:00
2d2ef38422 MainNavBar: harden Registry access 2024-12-22 15:45:44 +01:00
1e26a51d08 ErrorLog: use DELETE FROM instead of TRUNCATE 2024-12-22 15:35:50 +01:00
bb8a8bad27 GenericTable: refactor order and pagination initalisation 2024-12-19 15:00:00 +01:00
06c95853f5 GenericTable: drop $tableIsSortable property 2024-12-19 12:01:00 +01:00
e57289eeb6 GenericTable: drop support for get_count_params, get_data_params 2024-12-19 11:56:00 +01:00
adfb5a2198 ResetPassword: add time-out to password resets; prevent repeated mails 2024-11-05 17:19:59 +01:00
eb7a40a70d ResetPassword: introduce requestResetKey and verifyResetKey methods 2024-11-05 17:17:14 +01:00
084658820e Authentication: replace checkExists with Member::fromId 2024-11-05 16:46:53 +01:00
8eaeb6c332 Authentication: remove remnants of user agent checks 2024-11-05 16:45:40 +01:00
9c86d2c475 Authentication: replace getUserId with Member::fromEmailAddress 2024-11-05 16:44:54 +01:00
3de4e9391c Authentication: reorder methods alphabetically 2024-11-05 16:39:42 +01:00
814a1f82f6 ManageAssets: add thumbnails to asset table 2024-08-27 12:00:46 +02:00
01954d4a7d TabularData: split up into logical methods 2024-08-27 11:55:22 +02:00
d6f39a3410 Database: patch error handling to account for exceptions thrown by mysqli_query 2024-08-27 11:46:18 +02:00
b64f87a49d PhotoPage: only call printNewTagScript if $allowLinkingNewTags 2024-06-29 10:03:51 +02:00
ead4240173 AlbumButtonBox: un-float album_button_box 2024-06-28 20:25:00 +02:00
89cc00ffd9 EditAlbum: choose the first non-root album as the default parent 2024-05-08 13:21:13 +02:00
45b59636f6 EditAlbum: fix error handling 2024-05-08 13:17:31 +02:00
2bfbe67d91 Merge pull request 'Introduce edit menu for admins' (#49) from edit-menu into master
Reviewed-on: #49
2024-02-24 13:10:58 +01:00
9d4f35a0fd ViewPhotoAlbum: add ?in param for root tags, too
This was probably intended as an optimisation, but people tags are
at root level, and so id_parent == 0.
2024-02-24 13:08:37 +01:00
f0d286179a Fix edge case in color-modes.js
For details, see https://github.com/twbs/bootstrap/pull/39224
2024-02-21 15:45:27 +01:00
cf6adbf80c Merge pull request 'Allow users to filter albums by contributors' (#48) from refactor/viewalbum into master
Reviewed-on: #48
Reviewed-by: Bart Schuurmans <bart@minnozz.com>
2024-01-20 20:11:16 +01:00
25feb31c1a EditAsset: some hardening; deduplicate redirect code 2024-01-18 13:40:17 +01:00
6ec5994de0 ViewPhotoAlbum: build edit menu in controller 2024-01-18 13:18:22 +01:00
24c2e9cdcf PhotosIndex: allow setting image as the album cover as well 2024-01-17 18:28:24 +01:00
0487ad16b9 Asset: remove old setKeyData method 2024-01-17 17:54:18 +01:00
c2aae4fb6e EditAsset: replace Asset::setKeyData with Asset::save equivalent 2024-01-17 17:54:14 +01:00
069d56383e PhotosIndex: replace edit button with edit menu 2024-01-17 17:51:45 +01:00
8613054d69 Asset: introduce save method 2024-01-17 17:51:25 +01:00
30bc0bb884 ViewPhotoAlbum: don't include empty $by in page links 2024-01-15 13:44:51 +01:00
c0dd2cbd49 ViewPhotoAlbum: drop 'Show' from empty filter caption 2024-01-15 13:41:51 +01:00
bb81f7e086 Download: remove limits on maximum execution time 2024-01-15 11:46:01 +01:00
4b289a5e83 Download: allow limiting by user uploaded as well 2024-01-15 11:40:33 +01:00
ec2d702a0d ViewPhoto: simplify filter verification 2024-01-15 11:33:43 +01:00
52472d8b58 ViewPhotoAlbum: add 'label' key to empty filter as well 2024-01-15 11:26:17 +01:00
5d990501f6 ViewPhotoAlbum: move $is_person declaration to where it's used 2024-01-15 11:25:04 +01:00
1f53689e4b AlbumButtonBox: add visual cue to indicate a filter is active 2024-01-15 00:55:33 +01:00
accf093935 PageIndex: rewrite getLink to be way less messy 2024-01-15 00:51:06 +01:00
d8c3e76df6 ViewPhoto: take filter into account for prev/next links 2024-01-15 00:43:02 +01:00
f33a7e397c Asset: combine getUrlFor{Next,Previous}InSet into one private method 2024-01-15 00:19:39 +01:00
9c00248a7f ViewPhotoAlbum: don't populate filter box if there are no album contributors 2024-01-14 22:17:09 +01:00
99b867b241 AlbumButtonBox: add way for users to select an album filter 2024-01-14 21:28:45 +01:00
6a25ecec23 ViewPhotoAlbum: add method to filter by id_user_uploaded 2024-01-14 21:06:54 +01:00
16683d2f1f Tag: add getContributorList method 2024-01-14 21:06:34 +01:00
7cdcf8197c ViewPhotoAlbum: use Tag::getUrl instead of fumbling with $_GET['tag'] 2024-01-14 20:40:58 +01:00
25b9528628 ViewPhotoAlbum: simplify tag handling in getAlbumButtons 2024-01-14 20:40:58 +01:00
08cdbfe7b6 ViewPhotoAlbum: move some logic into new prepareHeaderBox method 2024-01-14 20:40:58 +01:00
64d1aadbdd Merge pull request 'Fix dereferencing $tag when null' (#47) from fix-null-tag into master
Reviewed-on: #47
Reviewed-by: Bart Schuurmans <bart@minnozz.com>
2024-01-14 16:19:40 +01:00
44ca9ed1a5 Fix dereferencing $tag when null 2024-01-14 16:15:23 +01:00
374fa5cccd PhotoPage: re-instate meta header styling lost in rebase 2024-01-13 17:35:34 +01:00
d556032a83 Merge pull request 'Change how tags are displayed on photo page' (#46) from tag-list into master
Reviewed-on: #46
Reviewed-by: Bart Schuurmans <bart@minnozz.com>
2024-01-13 17:28:09 +01:00
0da1558bd3 Merge pull request 'Rework meta data display on photo page' (#45) from photo-page into master
Reviewed-on: #45
Reviewed-by: Bart Schuurmans <bart@minnozz.com>
2024-01-13 17:23:05 +01:00
8eabc494d9 Merge pull request 'EXIF: add special handling for Pentax Model/Make pollution' (#44) from pentax-exif into master
Reviewed-on: #44
Reviewed-by: Bart Schuurmans <bart@minnozz.com>
2024-01-13 17:22:44 +01:00
b48f7dbb9e ViewPhoto: re-add accidentally omitted units 2024-01-12 10:42:51 +01:00
8eb6be02b1 PhotoPage: fade the tag delete buttons a little 2024-01-11 21:58:01 +01:00
e671b7da30 PhotoPage: simplify tag html nodes 2024-01-11 21:53:44 +01:00
e3d481caa1 PhotoPage: update and refactor tagging script slightly 2024-01-11 20:47:41 +01:00
b13701f7c0 PhotoPage: change how tags are displayed 2024-01-11 20:00:29 +01:00
d17d98a838 PhotoPage: move user actions inside photo description box 2024-01-11 19:20:46 +01:00
e374f7ed59 ViewPhoto: prepare meta data in controller; change layout 2024-01-11 19:13:21 +01:00
55c33c024e ViewPhoto: use class state to store Image object 2024-01-11 18:59:50 +01:00
bc08e867f0 PhotoPage: make prev/next photo logic more direct 2024-01-11 18:54:54 +01:00
f9ab90e925 EXIF: add special handling for Pentax Model/Make pollution 2024-01-11 18:45:22 +01:00
507357ba59 PhotosIndex: adjust thumbnail dimensions to better reflect usage 2023-12-23 16:22:48 +01:00
52fad8d1b9 PhotosIndex: fix dualMixed layout showing the same image twice 2023-12-23 13:47:16 +01:00
b1c2001c06 Merge pull request 'Improve the mosaic algorithm further' (#43) from improve-mosaic into master
Reviewed-on: #43
Reviewed-by: Roflin <d.brentjes@gmail.com>
2023-12-21 16:34:24 +01:00
321e2587b5 PhotoMosaic: break out early in case of perfect score 2023-12-20 16:25:58 +01:00
37cc627e20 PhotosIndex: add dualMixed layout
This combines one landscape with one portrait.
2023-12-20 16:23:19 +01:00
553744aeb5 PhotoMosaic: fit batch of photos to best layout instead 2023-12-19 21:57:29 +01:00
d2fa547257 PhotoMosaic: keep queue ordered by date captured 2023-12-19 17:16:57 +01:00
6150922a1f ErrorHandler: fix longstanding typo, occur*r*ed 2023-12-14 21:14:09 +01:00
f5721c3af7 Merge pull request 'Rewrite mosaic algorithm using declarative paradigm' (#42) from new-mosaic into master
Reviewed-on: #42
Reviewed-by: Roflin <d.brentjes@gmail.com>
2023-12-03 12:37:35 +01:00
4d9219586f PageIndexWidget: display current page on smartphones, too 2023-12-02 01:38:07 +01:00
efb35cfd6a PhotoMosaic: add sixLandscapes layout, combining side and row 2023-12-02 01:29:11 +01:00
d42c3c142c PhotosIndex: differentiate dual/single layouts by landscape/portrait 2023-12-02 00:50:04 +01:00
f66a400100 PhotosIndex: removing unnecessary limit/constant 2023-12-02 00:24:47 +01:00
d45b467bb1 PhotoMosaic: rewrite getRow to use availableLayouts 2023-12-02 00:24:43 +01:00
8700fc1417 PhotoMosaic: introduce availableLayouts method 2023-12-01 23:41:05 +01:00
b98785d7b2 PhotoMosaic: remove unused getRecentPhotos method 2023-12-01 23:39:55 +01:00
8e0e642d34 PhotoMosaic: reorder methods to be alphabetically ordered 2023-12-01 22:47:51 +01:00
aeaff887ca Merge pull request 'Asset: let slugs consist only of an explicit set of allowed characters' (#41) from clean-slugs into master
Reviewed-on: #41
2023-11-22 16:03:54 +01:00
0eece8ea3c Merge pull request 'Make pagination padding clickable again' (#40) from page-wildcards into master
Reviewed-on: #40
2023-11-22 16:03:47 +01:00
903fdba471 Merge pull request 'Simplify session handling' (#39) from simplify-sessions into master
Reviewed-on: #39
2023-11-22 16:03:35 +01:00
baa928531b Asset: let slugs consist only of an explicit set of allowed characters 2023-11-20 22:45:48 +01:00
f143b2ddcf PageIndexWidget: show first applicable wildcard link in responsive mode 2023-11-20 22:27:57 +01:00
56f21a6721 PageIndexWidget: disable text wrapping 2023-11-20 22:22:55 +01:00
230c65478f PageIndexWidget: restore wildcard functionality 2023-11-20 22:22:21 +01:00
65ee07d95b Session: centralise how session tokens are handled 2023-11-20 20:59:35 +01:00
5f778d73b4 Session: remove checks for matching IP address and user agent
This was considered good practice in the days before always-on https,
but is considered superfluous today. It even gets in the way of IPv6
privacy extensions, which is the main argument for removing them today.
2023-11-20 20:58:20 +01:00
202e263ea7 MainTemplate: Hotfix for cache invalidation of css stylesheet. 2023-11-15 15:42:05 +01:00
2ec565242e ViewPhoto: hotfix for getSessionTokenKey error 2023-11-15 14:40:45 +01:00
62d138192d MainNavBar: make nyan cat move on hover as well 2023-11-12 17:33:49 +01:00
b002c097e3 EditAssetForm: leave out asset filename from the form title 2023-11-12 17:30:13 +01:00
0b24ef8b07 EditAssetForm: add "View asset" button 2023-11-12 17:29:21 +01:00
8f4ed7e3b0 EditAssetForm: hide album tags in tag box 2023-11-12 17:27:59 +01:00
0c861bf976 EditAsset: allow changing an asset's parent album 2023-11-12 17:26:03 +01:00
44c6bf5914 EditAssetForm: use datetime-local input type for date captured field 2023-11-12 17:14:30 +01:00
b48dd324cd Remove unused WarningDialog template 2023-11-11 15:46:15 +01:00
995ab8c640 PageIndexWidget: add shadow to floating page indices 2023-11-11 15:44:49 +01:00
41d14b5aee ViewPeople: add space between tags and page index widget 2023-11-11 15:40:47 +01:00
a7ce206953 PhotosIndex: make edit button visible again 2023-11-11 15:38:28 +01:00
e63307d474 PhotoPage: remove obsolete is_asset_owner property 2023-11-11 15:36:10 +01:00
0c13a39d04 Image: don't re-queue thumbnails when deleting them 2023-11-11 15:34:45 +01:00
3a533b7644 Remove obsolete ConfirmDeletePage and Button templates 2023-11-11 15:31:06 +01:00
e28fcd8b03 Move photo deletion from ViewPhoto to EditAsset
Removes the intermediate confirmation page, instead using JavaScript for confirmation.

Fixes an XSS issue, in that the previous method was not passing or checking the session (!)
2023-11-11 15:29:32 +01:00
83da4a26ac EditAsset: allow users to edit their own photos 2023-11-11 15:14:57 +01:00
baf53ed42b AutoSuggest: improve contrast for highlighted item 2023-11-11 15:09:25 +01:00
5c5e4fbdd7 Merge pull request 'Add dark theme toggle' (#35) from dark-mode into master
Reviewed-on: #35
2023-11-11 12:17:30 +01:00
861be10010 PageIndexWidget: tweak dark and disabled colours 2023-11-11 12:24:25 +01:00
ad2f6a964e Merge pull request 'Add nyan-cat easter egg' (#36) from nyan-cat into master
Reviewed-on: #36
2023-11-11 12:05:11 +01:00
5aec2f25b1 Merge pull request 'Add gaussian blurs behind photos' (#34) from image-blur into master
Reviewed-on: #34
2023-11-11 12:05:00 +01:00
8a6631cec2 Add nyan-cat easter egg 2023-11-11 11:50:09 +01:00
68b5783a28 Add dark theme variant 2023-11-11 11:37:26 +01:00
0cf8d0fc11 PhotoPage: expand margins slightly 2023-11-11 00:10:25 +01:00
0133308113 PhotoPage: simplify styling a little 2023-11-10 23:36:49 +01:00
c8bf43b7f9 PhotoPage: fixed alignment for panoramas (now to simplify...) 2023-11-10 23:34:30 +01:00
9b192aa7a6 PhotoPage: fix position and size of blurred photo 2023-11-10 23:22:09 +01:00
aa82efe03e PhotoPage: trying out blur on the photo page 2023-11-10 22:50:51 +01:00
66478c5922 AlbumIndex: use blurred images for albums as well 2023-11-10 21:57:53 +01:00
a69c987510 PhotosIndex: add blurred versions of thumbnails for added coolness 2023-11-10 21:57:23 +01:00
978d6461c5 Database: add fetch_object, queryObject, queryObjects methods 2023-06-12 12:49:22 +02:00
61 changed files with 2840 additions and 2162 deletions

View File

@@ -21,22 +21,30 @@ class Download
$tag = (int)$_GET['tag'];
$album = Tag::fromId($tag);
if (isset($_GET['by']) && ($user = Member::fromSlug($_GET['by'])) !== false)
$id_user_uploaded = $user->getUserId();
else
$id_user_uploaded = null;
if (isset($_SESSION['current_export']))
throw new UserFacingException('You can only export one album at the same time. Please wait until the other download finishes, or try again later.');
// So far so good?
$this->exportAlbum($album);
$this->exportAlbum($album, $id_user_uploaded);
exit;
}
private function exportAlbum(Tag $album)
private function exportAlbum(Tag $album, $id_user_uploaded)
{
$files = [];
$album_ids = array_merge([$album->id_tag], $this->getChildAlbumIds($album->id_tag));
foreach ($album_ids as $album_id)
{
$iterator = AssetIterator::getByOptions(['id_tag' => $album_id]);
$iterator = AssetIterator::getByOptions([
'id_tag' => $album_id,
'id_user_uploaded' => $id_user_uploaded,
]);
while ($asset = $iterator->next())
$files[] = join(DIRECTORY_SEPARATOR, [$asset->getSubdir(), $asset->getFilename()]);
}
@@ -71,6 +79,9 @@ class Download
// STDOUT should not block.
stream_set_blocking($pipes[1], 0);
// Allow this the download to take its time...
set_time_limit(0);
header('Pragma: no-cache');
header('Content-Description: File Download');
header('Content-disposition: attachment; filename="' . $album->tag . '.tar"');

View File

@@ -6,8 +6,14 @@
* Kabuki CMS (C) 2013-2017, Aaron van Geffen
*****************************************************************************/
// TODO: extend EditTag?
class EditAlbum extends HTMLController
{
private $form;
private $formview;
const THUMBS_PER_PAGE = 20;
public function __construct()
{
// Ensure it's just admins at this point.
@@ -38,13 +44,13 @@ class EditAlbum extends HTMLController
exit;
}
else
trigger_error('Cannot delete album: an error occured while processing the request.', E_USER_ERROR);
throw new Exception('Cannot delete album: an error occured while processing the request.');
}
// Editing one, then, surely.
else
{
if ($album->kind !== 'Album')
trigger_error('Cannot edit album: not an album.', E_USER_ERROR);
throw new Exception('Cannot edit album: not an album.');
parent::__construct('Edit album \'' . $album->tag . '\'');
$form_title = 'Edit album \'' . $album->tag . '\'';
@@ -64,7 +70,7 @@ class EditAlbum extends HTMLController
// Gather possible parents for this album to be filed into
$parentChoices = [0 => '-root-'];
foreach (PhotoAlbum::getHierarchy('tag', 'up') as $parent)
foreach (Tag::getOffset(0, 9999, 'tag', 'up', true) as $parent)
{
if (!empty($id_tag) && $parent['id_tag'] == $id_tag)
continue;
@@ -78,11 +84,6 @@ class EditAlbum extends HTMLController
'label' => 'Parent album',
'options' => $parentChoices,
],
'id_asset_thumb' => [
'type' => 'numeric',
'label' => 'Thumbnail asset ID',
'is_optional' => true,
],
'tag' => [
'type' => 'text',
'label' => 'Album title',
@@ -104,22 +105,9 @@ class EditAlbum extends HTMLController
],
];
// Fetch image assets for this album
if (!empty($id_tag))
{
list($assets, $num_assets) = AssetIterator::getByOptions([
'direction' => 'desc',
'limit' => 500,
'id_tag' => $id_tag,
], true);
if ($num_assets > 0)
unset($fields['id_asset_thumb']);
}
$form = new Form([
$this->form = new Form([
'request_url' => BASEURL . '/editalbum/?' . ($id_tag ? 'id=' . $id_tag : 'add'),
'content_below' => $after_form,
'buttons_extra' => $after_form,
'fields' => $fields,
]);
@@ -136,23 +124,61 @@ class EditAlbum extends HTMLController
];
}
}
if (!isset($formDefaults))
$formDefaults = isset($album) ? get_object_vars($album) : $_POST;
elseif (empty($_POST) && isset($album))
{
$formDefaults = get_object_vars($album);
}
elseif (empty($_POST) && count($parentChoices) > 1)
{
// Choose the first non-root album as the default parent
reset($parentChoices);
next($parentChoices);
$formDefaults = ['id_parent' => key($parentChoices)];
}
else
$formDefaults = $_POST;
// Create the form, add in default values.
$form->setData($formDefaults);
$formview = new FormView($form, $form_title ?? '');
$this->page->adopt($formview);
$this->form->setData($formDefaults);
$this->formview = new FormView($this->form, $form_title ?? '');
$this->page->adopt($this->formview);
// If we have asset images, show the thumbnail manager
if (!empty($id_tag) && $num_assets > 0)
$this->page->adopt(new FeaturedThumbnailManager($assets, $id_tag ? $album->id_asset_thumb : 0));
if (!empty($id_tag))
{
$current_page = isset($_GET['page']) ? (int) $_GET['page'] : 1;
list($assets, $num_assets) = AssetIterator::getByOptions([
'direction' => 'desc',
'limit' => self::THUMBS_PER_PAGE,
'page' => $current_page,
'id_tag' => $id_tag,
], true);
// If we have asset images, show the thumbnail manager
if ($num_assets > 0)
{
$manager = new FeaturedThumbnailManager($assets, $id_tag ? $album->id_asset_thumb : 0);
$this->page->adopt($manager);
// Make a page index as needed, while we're at it.
if ($num_assets > self::THUMBS_PER_PAGE)
{
$index = new PageIndex([
'recordCount' => $num_assets,
'items_per_page' => self::THUMBS_PER_PAGE,
'start' => ($current_page - 1) * self::THUMBS_PER_PAGE,
'base_url' => BASEURL . '/editalbum/?id=' . $id_tag,
'page_slug' => '&page=%PAGE%',
]);
$manager->adopt(new PageIndexWidget($index));
}
}
}
if (isset($_POST['changeThumbnail']))
$this->processThumbnail($album);
elseif (!empty($_POST))
$this->processTagDetails($form, $id_tag, $album ?? null);
$this->processTagDetails($id_tag, $album ?? null);
}
private function processThumbnail($tag)
@@ -167,22 +193,22 @@ class EditAlbum extends HTMLController
exit;
}
private function processTagDetails($form, $id_tag, $album)
private function processTagDetails($id_tag, $album)
{
if (!empty($_POST))
{
$form->verify($_POST);
$this->form->verify($_POST);
// Anything missing?
if (!empty($form->getMissing()))
return $formview->adopt(new Alert('Some data missing', 'Please fill out the following fields: ' . implode(', ', $form->getMissing()), 'danger'));
if (!empty($this->form->getMissing()))
return $this->formview->adopt(new Alert('Some data missing', 'Please fill out the following fields: ' . implode(', ', $this->form->getMissing()), 'danger'));
$data = $form->getData();
$data = $this->form->getData();
// Sanity check: don't let an album be its own parent
if ($data['id_parent'] == $id_tag)
{
return $formview->adopt(new Alert('Invalid parent', 'An album cannot be its own parent.', 'danger'));
return $this->formview->adopt(new Alert('Invalid parent', 'An album cannot be its own parent.', 'danger'));
}
// Quick stripping.
@@ -198,7 +224,7 @@ class EditAlbum extends HTMLController
$data['kind'] = 'Album';
$newTag = Tag::createNew($data);
if ($newTag === false)
return $formview->adopt(new Alert('Cannot create this album', 'Something went wrong while creating the album...', 'danger'));
return $this->formview->adopt(new Alert('Cannot create this album', 'Something went wrong while creating the album...', 'danger'));
if (isset($_POST['submit_and_new']))
{

View File

@@ -10,10 +10,6 @@ class EditAsset extends HTMLController
{
public function __construct()
{
// Ensure it's just admins at this point.
if (!Registry::get('user')->isAdmin())
throw new NotAllowedException();
if (empty($_GET['id']))
throw new Exception('Invalid request.');
@@ -21,8 +17,72 @@ class EditAsset extends HTMLController
if (empty($asset))
throw new NotFoundException('Asset not found');
if (isset($_REQUEST['delete']))
throw new Exception('Not implemented.');
// Can we edit this asset?
$user = Registry::get('user');
if (!($user->isAdmin() || $asset->isOwnedBy($user)))
throw new NotAllowedException();
if (isset($_REQUEST['delete']) && Session::validateSession('get'))
{
$redirectUrl = BASEURL . '/' . $asset->getSubdir();
$asset->delete();
header('Location: ' . $redirectUrl);
exit;
}
else
{
$isPrioChange = isset($_REQUEST['inc_prio']) || isset($_REQUEST['dec_prio']);
$isCoverChange = isset($_REQUEST['album_cover'], $_REQUEST['in']);
$madeChanges = false;
if ($user->isAdmin() && $isPrioChange && Session::validateSession('get'))
{
if (isset($_REQUEST['inc_prio']))
$priority = $asset->priority + 1;
else
$priority = $asset->priority - 1;
$asset->priority = max(0, min(100, $priority));
$asset->save();
$madeChanges = true;
}
elseif ($user->isAdmin() && $isCoverChange && Session::validateSession('get'))
{
$tag = Tag::fromId($_REQUEST['in']);
$tag->id_asset_thumb = $asset->getId();
$tag->save();
$madeChanges = true;
}
if ($madeChanges)
{
if (isset($_SERVER['HTTP_REFERER']))
header('Location: ' . $_SERVER['HTTP_REFERER']);
else
header('Location: ' . BASEURL . '/' . $asset->getSubdir());
exit;
}
}
// Get a list of available photo albums
$allAlbums = [];
foreach (Tag::getOffset(0, 9999, 'tag', 'up', true) as $album)
$allAlbums[$album['id_tag']] = $album['tag'];
// Figure out the current album id
$currentAlbumId = 0;
$currentAlbumSlug = '';
$currentTags = $asset->getTags();
foreach ($currentTags as $tag)
{
if ($tag->kind === 'Album')
{
$currentAlbumId = $tag->id_tag;
$currentAlbumSlug = $tag->slug;
break;
}
}
if (!empty($_POST))
{
@@ -35,17 +95,46 @@ class EditAsset extends HTMLController
// Key info
if (isset($_POST['title'], $_POST['slug'], $_POST['date_captured'], $_POST['priority']))
{
$date_captured = !empty($_POST['date_captured']) ? new DateTime($_POST['date_captured']) : null;
$slug = strtr($_POST['slug'], [' ' => '-', '--' => '-', '&' => 'and', '=>' => '', "'" => "", ":"=> "", '\\' => '-']);
$asset->setKeyData(htmlspecialchars($_POST['title']), $slug, $date_captured, intval($_POST['priority']));
$asset->date_captured = !empty($_POST['date_captured']) ?
new DateTime(str_replace('T', ' ', $_POST['date_captured'])) : null;
$asset->slug = Asset::cleanSlug($_POST['slug']);
$asset->title = htmlspecialchars($_POST['title']);
$asset->priority = intval($_POST['priority']);
$asset->save();
}
// Changing parent album?
if ($_POST['id_album'] != $currentAlbumId)
{
$targetAlbum = Tag::fromId($_POST['id_album']);
// First move the asset, then sort out the album tag
if (($retCode = $asset->moveToSubDir($targetAlbum->slug)) === true)
{
if (!isset($_POST['tag']))
$_POST['tag'] = [];
// Unset tag for current parent album
if (isset($_POST['tag'][$currentAlbumId]))
unset($_POST['tag'][$currentAlbumId]);
// Set tag for new parent album
$_POST['tag'][$_POST['id_album']] = true;
}
}
else
{
$_POST['tag'][$currentAlbumId] = true;
}
// 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());
@@ -88,10 +177,13 @@ class EditAsset extends HTMLController
header('Location: ' . BASEURL . '/editasset/?id=' . $asset->getId());
}
// Get list of thumbnails
$thumbs = $this->getThumbs($asset);
$page = new EditAssetForm([
'asset' => $asset,
'thumbs' => $this->getThumbs($asset),
'allAlbums' => $allAlbums,
'currentAlbumId' => $currentAlbumId,
]);
$page = new EditAssetForm($asset, $thumbs);
parent::__construct('Edit asset \'' . $asset->getTitle() . '\' (' . $asset->getFilename() . ') - ' . SITE_TITLE);
$this->page->adopt($page);
}

View File

@@ -8,6 +8,8 @@
class EditTag extends HTMLController
{
const THUMBS_PER_PAGE = 20;
public function __construct()
{
$id_tag = isset($_GET['id']) ? (int) $_GET['id'] : 0;
@@ -39,13 +41,13 @@ class EditTag extends HTMLController
exit;
}
else
trigger_error('Cannot delete tag: an error occured while processing the request.', E_USER_ERROR);
throw new Exception('Cannot delete tag: an error occured while processing the request.');
}
// Editing one, then, surely.
else
{
if ($tag->kind === 'Album')
trigger_error('Cannot edit tag: is actually an album.', E_USER_ERROR);
throw new Exception('Cannot edit tag: is actually an album.');
parent::__construct('Edit tag \'' . $tag->tag . '\'');
$form_title = 'Edit tag \'' . $tag->tag . '\'';
@@ -106,7 +108,7 @@ class EditTag extends HTMLController
$form = new Form([
'request_url' => BASEURL . '/edittag/?' . ($id_tag ? 'id=' . $id_tag : 'add'),
'content_below' => $after_form,
'buttons_extra' => $after_form,
'fields' => $fields,
]);
@@ -117,14 +119,34 @@ class EditTag extends HTMLController
if (!empty($id_tag))
{
$current_page = isset($_GET['page']) ? (int) $_GET['page'] : 1;
list($assets, $num_assets) = AssetIterator::getByOptions([
'direction' => 'desc',
'limit' => 500,
'limit' => self::THUMBS_PER_PAGE,
'page' => $current_page,
'id_tag' => $id_tag,
], true);
// If we have asset images, show the thumbnail manager
if ($num_assets > 0)
$this->page->adopt(new FeaturedThumbnailManager($assets, $id_tag ? $tag->id_asset_thumb : 0));
{
$manager = new FeaturedThumbnailManager($assets, $id_tag ? $tag->id_asset_thumb : 0);
$this->page->adopt($manager);
// Make a page index as needed, while we're at it.
if ($num_assets > self::THUMBS_PER_PAGE)
{
$index = new PageIndex([
'recordCount' => $num_assets,
'items_per_page' => self::THUMBS_PER_PAGE,
'start' => ($current_page - 1) * self::THUMBS_PER_PAGE,
'base_url' => BASEURL . '/edittag/?id=' . $id_tag,
'page_slug' => '&page=%PAGE%',
]);
$manager->adopt(new PageIndexWidget($index));
}
}
}
if (isset($_POST['changeThumbnail']))

View File

@@ -33,7 +33,7 @@ class EditUser extends HTMLController
{
// Don't be stupid.
if ($current_user->getUserId() == $id_user)
trigger_error('Sorry, I cannot allow you to delete yourself.', E_USER_ERROR);
throw new Exception('Sorry, I cannot allow you to delete yourself.');
// So far so good?
$user = Member::fromId($id_user);
@@ -43,7 +43,7 @@ class EditUser extends HTMLController
exit;
}
else
trigger_error('Cannot delete user: an error occured while processing the request.', E_USER_ERROR);
throw new Exception('Cannot delete user: an error occured while processing the request.');
}
// Editing one, then, surely.
else
@@ -69,7 +69,7 @@ class EditUser extends HTMLController
$form = new Form([
'request_url' => BASEURL . '/edituser/?' . ($id_user ? 'id=' . $id_user : 'add'),
'content_below' => $after_form,
'buttons_extra' => $after_form,
'fields' => [
'first_name' => [
'type' => 'text',

View File

@@ -24,7 +24,9 @@ class Login extends HTMLController
if (Authentication::checkPassword($_POST['emailaddress'], $_POST['password']))
{
parent::__construct('Login');
$_SESSION['user_id'] = Authentication::getUserId($_POST['emailaddress']);
$user = Member::fromEmailAddress($_POST['emailaddress']);
$_SESSION['user_id'] = $user->getUserId();
if (isset($_POST['redirect_url']))
header('Location: ' . base64_decode($_POST['redirect_url']));

View File

@@ -11,7 +11,7 @@ class Logout extends HTMLController
public function __construct()
{
// Clear the entire sesssion.
$_SESSION = [];
Session::clear();
// Back to the frontpage you go.
header('Location: ' . BASEURL);

View File

@@ -18,8 +18,7 @@ class ManageAlbums extends HTMLController
'form' => [
'action' => BASEURL . '/editalbum/',
'method' => 'get',
'class' => 'col-md-6 text-end',
'buttons' => [
'controls' => [
'add' => [
'type' => 'submit',
'caption' => 'Add new album',
@@ -35,18 +34,14 @@ class ManageAlbums extends HTMLController
'tag' => [
'header' => 'Album',
'is_sortable' => true,
'parse' => [
'link' => BASEURL . '/editalbum/?id={ID_TAG}',
'data' => 'tag',
],
'link' => BASEURL . '/editalbum/?id={ID_TAG}',
'value' => 'tag',
],
'slug' => [
'header' => 'Slug',
'is_sortable' => true,
'parse' => [
'link' => BASEURL . '/editalbum/?id={ID_TAG}',
'data' => 'slug',
],
'link' => BASEURL . '/editalbum/?id={ID_TAG}',
'value' => 'slug',
],
'count' => [
'header' => '# Photos',
@@ -54,30 +49,20 @@ class ManageAlbums extends HTMLController
'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,
'default_sort_order' => 'tag',
'default_sort_direction' => 'up',
'start' => $_GET['start'] ?? 0,
'sort_order' => $_GET['order'] ?? '',
'sort_direction' => $_GET['dir'] ?? '',
'title' => 'Manage albums',
'no_items_label' => 'No albums meet the requirements of the current filter.',
'items_per_page' => 9999,
'index_class' => 'col-md-6',
'base_url' => BASEURL . '/managealbums/',
'get_data' => function($offset = 0, $limit = 9999, $order = '', $direction = 'up') {
if (!in_array($order, ['id_tag', 'tag', 'slug', 'count']))
$order = 'tag';
if (!in_array($direction, ['up', 'down']))
$direction = 'up';
$rows = PhotoAlbum::getHierarchy($order, $direction);
return [
'rows' => $rows,
'order' => $order,
'direction' => ($direction == 'up' ? 'up' : 'down'),
];
'get_data' => function($offset, $limit, $order, $direction) {
return Tag::getOffset($offset, $limit, $order, $direction, true);
},
'get_count' => function() {
return 9999;
return Tag::getCount(false, 'Album', true);
}
];

View File

@@ -23,9 +23,8 @@ class ManageAssets extends HTMLController
'form' => [
'action' => BASEURL . '/manageassets/?' . Session::getSessionTokenKey() . '=' . Session::getSessionToken(),
'method' => 'post',
'class' => 'col-md-6 text-end',
'is_embed' => true,
'buttons' => [
'controls' => [
'deleteChecked' => [
'type' => 'submit',
'caption' => 'Delete checked',
@@ -38,12 +37,33 @@ class ManageAssets extends HTMLController
'checkbox' => [
'header' => '<input type="checkbox" id="selectall">',
'is_sortable' => false,
'parse' => [
'type' => 'function',
'data' => function($row) {
return '<input type="checkbox" class="asset_select" name="delete[]" value="' . $row['id_asset'] . '">';
},
],
'format' => fn($row) =>
'<input type="checkbox" class="asset_select" name="delete[]" value="' . $row['id_asset'] . '">',
],
'thumbnail' => [
'header' => '&nbsp;',
'is_sortable' => false,
'cell_class' => 'text-center',
'format' => function($row) {
$asset = Image::byRow($row);
$width = $height = 65;
if ($asset->isImage())
{
if ($asset->isPortrait())
$width = null;
else
$height = null;
$thumb = $asset->getThumbnailUrl($width, $height);
}
else
$thumb = BASEURL . '/images/nothumb.svg';
$width = isset($width) ? $width . 'px' : 'auto';
$height = isset($height) ? $height . 'px' : 'auto';
return sprintf('<img src="%s" style="width: %s; height: %s;">', $thumb, $width, $height);
},
],
'id_asset' => [
'value' => 'id_asset',
@@ -59,72 +79,41 @@ class ManageAssets extends HTMLController
'value' => 'filename',
'header' => 'Filename',
'is_sortable' => true,
'parse' => [
'type' => 'value',
'link' => BASEURL . '/editasset/?id={ID_ASSET}',
'data' => 'filename',
],
'link' => BASEURL . '/editasset/?id={ID_ASSET}',
'value' => 'filename',
],
'id_user_uploaded' => [
'header' => 'User uploaded',
'is_sortable' => true,
'parse' => [
'type' => 'function',
'data' => function($row) {
if (!empty($row['id_user']))
return sprintf('<a href="%s/edituser/?id=%d">%s</a>', BASEURL, $row['id_user'],
$row['first_name'] . ' ' . $row['surname']);
else
return 'n/a';
},
],
'format' => function($row) {
if (!empty($row['id_user']))
return sprintf('<a href="%s/edituser/?id=%d">%s</a>', BASEURL, $row['id_user'],
$row['first_name'] . ' ' . $row['surname']);
else
return 'n/a';
},
],
'dimensions' => [
'header' => 'Dimensions',
'is_sortable' => false,
'parse' => [
'type' => 'function',
'data' => function($row) {
if (!empty($row['image_width']))
return $row['image_width'] . ' x ' . $row['image_height'];
else
return 'n/a';
},
],
'format' => function($row) {
if (!empty($row['image_width']))
return $row['image_width'] . ' x ' . $row['image_height'];
else
return 'n/a';
},
],
],
'start' => !empty($_GET['start']) ? (int) $_GET['start'] : 0,
'sort_order' => !empty($_GET['order']) ? $_GET['order'] : '',
'sort_direction' => !empty($_GET['dir']) ? $_GET['dir'] : '',
'default_sort_order' => 'id_asset',
'default_sort_direction' => 'down',
'start' => $_GET['start'] ?? 0,
'sort_order' => $_GET['order'] ?? '',
'sort_direction' => $_GET['dir'] ?? '',
'title' => 'Manage assets',
'no_items_label' => 'No assets meet the requirements of the current filter.',
'items_per_page' => 30,
'index_class' => 'col-md-6',
'base_url' => BASEURL . '/manageassets/',
'get_data' => function($offset = 0, $limit = 30, $order = '', $direction = 'down') {
if (!in_array($order, ['id_asset', 'id_user_uploaded', 'title', 'subdir', 'filename']))
$order = 'id_asset';
$data = Registry::get('db')->queryAssocs('
SELECT a.id_asset, a.subdir, a.filename,
a.image_width, a.image_height,
u.id_user, u.first_name, u.surname
FROM assets AS a
LEFT JOIN users AS u ON a.id_user_uploaded = u.id_user
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_data' => 'Asset::getOffset',
'get_count' => 'Asset::getCount',
];

View File

@@ -14,8 +14,8 @@ class ManageErrors extends HTMLController
if (!Registry::get('user')->isAdmin())
throw new NotAllowedException();
// Flushing, are we?
if (isset($_POST['flush']) && Session::validateSession('get'))
// Clearing, are we?
if (isset($_POST['clear']) && Session::validateSession('get'))
{
ErrorLog::flush();
header('Location: ' . BASEURL . '/manageerrors/');
@@ -29,9 +29,8 @@ class ManageErrors extends HTMLController
'form' => [
'action' => BASEURL . '/manageerrors/?' . Session::getSessionTokenKey() . '=' . Session::getSessionToken(),
'method' => 'post',
'class' => 'col-md-6 text-end',
'buttons' => [
'flush' => [
'controls' => [
'clear' => [
'type' => 'submit',
'caption' => 'Delete all',
'class' => 'btn-danger',
@@ -39,26 +38,23 @@ class ManageErrors extends HTMLController
],
],
'columns' => [
'id' => [
'id_entry' => [
'value' => 'id_entry',
'header' => '#',
'is_sortable' => true,
],
'message' => [
'parse' => [
'type' => 'function',
'data' => function($row) {
return $row['message'] . '<br>' .
'<div><a onclick="this.parentNode.childNodes[1].style.display=\'block\';this.style.display=\'none\';">Show debug info</a>' .
'<pre style="display: none">' . htmlspecialchars($row['debug_info']) .
'</pre></div>' .
'<small><a href="' . BASEURL .
htmlspecialchars($row['request_uri']) . '">' .
htmlspecialchars($row['request_uri']) . '</a></small>';
}
],
'header' => 'Message / URL',
'is_sortable' => false,
'format' => function($row) {
return $row['message'] . '<br>' .
'<div><a onclick="this.parentNode.childNodes[1].style.display=\'block\';this.style.display=\'none\';">Show debug info</a>' .
'<pre style="display: none">' . htmlspecialchars($row['debug_info']) .
'</pre></div>' .
'<small><a href="' . BASEURL .
htmlspecialchars($row['request_uri']) . '">' .
htmlspecialchars($row['request_uri']) . '</a></small>';
},
],
'file' => [
'value' => 'file',
@@ -71,12 +67,10 @@ class ManageErrors extends HTMLController
'is_sortable' => true,
],
'time' => [
'parse' => [
'format' => [
'type' => 'timestamp',
'data' => [
'timestamp' => 'time',
'pattern' => 'long',
],
'pattern' => 'long',
'value' => 'time',
],
'header' => 'Time',
'is_sortable' => true,
@@ -89,41 +83,20 @@ class ManageErrors extends HTMLController
'uid' => [
'header' => 'UID',
'is_sortable' => true,
'parse' => [
'link' => BASEURL . '/edituser/?id={ID_USER}',
'data' => 'id_user',
],
'link' => BASEURL . '/edituser/?id={ID_USER}',
'value' => 'id_user',
],
],
'start' => !empty($_GET['start']) ? (int) $_GET['start'] : 0,
'sort_order' => !empty($_GET['order']) ? $_GET['order'] : '',
'sort_direction' => !empty($_GET['dir']) ? $_GET['dir'] : '',
'default_sort_order' => 'id_entry',
'default_sort_direction' => 'down',
'start' => $_GET['start'] ?? 0,
'sort_order' => $_GET['order'] ?? '',
'sort_direction' => $_GET['dir'] ?? '',
'no_items_label' => "No errors to display -- we're all good!",
'items_per_page' => 20,
'index_class' => 'col-md-6',
'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,
];
},
'get_data' => 'ErrorLog::getOffset',
];
$error_log = new GenericTable($options);

View File

@@ -20,8 +20,7 @@ class ManageTags extends HTMLController
'form' => [
'action' => BASEURL . '/edittag/',
'method' => 'get',
'class' => 'col-md-6 text-end',
'buttons' => [
'controls' => [
'add' => [
'type' => 'submit',
'caption' => 'Add new tag',
@@ -37,32 +36,25 @@ class ManageTags extends HTMLController
'tag' => [
'header' => 'Tag',
'is_sortable' => true,
'parse' => [
'link' => BASEURL . '/edittag/?id={ID_TAG}',
'data' => 'tag',
],
'link' => BASEURL . '/edittag/?id={ID_TAG}',
'value' => 'tag',
],
'slug' => [
'header' => 'Slug',
'is_sortable' => true,
'parse' => [
'link' => BASEURL . '/edittag/?id={ID_TAG}',
'data' => 'slug',
],
'link' => BASEURL . '/edittag/?id={ID_TAG}',
'value' => 'slug',
],
'id_user_owner' => [
'header' => 'Owning user',
'is_sortable' => true,
'parse' => [
'type' => 'function',
'data' => function($row) {
if (!empty($row['id_user']))
return sprintf('<a href="%s/edituser/?id=%d">%s</a>', BASEURL, $row['id_user'],
$row['first_name'] . ' ' . $row['surname']);
else
return 'n/a';
},
],
'format' => function($row) {
if (!empty($row['id_user']))
return sprintf('<a href="%s/edituser/?id=%d">%s</a>', BASEURL, $row['id_user'],
$row['first_name'] . ' ' . $row['surname']);
else
return 'n/a';
},
],
'count' => [
'header' => 'Cardinality',
@@ -70,46 +62,20 @@ class ManageTags extends HTMLController
'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,
'default_sort_order' => 'tag',
'default_sort_direction' => 'up',
'start' => $_GET['start'] ?? 0,
'sort_order' => $_GET['order'] ?? '',
'sort_direction' => $_GET['dir'] ?? '',
'title' => 'Manage tags',
'no_items_label' => 'No tags meet the requirements of the current filter.',
'items_per_page' => 30,
'index_class' => 'col-md-6',
'items_per_page' => 9999,
'base_url' => BASEURL . '/managetags/',
'get_data' => function($offset = 0, $limit = 30, $order = '', $direction = 'up') {
if (!in_array($order, ['id_tag', 'tag', 'slug', 'kind', 'count']))
$order = 'tag';
if (!in_array($direction, ['up', 'down']))
$direction = 'up';
$data = Registry::get('db')->queryAssocs('
SELECT t.*, u.id_user, u.first_name, u.surname
FROM tags AS t
LEFT JOIN users AS u ON t.id_user_owner = u.id_user
WHERE kind != {string:album}
ORDER BY {raw:order}
LIMIT {int:offset}, {int:limit}',
[
'order' => $order . ($direction == 'up' ? ' ASC' : ' DESC'),
'offset' => $offset,
'limit' => $limit,
'album' => 'Album',
]);
return [
'rows' => $data,
'order' => $order,
'direction' => ($direction == 'up' ? 'up' : 'down'),
];
'get_data' => function($offset, $limit, $order, $direction) {
return Tag::getOffset($offset, $limit, $order, $direction, false);
},
'get_count' => function() {
return Registry::get('db')->queryValue('
SELECT COUNT(*)
FROM tags
WHERE kind != {string:album}',
['album' => 'Album']);
return Tag::getCount(false, null, false);
}
];

View File

@@ -20,8 +20,7 @@ class ManageUsers extends HTMLController
'form' => [
'action' => BASEURL . '/edituser/',
'method' => 'get',
'class' => 'col-md-6 text-end',
'buttons' => [
'controls' => [
'add' => [
'type' => 'submit',
'caption' => 'Add new user',
@@ -37,26 +36,20 @@ class ManageUsers extends HTMLController
'surname' => [
'header' => 'Last name',
'is_sortable' => true,
'parse' => [
'link' => BASEURL . '/edituser/?id={ID_USER}',
'data' => 'surname',
],
'link' => BASEURL . '/edituser/?id={ID_USER}',
'value' => 'surname',
],
'first_name' => [
'header' => 'First name',
'is_sortable' => true,
'parse' => [
'link' => BASEURL . '/edituser/?id={ID_USER}',
'data' => 'first_name',
],
'link' => BASEURL . '/edituser/?id={ID_USER}',
'value' => 'first_name',
],
'slug' => [
'header' => 'Slug',
'is_sortable' => true,
'parse' => [
'link' => BASEURL . '/edituser/?id={ID_USER}',
'data' => 'slug',
],
'link' => BASEURL . '/edituser/?id={ID_USER}',
'value' => 'slug',
],
'emailaddress' => [
'value' => 'emailaddress',
@@ -64,12 +57,11 @@ class ManageUsers extends HTMLController
'is_sortable' => true,
],
'last_action_time' => [
'parse' => [
'format' => [
'type' => 'timestamp',
'data' => [
'timestamp' => 'last_action_time',
'pattern' => 'long',
],
'pattern' => 'long',
'value' => 'last_action_time',
'if_null' => 'n/a',
],
'header' => 'Last activity',
'is_sortable' => true,
@@ -82,48 +74,20 @@ class ManageUsers extends HTMLController
'is_admin' => [
'is_sortable' => true,
'header' => 'Admin?',
'parse' => [
'type' => 'function',
'data' => function($row) {
return $row['is_admin'] ? 'yes' : 'no';
}
],
'format' => fn($row) => $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'] : '',
'default_sort_order' => 'id_user',
'default_sort_direction' => 'down',
'start' => $_GET['start'] ?? 0,
'sort_order' => $_GET['order'] ?? '',
'sort_direction' => $_GET['dir'] ?? '',
'title' => 'Manage users',
'no_items_label' => 'No users meet the requirements of the current filter.',
'items_per_page' => 30,
'index_class' => 'col-md-6',
'base_url' => BASEURL . '/manageusers/',
'get_data' => function($offset = 0, $limit = 30, $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');
}
'get_data' => 'Member::getOffset',
'get_count' => 'Member::getCount',
];
$table = new GenericTable($options);

View File

@@ -16,66 +16,94 @@ class ResetPassword extends HTMLController
// Verifying an existing reset key?
if (isset($_GET['step'], $_GET['email'], $_GET['key']) && $_GET['step'] == 2)
{
$email = rawurldecode($_GET['email']);
$id_user = Authentication::getUserid($email);
if ($id_user === false)
throw new UserFacingException('Invalid email address. Please make sure you copied the full link in the email you received.');
$key = $_GET['key'];
if (!Authentication::checkResetKey($id_user, $key))
throw new UserFacingException('Invalid reset token. Please make sure you copied the full link in the email you received. Note: you cannot use the same token twice.');
parent::__construct('Reset password - ' . SITE_TITLE);
$form = new PasswordResetForm($email, $key);
$this->page->adopt($form);
// Are they trying to set something already?
if (isset($_POST['password1'], $_POST['password2']))
{
$missing = [];
if (strlen($_POST['password1']) < 6 || !preg_match('~[^A-z]~', $_POST['password1']))
$missing[] = '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).';
if ($_POST['password1'] != $_POST['password2'])
$missing[] = 'The passwords you entered do not match.';
// So, are we good to go?
if (empty($missing))
{
Authentication::updatePassword($id_user, Authentication::computeHash($_POST['password1']));
$_SESSION['login_msg'] = ['Your password has been reset', 'You can now use the form below to log in to your account.', 'success'];
header('Location: ' . BASEURL . '/login/');
exit;
}
else
$form->adopt(new Alert('Some fields require your attention', '<ul><li>' . implode('</li><li>', $missing) . '</li></ul>', 'danger'));
}
}
$this->verifyResetKey();
else
$this->requestResetKey();
}
private function requestResetKey()
{
parent::__construct('Reset password - ' . SITE_TITLE);
$form = new ForgotPasswordForm();
$this->page->adopt($form);
// Have they submitted an email address yet?
if (isset($_POST['emailaddress']) && preg_match('~^.+@.+\.[a-z]+$~', trim($_POST['emailaddress'])))
{
parent::__construct('Reset password - ' . SITE_TITLE);
$form = new ForgotPasswordForm();
$this->page->adopt($form);
// Have they submitted an email address yet?
if (isset($_POST['emailaddress']) && preg_match('~^.+@.+\.[a-z]+$~', trim($_POST['emailaddress'])))
$user = Member::fromEmailAddress($_POST['emailaddress']);
if (!$user)
{
$id_user = Authentication::getUserid(trim($_POST['emailaddress']));
if ($id_user === false)
{
$form->adopt(new Alert('Invalid email address', 'The email address you provided could not be found in our system. Please try again.', 'danger'));
return;
}
Authentication::setResetKey($id_user);
Email::resetMail($id_user);
// Show the success message
$this->page->clear();
$box = new DummyBox('An email has been sent');
$box->adopt(new Alert('', 'We have sent an email to ' . $_POST['emailaddress'] . ' containing details on how to reset your password.', 'success'));
$this->page->adopt($box);
$form->adopt(new Alert('Invalid email address', 'The email address you provided could not be found in our system. Please try again.', 'danger'));
return;
}
if (Authentication::getResetTimeOut($user->getUserId()) > 0)
{
// Update the reset time-out to prevent hammering
$resetTimeOut = Authentication::updateResetTimeOut($user->getUserId());
// Present it to the user in a readable way
if ($resetTimeOut > 3600)
$timeOut = sprintf('%d hours', ceil($resetTimeOut / 3600));
elseif ($resetTimeOut > 60)
$timeOut = sprintf('%d minutes', ceil($resetTimeOut / 60));
else
$timeOut = sprintf('%d seconds', $resetTimeOut);
$form->adopt(new Alert('Password reset token already sent', 'We already sent a password reset token to this email address recently. ' .
'If no email was received, please wait ' . $timeOut . ' to try again.', 'error'));
return;
}
Authentication::setResetKey($user->getUserId());
Email::resetMail($user->getUserId());
// Show the success message
$this->page->clear();
$box = new DummyBox('An email has been sent');
$box->adopt(new Alert('', 'We have sent an email to ' . $_POST['emailaddress'] . ' containing details on how to reset your password.', 'success'));
$this->page->adopt($box);
}
}
private function verifyResetKey()
{
$email = rawurldecode($_GET['email']);
$user = Member::fromEmailAddress($email);
if (!$user)
throw new UserFacingException('Invalid email address. Please make sure you copied the full link in the email you received.');
$key = $_GET['key'];
if (!Authentication::checkResetKey($user->getUserId(), $key))
throw new UserFacingException('Invalid reset token. Please make sure you copied the full link in the email you received. Note: you cannot use the same token twice.');
parent::__construct('Reset password - ' . SITE_TITLE);
$form = new PasswordResetForm($email, $key);
$this->page->adopt($form);
// Are they trying to set something already?
if (isset($_POST['password1'], $_POST['password2']))
{
$missing = [];
if (strlen($_POST['password1']) < 6 || !preg_match('~[^A-z]~', $_POST['password1']))
$missing[] = '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).';
if ($_POST['password1'] != $_POST['password2'])
$missing[] = 'The passwords you entered do not match.';
// So, are we good to go?
if (empty($missing))
{
Authentication::updatePassword($user->getUserId(), Authentication::computeHash($_POST['password1']));
// Consume token, ensuring it isn't used again
Authentication::consumeResetKey($user->getUserId());
$_SESSION['login_msg'] = ['Your password has been reset', 'You can now use the form below to log in to your account.', 'success'];
header('Location: ' . BASEURL . '/login/');
exit;
}
else
$form->adopt(new Alert('Some fields require your attention', '<ul><li>' . implode('</li><li>', $missing) . '</li></ul>', 'danger'));
}
}
}

View File

@@ -52,7 +52,7 @@ class ViewPeople extends HTMLController
'start' => $start,
'base_url' => BASEURL . '/people/',
'page_slug' => 'page/%PAGE%/',
'index_class' => 'pagination-lg justify-content-center',
'index_class' => 'pagination-lg mt-5 justify-content-around justify-content-lg-center',
]);
$this->page->adopt(new PageIndexWidget($pagination));

View File

@@ -8,6 +8,8 @@
class ViewPhoto extends HTMLController
{
private Image $photo;
public function __construct()
{
// Ensure we're logged in at this point.
@@ -19,70 +21,48 @@ class ViewPhoto extends HTMLController
if (empty($photo))
throw new NotFoundException();
parent::__construct($photo->getTitle() . ' - ' . SITE_TITLE);
$this->photo = $photo->getImage();
$author = $photo->getAuthor();
Session::resetSessionToken();
if (isset($_REQUEST['confirm_delete']) || isset($_REQUEST['delete_confirmed']))
$this->handleConfirmDelete($user, $author, $photo);
else
$this->handleViewPhoto($user, $author, $photo);
}
parent::__construct($this->photo->getTitle() . ' - ' . SITE_TITLE);
private function handleConfirmDelete(User $user, User $author, Asset $photo)
{
if (!($user->isAdmin() || $user->getUserId() === $author->getUserId()))
throw new NotAllowedException();
if (isset($_REQUEST['confirm_delete']))
{
$page = new ConfirmDeletePage($photo->getImage());
$this->page->adopt($page);
}
elseif (isset($_REQUEST['delete_confirmed']))
{
$album_url = $photo->getSubdir();
$photo->delete();
header('Location: ' . BASEURL . '/' . $album_url);
exit;
}
}
private function handleViewPhoto(User $user, User $author, Asset $photo)
{
if (!empty($_POST))
$this->handleTagging($photo->getImage());
$this->handleTagging();
else
$this->handleViewPhoto();
}
$page = new PhotoPage($photo->getImage());
private function handleViewPhoto()
{
$page = new PhotoPage($this->photo);
// Exif data?
$exif = EXIF::fromFile($photo->getFullPath());
if ($exif)
$page->setExif($exif);
// Any (EXIF) meta data?
$metaData = $this->prepareMetaData();
$page->setMetaData($metaData);
// What tag are we browsing?
$tag = isset($_GET['in']) ? Tag::fromId($_GET['in']) : null;
$id_tag = isset($tag) ? $tag->id_tag : null;
if (isset($tag))
$page->setTag($tag);
// Find previous photo in set.
$previous_url = $photo->getUrlForPreviousInSet($id_tag);
if ($previous_url)
$page->setPreviousPhotoUrl($previous_url);
// Keeping tabs on a filter?
if (isset($_GET['by']))
{
// Let's first verify that the filter is valid
$user = Member::fromSlug($_GET['by']);
if (!$user)
throw new UnexpectedValueException('Invalid filter for this album or tag.');
// ... and the next photo, too.
$next_url = $photo->getUrlForNextInSet($id_tag);
if ($next_url)
$page->setNextPhotoUrl($next_url);
if ($user->isAdmin() || $user->getUserId() === $author->getUserId())
$page->setIsAssetOwner(true);
// Alright, let's run with it then
$page->setActiveFilter($user->getSlug());
}
$this->page->adopt($page);
$this->page->setCanonicalUrl($photo->getPageUrl());
$this->page->setCanonicalUrl($this->photo->getPageUrl());
}
private function handleTagging(Image $photo)
private function handleTagging()
{
header('Content-Type: text/json; charset=utf-8');
@@ -96,7 +76,7 @@ class ViewPhoto extends HTMLController
// We are!
if (!isset($_POST['delete']))
{
$photo->linkTags([(int) $_POST['id_tag']]);
$this->photo->linkTags([(int) $_POST['id_tag']]);
echo json_encode(['success' => true]);
exit;
}
@@ -104,9 +84,43 @@ class ViewPhoto extends HTMLController
// ... deleting, that is.
else
{
$photo->unlinkTags([(int) $_POST['id_tag']]);
$this->photo->unlinkTags([(int) $_POST['id_tag']]);
echo json_encode(['success' => true]);
exit;
}
}
private function prepareMetaData()
{
if (!($exif = EXIF::fromFile($this->photo->getFullPath())))
throw new UnexpectedValueException('Photo file not found!');
$metaData = [];
if (!empty($exif->created_timestamp))
$metaData['Date Taken'] = date("j M Y, H:i:s", $exif->created_timestamp);
if ($author = $this->photo->getAuthor())
$metaData['Uploaded by'] = $author->getfullName();
if (!empty($exif->camera))
$metaData['Camera Model'] = $exif->camera;
if (!empty($exif->shutter_speed))
$metaData['Shutter Speed'] = $exif->shutterSpeedFraction();
if (!empty($exif->aperture))
$metaData['Aperture'] = 'f/' . number_format($exif->aperture, 1);
if (!empty($exif->focal_length))
$metaData['Focal Length'] = $exif->focal_length . ' mm';
if (!empty($exif->iso))
$metaData['ISO Speed'] = $exif->iso;
if (!empty($exif->software))
$metaData['Software'] = $exif->software;
return $metaData;
}
}

View File

@@ -26,60 +26,92 @@ class ViewPhotoAlbum extends HTMLController
$tag = Tag::fromSlug($_GET['tag']);
$id_tag = $tag->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);
$back_link = BASEURL . '/' . (!empty($ptag->slug) ? $ptag->slug . '/' : '');
$back_link_title = 'Back to &quot;' . $ptag->tag . '&quot;';
}
elseif ($tag->kind === 'Person')
{
$back_link = BASEURL . '/people/';
$back_link_title = 'Back to &quot;People&quot;';
$is_person = true;
}
$header_box = new AlbumHeaderBox($title, $description, $back_link, $back_link_title);
$header_box = $this->getHeaderBox($tag);
}
// View the album root.
else
{
$id_tag = 1;
$tag = Tag::fromId($id_tag);
$title = 'Albums';
}
// What page are we at?
$page = isset($_GET['page']) ? (int) $_GET['page'] : 1;
$current_page = isset($_GET['page']) ? (int) $_GET['page'] : 1;
parent::__construct($title . ' - Page ' . $page . ' - ' . SITE_TITLE);
parent::__construct($title . ' - Page ' . $current_page . ' - ' . SITE_TITLE);
if (isset($header_box))
$this->page->adopt($header_box);
// Can we do fancy things here?
// !!! TODO: permission system?
$buttons = $this->getAlbumButtons($id_tag, $tag ?? null);
if (!empty($buttons))
$this->page->adopt(new AlbumButtonBox($buttons));
// Who contributed to this album?
$contributors = $tag->getContributorList();
// Enumerate possible filters
$filters = [];
if (!empty($contributors))
{
$filters[''] = ['id_user' => null, 'label' => '', 'caption' => 'All photos',
'link' => $tag->getUrl()];
foreach ($contributors as $contributor)
{
$filters[$contributor['slug']] = [
'id_user' => $contributor['id_user'],
'label' => $contributor['first_name'],
'caption' => sprintf('By %s (%s photos)',
$contributor['first_name'], $contributor['num_assets']),
'link' => $tag->getUrl() . '?by=' . $contributor['slug'],
];
}
}
// Limit to a particular uploader?
$active_filter = '';
$id_user_uploaded = null;
if (!empty($_GET['by']))
{
if (!isset($filters[$_GET['by']]))
throw new UnexpectedValueException('Invalid filter for this album or tag.');
$active_filter = $_GET['by'];
$id_user_uploaded = $filters[$active_filter]['id_user'];
$filters[$active_filter]['is_active'] = true;
}
// Add an interface to query and modify the album/tag
$buttons = $this->getAlbumButtons($tag, $active_filter);
$button_strip = new AlbumButtonBox($buttons, $filters, $active_filter);
$this->page->adopt($button_strip);
// Fetch subalbums, but only if we're on the first page.
if ($page === 1)
if ($current_page === 1)
{
$albums = $this->getAlbums($id_tag);
$index = new AlbumIndex($albums);
$this->page->adopt($index);
}
// Are we viewing a person tag?
$is_person = $tag->kind === 'Person';
// Load a photo mosaic for the current tag.
list($mosaic, $total_count) = $this->getPhotoMosaic($id_tag, $page, !isset($is_person));
list($mosaic, $total_count) = $this->getPhotoMosaic($id_tag, $id_user_uploaded, $current_page, !$is_person);
if (isset($mosaic))
{
$index = new PhotosIndex($mosaic, Registry::get('user')->isAdmin());
$this->page->adopt($index);
if ($id_tag > 1)
$index->setUrlSuffix('?in=' . $id_tag);
$url_params = [];
if (isset($tag))
$url_params['in'] = $tag->id_tag;
if (!empty($active_filter))
$url_params['by'] = $active_filter;
$url_suffix = http_build_query($url_params);
$index->setUrlSuffix('?' . $url_suffix);
$menu_items = $this->getEditMenuItems('&' . $url_suffix);
$index->setEditMenuItems($menu_items);
}
// Make a page index as needed, while we're at it.
@@ -88,24 +120,24 @@ class ViewPhotoAlbum extends HTMLController
$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%/',
'index_class' => 'pagination-lg justify-content-center',
'start' => ($current_page - 1) * self::PER_PAGE,
'base_url' => $tag->getUrl(),
'page_slug' => 'page/%PAGE%/' . (!empty($active_filter) ? '?by=' . $active_filter : ''),
'index_class' => 'pagination-lg justify-content-around justify-content-lg-center',
]);
$this->page->adopt(new PageIndexWidget($index));
}
// Set the canonical url.
$this->page->setCanonicalUrl(BASEURL . '/' . (isset($_GET['tag']) ? $_GET['tag'] . '/' : '') .
($page > 1 ? 'page/' . $page . '/' : ''));
$this->page->setCanonicalUrl($tag->getUrl() . ($current_page > 1 ? 'page/' . $current_page . '/' : ''));
}
public function getPhotoMosaic($id_tag, $page, $sort_linear)
public function getPhotoMosaic($id_tag, $id_user_uploaded, $page, $sort_linear)
{
// Create an iterator.
list($this->iterator, $total_count) = AssetIterator::getByOptions([
'id_tag' => $id_tag,
'id_user_uploaded' => $id_user_uploaded,
'order' => 'date_captured',
'direction' => $sort_linear ? 'asc' : 'desc',
'limit' => self::PER_PAGE,
@@ -145,25 +177,26 @@ class ViewPhotoAlbum extends HTMLController
return $albums;
}
private function getAlbumButtons($id_tag, $tag)
private function getAlbumButtons(Tag $tag, $active_filter)
{
$buttons = [];
$user = Registry::get('user');
if ($user->isLoggedIn())
{
$suffix = !empty($active_filter) ? '&by=' . $active_filter : '';
$buttons[] = [
'url' => BASEURL . '/download/?tag=' . $id_tag,
'url' => BASEURL . '/download/?tag=' . $tag->id_tag . $suffix,
'caption' => 'Download album',
];
}
if (isset($tag))
if ($tag->id_parent != 0)
{
if ($tag->kind === 'Album')
{
$buttons[] = [
'url' => BASEURL . '/uploadmedia/?tag=' . $id_tag,
'url' => BASEURL . '/uploadmedia/?tag=' . $tag->id_tag,
'caption' => 'Upload photos here',
];
}
@@ -173,14 +206,14 @@ class ViewPhotoAlbum extends HTMLController
if ($tag->kind === 'Album')
{
$buttons[] = [
'url' => BASEURL . '/editalbum/?id=' . $id_tag,
'url' => BASEURL . '/editalbum/?id=' . $tag->id_tag,
'caption' => 'Edit album',
];
}
elseif ($tag->kind === 'Person')
{
$buttons[] = [
'url' => BASEURL . '/edittag/?id=' . $id_tag,
'url' => BASEURL . '/edittag/?id=' . $tag->id_tag,
'caption' => 'Edit tag',
];
}
@@ -190,7 +223,7 @@ class ViewPhotoAlbum extends HTMLController
if ($user->isAdmin() && (!isset($tag) || $tag->kind === 'Album'))
{
$buttons[] = [
'url' => BASEURL . '/addalbum/?tag=' . $id_tag,
'url' => BASEURL . '/addalbum/?tag=' . $tag->id_tag,
'caption' => 'Create subalbum',
];
}
@@ -198,9 +231,62 @@ class ViewPhotoAlbum extends HTMLController
return $buttons;
}
public function __destruct()
private function getEditMenuItems($url_suffix)
{
if (isset($this->iterator))
$this->iterator->clean();
$items = [];
$sess = '&' . Session::getSessionTokenKey() . '=' . Session::getSessionToken();
if (Registry::get('user')->isLoggedIn())
{
$items[] = [
'label' => 'Edit image',
'uri' => fn($image) => $image->getEditUrl() . $url_suffix,
];
$items[] = [
'label' => 'Delete image',
'uri' => fn($image) => $image->getDeleteUrl() . $url_suffix . $sess,
'onclick' => 'return confirm(\'Are you sure you want to delete this image?\');',
];
}
if (Registry::get('user')->isAdmin())
{
$items[] = [
'label' => 'Make album cover',
'uri' => fn($image) => $image->getEditUrl() . $url_suffix . '&album_cover' . $sess,
];
$items[] = [
'label' => 'Increase priority',
'uri' => fn($image) => $image->getEditUrl() . $url_suffix . '&inc_prio' . $sess,
];
$items[] = [
'label' => 'Decrease priority',
'uri' => fn($image) => $image->getEditUrl() . $url_suffix . '&dec_prio' . $sess,
];
}
return $items;
}
private function getHeaderBox(Tag $tag)
{
// Can we go up a level?
if ($tag->id_parent != 0)
{
$ptag = Tag::fromId($tag->id_parent);
$back_link = BASEURL . '/' . (!empty($ptag->slug) ? $ptag->slug . '/' : '');
$back_link_title = 'Back to &quot;' . $ptag->tag . '&quot;';
}
elseif ($tag->kind === 'Person')
{
$back_link = BASEURL . '/people/';
$back_link_title = 'Back to &quot;People&quot;';
}
$description = !empty($tag->description) ? $tag->description : '';
return new AlbumHeaderBox($tag->tag, $description, $back_link, $back_link_title);
}
}

View File

@@ -46,7 +46,7 @@ class ViewTimeline extends HTMLController
'start' => (isset($_GET['page']) ? $_GET['page'] - 1 : 0) * self::PER_PAGE,
'base_url' => BASEURL . '/timeline/',
'page_slug' => 'page/%PAGE%/',
'index_class' => 'pagination-lg justify-content-center',
'index_class' => 'pagination-lg justify-content-around justify-content-lg-center',
]);
$this->page->adopt(new PageIndexWidget($index));
}
@@ -54,10 +54,4 @@ class ViewTimeline extends HTMLController
// Set the canonical url.
$this->page->setCanonicalUrl(BASEURL . '/timeline/');
}
public function __destruct()
{
if (isset($this->iterator))
$this->iterator->clean();
}
}

View File

@@ -0,0 +1,2 @@
/* Add time-out to password reset keys, and prevent repeated mails */
ALTER TABLE `users` ADD `reset_blocked_until` INT UNSIGNED NULL AFTER `reset_key`;

View File

@@ -8,23 +8,23 @@
class Asset
{
protected $id_asset;
protected $id_user_uploaded;
protected $subdir;
protected $filename;
protected $title;
protected $slug;
protected $mimetype;
protected $image_width;
protected $image_height;
protected $date_captured;
protected $priority;
public $id_asset;
public $id_user_uploaded;
public $subdir;
public $filename;
public $title;
public $slug;
public $mimetype;
public $image_width;
public $image_height;
public $date_captured;
public $priority;
protected $meta;
protected $tags;
protected $thumbnails;
protected function __construct(array $data)
public function __construct(array $data)
{
foreach ($data as $attribute => $value)
{
@@ -32,16 +32,31 @@ class Asset
$this->$attribute = $value;
}
if (!empty($data['date_captured']) && $data['date_captured'] !== 'NULL')
if (isset($data['date_captured']) && $data['date_captured'] !== null && !is_object($data['date_captured']))
$this->date_captured = new DateTime($data['date_captured']);
}
public function canBeEditedBy(User $user)
{
return $this->isOwnedBy($user) || $user->isAdmin();
}
public static function cleanSlug($slug)
{
// Only alphanumerical chars, underscores and forward slashes are allowed
if (!preg_match_all('~([A-z0-9\/_]+)~', $slug, $allowedTokens, PREG_PATTERN_ORDER))
throw new UnexpectedValueException('Slug does not make sense.');
// Join valid substrings together with hyphens
return implode('-', $allowedTokens[1]);
}
public static function fromId($id_asset, $return_format = 'object')
{
$row = Registry::get('db')->queryAssoc('
SELECT *
FROM assets
WHERE id_asset = {int:id_asset}',
WHERE id_asset = :id_asset',
[
'id_asset' => $id_asset,
]);
@@ -54,7 +69,7 @@ class Asset
$row = Registry::get('db')->queryAssoc('
SELECT *
FROM assets
WHERE slug = {string:slug}',
WHERE slug = :slug',
[
'slug' => $slug,
]);
@@ -70,7 +85,7 @@ class Asset
$row['meta'] = $db->queryPair('
SELECT variable, value
FROM assets_meta
WHERE id_asset = {int:id_asset}',
WHERE id_asset = :id_asset',
[
'id_asset' => $row['id_asset'],
]);
@@ -79,21 +94,20 @@ class Asset
$row['thumbnails'] = $db->queryPair('
SELECT
CONCAT(
width,
{string:x},
height,
IF(mode != {string:empty}, CONCAT({string:_}, mode), {string:empty})
width, :x, height,
IF(mode != :empty1, CONCAT(:_, mode), :empty2)
) AS selector, filename
FROM assets_thumbs
WHERE id_asset = {int:id_asset}',
WHERE id_asset = :id_asset',
[
'id_asset' => $row['id_asset'],
'empty' => '',
'empty1' => '',
'empty2' => '',
'x' => 'x',
'_' => '_',
]);
return $return_format == 'object' ? new Asset($row) : $row;
return $return_format === 'object' ? new static($row) : $row;
}
public static function fromIds(array $id_assets, $return_format = 'array')
@@ -106,14 +120,14 @@ class Asset
$res = $db->query('
SELECT *
FROM assets
WHERE id_asset IN ({array_int:id_assets})
WHERE id_asset IN (@id_assets)
ORDER BY id_asset',
[
'id_assets' => $id_assets,
]);
$assets = [];
while ($asset = $db->fetch_assoc($res))
while ($asset = $db->fetchAssoc($res))
{
$assets[$asset['id_asset']] = $asset;
$assets[$asset['id_asset']]['meta'] = [];
@@ -123,7 +137,7 @@ class Asset
$metas = $db->queryRows('
SELECT id_asset, variable, value
FROM assets_meta
WHERE id_asset IN ({array_int:id_assets})
WHERE id_asset IN (@id_assets)
ORDER BY id_asset',
[
'id_assets' => $id_assets,
@@ -135,17 +149,16 @@ class Asset
$thumbnails = $db->queryRows('
SELECT id_asset,
CONCAT(
width,
{string:x},
height,
IF(mode != {string:empty}, CONCAT({string:_}, mode), {string:empty})
width, :x, height,
IF(mode != :empty1, CONCAT(:_, mode), :empty2)
) AS selector, filename
FROM assets_thumbs
WHERE id_asset IN ({array_int:id_assets})
WHERE id_asset IN (@id_assets)
ORDER BY id_asset',
[
'id_assets' => $id_assets,
'empty' => '',
'empty1' => '',
'empty2' => '',
'x' => 'x',
'_' => '_',
]);
@@ -153,8 +166,10 @@ class Asset
foreach ($thumbnails as $thumb)
$assets[$thumb[0]]['thumbnails'][$thumb[1]] = $thumb[2];
if ($return_format == 'array')
if ($return_format === 'array')
{
return $assets;
}
else
{
$objects = [];
@@ -214,7 +229,7 @@ class Asset
$title = $data['title'] ?? $basename;
// Same with the slug.
$slug = $data['slug'] ?? sprintf('%s/%s', $preferred_subdir, $basename);
$slug = $data['slug'] ?? self::cleanSlug(sprintf('%s/%s', $preferred_subdir, $basename));
// Detected an image?
if (substr($mimetype, 0, 5) == 'image')
@@ -247,10 +262,10 @@ class Asset
INSERT INTO assets
(id_user_uploaded, subdir, filename, title, slug, mimetype, image_width, image_height, date_captured, priority)
VALUES
({int:id_user_uploaded}, {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})',
(:id_user_uploaded, :subdir, :filename, :title, :slug, :mimetype,
:image_width, :image_height,
' . (!empty($date_captured) ? 'FROM_UNIXTIME(:date_captured)' : 'NULL') . ',
:priority)',
[
'id_user_uploaded' => isset($id_user) ? $id_user : Registry::get('user')->getUserId(),
'subdir' => $preferred_subdir,
@@ -258,9 +273,9 @@ class Asset
'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',
'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,
]);
@@ -270,7 +285,7 @@ class Asset
return false;
}
$data['id_asset'] = $db->insert_id();
$data['id_asset'] = $db->insertId();
return $return_format === 'object' ? new self($data) : $data;
}
@@ -289,6 +304,16 @@ class Asset
return $this->date_captured;
}
public function getDeleteUrl()
{
return BASEURL . '/editasset/?id=' . $this->id_asset . '&delete';
}
public function getEditUrl()
{
return BASEURL . '/editasset/?id=' . $this->id_asset;
}
public function getFilename()
{
return $this->filename;
@@ -299,7 +324,7 @@ class Asset
$posts = Registry::get('db')->queryValues('
SELECT id_post
FROM posts_assets
WHERE id_asset = {int:id_asset}',
WHERE id_asset = :id_asset',
['id_asset' => $this->id_asset]);
// TODO: fix empty post iterator.
@@ -383,6 +408,50 @@ class Asset
return new Image(get_object_vars($this));
}
public function isOwnedBy(User $user)
{
return $this->id_user_uploaded == $user->getUserId();
}
public function moveToSubDir($destSubDir)
{
// Verify the original exists
$source = ASSETSDIR . '/' . $this->subdir . '/' . $this->filename;
if (!file_exists($source))
return -1;
// Ensure the intended target file doesn't exist yet
$destDir = ASSETSDIR . '/' . $destSubDir;
$destFile = $destDir . '/' . $this->filename;
if (file_exists($destFile))
return -2;
// Can we write to the target directory?
if (!is_writable($destDir))
return -3;
// Perform move
if (rename($source, $destFile))
{
$this->subdir = $destSubDir;
$this->slug = $this->subdir . '/' . $this->title;
Registry::get('db')->query('
UPDATE assets
SET subdir = :subdir,
slug = :slug
WHERE id_asset = :id_asset',
[
'id_asset' => $this->id_asset,
'subdir' => $this->subdir,
'slug' => $this->slug,
]);
return true;
}
return -4;
}
public function replaceFile($filename)
{
// No filename? Abort!
@@ -426,18 +495,18 @@ class Asset
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}',
mimetype = :mimetype,
image_width = :image_width,
image_height = :image_height,
date_captured = :date_captured,
priority = :priority
WHERE id_asset = :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',
'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,
]);
}
@@ -458,8 +527,8 @@ class Asset
if (!empty($to_remove))
$db->query('
DELETE FROM assets_meta
WHERE id_asset = {int:id_asset} AND
variable IN({array_string:variables})',
WHERE id_asset = :id_asset AND
variable IN(@variables)',
[
'id_asset' => $this->id_asset,
'variables' => array_keys($to_remove),
@@ -490,63 +559,40 @@ class Asset
{
$db = Registry::get('db');
// First: delete associated metadata
// Delete any and all thumbnails, if this is an image.
if ($this->isImage())
{
$image = $this->getImage();
$image->removeAllThumbnails();
}
// Delete all meta info for this asset.
$db->query('
DELETE FROM assets_meta
WHERE id_asset = {int:id_asset}',
[
'id_asset' => $this->id_asset,
]);
WHERE id_asset = :id_asset',
['id_asset' => $this->id_asset]);
// Second: figure out what tags to recount cardinality for
// Figure out what tags to recount cardinality for
$recount_tags = $db->queryValues('
SELECT id_tag
FROM assets_tags
WHERE id_asset = {int:id_asset}',
[
'id_asset' => $this->id_asset,
]);
WHERE id_asset = :id_asset',
['id_asset' => $this->id_asset]);
// Delete asset association for these tags
$db->query('
DELETE FROM assets_tags
WHERE id_asset = {int:id_asset}',
[
'id_asset' => $this->id_asset,
]);
WHERE id_asset = :id_asset',
['id_asset' => $this->id_asset]);
Tag::recount($recount_tags);
// Third: figure out what associated thumbs to delete
$thumbs_to_delete = $db->queryValues('
SELECT filename
FROM assets_thumbs
WHERE id_asset = {int:id_asset}',
[
'id_asset' => $this->id_asset,
]);
foreach ($thumbs_to_delete as $filename)
{
$thumb_path = THUMBSDIR . '/' . $this->subdir . '/' . $filename;
if (is_file($thumb_path))
unlink($thumb_path);
}
$db->query('
DELETE FROM assets_thumbs
WHERE id_asset = {int:id_asset}',
[
'id_asset' => $this->id_asset,
]);
// Reset asset ID for tags that use this asset for their thumbnail
$rows = $db->query('
$rows = $db->queryValues('
SELECT id_tag
FROM tags
WHERE id_asset_thumb = {int:id_asset}',
[
'id_asset' => $this->id_asset,
]);
WHERE id_asset_thumb = :id_asset',
['id_asset' => $this->id_asset]);
if (!empty($rows))
{
@@ -563,10 +609,8 @@ class Asset
$return = $db->query('
DELETE FROM assets
WHERE id_asset = {int:id_asset}',
[
'id_asset' => $this->id_asset,
]);
WHERE id_asset = :id_asset',
['id_asset' => $this->id_asset]);
return $return;
}
@@ -595,7 +639,7 @@ class Asset
Registry::get('db')->query('
DELETE FROM assets_tags
WHERE id_asset = {int:id_asset} AND id_tag IN ({array_int:id_tags})',
WHERE id_asset = :id_asset AND id_tag IN (@id_tags)',
[
'id_asset' => $this->id_asset,
'id_tags' => $id_tags,
@@ -611,87 +655,117 @@ class Asset
FROM assets');
}
public function setKeyData($title, $slug, DateTime $date_captured = null, $priority)
public static function getOffset($offset, $limit, $order, $direction)
{
$params = [
'id_asset' => $this->id_asset,
'title' => $title,
'slug' => $slug,
'priority' => $priority,
];
$order = $order . ($direction == 'up' ? ' ASC' : ' DESC');
if (isset($date_captured))
$params['date_captured'] = $date_captured->format('Y-m-d H:i:s');
return Registry::get('db')->queryAssocs('
SELECT a.id_asset, a.subdir, a.filename,
a.image_width, a.image_height, a.mimetype,
u.id_user, u.first_name, u.surname
FROM assets AS a
LEFT JOIN users AS u ON a.id_user_uploaded = u.id_user
ORDER BY ' . $order . '
LIMIT :offset, :limit',
[
'offset' => $offset,
'limit' => $limit,
]);
}
public function save()
{
if (empty($this->id_asset))
throw new UnexpectedValueException();
return Registry::get('db')->query('
UPDATE assets
SET title = {string:title},
slug = {string:slug},' . (isset($date_captured) ? '
date_captured = {datetime:date_captured},' : '') . '
priority = {int:priority}
WHERE id_asset = {int:id_asset}',
SET subdir = :subdir,
filename = :filename,
title = :title,
slug = :slug,
mimetype = :mimetype,
image_width = :image_width,
image_height = :image_height,
date_captured = :date_captured,
priority = :priority
WHERE id_asset = :id_asset',
get_object_vars($this));
}
protected function getUrlForAdjacentInSet($prevNext, ?Tag $tag, $activeFilter)
{
$next = $prevNext === 'next';
$previous = !$next;
$where = [];
$params = [
'id_asset' => $this->id_asset,
'date_captured' => $this->date_captured,
];
// Direction depends on whether we're browsing a tag or timeline
if (isset($tag))
{
$where[] = 't.id_tag = :id_tag';
$params['id_tag'] = $tag->id_tag;
$where_op = $previous ? '<' : '>';
$order_dir = $previous ? 'DESC' : 'ASC';
}
else
{
$where_op = $previous ? '>' : '<';
$order_dir = $previous ? 'ASC' : 'DESC';
}
// Take active filter into account as well
if (!empty($activeFilter) && ($user = Member::fromSlug($activeFilter)) !== false)
{
$where[] = 'id_user_uploaded = :id_user_uploaded';
$params['id_user_uploaded'] = $user->getUserId();
}
// Use complete ordering when sorting the set
$where[] = '(a.date_captured, a.id_asset) ' . $where_op .
' (:date_captured, :id_asset)';
// Stringify conditions together
$where = '(' . implode(') AND (', $where) . ')';
// Run query, leaving out tags table if not required
$row = Registry::get('db')->queryAssoc('
SELECT a.*
FROM assets AS a
' . (isset($tag) ? '
INNER JOIN assets_tags AS t ON a.id_asset = t.id_asset' : '') . '
WHERE ' . $where . '
ORDER BY a.date_captured ' . $order_dir . ', a.id_asset ' . $order_dir . '
LIMIT 1',
$params);
if (!$row)
return false;
$obj = self::byRow($row, 'object');
$urlParams = [];
if (isset($tag))
$urlParams['in'] = $tag->id_tag;
if (!empty($activeFilter))
$urlParams['by'] = $activeFilter;
$queryString = !empty($urlParams) ? '?' . http_build_query($urlParams) : '';
return $obj->getPageUrl() . $queryString;
}
public function getUrlForPreviousInSet($id_tag = null)
public function getUrlForPreviousInSet(?Tag $tag, $activeFilter)
{
$row = Registry::get('db')->queryAssoc('
SELECT a.*
' . (isset($id_tag) ? '
FROM assets_tags AS t
INNER JOIN assets AS a ON a.id_asset = t.id_asset
WHERE t.id_tag = {int:id_tag} AND
(a.date_captured, a.id_asset) < ({datetime:date_captured}, {int:id_asset})
ORDER BY a.date_captured DESC, a.id_asset DESC'
: '
FROM assets AS a
WHERE (a.date_captured, a.id_asset) > ({datetime:date_captured}, {int:id_asset})
ORDER BY date_captured ASC, a.id_asset ASC')
. '
LIMIT 1',
[
'id_asset' => $this->id_asset,
'id_tag' => $id_tag,
'date_captured' => $this->date_captured,
]);
if ($row)
{
$obj = self::byRow($row, 'object');
return $obj->getPageUrl() . ($id_tag ? '?in=' . $id_tag : '');
}
else
return false;
return $this->getUrlForAdjacentInSet('previous', $tag, $activeFilter);
}
public function getUrlForNextInSet($id_tag = null)
public function getUrlForNextInSet(?Tag $tag, $activeFilter)
{
$row = Registry::get('db')->queryAssoc('
SELECT a.*
' . (isset($id_tag) ? '
FROM assets_tags AS t
INNER JOIN assets AS a ON a.id_asset = t.id_asset
WHERE t.id_tag = {int:id_tag} AND
(a.date_captured, a.id_asset) > ({datetime:date_captured}, {int:id_asset})
ORDER BY a.date_captured ASC, a.id_asset ASC'
: '
FROM assets AS a
WHERE (a.date_captured, a.id_asset) < ({datetime:date_captured}, {int:id_asset})
ORDER BY date_captured DESC, a.id_asset DESC')
. '
LIMIT 1',
[
'id_asset' => $this->id_asset,
'id_tag' => $id_tag,
'date_captured' => $this->date_captured,
]);
if ($row)
{
$obj = self::byRow($row, 'object');
return $obj->getPageUrl() . ($id_tag ? '?in=' . $id_tag : '');
}
else
return false;
return $this->getUrlForAdjacentInSet('next', $tag, $activeFilter);
}
}

View File

@@ -1,39 +1,50 @@
<?php
/*****************************************************************************
* AssetIterator.php
* Contains key class AssetIterator.
* Contains model class AssetIterator.
*
* Kabuki CMS (C) 2013-2015, Aaron van Geffen
* Kabuki CMS (C) 2013-2025, Aaron van Geffen
*****************************************************************************/
class AssetIterator extends Asset
class AssetIterator implements Iterator
{
private $direction;
private $return_format;
private $res_assets;
private $res_meta;
private $res_thumbs;
private Database $db;
private $rowCount;
protected function __construct($res_assets, $res_meta, $res_thumbs, $return_format)
private $assets_iterator;
private $meta_iterator;
private $thumbs_iterator;
protected function __construct(PDOStatement $stmt_assets, PDOStatement $stmt_meta, PDOStatement $stmt_thumbs,
$return_format, $direction)
{
$this->db = Registry::get('db');
$this->res_assets = $res_assets;
$this->res_meta = $res_meta;
$this->res_thumbs = $res_thumbs;
$this->direction = $direction;
$this->return_format = $return_format;
$this->rowCount = $stmt_assets->rowCount();
$this->assets_iterator = new CachedPDOIterator($stmt_assets);
$this->assets_iterator->rewind();
$this->meta_iterator = new CachedPDOIterator($stmt_meta);
$this->thumbs_iterator = new CachedPDOIterator($stmt_thumbs);
}
public function next()
public static function all()
{
$row = $this->db->fetch_assoc($this->res_assets);
return self::getByOptions();
}
// No more rows?
public function current(): mixed
{
$row = $this->assets_iterator->current();
if (!$row)
return false;
return $row;
// Looks up metadata.
// Collect metadata
$row['meta'] = [];
while ($meta = $this->db->fetch_assoc($this->res_meta))
$this->meta_iterator->rewind();
foreach ($this->meta_iterator as $meta)
{
if ($meta['id_asset'] != $row['id_asset'])
continue;
@@ -41,54 +52,23 @@ class AssetIterator extends Asset
$row['meta'][$meta['variable']] = $meta['value'];
}
// Reset internal pointer for next asset.
$this->db->data_seek($this->res_meta, 0);
// Looks up thumbnails.
// Collect thumbnails
$row['thumbnails'] = [];
while ($thumbs = $this->db->fetch_assoc($this->res_thumbs))
$this->thumbs_iterator->rewind();
foreach ($this->thumbs_iterator as $thumb)
{
if ($thumbs['id_asset'] != $row['id_asset'])
if ($thumb['id_asset'] != $row['id_asset'])
continue;
$row['thumbnails'][$thumbs['selector']] = $thumbs['filename'];
$row['thumbnails'][$thumb['selector']] = $thumb['filename'];
}
// Reset internal pointer for next asset.
$this->db->data_seek($this->res_thumbs, 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);
$this->db->data_seek($this->res_thumbs, 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 = [
@@ -111,9 +91,14 @@ class AssetIterator extends Asset
{
$params['mime_type'] = $options['mime_type'];
if (is_array($options['mime_type']))
$where[] = 'a.mimetype IN({array_string:mime_type})';
$where[] = 'a.mimetype IN(@mime_type)';
else
$where[] = 'a.mimetype = {string:mime_type}';
$where[] = 'a.mimetype = :mime_type';
}
if (isset($options['id_user_uploaded']))
{
$params['id_user_uploaded'] = $options['id_user_uploaded'];
$where[] = 'id_user_uploaded = :id_user_uploaded';
}
if (isset($options['id_tag']))
{
@@ -121,7 +106,17 @@ class AssetIterator extends Asset
$where[] = 'id_asset IN(
SELECT l.id_asset
FROM assets_tags AS l
WHERE l.id_tag = {int:id_tag})';
WHERE l.id_tag = :id_tag)';
}
elseif (isset($options['tag']))
{
$params['tag'] = $options['tag'];
$where[] = 'id_asset IN(
SELECT l.id_asset
FROM assets_tags AS l
INNER JOIN tags AS t
ON l.id_tag = t.id_tag
WHERE t.slug = :tag)';
}
// Make it valid SQL.
@@ -137,7 +132,7 @@ class AssetIterator extends Asset
FROM assets AS a
WHERE ' . $where . '
ORDER BY ' . $order . (!empty($params['limit']) ? '
LIMIT {int:offset}, {int:limit}' : ''),
LIMIT :offset, :limit' : ''),
$params);
// Get a resource object for the asset meta.
@@ -157,9 +152,9 @@ class AssetIterator extends Asset
SELECT id_asset, filename,
CONCAT(
width,
{string:x},
:x,
height,
IF(mode != {string:empty}, CONCAT({string:_}, mode), {string:empty})
IF(mode != :empty1, CONCAT(:_, mode), :empty2)
) AS selector
FROM assets_thumbs
WHERE id_asset IN(
@@ -169,12 +164,13 @@ class AssetIterator extends Asset
)
ORDER BY id_asset',
$params + [
'empty' => '',
'empty1' => '',
'empty2' => '',
'x' => 'x',
'_' => '_',
]);
$iterator = new self($res_assets, $res_meta, $res_thumbs, $return_format);
$iterator = new self($res_assets, $res_meta, $res_thumbs, $return_format, $params['direction']);
// Returning total count, too?
if ($return_count)
@@ -190,4 +186,39 @@ class AssetIterator extends Asset
else
return $iterator;
}
public function key(): mixed
{
return $this->assets_iterator->key();
}
public function isAscending(): bool
{
return $this->direction === 'asc';
}
public function isDescending(): bool
{
return $this->direction === 'desc';
}
public function next(): void
{
$this->assets_iterator->next();
}
public function num(): int
{
return $this->rowCount;
}
public function rewind(): void
{
$this->assets_iterator->rewind();
}
public function valid(): bool
{
return $this->assets_iterator->valid();
}
}

View File

@@ -12,48 +12,27 @@
*/
class Authentication
{
/**
* Checks whether a user still exists in the database.
*/
public static function checkExists($id_user)
{
$res = Registry::get('db')->queryValue('
SELECT id_user
FROM users
WHERE id_user = {int:id}',
[
'id' => $id_user,
]);
return $res !== null;
}
const DEFAULT_RESET_TIMEOUT = 30;
/**
* Finds the user id belonging to a certain emailaddress.
* Checks a password for a given username against the database.
*/
public static function getUserId($emailaddress)
public static function checkPassword($emailaddress, $password)
{
$res = Registry::get('db')->queryValue('
SELECT id_user
// Retrieve password hash for user matching the provided emailaddress.
$password_hash = Registry::get('db')->queryValue('
SELECT password_hash
FROM users
WHERE emailaddress = {string:emailaddress}',
WHERE emailaddress = :emailaddress',
[
'emailaddress' => $emailaddress,
]);
return empty($res) ? false : $res;
}
// If there's no hash, the user likely does not exist.
if (!$password_hash)
return false;
public static function setResetKey($id_user)
{
return Registry::get('db')->query('
UPDATE users
SET reset_key = {string:key}
WHERE id_user = {int:id}',
[
'id' => $id_user,
'key' => self::newActivationKey(),
]);
return password_verify($password, $password_hash);
}
public static function checkResetKey($id_user, $reset_key)
@@ -61,7 +40,7 @@ class Authentication
$key = Registry::get('db')->queryValue('
SELECT reset_key
FROM users
WHERE id_user = {int:id}',
WHERE id_user = :id',
[
'id' => $id_user,
]);
@@ -69,22 +48,55 @@ class Authentication
return $key == $reset_key;
}
/**
* 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;
}
public static function consumeResetKey($id_user)
{
return Registry::get('db')->query('
UPDATE users
SET reset_key = NULL,
reset_blocked_until = NULL
WHERE id_user = :id_user',
['id_user' => $id_user]);
}
public static function getResetTimeOut($id_user)
{
$resetTime = Registry::get('db')->queryValue('
SELECT reset_blocked_until
FROM users
WHERE id_user = :id_user',
['id_user' => $id_user]);
return max(0, $resetTime - time());
}
/**
* 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'])))
if (!isset($_SESSION['user_id']))
return false;
try
{
$exists = Member::fromId($_SESSION['user_id']);
return true;
}
catch (NotFoundException $e)
{
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']);
}
/**
@@ -99,36 +111,17 @@ class Authentication
return $string;
}
/**
* Checks a password for a given username against the database.
*/
public static function checkPassword($emailaddress, $password)
public static function setResetKey($id_user)
{
// Retrieve password hash for user matching the provided emailaddress.
$password_hash = Registry::get('db')->queryValue('
SELECT password_hash
FROM users
WHERE emailaddress = {string:emailaddress}',
return Registry::get('db')->query('
UPDATE users
SET reset_key = :key,
reset_blocked_until = UNIX_TIMESTAMP() + ' . static::DEFAULT_RESET_TIMEOUT . '
WHERE id_user = :id',
[
'emailaddress' => $emailaddress,
'id' => $id_user,
'key' => self::newActivationKey(),
]);
// 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;
}
/**
@@ -139,13 +132,35 @@ class Authentication
return Registry::get('db')->query('
UPDATE users
SET
password_hash = {string:hash},
reset_key = {string:blank}
WHERE id_user = {int:id_user}',
password_hash = :hash,
reset_key = :blank
WHERE id_user = :id_user',
[
'id_user' => $id_user,
'hash' => $hash,
'blank' => '',
]);
}
public static function updateResetTimeOut($id_user)
{
$currentResetTimeOut = static::getResetTimeOut($id_user);
// New timeout: between 30 seconds, double the current timeout, and a full day
$newResetTimeOut = min(max(static::DEFAULT_RESET_TIMEOUT, $currentResetTimeOut * 2), 60 * 60 * 24);
$success = Registry::get('db')->query('
UPDATE users
SET reset_blocked_until = :new_time_out
WHERE id_user = :id_user',
[
'id_user' => $id_user,
'new_time_out' => time() + $newResetTimeOut,
]);
if (!$success)
throw new UnexpectedValueException('Could not set password reset timeout!');
return $newResetTimeOut;
}
}

View File

@@ -0,0 +1,56 @@
<?php
/*****************************************************************************
* CachedPDOIterator.php
* Contains model class CachedPDOIterator.
*
* Based on https://gist.github.com/hakre/5152090
*
* Kabuki CMS (C) 2013-2021, Aaron van Geffen
*****************************************************************************/
class CachedPDOIterator extends CachingIterator
{
private $index;
public function __construct(PDOStatement $statement)
{
parent::__construct(new IteratorIterator($statement), self::FULL_CACHE);
}
public function rewind(): void
{
if ($this->index === null)
{
parent::rewind();
}
$this->index = 0;
}
public function current(): mixed
{
if ($this->offsetExists($this->index))
{
return $this->offsetGet($this->index);
}
return parent::current();
}
public function key(): mixed
{
return $this->index;
}
public function next(): void
{
$this->index++;
if (!$this->offsetExists($this->index))
{
parent::next();
}
}
public function valid(): bool
{
return $this->offsetExists($this->index) || parent::valid();
}
}

View File

@@ -1,44 +1,34 @@
<?php
/*****************************************************************************
* Database.php
* Contains key class Database.
* Contains model class Database.
*
* Adapted from SMF 2.0's DBA (C) 2011 Simple Machines
* Used under BSD 3-clause license.
*
* Kabuki CMS (C) 2013-2015, Aaron van Geffen
* Kabuki CMS (C) 2013-2025, Aaron van Geffen
*****************************************************************************/
/**
* The database model used to communicate with the MySQL server.
*/
class Database
{
private $connection;
private $query_count = 0;
private $logged_queries = [];
private array $db_callback;
/**
* Initialises a new database connection.
* @param server: server to connect to.
* @param user: username to use for authentication.
* @param password: password to use for authentication.
* @param name: database to select.
*/
public function __construct($server, $user, $password, $name)
public function __construct($host, $user, $password, $name)
{
$this->connection = @mysqli_connect($server, $user, $password, $name);
// Give up if we have a connection error.
if (mysqli_connect_error())
try
{
header('HTTP/1.1 503 Service Temporarily Unavailable');
$this->connection = new PDO("mysql:host=$host;dbname=$name;charset=utf8mb4", $user, $password, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
// Give up if we have a connection error.
catch (PDOException $e)
{
http_response_code(503);
echo '<h2>Database Connection Problems</h2><p>Our software could not connect to the database. We apologise for any inconvenience and ask you to check back later.</p>';
exit;
}
$this->query('SET NAMES {string:utf8mb4}', ['utf8mb4' => 'utf8mb4']);
}
public function getQueryCount()
@@ -52,305 +42,227 @@ class Database
}
/**
* Fetches a row from a given recordset, using field names as keys.
* Fetches a row from a given statement/recordset, using field names as keys.
*/
public function fetch_assoc($resource)
public function fetchAssoc($stmt)
{
return mysqli_fetch_assoc($resource);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
/**
* Fetches a row from a given recordset, using numeric keys.
* Fetches a row from a given statement/recordset, encapsulating into an object.
*/
public function fetch_row($resource)
public function fetchObject($stmt, $class)
{
return mysqli_fetch_row($resource);
return $stmt->fetchObject($class);
}
/**
* Destroys a given recordset.
* Fetches a row from a given statement/recordset, using numeric keys.
*/
public function free_result($resource)
public function fetchNum($stmt)
{
return mysqli_free_result($resource);
}
public function data_seek($result, $row_num)
{
return mysqli_data_seek($result, $row_num);
return $stmt->fetch(PDO::FETCH_NUM);
}
/**
* Returns the amount of rows in a given recordset.
* Destroys a given statement/recordset.
*/
public function num_rows($resource)
public function free($stmt)
{
return mysqli_num_rows($resource);
return $stmt->closeCursor();
}
/**
* Returns the amount of fields in a given recordset.
* Returns the amount of rows in a given statement/recordset.
*/
public function num_fields($resource)
public function rowCount($stmt)
{
return mysqli_num_fields($resource);
return $stmt->rowCount();
}
/**
* Escapes a string.
* Returns the amount of fields in a given statement/recordset.
*/
public function escape_string($string)
public function columnCount($stmt)
{
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);
return $stmt->columnCount();
}
/**
* Returns the id of the row created by a previous query.
*/
public function insert_id()
public function insertId($name = null)
{
return mysqli_insert_id($this->connection);
return $this->connection->lastInsertId($name);
}
/**
* Do a MySQL transaction.
* Start a transaction.
*/
public function transaction($operation = 'commit')
public function beginTransaction()
{
switch ($operation)
{
case 'begin':
case 'rollback':
case 'commit':
return @mysqli_query($this->connection, strtoupper($operation));
default:
return false;
}
return $this->connection->beginTransaction();
}
/**
* Function used as a callback for the preg_match function that parses variables into database queries.
* Rollback changes in a transaction.
*/
private function replacement_callback($matches)
public function rollback()
{
list ($values, $connection) = $this->db_callback;
return $this->connection->rollBack();
}
if (!isset($matches[2]))
trigger_error('Invalid value inserted or no type specified.', E_USER_ERROR);
/**
* Commit changes in a transaction.
*/
public function commit()
{
return $this->connection->commit();
}
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])
private function expandPlaceholders($db_string, array &$db_values)
{
foreach ($db_values as $key => &$value)
{
case 'int':
if ((!is_numeric($replacement) || (string) $replacement !== (string) (int) $replacement) && $replacement !== 'NULL')
trigger_error('Wrong value type sent to the database for field: ' . $matches[2] . '. 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 (str_contains($db_string, ':' . $key))
{
if (is_array($value))
{
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 for field: ' . $matches[2] . '. Array of integers expected.', E_USER_ERROR);
$replacement[$key] = (string) (int) $value;
}
return implode(', ', $replacement);
throw new UnexpectedValueException('Array ' . $key .
' is used as a scalar placeholder. Did you mean to use \'@\' instead?');
}
else
trigger_error('Wrong value type sent to the database for field: ' . $matches[2] . '. Array of integers expected.', E_USER_ERROR);
break;
case 'array_string':
if (is_array($replacement))
// Prepare date/time values
if (is_a($value, 'DateTime'))
{
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);
$value = $value->format('Y-m-d H:i:s');
}
}
elseif (str_contains($db_string, '@' . $key))
{
if (!is_array($value))
{
throw new UnexpectedValueException('Scalar value ' . $key .
' is used as an array placeholder. Did you mean to use \':\' instead?');
}
else
trigger_error('Wrong value type sent to the database for field: ' . $matches[2] . '. 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 for field: ' . $matches[2] . '. 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 for field: ' . $matches[2] . '. DateTime expected.', E_USER_ERROR);
break;
case 'float':
if (!is_numeric($replacement) && $replacement !== 'NULL')
trigger_error('Wrong value type sent to the database for field: ' . $matches[2] . '. 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, ['`' => '', '.' => '']) . '`';
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 <b>' . $matches[1] . '</b> used in the database query', E_USER_ERROR);
break;
// Create placeholders for all array elements
$placeholders = array_map(fn($num) => ':' . $key . $num, range(0, count($value) - 1));
$db_string = str_replace('@' . $key, implode(', ', $placeholders), $db_string);
}
else
{
// throw new Exception('Warning: unused key in query: ' . $key);
}
}
}
/**
* Escapes and quotes a string using values passed, and executes the query.
*/
public function query($db_string, $db_values = [])
{
// 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 = [$db_values, $this->connection];
// Insert the values passed to this function.
$db_string = preg_replace_callback('~{([a-z_]+)(?::([a-zA-Z0-9_-]+))?}~', [&$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() . '<br>' . $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 = [])
{
// 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 = [$db_values, $this->connection];
// Insert the values passed to this function.
$db_string = preg_replace_callback('~{([a-z_]+)(?::([a-zA-Z0-9_-]+))?}~', [&$this, 'replacement_callback'], $db_string);
// Save some memory.
$this->db_callback = [];
return $db_string;
}
/**
* Executes a query, returning an array of all the rows it returns.
* Escapes and quotes a string using values passed, and executes the query.
*/
public function queryRow($db_string, $db_values = [])
public function query($db_string, array $db_values = []): PDOStatement
{
// One more query...
$this->query_count++;
// Error out if hardcoded strings are detected
if (strpos($db_string, '\'') !== false)
throw new UnexpectedValueException('Hack attempt: illegal character (\') used in query.');
if (defined('DB_LOG_QUERIES') && DB_LOG_QUERIES)
$this->logged_queries[] = $db_string;
try
{
// Preprocessing/checks: prepare any arrays for binding
$db_string = $this->expandPlaceholders($db_string, $db_values);
// Prepare query for execution
$statement = $this->connection->prepare($db_string);
// Bind parameters... the hard way, due to a limit/offset hack.
// NB: bindParam binds by reference, hence &$value here.
foreach ($db_values as $key => &$value)
{
// Assumption: both scalar and array values are preprocessed to use named ':' placeholders
if (!str_contains($db_string, ':' . $key))
continue;
if (!is_array($value))
{
$statement->bindParam(':' . $key, $value);
continue;
}
foreach (array_values($value) as $num => &$element)
{
$statement->bindParam(':' . $key . $num, $element);
}
}
$statement->execute();
return $statement;
}
catch (PDOException $e)
{
ob_start();
$debug = ob_get_clean();
throw new Exception($e->getMessage() . "\n" . var_export($e->errorInfo, true) . "\n" . var_export($db_values, true));
}
}
/**
* Executes a query, returning an object of the row it returns.
*/
public function queryObject($class, $db_string, $db_values = [])
{
$res = $this->query($db_string, $db_values);
if (!$res || $this->num_rows($res) == 0)
if (!$res || $this->rowCount($res) === 0)
return null;
$object = $this->fetchObject($res, $class);
$this->free($res);
return $object;
}
/**
* Executes a query, returning an array of objects of all the rows returns.
*/
public function queryObjects($class, $db_string, $db_values = [])
{
$res = $this->query($db_string, $db_values);
if (!$res || $this->rowCount($res) === 0)
return [];
$row = $this->fetch_row($res);
$this->free_result($res);
$rows = [];
while ($object = $this->fetchObject($res, $class))
$rows[] = $object;
$this->free($res);
return $rows;
}
/**
* Executes a query, returning an array of all the rows it returns.
*/
public function queryRow($db_string, array $db_values = [])
{
$res = $this->query($db_string, $db_values);
if ($this->rowCount($res) === 0)
return [];
$row = $this->fetchNum($res);
$this->free($res);
return $row;
}
@@ -358,18 +270,18 @@ class Database
/**
* Executes a query, returning an array of all the rows it returns.
*/
public function queryRows($db_string, $db_values = [])
public function queryRows($db_string, array $db_values = [])
{
$res = $this->query($db_string, $db_values);
if (!$res || $this->num_rows($res) == 0)
if ($this->rowCount($res) === 0)
return [];
$rows = [];
while ($row = $this->fetch_row($res))
while ($row = $this->fetchNum($res))
$rows[] = $row;
$this->free_result($res);
$this->free($res);
return $rows;
}
@@ -377,18 +289,18 @@ class Database
/**
* Executes a query, returning an array of all the rows it returns.
*/
public function queryPair($db_string, $db_values = [])
public function queryPair($db_string, array $db_values = [])
{
$res = $this->query($db_string, $db_values);
if (!$res || $this->num_rows($res) == 0)
if ($this->rowCount($res) === 0)
return [];
$rows = [];
while ($row = $this->fetch_row($res))
while ($row = $this->fetchNum($res))
$rows[$row[0]] = $row[1];
$this->free_result($res);
$this->free($res);
return $rows;
}
@@ -396,21 +308,21 @@ class Database
/**
* Executes a query, returning an array of all the rows it returns.
*/
public function queryPairs($db_string, $db_values = [])
public function queryPairs($db_string, $db_values = array())
{
$res = $this->query($db_string, $db_values);
if (!$res || $this->num_rows($res) == 0)
if (!$res || $this->rowCount($res) === 0)
return [];
$rows = [];
while ($row = $this->fetch_assoc($res))
while ($row = $this->fetchAssoc($res))
{
$key_value = reset($row);
$rows[$key_value] = $row;
}
$this->free_result($res);
$this->free($res);
return $rows;
}
@@ -418,15 +330,15 @@ class Database
/**
* Executes a query, returning an associative array of all the rows it returns.
*/
public function queryAssoc($db_string, $db_values = [])
public function queryAssoc($db_string, array $db_values = [])
{
$res = $this->query($db_string, $db_values);
if (!$res || $this->num_rows($res) == 0)
if ($this->rowCount($res) === 0)
return [];
$row = $this->fetch_assoc($res);
$this->free_result($res);
$row = $this->fetchAssoc($res);
$this->free($res);
return $row;
}
@@ -434,18 +346,18 @@ class Database
/**
* Executes a query, returning an associative array of all the rows it returns.
*/
public function queryAssocs($db_string, $db_values = [], $connection = null)
public function queryAssocs($db_string, array $db_values = [])
{
$res = $this->query($db_string, $db_values);
if (!$res || $this->num_rows($res) == 0)
if ($this->rowCount($res) === 0)
return [];
$rows = [];
while ($row = $this->fetch_assoc($res))
while ($row = $this->fetchAssoc($res))
$rows[] = $row;
$this->free_result($res);
$this->free($res);
return $rows;
}
@@ -453,16 +365,16 @@ class Database
/**
* Executes a query, returning the first value of the first row.
*/
public function queryValue($db_string, $db_values = [])
public function queryValue($db_string, array $db_values = [])
{
$res = $this->query($db_string, $db_values);
// If this happens, you're doing it wrong.
if (!$res || $this->num_rows($res) == 0)
if ($this->rowCount($res) === 0)
return null;
list($value) = $this->fetch_row($res);
$this->free_result($res);
list($value) = $this->fetchNum($res);
$this->free($res);
return $value;
}
@@ -470,18 +382,18 @@ class Database
/**
* Executes a query, returning an array of the first value of each row.
*/
public function queryValues($db_string, $db_values = [])
public function queryValues($db_string, array $db_values = [])
{
$res = $this->query($db_string, $db_values);
if (!$res || $this->num_rows($res) == 0)
if ($this->rowCount($res) === 0)
return [];
$rows = [];
while ($row = $this->fetch_row($res))
while ($row = $this->fetchNum($res))
$rows[] = $row[0];
$this->free_result($res);
$this->free($res);
return $rows;
}
@@ -499,35 +411,45 @@ class Database
if (!is_array($data[array_rand($data)]))
$data = [$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 = [];
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');
$method = $method == 'replace' ? 'REPLACE' : ($method == 'ignore' ? 'INSERT IGNORE' : 'INSERT');
// Do the insert.
return $this->query('
' . $queryTitle . ' INTO ' . $table . ' (`' . implode('`, `', $indexed_columns) . '`)
VALUES
' . implode(',
', $insertRows),
['security_override' => true]);
// What columns are we inserting?
$columns = array_keys($data[0]);
// Start building the query.
$db_string = $method . ' INTO ' . $table . ' (' . implode(',', $columns) . ') VALUES ';
// Create the mold for a single row insert.
$placeholders = '(' . substr(str_repeat('?, ', count($columns)), 0, -2) . '), ';
// Append it for every row we're to insert.
$values = [];
foreach ($data as $row)
{
$values = array_merge($values, array_values($row));
$db_string .= $placeholders;
}
// Get rid of the tailing comma.
$db_string = substr($db_string, 0, -2);
// Prepare for your impending demise!
$statement = $this->connection->prepare($db_string);
// Bind parameters... the hard way, due to a limit/offset hack.
foreach ($values as $key => $value)
$statement->bindValue($key + 1, $values[$key]);
// Handle errors.
try
{
$statement->execute();
return $statement;
}
catch (PDOException $e)
{
throw new Exception($e->getMessage() . '<br><br>' . $db_string . '<br><br>' . print_r($values, true));
}
}
}

View File

@@ -44,6 +44,19 @@ class Dispatcher
}
}
public static function errorPage($title, $body)
{
$page = new MainTemplate($title);
$page->adopt(new ErrorPage($title, $body));
if (Registry::get('user')->isAdmin())
{
$page->appendStylesheet(BASEURL . '/css/admin.css');
}
$page->html_main();
}
/**
* Kicks a guest to a login form, redirecting them back to this page upon login.
*/
@@ -60,37 +73,24 @@ class Dispatcher
exit;
}
public static function trigger400()
private static function trigger400()
{
header('HTTP/1.1 400 Bad Request');
$page = new MainTemplate('Bad request');
$page->adopt(new DummyBox('Bad request', '<p>The server does not understand your request.</p>'));
$page->html_main();
http_response_code(400);
self::errorPage('Bad request', 'The server does not understand your request.');
exit;
}
public static function trigger403()
private static function trigger403()
{
header('HTTP/1.1 403 Forbidden');
$page = new MainTemplate('Access denied');
$page->adopt(new DummyBox('Forbidden', '<p>You do not have access to the page you requested.</p>'));
$page->html_main();
http_response_code(403);
self::errorPage('Forbidden', 'You do not have access to this page.');
exit;
}
public static function trigger404()
private 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 DummyBox('Well, this is a bit embarrassing!', '<p>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!</p>', 'errormsg'));
$page->addClass('errorpage');
$page->html_main();
exit;
http_response_code(404);
$page = new ViewErrorPage('Page not found!');
$page->showContent();
}
}

View File

@@ -90,7 +90,9 @@ class EXIF
if (!empty($exif['Model']))
{
if (!empty($exif['Make']) && strpos($exif['Model'], $exif['Make']) === false)
if (strpos($exif['Model'], 'PENTAX') !== false)
$meta['camera'] = trim($exif['Model']);
elseif (!empty($exif['Make']) && strpos($exif['Model'], $exif['Make']) === false)
$meta['camera'] = trim($exif['Make']) . ' ' . trim($exif['Model']);
else
$meta['camera'] = trim($exif['Model']);

View File

@@ -69,7 +69,7 @@ class Email
$row = Registry::get('db')->queryAssoc('
SELECT first_name, surname, emailaddress, reset_key
FROM users
WHERE id_user = {int:id_user}',
WHERE id_user = :id_user',
[
'id_user' => $id_user,
]);

View File

@@ -3,7 +3,7 @@
* ErrorHandler.php
* Contains key class ErrorHandler.
*
* Kabuki CMS (C) 2013-2016, Aaron van Geffen
* Kabuki CMS (C) 2013-2025, Aaron van Geffen
*****************************************************************************/
class ErrorHandler
@@ -47,10 +47,8 @@ class ErrorHandler
// 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 . ')<br>' . $error_message, $debug_info);
// Display error and exit.
self::display($error_message, $file, $line, $debug_info);
// If it wasn't a fatal error, well...
self::$handling_error = false;
@@ -118,7 +116,7 @@ class ErrorHandler
}
// Logs an error into the database.
private static function logError($error_message = '', $debug_info = '', $file = '', $line = 0)
public static function logError($error_message = '', $debug_info = '', $file = '', $line = 0)
{
if (!ErrorLog::log([
'message' => $error_message,
@@ -130,15 +128,15 @@ class ErrorHandler
'request_uri' => isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '',
]))
{
header('HTTP/1.1 503 Service Temporarily Unavailable');
echo '<h2>An Error Occured</h2><p>Our software could not connect to the database. We apologise for any inconvenience and ask you to check back later.</p>';
http_response_code(503);
echo '<h2>An Error Occurred</h2><p>Our software could not connect to the database. We apologise for any inconvenience and ask you to check back later.</p>';
exit;
}
return $error_message;
}
public static function display($message, $debug_info, $is_sensitive = true)
public static function display($message, $file, $line, $debug_info, $is_sensitive = true)
{
$is_admin = Registry::has('user') && Registry::get('user')->isAdmin();
@@ -156,18 +154,19 @@ class ErrorHandler
elseif (!$is_sensitive)
echo json_encode(['error' => $message]);
else
echo json_encode(['error' => 'Our apologies, an error occured while we were processing your request. Please try again later, or contact us if the problem persists.']);
echo json_encode(['error' => 'Our apologies, an error occurred while we were processing your request. Please try again later, or contact us if the problem persists.']);
exit;
}
// Initialise the main template to present a nice message to the user.
$page = new MainTemplate('An error occured!');
$page = new MainTemplate('An error occurred!');
// Show the error.
$is_admin = Registry::has('user') && Registry::get('user')->isAdmin();
if (DEBUG || $is_admin)
{
$page->adopt(new DummyBox('An error occured!', '<p>' . $message . '</p><pre>' . $debug_info . '</pre>'));
$debug_info = sprintf("Trigger point:\n%s (L%d)\n\n%s", $file, $line, $debug_info);
$page->adopt(new ErrorPage('An error occurred!', $message, $debug_info));
// Let's provide the admin navigation despite it all!
if ($is_admin)
@@ -176,9 +175,9 @@ class ErrorHandler
}
}
elseif (!$is_sensitive)
$page->adopt(new DummyBox('An error occured!', '<p>' . $message . '</p>'));
$page->adopt(new ErrorPage('An error occurred!', '<p>' . $message . '</p>'));
else
$page->adopt(new DummyBox('An error occured!', '<p>Our apologies, an error occured while we were processing your request. Please try again later, or contact us if the problem persists.</p>'));
$page->adopt(new ErrorPage('An error occurred!', 'Our apologies, an error occurred 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();

View File

@@ -17,14 +17,14 @@ class ErrorLog
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})',
(:id_user, :message, :debug_info, :file, :line,
:request_uri, CURRENT_TIMESTAMP, :ip_address)',
$data);
}
public static function flush()
{
return Registry::get('db')->query('TRUNCATE log_errors');
return Registry::get('db')->query('DELETE FROM log_errors');
}
public static function getCount()
@@ -33,4 +33,20 @@ class ErrorLog
SELECT COUNT(*)
FROM log_errors');
}
public static function getOffset($offset, $limit, $order, $direction)
{
assert(in_array($order, ['id_entry', 'file', 'line', 'time', 'ipaddress', 'id_user']));
$order = $order . ($direction === 'up' ? ' ASC' : ' DESC');
return Registry::get('db')->queryAssocs('
SELECT *
FROM log_errors
ORDER BY ' . $order . '
LIMIT :offset, :limit',
[
'offset' => $offset,
'limit' => $limit,
]);
}
}

View File

@@ -10,24 +10,36 @@ class Form
{
public $request_method;
public $request_url;
public $content_above;
public $content_below;
private $fields = [];
public $before_fields;
public $after_fields;
private $submit_caption;
public $buttons_extra;
private $trim_inputs;
private $data = [];
private $missing = [];
private $submit_caption;
private $trim_inputs;
// NOTE: this class does not verify the completeness of form options.
public function __construct($options)
{
$this->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;
$this->submit_caption = !empty($options['submit_caption']) ? $options['submit_caption'] : 'Save information';
$this->trim_inputs = !empty($options['trim_inputs']);
static $optionKeys = [
'request_method' => 'POST',
'request_url' => BASEURL,
'fields' => [],
'before_fields' => null,
'after_fields' => null,
'submit_caption' => 'Save information',
'buttons_extra' => null,
'trim_inputs' => true,
];
foreach ($optionKeys as $optionKey => $default)
$this->$optionKey = !empty($options[$optionKey]) ? $options[$optionKey] : $default;
}
public function getFields()

View File

@@ -15,7 +15,6 @@ class GenericTable
private $title;
private $title_class;
private $tableIsSortable = false;
public $form_above;
public $form_below;
@@ -29,58 +28,22 @@ class GenericTable
public function __construct($options)
{
// Make sure we're actually sorting on something sortable.
if (!isset($options['sort_order']) || (!empty($options['sort_order']) && empty($options['columns'][$options['sort_order']]['is_sortable'])))
$options['sort_order'] = '';
$this->initOrder($options);
$this->initPagination($options);
// Order in which direction?
if (!empty($options['sort_direction']) && !in_array($options['sort_direction'], ['up', 'down']))
$options['sort_direction'] = 'up';
// Make sure we know whether we can actually sort on something.
$this->tableIsSortable = !empty($options['base_url']);
// How much data do we have?
$this->recordCount = $options['get_count'](...(!empty($options['get_count_params']) ? $options['get_count_params'] : []));
// How much data do we need to retrieve?
$this->items_per_page = !empty($options['items_per_page']) ? $options['items_per_page'] : 30;
// Figure out where to start.
$this->start = empty($options['start']) || !is_numeric($options['start']) || $options['start'] < 0 || $options['start'] > $this->recordCount ? 0 : $options['start'];
// Figure out where we are on the whole, too.
$numPages = max(1, ceil($this->recordCount / $this->items_per_page));
$this->currentPage = min(ceil($this->start / $this->items_per_page) + 1, $numPages);
// Let's bear a few things in mind...
$this->base_url = $options['base_url'];
// Gather parameters for the data gather function first.
$parameters = [$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 = $options['get_data'](...$parameters);
// Extract data into local variables.
$rawRowData = $data['rows'];
$this->sort_order = $data['order'];
$this->sort_direction = $data['direction'];
unset($data);
$data = $options['get_data']($this->start, $this->items_per_page,
$this->sort_order, $this->sort_direction);
// Okay, now for the column headers...
$this->generateColumnHeaders($options);
// Should we create a page index?
$needsPageIndex = !empty($this->items_per_page) && $this->recordCount > $this->items_per_page;
if ($needsPageIndex)
if ($this->recordCount > $this->items_per_page)
$this->generatePageIndex($options);
// Process the data to be shown into rows.
if (!empty($rawRowData))
$this->processAllRows($rawRowData, $options);
if (!empty($data))
$this->processAllRows($data, $options);
else
$this->body = $options['no_items_label'] ?? '';
@@ -95,6 +58,38 @@ class GenericTable
$this->form_below = $options['form_below'] ?? $options['form'] ?? null;
}
private function initOrder($options)
{
assert(isset($options['default_sort_order']));
assert(isset($options['default_sort_direction']));
// Validate sort order (column)
$this->sort_order = $options['sort_order'];
if (empty($this->sort_order) || empty($options['columns'][$this->sort_order]['is_sortable']))
$this->sort_order = $options['default_sort_order'];
// Validate sort direction
$this->sort_direction = $options['sort_direction'];
if (empty($this->sort_direction) || !in_array($this->sort_direction, ['up', 'down']))
$this->sort_direction = $options['default_sort_direction'];
}
private function initPagination(array $options)
{
assert(isset($options['base_url']));
assert(isset($options['items_per_page']));
$this->base_url = $options['base_url'];
$this->recordCount = $options['get_count']();
$this->items_per_page = !empty($options['items_per_page']) ? $options['items_per_page'] : 30;
$this->start = empty($options['start']) || !is_numeric($options['start']) || $options['start'] < 0 || $options['start'] > $this->recordCount ? 0 : $options['start'];
$numPages = max(1, ceil($this->recordCount / $this->items_per_page));
$this->currentPage = min(ceil($this->start / $this->items_per_page) + 1, $numPages);
}
private function generateColumnHeaders($options)
{
foreach ($options['columns'] as $key => $column)
@@ -102,14 +97,14 @@ class GenericTable
if (empty($column['header']))
continue;
$isSortable = $this->tableIsSortable && !empty($column['is_sortable']);
$isSortable = !empty($column['is_sortable']);
$sortDirection = $key == $this->sort_order && $this->sort_direction === 'up' ? 'down' : 'up';
$header = [
'class' => isset($column['class']) ? $column['class'] : '',
'cell_class' => isset($column['cell_class']) ? $column['cell_class'] : null,
'colspan' => !empty($column['header_colspan']) ? $column['header_colspan'] : 1,
'href' => $isSortable ? $this->getLink($this->start, $key, $sortDirection) : null,
'href' => $isSortable ? $this->getHeaderLink($this->start, $key, $sortDirection) : null,
'label' => $column['header'],
'scope' => 'col',
'sort_mode' => $key == $this->sort_order ? $this->sort_direction : null,
@@ -126,7 +121,7 @@ class GenericTable
'base_url' => $this->base_url,
'index_class' => $options['index_class'] ?? '',
'items_per_page' => $this->items_per_page,
'linkBuilder' => [$this, 'getLink'],
'linkBuilder' => [$this, 'getHeaderLink'],
'recordCount' => $this->recordCount,
'sort_direction' => $this->sort_direction,
'sort_order' => $this->sort_order,
@@ -134,7 +129,7 @@ class GenericTable
]);
}
public function getLink($start = null, $order = null, $dir = null)
public function getHeaderLink($start = null, $order = null, $dir = null)
{
if ($start === null)
$start = $this->start;
@@ -196,12 +191,18 @@ class GenericTable
foreach ($options['columns'] as $column)
{
// Process data for this particular cell.
if (isset($column['parse']))
$value = self::processCell($column['parse'], $row);
// Process formatting
if (isset($column['format']) && is_callable($column['format']))
$value = $column['format']($row);
elseif (isset($column['format']))
$value = self::processFormatting($column['format'], $row);
else
$value = $row[$column['value']];
// Turn value into a link?
if (!empty($column['link']))
$value = $this->processLink($column['link'], $value, $row);
// Append the cell to the row.
$newRow['cells'][] = [
'class' => $column['cell_class'] ?? '',
@@ -214,68 +215,47 @@ class GenericTable
}
}
private function processCell($options, $rowData)
private function processFormatting($options, $rowData)
{
if (!isset($options['type']))
$options['type'] = 'value';
// Parse the basic value first.
switch ($options['type'])
if ($options['type'] === 'timestamp')
{
// Basic option: simply take a use a particular data property.
case 'value':
$value = htmlspecialchars($rowData[$options['data']]);
break;
if (empty($options['pattern']) || $options['pattern'] === 'long')
$pattern = 'Y-m-d H:i';
elseif ($options['pattern'] === 'short')
$pattern = 'Y-m-d';
else
$pattern = $options['pattern'];
// Processing via a lambda function.
case 'function':
$value = $options['data']($rowData);
break;
assert(array_key_exists($options['value'], $rowData));
if (isset($rowData[$options['value']]) && !is_numeric($rowData[$options['value']]))
$timestamp = strtotime($rowData[$options['value']]);
else
$timestamp = (int) $rowData[$options['value']];
// Using sprintf to fill out a particular pattern.
case 'sprintf':
$parameters = [$options['data']['pattern']];
foreach ($options['data']['arguments'] as $identifier)
$parameters[] = $rowData[$identifier];
if (isset($options['if_null']) && $timestamp == 0)
$value = $options['if_null'];
else
$value = date($pattern, $timestamp);
$value = sprintf(...$parameters);
break;
// Timestamps get custom treatment.
case 'timestamp':
if (empty($options['data']['pattern']) || $options['data']['pattern'] === 'long')
$pattern = 'Y-m-d H:i';
elseif ($options['data']['pattern'] === 'short')
$pattern = 'Y-m-d';
else
$pattern = $options['data']['pattern'];
if (!isset($rowData[$options['data']['timestamp']]))
$timestamp = 0;
elseif (!is_numeric($rowData[$options['data']['timestamp']]))
$timestamp = strtotime($rowData[$options['data']['timestamp']]);
else
$timestamp = (int) $rowData[$options['data']['timestamp']];
if (isset($options['data']['if_null']) && $timestamp == 0)
$value = $options['data']['if_null'];
else
$value = date($pattern, $timestamp);
break;
return $value;
}
else
throw ValueError('Unexpected formatter type: ' . $options['type']);
}
// Generate a link, if requested.
if (!empty($options['link']))
{
// First, generate the replacement variables.
$keys = array_keys($rowData);
$values = array_values($rowData);
foreach ($keys as $keyKey => $keyValue)
$keys[$keyKey] = '{' . strtoupper($keyValue) . '}';
private function processLink($template, $value, array $rowData)
{
$href = $this->rowReplacements($template, $rowData);
return '<a href="' . $href . '">' . $value . '</a>';
}
$value = '<a href="' . str_replace($keys, $values, $options['link']) . '">' . $value . '</a>';
}
private function rowReplacements($template, array $rowData)
{
$keys = array_keys($rowData);
$values = array_values($rowData);
foreach ($keys as $keyKey => $keyValue)
$keys[$keyKey] = '{' . strtoupper($keyValue) . '}';
return $value;
return str_replace($keys, $values, $template);
}
}

View File

@@ -12,12 +12,6 @@ class Image extends Asset
const TYPE_LANDSCAPE = 2;
const TYPE_PORTRAIT = 4;
protected function __construct(array $data)
{
foreach ($data as $attribute => $value)
$this->$attribute = $value;
}
public static function fromId($id_asset, $return_format = 'object')
{
$asset = parent::fromId($id_asset, 'array');
@@ -145,6 +139,16 @@ class Image extends Asset
return $ratio >= 1 && $ratio <= 2;
}
public function getType()
{
if ($this->isPortrait())
return self::TYPE_PORTRAIT;
elseif ($this->isPanorama())
return self::TYPE_PANORAMA;
else
return self::TYPE_LANDSCAPE;
}
public function getThumbnails()
{
return $this->thumbnails;
@@ -160,9 +164,8 @@ class Image extends Asset
}
return Registry::get('db')->query('
UPDATE assets_thumbs
SET filename = NULL
WHERE id_asset = {int:id_asset}',
DELETE FROM assets_thumbs
WHERE id_asset = :id_asset',
['id_asset' => $this->id_asset]);
}
@@ -180,9 +183,9 @@ class Image extends Asset
return Registry::get('db')->query('
DELETE FROM assets_thumbs
WHERE id_asset = {int:id_asset} AND
width = {int:width} AND
height = {int:height}',
WHERE id_asset = :id_asset AND
width = :width AND
height = :height',
[
'height' => $height,
'id_asset' => $this->id_asset,

View File

@@ -8,7 +8,7 @@
class Member extends User
{
private function __construct($data)
private function __construct($data = [])
{
foreach ($data as $key => $value)
$this->$key = $value;
@@ -18,12 +18,21 @@ class Member extends User
$this->is_admin = $this->is_admin == 1;
}
public static function fromEmailAddress($email_address)
{
return Registry::get('db')->queryObject(static::class, '
SELECT *
FROM users
WHERE emailaddress = :email_address',
['email_address' => $email_address]);
}
public static function fromId($id_user)
{
$row = Registry::get('db')->queryAssoc('
SELECT *
FROM users
WHERE id_user = {int:id_user}',
WHERE id_user = :id_user',
[
'id_user' => $id_user,
]);
@@ -40,7 +49,7 @@ class Member extends User
$row = Registry::get('db')->queryAssoc('
SELECT *
FROM users
WHERE slug = {string:slug}',
WHERE slug = :slug',
[
'slug' => $slug,
]);
@@ -68,6 +77,7 @@ class Member extends User
'creation_time' => time(),
'ip_address' => isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '',
'is_admin' => empty($data['is_admin']) ? 0 : 1,
'reset_key' => '',
];
if ($error)
@@ -83,12 +93,13 @@ class Member extends User
'creation_time' => 'int',
'ip_address' => 'string-45',
'is_admin' => 'int',
'reset_key' => 'string-16'
], $new_user, ['id_user']);
if (!$bool)
return false;
$new_user['id_user'] = $db->insert_id();
$new_user['id_user'] = $db->insertId();
$member = new Member($new_user);
return $member;
@@ -116,14 +127,14 @@ class Member extends User
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}',
$params);
first_name = :first_name,
surname = :surname,
slug = :slug,
emailaddress = :emailaddress,
password_hash = :password_hash,
is_admin = :is_admin
WHERE id_user = :id_user',
get_object_vars($this));
}
/**
@@ -134,7 +145,7 @@ class Member extends User
{
return Registry::get('db')->query('
DELETE FROM users
WHERE id_user = {int:id_user}',
WHERE id_user = :id_user',
['id_user' => $this->id_user]);
}
@@ -149,7 +160,7 @@ class Member extends User
$res = Registry::get('db')->queryValue('
SELECT id_user
FROM users
WHERE emailaddress = {string:emailaddress}',
WHERE emailaddress = :emailaddress',
[
'emailaddress' => $emailaddress,
]);
@@ -165,9 +176,9 @@ class Member extends User
return Registry::get('db')->query('
UPDATE users
SET
last_action_time = {int:now},
ip_address = {string:ip}
WHERE id_user = {int:id}',
last_action_time = :now,
ip_address = :ip
WHERE id_user = :id',
[
'now' => time(),
'id' => $this->id_user,
@@ -187,6 +198,22 @@ class Member extends User
FROM users');
}
public static function getOffset($offset, $limit, $order, $direction)
{
assert(in_array($order, ['id_user', 'surname', 'first_name', 'slug', 'emailaddress', 'last_action_time', 'ip_address', 'is_admin']));
$order = $order . ($direction === 'up' ? ' ASC' : ' DESC');
return Registry::get('db')->queryAssocs('
SELECT *
FROM users
ORDER BY ' . $order . '
LIMIT :offset, :limit',
[
'offset' => $offset,
'limit' => $limit,
]);
}
public function getProps()
{
// We should probably phase out the use of this function, or refactor the access levels of member properties...
@@ -196,7 +223,7 @@ class Member extends User
public static function getMemberMap()
{
return Registry::get('db')->queryPair('
SELECT id_user, CONCAT(first_name, {string:blank}, surname) AS full_name
SELECT id_user, CONCAT(first_name, :blank, surname) AS full_name
FROM users
ORDER BY first_name, surname',
[

View File

@@ -155,24 +155,20 @@ class PageIndex
public function getLink($start = null, $order = null, $dir = null)
{
$url = $this->base_url;
$amp = strpos($this->base_url, '?') ? '&' : '?';
$page = !is_string($start) ? ($start / $this->items_per_page) + 1 : $start;
$url = $this->base_url . str_replace('%PAGE%', $page, $this->page_slug);
if (!empty($start))
{
$page = $start !== '%d' ? ($start / $this->items_per_page) + 1 : $start;
$url .= strtr($this->page_slug, ['%PAGE%' => $page, '%AMP%' => $amp]);
$amp = '&';
}
$urlParams = [];
if (!empty($order))
{
$url .= $amp . 'order=' . $order;
$amp = '&';
}
$urlParams['order'] = $order;
if (!empty($dir))
$urlParams['dir'] = $dir;
if (!empty($urlParams))
{
$url .= $amp . 'dir=' . $dir;
$amp = '&';
$queryString = (strpos($uri, '?') !== false ? '&' : '?');
$queryString .= http_build_query($urlParams);
$url .= $queryString;
}
return $url;

View File

@@ -1,76 +0,0 @@
<?php
/*****************************************************************************
* PhotoAlbum.php
* Contains key class PhotoAlbum.
*
* Kabuki CMS (C) 2013-2015, Aaron van Geffen
*****************************************************************************/
class PhotoAlbum extends Tag
{
public static function getHierarchy($order, $direction)
{
$db = Registry::get('db');
$res = $db->query('
SELECT *
FROM tags
WHERE kind = {string:album}
ORDER BY id_parent, {raw:order}',
[
'order' => $order . ($direction == 'up' ? ' ASC' : ' DESC'),
'album' => 'Album',
]);
$albums_by_parent = [];
while ($row = $db->fetch_assoc($res))
{
if (!isset($albums_by_parent[$row['id_parent']]))
$albums_by_parent[$row['id_parent']] = [];
$albums_by_parent[$row['id_parent']][] = $row + ['children' => []];
}
$albums = self::getChildrenRecursively(0, 0, $albums_by_parent);
$rows = self::flattenChildrenRecursively($albums);
return $rows;
}
private static function getChildrenRecursively($id_parent, $level, &$albums_by_parent)
{
$children = [];
if (!isset($albums_by_parent[$id_parent]))
return $children;
foreach ($albums_by_parent[$id_parent] as $child)
{
if (isset($albums_by_parent[$child['id_tag']]))
$child['children'] = self::getChildrenRecursively($child['id_tag'], $level + 1, $albums_by_parent);
$child['tag'] = ($level ? str_repeat('—', $level * 2) . ' ' : '') . $child['tag'];
$children[] = $child;
}
return $children;
}
private static function flattenChildrenRecursively($albums)
{
if (empty($albums))
return [];
$rows = [];
foreach ($albums as $album)
{
$rows[] = array_intersect_key($album, array_flip(['id_tag', 'tag', 'slug', 'count']));
if (!empty($album['children']))
{
$children = self::flattenChildrenRecursively($album['children']);
foreach ($children as $child)
$rows[] = array_intersect_key($child, array_flip(['id_tag', 'tag', 'slug', 'count']));
}
}
return $rows;
}
}

View File

@@ -8,167 +8,255 @@
class PhotoMosaic
{
private $queue = [];
const NUM_DAYS_CUTOFF = 7;
private bool $descending;
private AssetIterator $iterator;
private array $layouts;
private int $processedImages = 0;
private array $queue = [];
const IMAGE_MASK_ALL = Image::TYPE_PORTRAIT | Image::TYPE_LANDSCAPE | Image::TYPE_PANORAMA;
const NUM_DAYS_CUTOFF = 7;
const NUM_BATCH_PHOTOS = 6;
public function __construct(AssetIterator $iterator)
{
$this->iterator = $iterator;
$this->layouts = $this->availableLayouts();
$this->descending = $iterator->isDescending();
}
public function __destruct()
private function availableLayouts()
{
$this->iterator->clean();
static $layouts = [
// Single panorama
'panorama' => [Image::TYPE_PANORAMA],
// A whopping six landscapes?
'sixLandscapes' => [Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE,
Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE],
// Big-small juxtapositions
'sidePortrait' => [Image::TYPE_PORTRAIT, Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE,
Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE],
'sideLandscape' => [Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE],
// Single row of three
'threeLandscapes' => [Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE],
'threePortraits' => [Image::TYPE_PORTRAIT, Image::TYPE_PORTRAIT, Image::TYPE_PORTRAIT],
// Dual layouts
'dualLandscapes' => [Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE],
'dualPortraits' => [Image::TYPE_PORTRAIT, Image::TYPE_PORTRAIT],
'dualMixed' => [Image::TYPE_LANDSCAPE, Image::TYPE_PORTRAIT],
// Fallback layouts
'singleLandscape' => [Image::TYPE_LANDSCAPE],
'singlePortrait' => [Image::TYPE_PORTRAIT],
];
return $layouts;
}
public static function getRecentPhotos()
private static function daysApart(DateTime $a, DateTime $b)
{
return new self(AssetIterator::getByOptions([
'tag' => 'photo',
'order' => 'date_captured',
'direction' => 'desc',
'limit' => 15, // worst case: 3 rows * (portrait + 4 thumbs)
]));
return $a->diff($b)->days;
}
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)
private function fetchImage($desired_type = self::IMAGE_MASK_ALL, ?DateTime $refDate = null)
{
// First, check if we have what we're looking for in the queue.
foreach ($this->queue as $i => $image)
{
// Give up on the queue once the dates are too far apart
if (isset($refDate) && abs(self::daysApart($image->getDateCaptured(), $refDate)) > self::NUM_DAYS_CUTOFF)
{
break;
}
// 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))
if (self::matchTypeMask($image, $desired_type))
{
unset($this->queue[$i]);
return $image;
}
}
// Check whatever's next up!
while (($asset = $this->iterator->next()) && ($image = $asset->getImage()))
// Check whatever's up next!
// NB: not is not a `foreach` so as to not reset the iterator implicitly
while ($this->iterator->valid())
{
// 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
$asset = $this->iterator->current();
$image = $asset->getImage();
$this->iterator->next();
// Give up on the recordset once dates are too far apart
if (isset($refDate) && abs(self::daysApart($image->getDateCaptured(), $refDate)) > self::NUM_DAYS_CUTOFF)
{
$this->pushToQueue($image);
break;
}
// Image has to match the desired type and be taken within a week of the reference image.
if (self::matchTypeMask($image, $desired_type))
{
return $image;
}
else
{
$this->pushToQueue($image);
}
}
return false;
}
private function pushToQueue(Image $image)
public function fetchImages($num, $refDate = null, $spec = self::IMAGE_MASK_ALL)
{
$this->queue[] = $image;
}
$refDate = null;
$prevImage = true;
$images = [];
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;
for ($i = 0; $i < $num || !$prevImage; $i++)
{
$image = $this->fetchImage($spec, $refDate);
if ($image !== false)
{
$images[] = $image;
$refDate = $image->getDateCaptured();
$prevImage = $image;
}
}
// In other cases, we'll just show the newest first.
return $a->getDateCaptured() <=> $b->getDateCaptured();
}
private static function daysApart(Image $a, Image $b)
{
return $a->getDateCaptured()->diff($b->getDateCaptured())->days;
return $images;
}
public function getRow()
{
// Fetch the first image...
$image = $this->fetchImage();
$requiredImages = array_map('count', $this->layouts);
$currentImages = $this->fetchImages(self::NUM_BATCH_PHOTOS);
$selectedLayout = null;
// No image at all?
if (!$image)
if (empty($currentImages))
{
// Ensure we have no images left in the iterator before giving up
assert($this->processedImages === $this->iterator->num());
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(...));
// Assign fitness score for each layout
$fitnessScores = $this->getScoresByLayout($currentImages);
$scoresByLayout = array_map(fn($el) => $el[0], $fitnessScores);
// Three portraits?
if ($num_portrait === 3)
return [$photos, 'portraits'];
// Select the best-fitting layout
$bestLayouts = array_keys($scoresByLayout, max($scoresByLayout));
$bestLayout = $bestLayouts[0];
$layoutImages = $fitnessScores[$bestLayout][1];
// At least one portrait?
if ($num_portrait >= 1)
// Push any unused back into the queue
if (count($layoutImages) < count($currentImages))
{
// 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::TYPE_PORTRAIT, $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);
$diff = array_udiff($currentImages, $layoutImages, function($a, $b) {
return $a->getId() <=> $b->getId();
});
// 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'];
array_map([$this, 'pushToQueue'], $diff);
}
// One landscape at least, hopefully?
if ($num_landscape >= 1)
// Finally, allow tweaking image order through display priority
usort($layoutImages, [$this, 'orderPhotosByPriority']);
// Done! Return the result
$this->processedImages += count($layoutImages);
return [$layoutImages, $bestLayout];
}
public function getScoreForRow(array $images, array $specs)
{
assert(count($images) === count($specs));
$score = 0;
foreach ($images as $i => $image)
{
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'];
if (self::matchTypeMask($image, $specs[$i]))
$score += 1;
else
return [$photos, 'single'];
$score -= 10;
}
// Last resort: majority vote
if ($num_portrait > $num_landscape)
return [$photos, 'portraits'];
else
return [$photos, 'landscapes'];
return $score;
}
public function getScoresByLayout(array $candidateImages)
{
$fitnessScores = [];
foreach ($this->layouts as $layout => $requiredImageTypes)
{
// If we don't have enough candidate images for this layout, skip it
if (count($candidateImages) < count($requiredImageTypes))
continue;
$imageSelection = [];
$remainingImages = $candidateImages;
// Try to satisfy the layout spec using the images available
foreach ($requiredImageTypes as $spec)
{
foreach ($remainingImages as $i => $candidate)
{
// Satisfied spec from selection?
if (self::matchTypeMask($candidate, $spec))
{
$imageSelection[] = $candidate;
unset($remainingImages[$i]);
continue 2;
}
}
// Unable to satisfy spec from selection
break;
}
// Have we satisfied the spec? Great, assign a score
if (count($imageSelection) === count($requiredImageTypes))
{
$score = $this->getScoreForRow($imageSelection, $requiredImageTypes);
$fitnessScores[$layout] = [$score, $imageSelection];
// Perfect score? Bail out early
if ($score === count($requiredImageTypes))
break;
}
}
return $fitnessScores;
}
private static function matchTypeMask(Image $image, $type_mask)
{
return $image->getType() & $type_mask;
}
private static function orderPhotosByPriority(Image $a, Image $b)
{
// Leave images of different types as-is
if ($a->isLandscape() !== $b->isLandscape())
return 0;
// Otherwise, show images of highest priority first
$priority_diff = $a->getPriority() - $b->getPriority();
return -$priority_diff;
}
private function orderQueueByDate()
{
usort($this->queue, function($a, $b) {
$score = $a->getDateCaptured() <=> $b->getDateCaptured();
return $score * ($this->descending ? -1 : 1);
});
}
private function pushToQueue(Image $image)
{
$this->queue[] = $image;
$this->orderQueueByDate();
}
}

View File

@@ -24,7 +24,7 @@ class Registry
public static function get($key)
{
if (!isset(self::$storage[$key]))
trigger_error('Key does not exist in Registry: ' . $key, E_USER_ERROR);
throw new Exception('Key does not exist in Registry: ' . $key);
return self::$storage[$key];
}
@@ -32,7 +32,7 @@ class Registry
public static function remove($key)
{
if (!isset(self::$storage[$key]))
trigger_error('Key does not exist in Registry: ' . $key, E_USER_ERROR);
throw new Exception('Key does not exist in Registry: ' . $key);
unset(self::$storage[$key]);
}

View File

@@ -3,47 +3,55 @@
* Session.php
* Contains the key class Session.
*
* Kabuki CMS (C) 2013-2015, Aaron van Geffen
* Kabuki CMS (C) 2013-2023, Aaron van Geffen
*****************************************************************************/
class Session
{
public static function clear()
{
$_SESSION = [];
}
public static function start()
{
session_start();
// Resuming an existing session? Check what we know!
if (isset($_SESSION['user_id'], $_SESSION['ip_address'], $_SESSION['user_agent']))
{
// If we're not browsing over HTTPS, protect against session hijacking.
if (!isset($_SERVER['HTTPS']) && isset($_SERVER['REMOTE_ADDR']) && $_SESSION['ip_address'] !== $_SERVER['REMOTE_ADDR'])
{
$_SESSION = [];
Dispatcher::kickGuest('Your session failed to validate', 'Your IP address has changed. Please re-login and try again.');
}
// Either way, require re-login if the browser identifier has changed.
elseif (isset($_SERVER['HTTP_USER_AGENT']) && $_SESSION['user_agent'] !== $_SERVER['HTTP_USER_AGENT'])
{
$_SESSION = [];
Dispatcher::kickGuest('Your session failed to validate', 'Your browser identifier has changed. Please re-login and try again.');
}
}
elseif (!isset($_SESSION['ip_address'], $_SESSION['user_agent']))
$_SESSION = [
'ip_address' => isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '',
'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '',
];
if (!isset($_SESSION['session_token_key'], $_SESSION['session_token']))
self::generateSessionToken();
return true;
}
public static function resetSessionToken()
public static function generateSessionToken()
{
$_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 getSessionToken()
{
if (empty($_SESSION['session_token']))
throw new Exception('Call to getSessionToken without a session token being set!');
return $_SESSION['session_token'];
}
public static function getSessionTokenKey()
{
if (empty($_SESSION['session_token_key']))
throw new Exception('Call to getSessionTokenKey without a session token key being set!');
return $_SESSION['session_token_key'];
}
public static function resetSessionToken()
{
// Old interface; now always true.
return true;
}
public static function validateSession($method = 'post')
{
// First, check whether the submitted token and key match the ones in storage.
@@ -67,23 +75,7 @@ class Session
throw new UserFacingException('Invalid referring URL. Please reload the page and try again.');
}
// 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'];
// All looks good from here!
return true;
}
}

View File

@@ -21,7 +21,7 @@ class Setting
REPLACE INTO settings
(id_user, variable, value, time_set)
VALUES
({int:id_user}, {string:key}, {string:value}, CURRENT_TIMESTAMP())',
(:id_user, :key, :value, CURRENT_TIMESTAMP())',
[
'id_user' => $id_user,
'key' => $key,
@@ -45,7 +45,7 @@ class Setting
$value = Registry::get('db')->queryValue('
SELECT value
FROM settings
WHERE id_user = {int:id_user} AND variable = {string:key}',
WHERE id_user = :id_user AND variable = :key',
[
'id_user' => $id_user,
'key' => $key,
@@ -63,11 +63,30 @@ class Setting
public static function remove($key, $id_user = null)
{
$id_user = Registry::get('user')->getUserId();
// User setting or global setting?
if ($id_user === null)
$id_user = Registry::get('user')->getUserId();
$pairs = Registry::get('db')->queryPair('
SELECT variable, value
FROM settings
WHERE id_user = :id_user',
[
'id_user' => $id_user,
]);
return $pairs;
}
public static function remove($key, $id_user = 0)
{
// User setting or global setting?
if ($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}',
WHERE id_user = :id_user AND variable = :key',
[
'id_user' => $id_user,
'key' => $key,

View File

@@ -24,6 +24,11 @@ class Tag
$this->$attribute = $value;
}
public function __toString()
{
return $this->tag;
}
public static function fromId($id_tag, $return_format = 'object')
{
$db = Registry::get('db');
@@ -31,7 +36,7 @@ class Tag
$row = $db->queryAssoc('
SELECT *
FROM tags
WHERE id_tag = {int:id_tag}',
WHERE id_tag = :id_tag',
[
'id_tag' => $id_tag,
]);
@@ -50,7 +55,7 @@ class Tag
$row = $db->queryAssoc('
SELECT *
FROM tags
WHERE slug = {string:slug}',
WHERE slug = :slug',
[
'slug' => $slug,
]);
@@ -68,7 +73,7 @@ class Tag
SELECT *
FROM tags
ORDER BY ' . ($limit > 0 ? 'count
LIMIT {int:limit}' : 'tag'),
LIMIT :limit' : 'tag'),
[
'limit' => $limit,
]);
@@ -102,14 +107,14 @@ class Tag
$res = $db->query('
SELECT *
FROM tags
WHERE id_user_owner = {int:id_user_owner}
WHERE id_user_owner = :id_user_owner
ORDER BY tag',
[
'id_user_owner' => $id_user_owner,
]);
$objects = [];
while ($row = $db->fetch_assoc($res))
while ($row = $db->fetchAssoc($res))
$objects[$row['id_tag']] = new Tag($row);
return $objects;
@@ -120,9 +125,9 @@ class Tag
$rows = Registry::get('db')->queryAssocs('
SELECT *
FROM tags
WHERE id_parent = {int:id_parent} AND kind = {string:kind}
WHERE id_parent = :id_parent AND kind = :kind
ORDER BY tag ASC
LIMIT {int:offset}, {int:limit}',
LIMIT :offset, :limit',
[
'id_parent' => $id_parent,
'kind' => 'Album',
@@ -141,14 +146,29 @@ class Tag
return $rows;
}
public function getContributorList()
{
return Registry::get('db')->queryPairs('
SELECT u.id_user, u.first_name, u.surname, u.slug, COUNT(*) AS num_assets
FROM assets_tags AS at
LEFT JOIN assets AS a ON at.id_asset = a.id_asset
LEFT JOIN users AS u ON a.id_user_uploaded = u.id_user
WHERE at.id_tag = :id_tag
GROUP BY a.id_user_uploaded
ORDER BY u.first_name, u.surname',
[
'id_tag' => $this->id_tag,
]);
}
public static function getPeople($id_parent = 0, $offset = 0, $limit = 24, $return_format = 'array')
{
$rows = Registry::get('db')->queryAssocs('
SELECT *
FROM tags
WHERE id_parent = {int:id_parent} AND kind = {string:kind}
WHERE id_parent = :id_parent AND kind = :kind
ORDER BY tag ASC
LIMIT {int:offset}, {int:limit}',
LIMIT :offset, :limit',
[
'id_parent' => $id_parent,
'kind' => 'Person',
@@ -175,7 +195,7 @@ class Tag
WHERE id_tag IN(
SELECT id_tag
FROM assets_tags
WHERE id_asset = {int:id_asset}
WHERE id_asset = :id_asset
)
ORDER BY count DESC',
[
@@ -205,7 +225,7 @@ class Tag
WHERE id_tag IN(
SELECT id_tag
FROM posts_tags
WHERE id_post = {int:id_post}
WHERE id_post = :id_post
)
ORDER BY count DESC',
[
@@ -235,7 +255,7 @@ class Tag
FROM `assets_tags` AS at
WHERE at.id_tag = t.id_tag
)' . (!empty($id_tags) ? '
WHERE t.id_tag IN({array_int:id_tags})' : ''),
WHERE t.id_tag IN(@id_tags)' : ''),
['id_tags' => $id_tags]);
}
@@ -256,14 +276,14 @@ class Tag
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})
(:id_parent, :tag, :slug, :kind, :description, :count)
ON DUPLICATE KEY UPDATE count = count + 1',
$data);
if (!$res)
trigger_error('Could not create the requested tag.', E_USER_ERROR);
throw new Exception('Could not create the requested tag.');
$data['id_tag'] = $db->insert_id();
$data['id_tag'] = $db->insertId();
return $return_format === 'object' ? new Tag($data) : $data;
}
@@ -277,14 +297,15 @@ class Tag
return Registry::get('db')->query('
UPDATE tags
SET
id_parent = {int:id_parent},
id_asset_thumb = {int:id_asset_thumb},' . (isset($this->id_user_owner) ? '
id_user_owner = {int:id_user_owner},' : '') . '
tag = {string:tag},
slug = {string:slug},
description = {string:description},
count = {int:count}
WHERE id_tag = {int:id_tag}',
id_parent = :id_parent,
id_asset_thumb = :id_asset_thumb,' . (isset($this->id_user_owner) ? '
id_user_owner = :id_user_owner,' : '') . '
tag = :tag,
slug = :slug,
kind = :kind,
description = :description,
count = :count
WHERE id_tag = :id_tag',
get_object_vars($this));
}
@@ -292,9 +313,10 @@ class Tag
{
$db = Registry::get('db');
// Unlink any tagged assets
$res = $db->query('
DELETE FROM assets_tags
WHERE id_tag = {int:id_tag}',
WHERE id_tag = :id_tag',
[
'id_tag' => $this->id_tag,
]);
@@ -302,9 +324,10 @@ class Tag
if (!$res)
return false;
// Delete the actual tag
return $db->query('
DELETE FROM tags
WHERE id_tag = {int:id_tag}',
WHERE id_tag = :id_tag',
[
'id_tag' => $this->id_tag,
]);
@@ -316,15 +339,15 @@ class Tag
$new_id = $db->queryValue('
SELECT MAX(id_asset) as new_id
FROM assets_tags
WHERE id_tag = {int:id_tag}',
WHERE id_tag = :id_tag',
[
'id_tag' => $this->id_tag,
]);
return $db->query('
UPDATE tags
SET id_asset_thumb = {int:new_id}
WHERE id_tag = {int:id_tag}',
SET id_asset_thumb = :new_id
WHERE id_tag = :id_tag',
[
'new_id' => $new_id ?? 0,
'id_tag' => $this->id_tag,
@@ -339,7 +362,7 @@ class Tag
return Registry::get('db')->queryPair('
SELECT id_tag, tag
FROM tags
WHERE LOWER(tag) LIKE {string:tokens}
WHERE LOWER(tag) LIKE :tokens
ORDER BY tag ASC',
['tokens' => '%' . strtolower(implode('%', $tokens)) . '%']);
}
@@ -352,8 +375,8 @@ class Tag
return Registry::get('db')->queryPairs('
SELECT id_tag, tag, slug
FROM tags
WHERE LOWER(tag) LIKE {string:tokens} AND
kind = {string:person}
WHERE LOWER(tag) LIKE :tokens AND
kind = :person
ORDER BY tag ASC',
[
'tokens' => '%' . strtolower(implode('%', $tokens)) . '%',
@@ -369,7 +392,7 @@ class Tag
return Registry::get('db')->queryPair('
SELECT id_tag, tag
FROM tags
WHERE tag = {string:tag}',
WHERE tag = :tag',
['tag' => $tag]);
}
@@ -381,7 +404,7 @@ class Tag
return Registry::get('db')->queryValue('
SELECT id_tag
FROM tags
WHERE slug = {string:slug}',
WHERE slug = :slug',
['slug' => $slug]);
}
@@ -390,31 +413,103 @@ class Tag
return Registry::get('db')->queryPair('
SELECT tag, id_tag
FROM tags
WHERE tag IN ({array_string:tags})',
WHERE tag IN (:tags)',
['tags' => $tags]);
}
public static function getCount($only_active = 1, $kind = '')
public static function getCount($only_used = true, $kind = '', $isAlbum = false)
{
$where = [];
if ($only_active)
if ($only_used)
$where[] = 'count > 0';
if (!empty($kind))
$where[] = 'kind = {string:kind}';
if (empty($kind))
$kind = 'Album';
if (!empty($where))
$where = 'WHERE ' . implode(' AND ', $where);
else
$where = '';
$operator = $isAlbum ? '=' : '!=';
$where[] = 'kind ' . $operator . ' :kind';
$where = implode(' AND ', $where);
return Registry::get('db')->queryValue('
SELECT COUNT(*)
FROM tags ' . $where,
['kind' => $kind]);
FROM tags
WHERE ' . $where,
[
'kind' => $kind,
]);
}
public function __toString()
public static function getOffset($offset, $limit, $order, $direction, $isAlbum = false)
{
return $this->tag;
assert(in_array($order, ['id_tag', 'tag', 'slug', 'count']));
$order = $order . ($direction === 'up' ? ' ASC' : ' DESC');
$operator = $isAlbum ? '=' : '!=';
$db = Registry::get('db');
$res = $db->query('
SELECT t.*, u.id_user, u.first_name, u.surname
FROM tags AS t
LEFT JOIN users AS u ON t.id_user_owner = u.id_user
WHERE kind ' . $operator . ' :album
ORDER BY id_parent, ' . $order . '
LIMIT :offset, :limit',
[
'offset' => $offset,
'limit' => $limit,
'album' => 'Album',
]);
$albums_by_parent = [];
while ($row = $db->fetchAssoc($res))
{
if (!isset($albums_by_parent[$row['id_parent']]))
$albums_by_parent[$row['id_parent']] = [];
$albums_by_parent[$row['id_parent']][] = $row + ['children' => []];
}
$albums = self::getChildrenRecursively(0, 0, $albums_by_parent);
$rows = self::flattenChildrenRecursively($albums);
return $rows;
}
private static function getChildrenRecursively($id_parent, $level, &$albums_by_parent)
{
$children = [];
if (!isset($albums_by_parent[$id_parent]))
return $children;
foreach ($albums_by_parent[$id_parent] as $child)
{
if (isset($albums_by_parent[$child['id_tag']]))
$child['children'] = self::getChildrenRecursively($child['id_tag'], $level + 1, $albums_by_parent);
$child['tag'] = ($level ? str_repeat('—', $level * 2) . ' ' : '') . $child['tag'];
$children[] = $child;
}
return $children;
}
private static function flattenChildrenRecursively($albums)
{
if (empty($albums))
return [];
$rows = [];
foreach ($albums as $album)
{
static $headers_to_keep = ['id_tag', 'tag', 'slug', 'count', 'id_user', 'first_name', 'surname'];
$rows[] = array_intersect_key($album, array_flip($headers_to_keep));
if (!empty($album['children']))
{
$children = self::flattenChildrenRecursively($album['children']);
foreach ($children as $child)
$rows[] = array_intersect_key($child, array_flip($headers_to_keep));
}
}
return $rows;
}
}

View File

@@ -335,7 +335,7 @@ class Thumbnail
if ($success)
{
$thumb_selector = $this->width . 'x' . $this->height . $this->filename_suffix;
$this->thumbnails[$thumb_selector] = $filename !== 'NULL' ? $filename : null;
$this->thumbnails[$thumb_selector] = $filename ?? null;
// For consistency, write new thumbnail filename to parent Image object.
// TODO: there could still be an inconsistency if multiple objects exists for the same image asset.
@@ -349,7 +349,7 @@ class Thumbnail
private function markAsQueued()
{
$this->updateDb('NULL');
$this->updateDb(null);
}
private function markAsGenerated($filename)

View File

@@ -23,6 +23,7 @@ abstract class User
protected $ip_address;
protected $is_admin;
protected $reset_key;
protected $reset_blocked_until;
protected bool $is_logged;
protected bool $is_guest;
@@ -75,6 +76,11 @@ abstract class User
return $this->ip_address;
}
public function getSlug()
{
return $this->slug;
}
/**
* Returns whether user is logged in.
*/

View File

@@ -1,26 +1,3 @@
/* Edit icon on tiled grids
-----------------------------*/
.polaroid {
position: relative;
}
.polaroid a.edit {
background: var(--bs-body-bg);
border-radius: 3px;
box-shadow: 1px 1px 2px rgba(0,0,0,0.3);
color: var(--bs-body-color);
opacity: 0;
left: 20px;
line-height: 1.5;
padding: 5px 10px;
position: absolute;
transition: 0.25s;
top: 20px;
}
.polaroid:hover > a.edit {
opacity: 1;
}
/* Crop editor
----------------*/
#crop_editor {

View File

@@ -48,6 +48,10 @@ a:hover {
.page-link {
--bs-pagination-disabled-bg: var(--bs-body-bg);
--bs-pagination-disabled-color: var(--bs-body-color);
}
.page-link, .page-padding {
color: #b50707;
font-family: 'Coda', sans-serif;
}
@@ -58,30 +62,36 @@ a:hover {
background-color: #990b0b;
border-color: #a40d0d;
}
[data-bs-theme=dark] .page-link,
[data-bs-theme=dark] .page-padding{
color: #ae473c;
}
[data-bs-theme=dark] .active > .page-link, .page-link.active {
background-color: #4a0e0e;
border-color: #5b5a5a;
color: #ddd;
}
.pagination .page-item {
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.3);
white-space: nowrap;
}
.pagination .wildcard {
cursor: pointer;
}
@media (max-width: 767px) {
.pagination {
box-shadow: none;
height: 52px;
display: block;
position: relative;
}
.pagination .page-number, .pagination .page-padding {
display: none;
}
.pagination .page-link {
border-radius: var(--bs-pagination-border-radius) !important;
}
.pagination > :first-child, .pagination > :last-child {
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
display: inline-block;
position: absolute;
}
.pagination > :first-child {
left: 0;
}
.pagination > :last-child {
right: 0;
.pagination > :first-child,
.pagination > :last-child,
.pagination .first-wildcard,
.pagination .page-number.active {
display: inline-flex;
}
}
@@ -136,9 +146,6 @@ i.space-invader::before {
transition: 0.25s;
width: 110px;
}
.navbar-brand:hover i.space-invader::before {
transform: rotate(5deg);
}
i.space-invader.alt-1::before {
content: 'C';
}
@@ -160,6 +167,24 @@ i.space-invader.alt-6::before {
i.space-invader.alt-7::before {
content: 'O';
}
i.nyan-cat {
background-image: url('../images/nyan-cat.gif');
background-position: 0 -25px;
background-size: cover;
display: inline-block;
height: 85px;
left: -40px;
position: absolute;
top: -5px;
transform: rotate(-5deg);
transition: 0.25s;
width: 110px;
}
.navbar-brand:hover i.space-invader::before,
.navbar-brand:hover i.nyan-cat {
transform: rotate(5deg);
}
@media (max-width: 991px) {
.navbar-brand {
padding-left: 60px;
@@ -170,6 +195,10 @@ i.space-invader.alt-7::before {
top: -7px;
width: 70px;
}
i.nyan-cat {
left: -57px;
top: -7px;
}
}
@@ -188,6 +217,9 @@ i.space-invader.alt-7::before {
font-family: 'Coda', sans-serif;
margin-bottom: 0.5em;
}
.content-box .pagination .page-item {
box-shadow: none;
}
/* Album and photo index pages
@@ -212,32 +244,84 @@ i.space-invader.alt-7::before {
div.polaroid {
background: var(--bs-body-bg);
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.3);
line-height: 0;
position: relative;
transition: 0.25s;
}
div.polaroid img {
background: url('../images/nothumb.svg') center no-repeat;
div.polaroid:hover {
box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.5);
}
div.polaroid img.normal-photo {
background: var(--bs-body-bg) url('../images/nothumb.svg') center no-repeat;
border: none;
left: 0;
object-fit: cover;
position: absolute;
top: 0;
width: 100%;
z-index: 20;
}
div.polaroid img.blur-photo {
filter: blur(50px);
left: 0;
object-fit: cover;
position: absolute;
top: 0;
width: 100%;
z-index: 0;
}
div.polaroid img.placeholder-image {
background: var(--bs-body-bg);
z-index: 20;
}
div.polaroid h4 {
background: var(--bs-body-bg);
bottom: 0;
color: var(--bs-body-color);
margin: 0;
font: 400 18px 'Coda', sans-serif;
padding: 15px 5px;
position: absolute;
text-overflow: ellipsis;
text-align: center;
width: 100%;
white-space: nowrap;
overflow: hidden;
z-index: 20;
}
div.polaroid a {
display: block;
text-decoration: none;
}
div.polaroid:hover {
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
/* Edit icon on tiled grids
-----------------------------*/
.polaroid {
position: relative;
}
.polaroid div.edit {
box-shadow: 1px 1px 2px rgba(0,0,0,0.3);
opacity: 0;
left: 20px;
position: absolute;
transition: 0.25s;
top: 20px;
z-index: 50;
}
.polaroid div.edit .dropdown-item {
line-height: 1.4;
}
.polaroid div.edit .dropdown-toggle {
line-height: 1.4;
padding: 0.25rem 0.5rem;
}
.polaroid div.edit .dropdown-toggle::after {
margin-left: 0;
}
.polaroid:hover > div.edit {
opacity: 1;
}
@@ -282,10 +366,12 @@ div.polaroid:hover {
/* Album button box
---------------------*/
.album_button_box {
float: right;
display: flex;
justify-content: flex-end;
margin-bottom: 3rem;
}
.album_button_box > a {
.album_button_box > a,
.album_button_box .btn {
background: var(--bs-body-bg);
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
border-color: var( --bs-secondary-bg);
@@ -293,7 +379,9 @@ div.polaroid:hover {
padding: 8px 10px;
margin-left: 12px;
}
.album_button_box > a:hover {
.album_button_box > a:hover,
.album_button_box .btn:hover,
.album_button_box .btn:focus {
background: var(--bs-secondary-bg);
border-color: var(--bs-tertiary-bg);
color: var(--bs-secondary-color);
@@ -308,7 +396,7 @@ div.polaroid:hover {
}
.autosuggest {
background: var(--bs-body-bg);
border: 1px solid #ccc;
border: 1px solid var( --bs-border-color);
color: var(--bs-body-color);
position: absolute;
left: 2px;
@@ -321,7 +409,8 @@ div.polaroid:hover {
padding: 3px 8px;
}
.autosuggest li:hover, .autosuggest li.selected {
background: #CFECF7;
background-color: #990b0b;
color: #eee;
cursor: pointer;
}
@@ -388,22 +477,53 @@ footer a {
/* Styling for the photo pages
--------------------------------*/
#photo_frame {
padding-top: 1.5vh;
text-align: center;
}
#photo_frame a {
#photo_frame {
padding: 3.5vh 0;
text-align: center;
height: 95vh;
}
#photo_frame a img {
border: none;
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
cursor: -moz-zoom-in;
display: inline-block;
height: 97vh;
max-width: 100%;
#photo-figure {
position: relative;
object-fit: contain;
width: auto;
height: 93vh;
object-position: center center;
}
#photo-figure img {
object-position: center center;
}
#photo-figure img.normal-photo {
border: none;
cursor: -moz-zoom-in;
object-fit: contain;
z-index: 20;
position: absolute;
left: 0;
top: 0;
}
#photo-figure img.blur-photo {
filter: blur(50px);
left: 0;
object-fit: contain;
position: absolute;
top: 0;
z-index: 0;
}
figure.portrait-figure,
figure.landscape-figure {
height: 93vh;
margin: 0 auto;
}
figure.panorama-figure {
object-fit: cover;
margin: 0 auto;
}
figure#photo-figure img.normal-photo,
figure#photo-figure img.blur-photo {
height: 100%;
width: 100%;
}
#previous_photo, #next_photo {
@@ -435,37 +555,43 @@ a#previous_photo:hover, a#next_photo:hover {
right: 0;
}
#sub_photo h2, #sub_photo h3, #photo_exif_box h3, #user_actions_box h3 {
#sub_photo h2, #sub_photo h3 {
margin-bottom: 1rem;
}
#sub_photo #tag_list {
#sub_photo .tag-list {
list-style: none;
margin: 1em 0;
padding: 0;
}
#sub_photo #tag_list li {
display: inline;
padding-right: 0.75em;
#sub_photo .tag-list > li {
display: inline-block;
margin-bottom: 0.75em;
margin-right: 0.75em;
}
#tag_list .delete-tag {
opacity: 0.25;
#sub_photo .tag-list .input-group-text {
color: inherit;
}
#tag_list .delete-tag:hover {
opacity: 1.0;
[data-bs-theme=light] #sub_photo .tag-list .btn-danger {
background-color: rgba(175, 0, 0, 0.5);
border-color: rgba(175, 0, 0, 0.5);
}
[data-bs-theme=dark] #sub_photo .tag-list .btn-danger {
background-color: rgba(100, 0, 0, 0.5);
border-color: rgb(100, 0, 0);
}
#photo_exif_box dt {
.photo_meta {
background-color: var(--bs-body-bg);
box-shadow: 0 0.25rem 0.5rem rgba(0,0,0,0.1);
}
.photo_meta li {
padding: 0.6rem 1rem;
}
.photo_meta h4 {
font-family: 'Coda', sans-serif;
font-size: inherit;
font-weight: bold;
float: left;
clear: left;
width: 120px;
}
#photo_exif_box dt:after {
content: ':';
}
#photo_exif_box dd {
float: left;
margin: 0;
}
@@ -495,13 +621,6 @@ a#previous_photo:hover, a#next_photo:hover {
#previous_photo, #next_photo {
display: none;
}
#sub_photo, #photo_exif_box {
float: none;
margin: 30px 0;
padding: 10px;
width: auto;
}
}

BIN
public/images/nyan-cat.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -20,8 +20,8 @@
}
const setTheme = theme => {
if (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-bs-theme', 'dark');
if (theme === 'auto') {
document.documentElement.setAttribute('data-bs-theme', (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'))
} else {
document.documentElement.setAttribute('data-bs-theme', theme);
}

View File

@@ -8,22 +8,59 @@
class AlbumButtonBox extends Template
{
private $active_filter;
private $buttons;
private $filters;
public function __construct($buttons)
public function __construct(array $buttons, array $filters, $active_filter)
{
$this->active_filter = $active_filter;
$this->buttons = $buttons;
$this->filters = $filters;
}
public function html_main()
{
echo '
<div class="album_button_box">';
<div class="container album_button_box">';
foreach ($this->buttons as $button)
echo '
<a class="btn btn-light" href="', $button['url'], '">', $button['caption'], '</a>';
if (!empty($this->filters))
{
echo '
<div class="dropdown">
<button class="btn btn-light dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-filter"></i>';
if ($this->active_filter)
{
echo '
<span class="badge text-bg-danger">',
$this->filters[$this->active_filter]['label'], '</span>';
}
echo '
</button>
<ul class="dropdown-menu">';
foreach ($this->filters as $key => $filter)
{
$is_active = $key === $this->active_filter;
echo '
<li><a class="dropdown-item', $is_active ? ' active' : '',
'" href="', $filter['link'], '">',
$filter['caption'],
'</a></li>';
}
echo '
</ul>
</div>';
}
echo '
</div>';
}

View File

@@ -42,7 +42,7 @@ class AlbumIndex extends Template
{
echo '
<div class="col-md-6 col-xl-4">
<div class="polaroid landscape">';
<div class="polaroid landscape" style="aspect-ratio: 1.12">';
if ($this->show_edit_buttons)
echo '
@@ -58,15 +58,20 @@ class AlbumIndex extends Template
$thumbs[$factor] = $album['thumbnail']->getThumbnailUrl(
static::TILE_WIDTH * $factor, static::TILE_HEIGHT * $factor, true, true);
echo '
foreach (['normal-photo', 'blur-photo'] as $className)
{
echo '
<img alt="" src="', $thumbs[1], '"' . (isset($thumbs[2]) ?
' srcset="' . $thumbs[2] . ' 2x"' : '') .
' class="', $className, '"' .
' alt="" style="aspect-ratio: ', self::TILE_RATIO, '">';
}
}
else
{
echo '
<img alt="" src="', BASEURL, '/images/nothumb.svg"',
' class="placeholder-image"',
' style="aspect-ratio: ', self::TILE_RATIO, '; object-fit: unset">';
}

View File

@@ -1,27 +0,0 @@
<?php
/*****************************************************************************
* Button.php
* Defines the Button template.
*
* Kabuki CMS (C) 2013-2015, Aaron van Geffen
*****************************************************************************/
class Button extends Template
{
private $content = '';
private $href = '';
private $class = '';
public function __construct($content = '', $href = '', $class = '')
{
$this->content = $content;
$this->href = $href;
$this->class = $class;
}
public function html_main()
{
echo '
<a class="', $this->class, '" href="', $this->href, '">', $this->content, '</a>';
}
}

View File

@@ -1,35 +0,0 @@
<?php
/*****************************************************************************
* ConfirmDeletePage.php
* Contains the confirm delete page template.
*
* Kabuki CMS (C) 2013-2016, Aaron van Geffen
*****************************************************************************/
class ConfirmDeletePage extends PhotoPage
{
public function __construct(Image $photo)
{
parent::__construct($photo);
}
public function html_main()
{
$this->confirm();
$this->photo();
}
private function confirm()
{
$buttons = [];
$buttons[] = new Button("Delete", BASEURL . '/' . $this->photo->getSlug() . '/?delete_confirmed', "btn btn-danger");
$buttons[] = new Button("Cancel", $this->photo->getPageUrl(), "btn");
$alert = new WarningDialog(
"Confirm deletion.",
"You are about to permanently delete the following photo.",
$buttons
);
$alert->html_main();
}
}

View File

@@ -8,13 +8,17 @@
class EditAssetForm extends Template
{
private $allAlbums;
private $asset;
private $currentAlbumId;
private $thumbs;
public function __construct(Asset $asset, array $thumbs = [])
public function __construct(array $options)
{
$this->asset = $asset;
$this->thumbs = $thumbs;
$this->allAlbums = $options['allAlbums'];
$this->asset = $options['asset'];
$this->currentAlbumId = $options['currentAlbumId'];
$this->thumbs = $options['thumbs'];
}
public function html_main()
@@ -23,10 +27,14 @@ class EditAssetForm extends Template
<form id="asset_form" action="" method="post" enctype="multipart/form-data">
<div class="content-box">
<div class="float-end">
<a class="btn btn-danger" href="', BASEURL, '/', $this->asset->getSlug(), '?delete_confirmed">Delete asset</a>
<a class="btn btn-danger" href="', $this->asset->getDeleteUrl(), '&',
Session::getSessionTokenKey(), '=', Session::getSessionToken(),
'" onclick="return confirm(\'Are you sure you want to delete this asset?\');">',
'Delete asset</a>
<a class="btn btn-light" href="', $this->asset->getPageUrl(), '#photo_frame">View asset</a>
<button class="btn btn-primary" type="submit">Save asset data</button>
</div>
<h2>Edit asset \'', $this->asset->getTitle(), '\' (', $this->asset->getFilename(), ')</h2>
<h2 class="mb-0">Edit asset \'', $this->asset->getTitle(), '\'</h2>
</div>';
$this->section_replace();
@@ -64,6 +72,21 @@ class EditAssetForm extends Template
<div class="content-box key_info">
<h3>Key info</h3>
<div class="row mb-2">
<label class="col-form-label col-sm-3">Album:</label>
<div class="col-sm">
<select class="form-select" name="id_album">';
foreach ($this->allAlbums as $id_album => $album)
echo '
<option value="', $id_album, '"',
$this->currentAlbumId == $id_album ? ' selected' : '',
'>', htmlspecialchars($album), '</option>';
echo '
</select>
</div>
</div>
<div class="row mb-2">
<label class="col-form-label col-sm-3">Title (internal):</label>
<div class="col-sm">
@@ -79,8 +102,9 @@ class EditAssetForm extends Template
<div class="row mb-2">
<label class="col-form-label col-sm-3">Date captured:</label>
<div class="col-sm">
<input class="form-control" name="date_captured" size="30" value="',
$date_captured ? $date_captured->format('Y-m-d H:i:s') : '', '" placeholder="Y-m-d H:i:s">
<input class="form-control" type="datetime-local" step="1"
name="date_captured" size="30" placeholder="Y-m-d H:i:s" value="',
$date_captured ? $date_captured->format('Y-m-d H:i:s') : '', '">
</div>
</div>
<div class="row mb-2">
@@ -100,11 +124,16 @@ class EditAssetForm extends Template
<ul class="list-unstyled" id="tag_list">';
foreach ($this->asset->getTags() as $tag)
echo '
{
if ($tag->kind === 'Album')
continue;
echo '
<li>
<input class="tag_check" type="checkbox" name="tag[', $tag->id_tag, ']" id="linked_tag_', $tag->id_tag, '" title="Uncheck to delete" checked>
', $tag->tag, '
</li>';
}
echo '
<li id="new_tag_container"><input class="form-control" type="text" id="new_tag" placeholder="Type to link a new tag"></li>

41
templates/ErrorPage.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
/*****************************************************************************
* ErrorPage.php
* Defines the template class ErrorPage.
*
* Kabuki CMS (C) 2013-2025, Aaron van Geffen
*****************************************************************************/
class ErrorPage extends Template
{
private $debug_info;
private $message;
private $title;
public function __construct($title, $message, $debug_info = null)
{
$this->title = $title;
$this->message = $message;
$this->debug_info = $debug_info;
}
public function html_main()
{
echo '
<div class="content-box container">
<h2>', $this->title, '</h2>
<p>', nl2br(htmlspecialchars($this->message)), '</p>';
if (isset($this->debug_info))
{
echo '
</div>
<div class="content-box container">
<h4>Debug Info</h4>
<pre>', htmlspecialchars($this->debug_info), '</pre>';
}
echo '
</div>';
}
}

View File

@@ -8,12 +8,12 @@
class FeaturedThumbnailManager extends SubTemplate
{
private $assets;
private $iterator;
private $currentThumbnailId;
public function __construct(AssetIterator $assets, $currentThumbnailId)
public function __construct(AssetIterator $iterator, $currentThumbnailId)
{
$this->assets = $assets;
$this->iterator = $iterator;
$this->currentThumbnailId = $currentThumbnailId;
}
@@ -21,11 +21,24 @@ class FeaturedThumbnailManager extends SubTemplate
{
echo '
<form action="" method="post">
<button class="btn btn-primary float-end" type="submit" name="changeThumbnail">Save thumbnail selection</button>
<h2>Select thumbnail</h2>
<div class="row">
<div class="col-lg">
<h2>Select thumbnail</h2>
</div>
<div class="col-lg">';
foreach ($this->_subtemplates as $template)
$template->html_main();
echo '
</div>
<div class="col-lg-auto">
<button class="btn btn-primary" type="submit" name="changeThumbnail">Save thumbnail selection</button>
</div>
</div>
<ul id="featuredThumbnail">';
while ($asset = $this->assets->next())
foreach ($this->iterator as $asset)
{
$image = $asset->getImage();
echo '
@@ -36,8 +49,6 @@ class FeaturedThumbnailManager extends SubTemplate
</li>';
}
$this->assets->clean();
echo '
</ul>
<input type="hidden" name="', Session::getSessionTokenKey(), '" value="', Session::getSessionToken(), '">

View File

@@ -19,7 +19,7 @@ class FormView extends SubTemplate
$this->title = $title;
}
protected function html_content($exclude = [], $include = [])
protected function html_content()
{
if (!empty($this->title))
echo '
@@ -31,34 +31,29 @@ class FormView extends SubTemplate
echo '
<form action="', $this->form->request_url, '" method="', $this->form->request_method, '" enctype="multipart/form-data">';
if (isset($this->form->content_above))
echo $this->form->content_above;
if (isset($this->form->before_fields))
echo $this->form->before_fields;
$this->missing = $this->form->getMissing();
$this->data = $this->form->getData();
foreach ($this->form->getFields() 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);
}
if (isset($this->form->after_fields))
echo $this->form->after_fields;
echo '
<input type="hidden" name="', Session::getSessionTokenKey(), '" value="', Session::getSessionToken(), '">
<div class="form-group">
<div class="offset-sm-2 col-sm-10">
<button type="submit" name="submit" class="btn btn-primary">', $this->form->getSubmitButtonCaption(), '</button>';
if (isset($this->form->content_below))
if (isset($this->form->buttons_extra))
echo '
', $this->form->content_below;
', $this->form->buttons_extra;
echo '
</div>
@@ -75,10 +70,8 @@ class FormView extends SubTemplate
echo '
<div class="row mb-2">';
if (isset($field['before']))
echo $field['before'];
if ($field['type'] !== 'checkbox')
{
if (isset($field['label']))
echo '
<label class="col-sm-2 col-form-label" for="', $field_id, '"', in_array($field_id, $this->missing) ? ' style="color: red"' : '', '>', $field['label'], ':</label>
@@ -86,6 +79,7 @@ class FormView extends SubTemplate
else
echo '
<div class="offset-sm-2 ', isset($field['class']) ? $field['class'] : 'col-sm-6', '">';
}
switch ($field['type'])
{
@@ -127,15 +121,16 @@ class FormView extends SubTemplate
$this->renderText($field_id, $field);
}
if (isset($field['after']))
echo ' ', $field['after'];
if ($field['type'] !== 'checkbox')
echo '
</div>';
echo '
</div>';
if (isset($field['after_html']))
echo '
', $field['after_html'];
}
private function renderCaptcha($field_id, array $field)

View File

@@ -0,0 +1,105 @@
<?php
/*****************************************************************************
* InlineFormView.php
* Contains the template that renders inline forms.
*
* Kabuki CMS (C) 2013-2025, Aaron van Geffen
*****************************************************************************/
class InlineFormView
{
public static function renderInlineForm($form)
{
if (!isset($form['is_embed']))
echo '
<form action="', $form['action'], '" method="', $form['method'], '" class="', $form['class'] ?? '', '">';
else
echo '
<div class="', $form['class'] ?? '', '">';
if (!empty($form['is_group']))
echo '
<div class="input-group">';
foreach ($form['controls'] as $name => $control)
{
if ($control['type'] === 'select')
self::renderSelectBox($control, $name);
elseif ($control['type'] === 'submit')
self::renderSubmitButton($control, $name);
else
self::renderInputBox($control, $name);
}
echo '
<input type="hidden" name="', Session::getSessionTokenKey(), '" value="', Session::getSessionToken(), '">';
if (!empty($form['is_group']))
echo '
</div>';
if (!isset($form['is_embed']))
echo '
</form>';
else
echo '
</div>';
}
private static function renderInputBox(array $field, $name)
{
echo '
<input name="', $name, '" id="field_', $name, '" type="', $field['type'], '" ',
'class="form-control', isset($field['class']) ? ' ' . $field['class'] : '', '"',
isset($field['placeholder']) ? ' placeholder="' . $field['placeholder'] . '"' : '',
isset($field['value']) ? ' value="' . htmlspecialchars($field['value']) . '"' : '', '>';
}
private static function renderSelectBox(array $field, $name)
{
echo '
<select class="form-select" name="', $name, '"',
(isset($field['onchange']) ? ' onchange="' . $field['onchange'] . '"' : ''), '>';
foreach ($field['values'] as $value => $caption)
{
if (!is_array($caption))
{
echo '
<option value="', $value, '"', $value === $field['selected'] ? ' selected' : '', '>', $caption, '</option>';
}
else
{
$label = $value;
$options = $caption;
echo '
<optgroup label="', $label, '">';
foreach ($options as $value => $caption)
{
echo '
<option value="', $value, '"', $value === $field['selected'] ? ' selected' : '', '>', $caption, '</option>';
}
echo '
</optgroup>';
}
}
echo '
</select>';
}
private static function renderSubmitButton(array $button, $name)
{
echo '
<button class="btn ', isset($button['class']) ? $button['class'] : 'btn-primary', '" ',
'type="', $button['type'], '" name="', $name, '"';
if (isset($button['onclick']))
echo ' onclick="', $button['onclick'], '"';
echo '>', $button['caption'], '</button>';
}
}

View File

@@ -20,19 +20,20 @@ class MainNavBar extends NavBar
// Select a random space invader, with a bias towards the mascot
$rnd = rand(0, 100);
$alt = $rnd > 50 ? ' alt-' . ($rnd % 6 + 1) : '';
$className = $rnd > 5 ? 'space-invader' . $alt : 'nyan-cat';
echo '
<nav id="', $this->outerMenuId, '" class="navbar navbar-expand-lg ', $this->navBarClasses, '" aria-label="', $this->ariaLabel, '">
<div class="container">
<a class="navbar-brand flex-grow-1" href="', BASEURL, '/">
<i class="space-invader', $alt, '"></i>
<i class="', $className, '"></i>
HashRU Pics
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#', $this->innerMenuId, '" aria-controls="', $this->innerMenuId, '" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>';
if (Registry::get('user')->isLoggedIn())
if (Registry::has('user') && Registry::get('user')->isLoggedIn())
{
echo '
<div class="collapse navbar-collapse justify-content-end" id="', $this->innerMenuId, '">

View File

@@ -38,7 +38,7 @@ class MainTemplate extends Template
echo '
<link rel="stylesheet" href="', BASEURL, '/vendor/twbs/bootstrap/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="', BASEURL, '/vendor/twbs/bootstrap-icons/font/bootstrap-icons.css">
<link type="text/css" rel="stylesheet" href="', BASEURL, '/css/default.css">
<link type="text/css" rel="stylesheet" href="', BASEURL, '/css/default.css?v2">
<script type="text/javascript" src="', BASEURL, '/js/main.js"></script>
<script type="text/javascript" src="', BASEURL, '/js/color-modes.js"></script>'
, $this->header_html, '

View File

@@ -11,6 +11,8 @@ class PageIndexWidget extends Template
private $index;
private string $class;
private static $unique_index_count = 0;
public function __construct(PageIndex $index)
{
$this->index = $index;
@@ -37,14 +39,22 @@ class PageIndexWidget extends Template
'<a class="page-link"', !empty($page_index['previous']) ? ' href="' . $page_index['previous']['href'] . '"' : '', '>',
'&laquo; previous</a></li>';
$num_wildcards = 0;
foreach ($page_index as $key => $page)
{
if (!is_numeric($key))
continue;
if (!is_array($page))
{
$first_wildcard = $num_wildcards === 0;
$num_wildcards++;
echo '
<li class="page-item page-padding disabled"><a class="page-link">...</a></li>';
<li class="page-item page-padding wildcard',
$first_wildcard ? ' first-wildcard' : '',
'" onclick="javascript:promptGoToPage(',
self::$unique_index_count, ')"><a class="page-link">...</a></li>';
}
else
echo '
<li class="page-item page-number', $page['is_selected'] ? ' active" aria-current="page' : '', '">',
@@ -56,5 +66,17 @@ class PageIndexWidget extends Template
'<a class="page-link"', !empty($page_index['next']) ? ' href="' . $page_index['next']['href'] . '"' : '', '>',
'next &raquo;</a></li>
</ul>';
if ($num_wildcards)
{
echo '
<script type="text/javascript">
var page_index_', self::$unique_index_count++, ' = {
wildcard_url: "', $index->getLink("%d"), '",
num_pages: ', $index->getNumberOfPages(), ',
per_page: ', $index->getItemsPerPage(), '
};
</script>';
}
}
}

View File

@@ -8,32 +8,16 @@
class PhotoPage extends Template
{
protected $photo;
private $exif;
private $previous_photo_url = '';
private $next_photo_url = '';
private $is_asset_owner = false;
private $activeFilter;
private $photo;
private $metaData;
private $tag;
public function __construct(Image $photo)
{
$this->photo = $photo;
}
public function setPreviousPhotoUrl($url)
{
$this->previous_photo_url = $url;
}
public function setNextPhotoUrl($url)
{
$this->next_photo_url = $url;
}
public function setIsAssetOwner($flag)
{
$this->is_asset_owner = $flag;
}
public function html_main()
{
$this->photoNav();
@@ -41,24 +25,27 @@ class PhotoPage extends Template
echo '
<div class="row mt-5">
<div class="col-lg-8">
<div id="sub_photo" class="content-box">
<h2 class="entry-title">', $this->photo->getTitle(), '</h2>';
$this->taggedPeople();
$this->linkNewTags();
echo '
</div>
</div>
<div class="col-lg-4">';
<div class="col-lg">';
$this->photoMeta();
if ($this->is_asset_owner)
$this->addUserActions();
echo '
</div>
</div>
<div class="row mt-5">
<div class="col-lg">
<div id="sub_photo" class="content-box">';
$this->userActions();
echo '
<h2 class="entry-title">', $this->photo->getTitle(), '</h2>';
$this->printTags('Album', 'Album', false);
$this->printTags('Tagged People', 'Person', true);
echo '
</div>
</div>
</div>
<script type="text/javascript" src="', BASEURL, '/js/photonav.js"></script>';
@@ -67,31 +54,55 @@ class PhotoPage extends Template
protected function photo()
{
echo '
<div id="photo_frame">
<a href="', $this->photo->getUrl(), '">';
<a href="', $this->photo->getUrl(), '">
<div id="photo_frame">';
if ($this->photo->isPortrait())
echo $this->photo->getInlineImage(null, 960);
{
echo '
<figure id="photo-figure" class="portrait-figure">',
$this->photo->getInlineImage(null, 960, 'normal-photo'),
$this->photo->getInlineImage(null, 960, 'blur-photo'), '
</figure>';
}
else
echo $this->photo->getInlineImage(1280, null);
{
$className = $this->photo->isPanorama() ? 'panorama-figure' : 'landscape-figure';
echo '
<figure id="photo-figure" class="', $className, '">',
$this->photo->getInlineImage(1280, null, 'normal-photo'),
$this->photo->getInlineImage(1280, null, 'blur-photo'), '
</figure>';
}
echo '
</a>
</div>';
</figure>
</div>
</a>';
}
public function setActiveFilter($filter)
{
$this->activeFilter = $filter;
}
public function setTag(Tag $tag)
{
$this->tag = $tag;
}
private function photoNav()
{
if ($this->previous_photo_url)
if ($previousUrl = $this->photo->getUrlForPreviousInSet($this->tag, $this->activeFilter))
echo '
<a href="', $this->previous_photo_url, '#photo_frame" id="previous_photo"><i class="bi bi-arrow-left"></i></a>';
<a href="', $previousUrl, '#photo_frame" id="previous_photo"><i class="bi bi-arrow-left"></i></a>';
else
echo '
<span id="previous_photo"><i class="bi bi-arrow-left"></i></span>';
if ($this->next_photo_url)
if ($nextUrl = $this->photo->getUrlForNextInSet($this->tag, $this->activeFilter))
echo '
<a href="', $this->next_photo_url, '#photo_frame" id="next_photo"><i class="bi bi-arrow-right"></i></a>';
<a href="', $nextUrl, '#photo_frame" id="next_photo"><i class="bi bi-arrow-right"></i></a>';
else
echo '
<span id="next_photo"><i class="bi bi-arrow-right"></i></span>';
@@ -100,139 +111,129 @@ class PhotoPage extends Template
private function photoMeta()
{
echo '
<div id="photo_exif_box" class="content-box clearfix">
<h3>EXIF</h3>
<dl class="photo_meta">';
<ul class="list-group list-group-horizontal photo_meta">';
if (!empty($this->exif->created_timestamp))
foreach ($this->metaData as $header => $body)
{
echo '
<dt>Date Taken</dt>
<dd>', date("j M Y, H:i:s", $this->exif->created_timestamp), '</dd>';
<li class="list-group-item flex-fill">
<h4>', $header, '</h4>
', $body, '
</li>';
}
echo '
<dt>Uploaded by</dt>
<dd>', $this->photo->getAuthor()->getfullName(), '</dd>';
if (!empty($this->exif->camera))
echo '
<dt>Camera Model</dt>
<dd>', $this->exif->camera, '</dd>';
if (!empty($this->exif->shutter_speed))
echo '
<dt>Shutter Speed</dt>
<dd>', $this->exif->shutterSpeedFraction(), '</dd>';
if (!empty($this->exif->aperture))
echo '
<dt>Aperture</dt>
<dd>f/', number_format($this->exif->aperture, 1), '</dd>';
if (!empty($this->exif->focal_length))
echo '
<dt>Focal Length</dt>
<dd>', $this->exif->focal_length, ' mm</dd>';
if (!empty($this->exif->iso))
echo '
<dt>ISO Speed</dt>
<dd>', $this->exif->iso, '</dd>';
if (!empty($this->exif->software))
echo '
<dt>Software</dt>
<dd>', $this->exif->software, '</dd>';
echo '
</dl>
</div>';
</ul>';
}
private function taggedPeople()
private function printTags($header, $tagKind, $allowLinkingNewTags)
{
static $nextTagListId = 1;
$tagListId = 'tagList' . ($nextTagListId++);
echo '
<h3>Tags</h3>
<ul id="tag_list">';
<h3>', $header, '</h3>
<ul id="', $tagListId, '" class="tag-list">';
foreach ($this->photo->getTags() as $tag)
{
if ($tag->kind !== $tagKind)
continue;
echo '
<li id="tag-', $tag->id_tag, '">
<a rel="tag" title="View all posts tagged ', $tag->tag, '" href="', $tag->getUrl(), '" class="entry-tag">', $tag->tag, '</a>';
<div class="input-group">
<a class="input-group-text" href="', $tag->getUrl(), '" title="View all posts tagged ', $tag->tag, '">
', $tag->tag, '
</a>';
if ($tag->kind === 'Person')
{
echo '
<a class="delete-tag" title="Unlink this tag from this photo" href="#" data-id="', $tag->id_tag, '">❌</a>';
<a class="delete-tag btn btn-danger px-1" title="Unlink this tag from this photo" href="#" data-id="', $tag->id_tag, '">
<i class="bi bi-x"></i>
</a>';
}
echo '
</div>
</li>';
}
static $nextNewTagId = 1;
$newTagId = 'newTag' . ($nextNewTagId++);
if ($allowLinkingNewTags)
{
echo '
<li style="position: relative">
<input class="form-control w-auto" type="text" id="', $newTagId, '" placeholder="Type to link a new tag">
</li>';
}
echo '
</ul>';
if ($allowLinkingNewTags)
{
$this->printNewTagScript($tagKind, $tagListId, $newTagId);
}
}
private function linkNewTags()
private function printNewTagScript($tagKind, $tagListId, $newTagId)
{
echo '
<div>
<h3>Link tags</h3>
<p style="position: relative">
<input class="form-control w-auto" type="text" id="new_tag" placeholder="Type to link a new tag">
</p>
</div>
<script type="text/javascript" src="', BASEURL, '/js/ajax.js"></script>
<script type="text/javascript" src="', BASEURL, '/js/autosuggest.js"></script>
<script type="text/javascript">
setTimeout(function() {
var removeTag = function(event) {
const removeTag = function(event) {
event.preventDefault();
var that = this;
var request = new HttpRequest("post", "', $this->photo->getPageUrl(), '",
"id_tag=" + this.dataset["id"] + "&delete", function(response) {
const request = new HttpRequest("post", "', $this->photo->getPageUrl(), '",
"id_tag=" + this.dataset["id"] + "&delete", (response) => {
if (!response.success) {
return;
}
var tagNode = document.getElementById("tag-" + that.dataset["id"]);
const tagNode = document.getElementById("tag-" + this.dataset["id"]);
tagNode.parentNode.removeChild(tagNode);
});
};
var tagRemovalTargets = document.getElementsByClassName("delete-tag");
for (var i = 0; i < tagRemovalTargets.length; i++) {
tagRemovalTargets[i].addEventListener("click", removeTag);
}
let tagRemovalTargets = document.querySelectorAll(".delete-tag");
tagRemovalTargets.forEach(el => el.addEventListener("click", removeTag));
var tag_autosuggest = new TagAutoSuggest({
inputElement: "new_tag",
listElement: "tag_list",
let tag_autosuggest = new TagAutoSuggest({
inputElement: "', $newTagId, '",
listElement: "', $tagListId, '",
baseUrl: "', BASEURL, '",
appendCallback: function(item) {
var request = new HttpRequest("post", "', $this->photo->getPageUrl(), '",
"id_tag=" + item.id_tag, function(response) {
var newLink = document.createElement("a");
appendCallback: (item) => {
const request = new HttpRequest("post", "', $this->photo->getPageUrl(), '",
"id_tag=" + item.id_tag, (response) => {
const newListItem = document.createElement("li");
newListItem.id = "tag-" + item.id_tag;
const newInputGroup = document.createElement("div");
newInputGroup.className = "input-group";
newListItem.appendChild(newInputGroup);
const newLink = document.createElement("a");
newLink.className = "input-group-text";
newLink.href = item.url;
newLink.title = "View all posts tagged " + item.label;
newLink.textContent = item.label;
newInputGroup.appendChild(newLink);
var newLabel = document.createTextNode(item.label);
newLink.appendChild(newLabel);
var removeLink = document.createElement("a");
removeLink.className = "delete-tag";
const removeLink = document.createElement("a");
removeLink.className = "delete-tag btn btn-danger px-1";
removeLink.dataset["id"] = item.id_tag;
removeLink.href = "#";
removeLink.innerHTML = \'<i class="bi bi-x"></i>\';
removeLink.addEventListener("click", removeTag);
newInputGroup.appendChild(removeLink);
var crossmark = document.createTextNode("❌");
removeLink.appendChild(crossmark);
var newNode = document.createElement("li");
newNode.id = "tag-" + item.id_tag;
newNode.appendChild(newLink);
newNode.appendChild(removeLink);
var list = document.getElementById("tag_list");
list.appendChild(newNode);
const list = document.getElementById("', $tagListId, '");
list.insertBefore(newListItem, list.querySelector("li:last-child"));
}, this);
}
});
@@ -240,18 +241,24 @@ class PhotoPage extends Template
</script>';
}
public function setExif(EXIF $exif)
public function setMetaData(array $metaData)
{
$this->exif = $exif;
$this->metaData = $metaData;
}
public function addUserActions()
public function userActions()
{
if (!$this->photo->isOwnedBy(Registry::get('user')))
return;
echo '
<div id="user_actions_box" class="content-box">
<h3>Actions</h3>
<a class="btn btn-primary" href="', BASEURL, '/editasset/?id=', $this->photo->getId(), '">Edit photo</a>
<a class="btn btn-danger" href="', BASEURL, '/', $this->photo->getSlug(), '/?confirm_delete">Delete photo</a>
<div class="float-end">
<a class="btn btn-primary" href="', $this->photo->getEditUrl(), '">
<i class="bi bi-pencil"></i> Edit</a>
<a class="btn btn-danger" href="', $this->photo->getDeleteUrl(), '&',
Session::getSessionTokenKey(), '=', Session::getSessionToken(),
'" onclick="return confirm(\'Are you sure you want to delete this photo?\');"',
'"><i class="bi bi-pencil"></i> Delete</a></a>
</div>';
}
}

View File

@@ -12,27 +12,28 @@ class PhotosIndex extends Template
protected $show_edit_buttons;
protected $show_headers;
protected $show_labels;
protected $row_limit = 1000;
protected $previous_header = '';
protected $url_suffix;
const PANORAMA_WIDTH = 1280;
protected $edit_menu_items = [];
protected $photo_url_suffix;
const PANORAMA_WIDTH = 1256;
const PANORAMA_HEIGHT = null;
const PORTRAIT_WIDTH = 400;
const PORTRAIT_HEIGHT = 645;
const PORTRAIT_WIDTH = 387;
const PORTRAIT_HEIGHT = 628;
const LANDSCAPE_WIDTH = 850;
const LANDSCAPE_HEIGHT = 640;
const LANDSCAPE_WIDTH = 822;
const LANDSCAPE_HEIGHT = 628;
const DUO_WIDTH = 618;
const DUO_HEIGHT = 412;
const DUO_WIDTH = 604;
const DUO_HEIGHT = 403;
const SINGLE_WIDTH = 618;
const SINGLE_HEIGHT = 412;
const TILE_WIDTH = 400;
const TILE_HEIGHT = 300;
const TILE_WIDTH = 387;
const TILE_HEIGHT = 290;
public function __construct(PhotoMosaic $mosaic, $show_edit_buttons = false, $show_labels = false, $show_headers = true)
{
@@ -47,11 +48,12 @@ class PhotosIndex extends Template
echo '
<div class="container photo-index">';
for ($i = $this->row_limit; $i > 0 && $row = $this->mosaic->getRow(); $i--)
$i = 0;
while ($row = $this->mosaic->getRow())
{
list($photos, $what) = $row;
[$photos, $what] = $row;
$this->header($photos);
$this->$what($photos, $i % 2);
$this->$what($photos, ($i++) % 2);
}
echo '
@@ -81,29 +83,58 @@ class PhotosIndex extends Template
$this->previous_header = $header;
}
protected function editMenu(Image $image)
{
if (empty($this->edit_menu_items))
return;
echo '
<div class="edit dropdown">
<button class="btn btn-primary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
</button>
<ul class="dropdown-menu">';
foreach ($this->edit_menu_items as $item)
{
echo '
<li><a class="dropdown-item" href="', $item['uri']($image), '"',
isset($item['onclick']) ? ' onclick="' . $item['onclick'] . '"' : '',
'>', $item['label'], '</a></li>';
}
echo '
</ul>
</div>';
}
protected function photo(Image $image, $className, $width, $height, $crop = true, $fit = true)
{
echo '
<div class="polaroid ', $className, '">';
if ($this->show_edit_buttons)
echo '
<a class="edit" href="', BASEURL, '/editasset/?id=', $image->getId(), '">Edit</a>';
echo '
<a href="', $image->getPageUrl(), $this->url_suffix, '#photo_frame">
<img src="', $image->getThumbnailUrl($width, $height, $crop, $fit), '"';
// Can we offer double-density thumbs?
if ($image->width() >= $width * 2 && $image->height() >= $height * 2)
echo ' srcset="', $image->getThumbnailUrl($width * 2, $height * 2, $crop, $fit), ' 2x"';
else
echo ' srcset="', $image->getThumbnailUrl($image->width(), $image->height(), true), ' 2x"';
// Prefer thumbnail aspect ratio if available, otherwise use image aspect ratio.
$aspectRatio = isset($width, $height) ? $width / $height : $image->ratio();
echo ' alt="" title="', $image->getTitle(), '" style="aspect-ratio: ', $aspectRatio, '">';
echo '
<div class="polaroid ', $className, '" style="aspect-ratio: ', $aspectRatio, '">';
if ($this->show_edit_buttons && $image->canBeEditedBy(Registry::get('user')))
$this->editMenu($image);
echo '
<a href="', $image->getPageUrl(), $this->photo_url_suffix, '#photo_frame">';
foreach (['normal-photo', 'blur-photo'] as $className)
{
echo '
<img src="', $image->getThumbnailUrl($width, $height, $crop, $fit), '"';
// Can we offer double-density thumbs?
if ($image->width() >= $width * 2 && $image->height() >= $height * 2)
echo ' srcset="', $image->getThumbnailUrl($width * 2, $height * 2, $crop, $fit), ' 2x"';
else
echo ' srcset="', $image->getThumbnailUrl($image->width(), $image->height(), true), ' 2x"';
echo ' alt="" title="', $image->getTitle(), '" class="', $className, '" style="aspect-ratio: ', $aspectRatio, '">';
}
if ($this->show_labels)
echo '
@@ -131,7 +162,14 @@ class PhotosIndex extends Template
}
}
protected function portrait(array $photos, $altLayout)
protected function sixLandscapes(array $photos, $altLayout)
{
$chunks = array_chunk($photos, 3);
$this->sideLandscape($chunks[0], $altLayout);
$this->threeLandscapes($chunks[1], $altLayout);
}
protected function sidePortrait(array $photos, $altLayout)
{
$image = array_shift($photos);
@@ -164,7 +202,7 @@ class PhotosIndex extends Template
</div>';
}
protected function landscape(array $photos, $altLayout)
protected function sideLandscape(array $photos, $altLayout)
{
$image = array_shift($photos);
@@ -197,41 +235,7 @@ class PhotosIndex extends Template
</div>';
}
protected function duo(array $photos, $altLayout)
{
echo '
<div class="row g-5 mb-5 tile-duo">';
foreach ($photos as $image)
{
echo '
<div class="col-md-6">';
$this->photo($image, 'duo', static::DUO_WIDTH, static::DUO_HEIGHT, true);
echo '
</div>';
}
echo '
</div>';
}
protected function single(array $photos, $altLayout)
{
echo '
<div class="row g-5 mb-5 tile-single">
<div class="col-md-6">';
$image = array_shift($photos);
$this->photo($image, 'single', static::SINGLE_WIDTH, static::SINGLE_HEIGHT, 'top');
echo '
</div>
</div>';
}
protected function landscapes(array $photos, $altLayout)
protected function threeLandscapes(array $photos, $altLayout)
{
echo '
<div class="row g-5 mb-5 tile-row-landscapes">';
@@ -251,7 +255,7 @@ class PhotosIndex extends Template
</div>';
}
protected function portraits(array $photos, $altLayout)
protected function threePortraits(array $photos, $altLayout)
{
echo '
<div class="row g-5 mb-5 tile-row-portraits">';
@@ -271,8 +275,82 @@ class PhotosIndex extends Template
</div>';
}
protected function dualLandscapes(array $photos, $altLayout)
{
echo '
<div class="row g-5 mb-5 tile-duo">';
foreach ($photos as $image)
{
echo '
<div class="col-md-6">';
$this->photo($image, 'duo', static::DUO_WIDTH, static::DUO_HEIGHT, true);
echo '
</div>';
}
echo '
</div>';
}
protected function dualMixed(array $photos, $altLayout)
{
echo '
<div class="row g-5 mb-5 tile-feat-landscape',
$altLayout ? ' flex-row-reverse' : '', '">
<div class="col-md-8">';
$image = array_shift($photos);
$this->photo($image, 'landscape', static::LANDSCAPE_WIDTH, static::LANDSCAPE_HEIGHT, 'top');
echo '
</div>
<div class="col-md-4">';
$image = array_shift($photos);
$this->photo($image, 'portrait', static::PORTRAIT_WIDTH, static::PORTRAIT_HEIGHT, true);
echo '
</div>
</div>
</div>';
}
protected function dualPortraits(array $photos, $altLayout)
{
// Recycle the row layout so portraits don't appear too large
$this->threePortraits($photos, $altLayout);
}
protected function singleLandscape(array $photos, $altLayout)
{
echo '
<div class="row g-5 mb-5 tile-single">
<div class="col-md-6">';
$image = array_shift($photos);
$this->photo($image, 'single', static::SINGLE_WIDTH, static::SINGLE_HEIGHT, 'top');
echo '
</div>
</div>';
}
protected function singlePortrait(array $photos, $altLayout)
{
// Recycle the row layout so portraits don't appear too large
$this->threePortraits($photos, $altLayout);
}
public function setEditMenuItems(array $items)
{
$this->edit_menu_items = $items;
}
public function setUrlSuffix($suffix)
{
$this->url_suffix = $suffix;
$this->photo_url_suffix = $suffix;
}
}

View File

@@ -3,12 +3,12 @@
* TabularData.php
* Contains the template that displays tabular data.
*
* Kabuki CMS (C) 2013-2023, Aaron van Geffen
* Kabuki CMS (C) 2013-2025, Aaron van Geffen
*****************************************************************************/
class TabularData extends SubTemplate
{
private GenericTable $_t;
protected GenericTable $_t;
public function __construct(GenericTable $table)
{
@@ -16,6 +16,47 @@ class TabularData extends SubTemplate
}
protected function html_content()
{
$this->renderTitle();
foreach ($this->_subtemplates as $template)
$template->html_main();
// Showing an inline form?
$pager = $this->_t->getPageIndex();
if (!empty($pager) || isset($this->_t->form_above))
$this->renderPaginationForm($pager, $this->_t->form_above);
$tableClass = $this->_t->getTableClass();
if ($tableClass)
echo '
<div class="', $tableClass, '">';
// Build the table!
echo '
<table class="table table-striped table-condensed">';
$this->renderTableHead($this->_t->getHeader());
$this->renderTableBody($this->_t->getBody());
echo '
</table>';
if ($tableClass)
echo '
</div>';
// Showing an inline form?
if (!empty($pager) || isset($this->_t->form_below))
$this->renderPaginationForm($pager, $this->_t->form_below);
$title = $this->_t->getTitle();
if (!empty($title))
echo '
</div>';
}
protected function renderTitle()
{
$title = $this->_t->getTitle();
if (!empty($title))
@@ -25,43 +66,48 @@ class TabularData extends SubTemplate
<div class="generic-table', !empty($titleclass) ? ' ' . $titleclass : '', '">
<h1>', htmlspecialchars($title), '</h1>';
}
}
foreach ($this->_subtemplates as $template)
$template->html_main();
protected function renderPaginationForm($pager, $form)
{
echo '
<div class="row clearfix justify-content-end">';
// Showing an inline form?
$pager = $this->_t->getPageIndex();
if (!empty($pager) || isset($this->_t->form_above))
// Page index?
if (!empty($pager))
{
echo '
<div class="row clearfix justify-content-end">';
<div class="col-md">';
// Page index?
if (!empty($pager))
PageIndexWidget::paginate($pager);
// Form controls?
if (isset($this->_t->form_above))
$this->showForm($this->_t->form_above);
PageIndexWidget::paginate($pager);
echo '
</div>';
}
$tableClass = $this->_t->getTableClass();
if ($tableClass)
// Form controls?
if (isset($form))
{
echo '
<div class="', $tableClass, '">';
<div class="col-md-auto">';
InlineFormView::renderInlineForm($form);
echo '
</div>';
}
// Build the table!
echo '
<table class="table table-striped table-condensed">
</div>';
}
protected function renderTableHead(array $headers)
{
echo '
<thead>
<tr>';
// Show all headers in their full glory!
$header = $this->_t->getHeader();
foreach ($header as $th)
foreach ($headers as $th)
{
echo '
<th', (!empty($th['width']) ? ' width="' . $th['width'] . '"' : ''), (!empty($th['class']) ? ' class="' . $th['class'] . '"' : ''), ($th['colspan'] > 1 ? ' colspan="' . $th['colspan'] . '"' : ''), ' scope="', $th['scope'], '">',
@@ -75,11 +121,14 @@ class TabularData extends SubTemplate
echo '
</tr>
</thead>
</thead>';
}
protected function renderTableBody($body)
{
echo '
<tbody>';
// The body is what we came to see!
$body = $this->_t->getBody();
if (is_array($body))
{
foreach ($body as $tr)
@@ -90,145 +139,27 @@ class TabularData extends SubTemplate
foreach ($tr['cells'] as $td)
{
echo '
<td', (!empty($td['width']) ? ' width="' . $td['width'] . '"' : ''), '>';
if (!empty($td['class']))
echo '<span class="', $td['class'], '">', $td['value'], '</span>';
else
echo $td['value'];
echo '</td>';
<td',
(!empty($td['class']) ? ' class="' . $td['class'] . '"' : ''),
(!empty($td['width']) ? ' width="' . $td['width'] . '"' : ''), '>',
$td['value'],
'</td>';
}
echo '
</tr>';
}
}
// !!! Sum colspan!
else
{
$header = $this->_t->getHeader();
echo '
<tr>
<td colspan="', count($header), '" class="fullwidth">', $body, '</td>
</tr>';
echo '
</tbody>
</table>';
if ($tableClass)
echo '
</div>';
// Showing an inline form?
if (!empty($pager) || isset($this->_t->form_below))
{
echo '
<div class="row clearfix justify-content-end">';
// Page index?
if (!empty($pager))
PageIndexWidget::paginate($pager);
// Form controls?
if (isset($this->_t->form_below))
$this->showForm($this->_t->form_below);
echo '
</div>';
}
if (!empty($title))
echo '
</div>';
}
protected function showForm($form)
{
if (!isset($form['is_embed']))
echo '
<form action="', $form['action'], '" method="', $form['method'], '" class="', $form['class'], '">';
else
echo '
<div class="', $form['class'], '">';
if (!empty($form['is_group']))
echo '
<div class="input-group">';
if (!empty($form['fields']))
{
foreach ($form['fields'] as $name => $field)
{
if ($field['type'] === 'select')
{
echo '
<select class="form-select" name="', $name, '"', (isset($field['onchange']) ? ' onchange="' . $field['onchange'] . '"' : ''), '>';
foreach ($field['values'] as $value => $caption)
{
if (!is_array($caption))
{
echo '
<option value="', $value, '"', $value === $field['selected'] ? ' selected' : '', '>', $caption, '</option>';
}
else
{
$label = $value;
$options = $caption;
echo '
<optgroup label="', $label, '">';
foreach ($options as $value => $caption)
{
echo '
<option value="', $value, '"', $value === $field['selected'] ? ' selected' : '', '>', $caption, '</option>';
}
echo '
</optgroup>';
}
}
echo '
</select>';
}
else
echo '
<input name="', $name, '" id="field_', $name, '" type="', $field['type'], '" placeholder="', $field['placeholder'], '" class="form-control', isset($field['class']) ? ' ' . $field['class'] : '', '"', isset($field['value']) ? ' value="' . htmlspecialchars($field['value']) . '"' : '', '>';
if (isset($field['html_after']))
echo $field['html_after'];
}
}
echo '
<input type="hidden" name="', Session::getSessionTokenKey(), '" value="', Session::getSessionToken(), '">';
if (!empty($form['buttons']))
foreach ($form['buttons'] as $name => $button)
{
echo '
<button class="btn ', isset($button['class']) ? $button['class'] : 'btn-primary', '" type="', $button['type'], '" name="', $name, '"';
if (isset($button['onclick']))
echo ' onclick="', $button['onclick'], '"';
echo '>', $button['caption'], '</button>';
if (isset($button['html_after']))
echo $button['html_after'];
}
if (!empty($form['is_group']))
echo '
</div>';
if (!isset($form['is_embed']))
echo '
</form>';
else
echo '
</div>';
</tbody>';
}
}

View File

@@ -1,29 +0,0 @@
<?php
/*****************************************************************************
* WarningDialog.php
* Defines the WarningDialog template.
*
* Kabuki CMS (C) 2013-2015, Aaron van Geffen
*****************************************************************************/
class WarningDialog extends Alert
{
protected $buttons;
public function __construct($title = '', $message = '', $buttons = [])
{
parent::__construct($title, $message);
$this->buttons = $buttons;
}
protected function additional_alert_content()
{
$this->addButtons();
}
private function addButtons()
{
foreach ($this->buttons as $button)
$button->html_main();
}
}