297 Commits

Author SHA1 Message Date
b0ee3081a6 Tag: invert behaviour of getCount and getOffset methods 2026-02-14 12:56:50 +01:00
2cd2f472d0 Merge pull request 'ViewPeople: fix incorrect pagination count' (#55) from yorick/pics:fix/viewpeople-pagination-count into master
Reviewed-on: Public/pics#55
2026-02-14 12:43:56 +01:00
7f7067852a ViewPeople: fix incorrect pagination count
Tag::getCount was called without the third argument, causing it to
count tags where kind \!= 'Person' instead of kind = 'Person'.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 11:19:04 +01:00
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: Public/pics#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: Public/pics#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: Public/pics#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: Public/pics#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: Public/pics#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: Public/pics#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: Public/pics#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: Public/pics#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: Public/pics#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: Public/pics#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: Public/pics#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: Public/pics#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: Public/pics#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: Public/pics#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: Public/pics#40
2023-11-22 16:03:47 +01:00
903fdba471 Merge pull request 'Simplify session handling' (#39) from simplify-sessions into master
Reviewed-on: Public/pics#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: Public/pics#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: Public/pics#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: Public/pics#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
238dc1d6e7 Merge pull request 'Replace the last vestiges of htmlentities with htmlspecialchars' (#33) from htmlentities into master
Reviewed-on: Public/pics#33
2023-09-03 19:49:51 +02:00
1fa4cb19a2 Replace the last vestiges of htmlentities with htmlspecialchars 2023-09-03 19:47:22 +02:00
978d6461c5 Database: add fetch_object, queryObject, queryObjects methods 2023-06-12 12:49:22 +02:00
03ad26655c Remove unused Cache class
Kabuki CMS uses a Cache class to cache objects using APCU, but Pics has never used it.
2023-06-06 12:25:36 +02:00
bd03659b39 Bump bootstrap version to 5.3 (now stable)
This reverts commit d7837741cc.
2023-06-02 17:35:34 +02:00
2bbe1881b6 Merge pull request 'Switch crop editor to bootstrap layout' (#32) from cron-editor into master
Reviewed-on: Public/pics#32
2023-06-02 17:24:46 +02:00
d5cddba5e9 CropEditor: adjust input group background colour 2023-05-19 12:35:04 +02:00
33bc262f0a CropEditor: adopt a more Bootstrap-savvy form layout 2023-05-19 12:35:00 +02:00
8b0459fae4 CropEditor: refactor numeric control initialisation 2023-05-19 12:34:56 +02:00
6930c0a06a Misc: use the correct copyright headers 2023-04-08 21:32:38 +02:00
ed07668b2e Database: connect using utf8mb4 2023-04-08 14:54:55 +02:00
ef7fe60fca Merge pull request 'Use Bootstrap for album/photo grid' (#31) from bootstrap-tiles into master
Reviewed-on: Public/pics#31
2023-04-05 17:08:13 +02:00
87777a6ace Fixup: cleanup responsive styles too 2023-04-01 15:01:14 +02:00
9fcde24c39 PhotosIndex: reintroduce alternating odd/even layouts 2023-04-01 14:53:56 +02:00
d315f4d0c2 AlbumHeaderBox: fix slight misalignment
The 'back' arrow was one pixel taller than the header itself.
Couldn't let that slide :-)
2023-04-01 14:45:06 +02:00
be909bf54d PhotosIndex: rename 'row' layout to 'landscapes' 2023-04-01 14:41:24 +02:00
68ef80fb9f PhotoMosaic: improve heuristic for landscape/portrait row 2023-04-01 14:40:19 +02:00
31ea4196cf Remove old grid from stylesheet 2023-04-01 14:36:03 +02:00
cfb5ab9d82 PhotosIndex: rewrite to use Bootstrap grid for tiles 2023-04-01 14:29:14 +02:00
b05015e76e AlbumIndex: rewrite to use Bootstrap grid for tiles 2023-04-01 14:02:58 +02:00
a260f4ff88 ErrorHandler: use var_export for dumping superglobals as well 2023-03-28 19:21:19 +02:00
2a528f2830 ErrorHandling: improve argument handling for debug info
`var_dump` was the wrong function to call for objects, as it would just output all object
data to the output buffer... Let's generalise and use `var_export` instead :-)
2023-03-28 19:21:07 +02:00
6c5d814a99 PageIndexWidget: hide page numbers on smaller screens 2023-03-21 23:12:47 +01:00
9a8a91343b Remove old import and upgrade scripts 2023-03-21 22:48:18 +01:00
af0c8990a6 PhotosIndex: fix arrow-key based navigation 2023-03-20 18:30:30 +01:00
b2bcb6a124 Fix error handling for functions without arguments 2023-03-15 09:49:55 +01:00
d1741f2478 User: less strict typing for $reset_key property 2023-03-14 21:22:35 +01:00
d7837741cc Changes version of bootstrap to 5.2 (stable) 2023-03-14 19:33:59 +01:00
e496c7cc14 Merge pull request 'New bootstrap-based layout' (#30) from bootstrap into master
Reviewed-on: Public/pics#30
2023-03-14 19:11:24 +01:00
65cea8ed8a UploadMedia: only set thumb asset id for tags that don't have one yet 2023-03-13 16:30:24 +01:00
c6dc6bbac4 AlbumIndex: don't over-fit placeholder images 2023-03-13 01:37:31 +01:00
e48f065c25 PhotoIndex: fix inadvertent thumb stretching in rare cases 2023-03-13 01:33:29 +01:00
c991f05dd3 ViewPhoto: rework solution to work for panoramas, too 2023-03-12 12:58:58 +01:00
5c2eff09b8 PhotoPage: apply #photo_frame anchor to clicks as well 2023-03-12 12:55:32 +01:00
85be093a36 ViewPhoto: improve vertical alignment of prev/next buttons 2023-03-12 12:42:43 +01:00
c735648468 ViewPhoto: improve image alignment in page 2023-03-12 12:37:57 +01:00
41881594e9 PhotoMosaic: make photo order more intuitive 2023-03-12 12:34:47 +01:00
29bf6af1f8 Asset: delete thumbnails when deleting an assets 2023-03-12 12:21:43 +01:00
3f66fce262 MediaUploader: explicitly support image/jpeg only 2023-03-12 12:07:17 +01:00
244af88a9a Asset: cleaner handling of conflicting filenames 2023-03-12 12:02:21 +01:00
3ed84eb4d5 UploadQueue: more correct HEIC extension check 2023-03-12 11:47:36 +01:00
229fb9e5bf UploadQueue: refactor into proper ECMAScript class 2023-03-12 11:45:37 +01:00
54b69ecd11 MediaUploader: simplify form control design 2023-03-12 11:33:16 +01:00
544944a7f5 Edit{Album,Tag}: fix new tag creation 2023-03-12 11:32:13 +01:00
6087ebe249 AutoSuggest: fix click/append event
Keyboard was fine, it was just mouse events that were broken ^^'
2023-03-12 01:19:43 +01:00
3cf281b24d AdminMenu: add error count to badge iff count > 0 2023-03-12 01:04:28 +01:00
01822cdccf Fix Button, ConfirmDeletePage, WarningDialog templates 2023-03-12 01:00:50 +01:00
0325a2ec90 EditAssetForm: make form look presentable 2023-03-12 00:53:47 +01:00
70fcd097cc EditAsset: remove reference to old admin bar 2023-03-12 00:39:15 +01:00
2c24a0a7e7 MainTemplate: open vanity link in new tab 2023-03-11 22:15:17 +01:00
c7e4351375 Change album/tile label font to Coda, too 2023-03-11 22:13:55 +01:00
0b8c614191 Manage{Assets,Tags}: link user names to edituser 2023-03-11 22:07:00 +01:00
e916489d00 PhotoPage: only use columns on large displays 2023-03-11 22:04:02 +01:00
1859a9ea2a LogInForm: fix smartphone view 2023-03-11 21:57:55 +01:00
d83dd6ea6e Remove more obsolete styling 2023-03-11 21:55:44 +01:00
eb04e87085 Change autosuggest padding 2023-03-11 21:52:44 +01:00
16eda4cfe7 Move autosuggest styles to default.css 2023-03-11 21:50:08 +01:00
4c928af9ad AlbumIndex: set thumbnail dimensions for 'no thumb' images too 2023-03-11 21:46:23 +01:00
b8c53d7d4d ViewPhotoAlbum: prevent undefined index due to missing thumb 2023-03-11 21:45:03 +01:00
1b7e745f11 Clean up Tag::resetIdAsset 2023-03-11 21:41:23 +01:00
aa3a54f237 Asset: guard using property_exists in constructor 2023-03-11 21:39:20 +01:00
0b0d47acb8 UploadQueue: error out of HEIC files are presented 2023-03-11 21:36:32 +01:00
a4cc528951 ManageAssets: allow batch deletion of assets 2023-03-11 21:24:55 +01:00
5b8551a726 EditAlbum: allow specifying a thumbnail ID manually if none are present 2023-03-11 20:46:31 +01:00
5cff62836e ManageTags: display owning user in table 2023-03-11 20:39:55 +01:00
310fe7c3d6 Hide thumbnail selection when none available 2023-03-11 20:37:39 +01:00
167a50cb92 ViewPhotoAlbum: tweak album buttons to be more useful 2023-03-11 20:34:58 +01:00
d9fd2ae20d Add upgrade script for new tag ownership 2023-03-11 20:27:45 +01:00
a76dde927b AccountSettings: list tags owned by current user 2023-03-11 20:27:09 +01:00
daa8b051c5 EditTag: on saving, redirect users to a page they can see 2023-03-11 20:03:09 +01:00
27f69b0a74 EditTag: disallow users to disown their own tags 2023-03-11 20:01:25 +01:00
ad816f10a3 EditTag: allow designating a tag owner 2023-03-11 19:57:19 +01:00
59b1fa7a72 EditAlbum: allow updating the thumbnail visually 2023-03-11 19:52:30 +01:00
6d0aef4df6 EditTag: allow updating the thumbnail visually 2023-03-11 19:49:17 +01:00
a06902335b Manage{Tags,Users}: add call to resetSessionToken 2023-03-11 19:34:52 +01:00
cf0b9ebaf9 LogInForm: change title to something #RU-like 2023-03-11 19:34:01 +01:00
edc857f6fd EditTag: introduce featured thumbnail manager 2023-03-11 18:22:27 +01:00
a9a347c638 Adjust dropdown focus colours 2023-03-11 17:59:57 +01:00
fa01bf8961 ManageAssets: trade filename for user uploaded field 2023-03-11 17:53:53 +01:00
54df35073d EditAlbum: make parent selection more intuitive 2023-03-11 17:35:47 +01:00
4684482d67 ManageAlbums: move hierarchy logic to PhotoAlbum model 2023-03-11 17:28:21 +01:00
4033a8813c EditTag: hide option for assigning parent 2023-03-11 17:23:44 +01:00
4d47696dcd Use Coda font for page links, too 2023-03-11 17:20:22 +01:00
54c4294d08 Add 'no thumb' vector image for use while loading 2023-03-11 17:16:53 +01:00
e6f7476037 MainNavBar: let space invader rotate on hover 2023-03-11 17:15:59 +01:00
7d19cf823d Pass aspect ratio into photo thumbnails 2023-03-11 17:04:30 +01:00
326c8f11ee Change colours for buttons and page indices 2023-03-11 16:55:22 +01:00
556bbb2753 Use Coda font for buttons and headers 2023-03-11 16:43:53 +01:00
febe7bb405 MainNavBar: hide navigation when not logged in 2023-03-11 16:39:30 +01:00
0a8da104cc MainNavBar: randomize space invader; add Coda font 2023-03-11 16:38:03 +01:00
02b43035f3 AccountSettings: allow users to change their personal details 2023-03-11 15:32:07 +01:00
87df775c51 MainNavBar: re-introduce the space invader 2023-03-11 15:27:15 +01:00
c6902150f0 PhotoPage: move edit button from old admin bar to widget 2023-03-11 15:17:36 +01:00
277611e0ac Introduce new menu classes and navigation templates 2023-03-11 15:14:05 +01:00
b1378a3b59 DummyBox: fix SubTemplate inheritance 2023-03-11 14:38:49 +01:00
5bb8c020bd EditAssetForm: replace widget class with generic content box 2023-03-11 14:31:44 +01:00
a6fd8d2764 Admin controllers: apply new column classes 2023-03-11 14:24:17 +01:00
b9bd2bf499 AlbumHeaderBox: apply some border radius to tag headers 2023-03-11 14:17:38 +01:00
812c7a4f20 PhotoPage: change previous/next icons 2023-03-11 14:13:29 +01:00
021df2df93 Pagination: use larger page indices on photo and album index pages 2023-03-11 14:12:56 +01:00
a9a2c64d81 PhotoPage: replace custom sub-photo boxes with generic equivalents 2023-03-11 13:57:57 +01:00
cf31f0af07 Replace more custom button classes with Bootstrap counterparts 2023-03-11 13:51:12 +01:00
2d1a299fe0 Replace login and password reset templates 2023-03-11 13:44:36 +01:00
307d34430a SubTemplate: use SubTemplates for boxed content only 2023-03-11 13:37:59 +01:00
0366df9b5f Alerts: replace 'error' class with 'danger' 2023-03-11 13:30:02 +01:00
f9eefe7b41 Replace generic alert, form and table templates with new Bootstrap equivalents 2023-03-11 13:20:59 +01:00
daf6b6b264 MainTemplate: clean up HTML head; remove unused inline CSS function 2023-03-11 13:12:12 +01:00
07bc784859 Add bootstrap as a dependency 2023-03-11 12:58:30 +01:00
09f498695d Router: split off from Dispatcher 2023-01-01 19:48:19 +01:00
6b028aac41 AlbumIndex: enable rendering of more double-density thumbnails 2023-01-01 19:37:22 +01:00
2ef1289628 PhotosIndex: enable rendering of more double-density thumbnails 2023-01-01 19:37:07 +01:00
4d05cebc40 PhotoMosaic: address deprecation notice in usort call 2022-12-25 14:06:54 +01:00
ce909ccfe5 default.css: fix overflow declarations 2022-12-25 13:56:42 +01:00
1314cfdd30 composer.json: include dependent PHP extensions 2022-12-25 13:56:42 +01:00
7897172256 Address dynamic class property deprecation warnings 2022-12-25 13:56:42 +01:00
49390c372d Use triple-equals in a few more places 2022-12-25 13:50:03 +01:00
2174e1d08b PhotoPage: show software used to edit photo 2022-12-25 13:44:19 +01:00
d66f071aab Merge pull request 'Add double-density support to photo thumbnails' (#28) from improve_thumbs into master
Reviewed-on: Public/pics#28
2022-11-27 14:38:21 +01:00
7d82a4a924 Merge pull request 'Complete date-ordered orderings' (#29) from electricdusk/pics:assets-complete-ordering into master
Reviewed-on: Public/pics#29
2022-11-22 21:09:10 +01:00
b7a37c85f6 Complete date-ordered orderings
Bug as reported by Yorick: When two Assets have the same capture
date, a bug occurs in the interface where the user gets stuck in
a loop when moving to the next image.

This patch uses the primary key as a fallback when ordering the
images by capture date.  This way, the asset ordering is complete
and it should resolve the bug.
2022-11-22 12:00:53 +01:00
3de87809bb GenericTable: prevent passing NULL to strtotime 2022-07-14 16:45:32 +02:00
c763967463 Prevent current page from being 0 if no items are present 2022-07-14 16:45:17 +02:00
6369187eb7 Add double-density thumbnails to albums and photo pages 2022-07-08 23:53:28 +02:00
b3808144ca Address deprecation notices for certain function signatures 2022-07-08 23:52:03 +02:00
d8858c78bb Thumbnails: crop from original size if 2x is unavailable 2022-07-07 14:54:00 +02:00
c0d69f7205 Do not delete thumbnail queue when replacing an asset
Thumbnails are normally created on demand, e.g. when processing the format codes in a post's body text.
Normally, the temporary URL is only used once to generate thumbnails ad-hoc. However, when cache is
enabled, a reference to the asset may be used in a cached version of a formatted body text, skipping
the normal thumbnail generation routine.

When an asset is replaced, currently, all thumbnails are removed and references to them are removed
from the database. In case the asset is still referenced in a cached formatted body text, this could lead
to an error when requesting the thumbnail, as the thumbnail request is no longer present in the system.

As we do not know what posts use particular assets at this point in the code, it is best to work around this
issue by unsetting the thumbnail filenames rather than deleting the entries outright. This effectively
generates them again on the next request.

In the future, we should aim to keep track of what posts make use of assets, so cache may be invalidated
in a more targeted way.
2022-07-07 14:22:22 +02:00
b5edf09a69 Don't try to generate double-density thumbs for small images 2022-07-07 14:05:33 +02:00
54fb7ab410 Write new thumbnail filenames to parent Image object as well 2022-07-07 13:55:55 +02:00
086102d007 Thumbnail class: minor refactor of generate method 2022-07-07 13:51:03 +02:00
56b60b74bc Thumbnail class: refactor getUrl method 2022-07-07 13:33:40 +02:00
fc59708914 Split Image::getImageUrls from Image::getInlineImage 2022-07-05 12:01:02 +02:00
1c02cbea93 Rewrite Image::getInlineImage to support double density displays 2022-07-05 11:41:40 +02:00
52420b8715 Add Image::getInlineImage method 2022-06-30 15:22:08 +02:00
97 changed files with 5376 additions and 4046 deletions

View File

@@ -14,5 +14,14 @@
"models/",
"templates/"
]
},
"require": {
"ext-mysqli": "*",
"ext-imagick": "*",
"ext-gd": "*",
"ext-imagick": "*",
"ext-mysqli": "*",
"twbs/bootstrap": "^5.3",
"twbs/bootstrap-icons": "^1.10"
}
}

View File

@@ -0,0 +1,134 @@
<?php
/*****************************************************************************
* AccountSettings.php
* Contains the account settings controller.
*
* Kabuki CMS (C) 2013-2023, Aaron van Geffen
*****************************************************************************/
class AccountSettings extends HTMLController
{
public function __construct()
{
// Not logged in yet?
if (!Registry::get('user')->isLoggedIn())
throw new NotAllowedException('You need to be logged in to view this page.');
parent::__construct('Account settings');
$form_title = 'Account settings';
// Session checking!
if (empty($_POST))
Session::resetSessionToken();
else
Session::validateSession();
$fields = [
'first_name' => [
'type' => 'text',
'label' => 'First name',
'size' => 50,
'maxlength' => 255,
],
'surname' => [
'type' => 'text',
'label' => 'Family name',
'size' => 50,
'maxlength' => 255,
],
'emailaddress' => [
'type' => 'text',
'label' => 'Email address',
'size' => 50,
'maxlength' => 255,
],
'password1' => [
'before_html' => '<div class="offset-sm-2 mt-4"><p>To change your password, please fill out the fields below.</p></div>',
'type' => 'password',
'label' => 'Password',
'size' => 50,
'maxlength' => 255,
'is_optional' => true,
],
'password2' => [
'type' => 'password',
'label' => 'Password (repeat)',
'size' => 50,
'maxlength' => 255,
'is_optional' => true,
],
];
$form = new Form([
'request_url' => BASEURL . '/' . $_GET['action'] . '/',
'fields' => $fields,
'submit_caption' => 'Save details',
]);
$user = Registry::get('user');
// Create the form, add in default values.
$form->setData(empty($_POST) ? $user->getProps() : $_POST);
$formview = new FormView($form, $form_title);
$this->page->adopt($formview);
// Fetch user tags
$tags = Tag::getAllByOwner($user->getUserId());
if (!empty($tags))
$this->page->adopt(new MyTagsView($tags));
// Left a message?
if (isset($_SESSION['account_msg']))
{
$alert = $_SESSION['account_msg'];
$formview->adopt(new Alert($alert[0], $alert[1], $alert[2]));
unset($_SESSION['account_msg']);
}
// Just updating account settings?
if (!empty($_POST))
{
$form->verify($_POST);
// Anything missing?
if (!empty($form->getMissing()))
{
$missingFields = array_intersect_key($fields, array_flip($form->getMissing()));
$missingFields = array_map(function($field) { return strtolower($field['label']); }, $missingFields);
return $formview->adopt(new Alert('Some data missing', 'Please fill out the following fields: ' . implode(', ', $missingFields), 'danger'));
}
$data = $form->getData();
// Just to be on the safe side.
$data['first_name'] = htmlspecialchars(trim($data['first_name']));
$data['surname'] = htmlspecialchars(trim($data['surname']));
$data['emailaddress'] = trim($data['emailaddress']);
// If it looks like an e-mail address...
if (!empty($data['emailaddress']) && !preg_match('~^[^ ]+@[^ ]+\.[a-z]+$~', $data['emailaddress']))
return $formview->adopt(new Alert('Email addresses invalid', 'The email address you entered is not a valid email address.', 'danger'));
// Check whether email address is already linked to an account in the database -- just not to the account we happen to be editing, of course.
elseif (!empty($data['emailaddress']) && $user->getEmailAddress() !== $data['emailaddress'] && Member::exists($data['emailaddress']))
return $formview->adopt(new Alert('Email address already in use', 'Another account is already using this e-mail address.', 'danger'));
// Changing passwords?
if (!empty($data['password1']) && !empty($data['password2']))
{
if (strlen($data['password1']) < 6 || !preg_match('~[^A-z]~', $data['password1']))
return $formview->adopt(new Alert('Password not acceptable', 'Please use a password that is at least six characters long and contains at least one non-alphabetic character (e.g. a number or symbol).', 'danger'));
elseif ($data['password1'] !== $data['password2'])
return $formview->adopt(new Alert('Passwords do not match', 'The passwords you entered do not match. Please try again.', 'danger'));
// Keep just the one.
$data['password'] = $data['password1'];
unset($data['password1'], $data['password2']);
$formview->adopt(new Alert('Your password has been changed', 'Next time you log in, you can use your new password to authenticate yourself.', 'success'));
}
else
$formview->adopt(new Alert('Your account settings have been saved', 'Thank you for keeping your information current.', 'success'));
$user->update($data);
}
}
}

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.
@@ -18,6 +24,9 @@ class EditAlbum extends HTMLController
if (empty($id_tag) && !isset($_GET['add']) && $_GET['action'] !== 'addalbum')
throw new UnexpectedValueException('Requested album not found or not requesting a new album.');
if (!empty($id_tag))
$album = Tag::fromId($id_tag);
// Adding an album?
if (isset($_GET['add']) || $_GET['action'] === 'addalbum')
{
@@ -29,21 +38,19 @@ class EditAlbum extends HTMLController
elseif (isset($_GET['delete']))
{
// So far so good?
$album = Tag::fromId($id_tag);
if (Session::validateSession('get') && $album->kind === 'Album' && $album->delete())
{
header('Location: ' . BASEURL . '/managealbums/');
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
{
$album = Tag::fromId($id_tag);
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 . '\'';
@@ -61,41 +68,50 @@ class EditAlbum extends HTMLController
elseif (!$id_tag)
$after_form = '<button name="submit_and_new" class="btn">Save and add another</button>';
$form = new Form([
'request_url' => BASEURL . '/editalbum/?' . ($id_tag ? 'id=' . $id_tag : 'add'),
'content_below' => $after_form,
'fields' => [
'id_parent' => [
'type' => 'numeric',
'label' => 'Parent album ID',
],
'id_asset_thumb' => [
'type' => 'numeric',
'label' => 'Thumbnail asset ID',
'is_optional' => true,
],
'tag' => [
'type' => 'text',
'label' => 'Album title',
'size' => 50,
'maxlength' => 255,
],
'slug' => [
'type' => 'text',
'label' => 'URL slug',
'size' => 50,
'maxlength' => 255,
],
'description' => [
'type' => 'textbox',
'label' => 'Description',
'size' => 50,
'maxlength' => 255,
'is_optional' => true,
],
// Gather possible parents for this album to be filed into
$parentChoices = [0 => '-root-'];
foreach (Tag::getOffset(0, 9999, 'tag', 'up', true) as $parent)
{
if (!empty($id_tag) && $parent['id_tag'] == $id_tag)
continue;
$parentChoices[$parent['id_tag']] = $parent['tag'];
}
$fields = [
'id_parent' => [
'type' => 'select',
'label' => 'Parent album',
'options' => $parentChoices,
],
'tag' => [
'type' => 'text',
'label' => 'Album title',
'size' => 50,
'maxlength' => 255,
],
'slug' => [
'type' => 'text',
'label' => 'URL slug',
'size' => 50,
'maxlength' => 255,
],
'description' => [
'type' => 'textbox',
'label' => 'Description',
'size' => 50,
'maxlength' => 255,
'is_optional' => true,
],
];
$this->form = new Form([
'request_url' => BASEURL . '/editalbum/?' . ($id_tag ? 'id=' . $id_tag : 'add'),
'buttons_extra' => $after_form,
'fields' => $fields,
]);
// Add defaults for album if none present
if (empty($_POST) && isset($_GET['tag']))
{
$parentTag = Tag::fromId($_GET['tag']);
@@ -108,28 +124,96 @@ 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 (!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($id_tag, $album ?? null);
}
private function processThumbnail($tag)
{
if (empty($_POST))
return;
$tag->id_asset_thumb = $_POST['featuredThumbnail'];
$tag->save();
header('Location: ' . BASEURL . '/editalbum/?id=' . $tag->id_tag);
exit;
}
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()), 'error'));
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 $this->formview->adopt(new Alert('Invalid parent', 'An album cannot be its own parent.', 'danger'));
}
// Quick stripping.
$data['tag'] = htmlentities($data['tag']);
$data['description'] = htmlentities($data['description']);
$data['tag'] = htmlspecialchars($data['tag']);
$data['description'] = htmlspecialchars($data['description']);
$data['slug'] = strtr($data['slug'], [' ' => '-', '--' => '-', '&' => 'and', '=>' => '', "'" => "", ":"=> "", '\\' => '-']);
// TODO: when updating slug, update slug for all photos in this album.
@@ -140,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...', 'error'));
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(htmlentities($_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,16 +177,15 @@ 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);
// Add a view button to the admin bar for photos.
if ($asset->isImage())
$this->admin_bar->appendItem($asset->getImage()->getPageUrl(), 'View this photo');
}
private function getThumbs(Asset $asset)

View File

@@ -8,16 +8,22 @@
class EditTag extends HTMLController
{
const THUMBS_PER_PAGE = 20;
public function __construct()
{
// Ensure it's just admins at this point.
if (!Registry::get('user')->isAdmin())
throw new NotAllowedException();
$id_tag = isset($_GET['id']) ? (int) $_GET['id'] : 0;
if (empty($id_tag) && !isset($_GET['add']))
throw new UnexpectedValueException('Requested tag not found or not requesting a new tag.');
if (!empty($id_tag))
$tag = Tag::fromId($id_tag);
// Are we allowed to edit this tag?
$user = Registry::get('user');
if (!($user->isAdmin() || $user->getUserId() == $tag->id_user_owner))
throw new NotAllowedException();
// Adding an tag?
if (isset($_GET['add']))
{
@@ -29,21 +35,19 @@ class EditTag extends HTMLController
elseif (isset($_GET['delete']))
{
// So far so good?
$tag = Tag::fromId($id_tag);
if (Session::validateSession('get') && $tag->kind !== 'Album' && $tag->delete())
{
header('Location: ' . BASEURL . '/managetags/');
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
{
$tag = Tag::fromId($id_tag);
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 . '\'';
@@ -61,47 +65,51 @@ class EditTag extends HTMLController
elseif (!$id_tag)
$after_form = '<button name="submit_and_new" class="btn">Save and add another</button>';
$form = new Form([
'request_url' => BASEURL . '/edittag/?' . ($id_tag ? 'id=' . $id_tag : 'add'),
'content_below' => $after_form,
'fields' => [
'id_parent' => [
'type' => 'numeric',
'label' => 'Parent tag ID',
],
'id_asset_thumb' => [
'type' => 'numeric',
'label' => 'Thumbnail asset ID',
'is_optional' => true,
],
'kind' => [
'type' => 'select',
'label' => 'Kind of tag',
'options' => [
'Location' => 'Location',
'Person' => 'Person',
],
],
'tag' => [
'type' => 'text',
'label' => 'Tag title',
'size' => 50,
'maxlength' => 255,
],
'slug' => [
'type' => 'text',
'label' => 'URL slug',
'size' => 50,
'maxlength' => 255,
],
'description' => [
'type' => 'textbox',
'label' => 'Description',
'size' => 50,
'maxlength' => 255,
'is_optional' => true,
$fields = [
'kind' => [
'type' => 'select',
'label' => 'Kind of tag',
'options' => [
'Location' => 'Location',
'Person' => 'Person',
],
],
'id_user_owner' => [
'type' => 'select',
'label' => 'Owner',
'options' => [0 => '(nobody)'] + Member::getMemberMap(),
],
'tag' => [
'type' => 'text',
'label' => 'Tag title',
'size' => 50,
'maxlength' => 255,
],
'slug' => [
'type' => 'text',
'label' => 'URL slug',
'size' => 50,
'maxlength' => 255,
],
'description' => [
'type' => 'textbox',
'label' => 'Description',
'size' => 50,
'maxlength' => 255,
'is_optional' => true,
],
];
if (!$user->isAdmin())
{
unset($fields['kind']);
unset($fields['id_user_owner']);
}
$form = new Form([
'request_url' => BASEURL . '/edittag/?' . ($id_tag ? 'id=' . $id_tag : 'add'),
'buttons_extra' => $after_form,
'fields' => $fields,
]);
// Create the form, add in default values.
@@ -109,15 +117,68 @@ class EditTag extends HTMLController
$formview = new FormView($form, $form_title ?? '');
$this->page->adopt($formview);
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 ? $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']))
$this->processThumbnail($tag);
elseif (!empty($_POST))
$this->processTagDetails($form, $id_tag, $tag ?? null);
}
private function processThumbnail($tag)
{
if (empty($_POST))
return;
$tag->id_asset_thumb = $_POST['featuredThumbnail'];
$tag->save();
header('Location: ' . BASEURL . '/edittag/?id=' . $tag->id_tag);
exit;
}
private function processTagDetails($form, $id_tag, $tag)
{
if (!empty($_POST))
{
$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()), 'error'));
return $formview->adopt(new Alert('Some data missing', 'Please fill out the following fields: ' . implode(', ', $form->getMissing()), 'danger'));
$data = $form->getData();
$data['id_parent'] = 0;
// Quick stripping.
$data['slug'] = strtr($data['slug'], [' ' => '-', '--' => '-', '&' => 'and', '=>' => '', "'" => "", ":"=> "", '/' => '-', '\\' => '-']);
@@ -127,7 +188,7 @@ class EditTag extends HTMLController
{
$return = Tag::createNew($data);
if ($return === false)
return $formview->adopt(new Alert('Cannot create this tag', 'Something went wrong while creating the tag...', 'error'));
return $formview->adopt(new Alert('Cannot create this tag', 'Something went wrong while creating the tag...', 'danger'));
if (isset($_POST['submit_and_new']))
{
@@ -144,8 +205,11 @@ class EditTag extends HTMLController
$tag->save();
}
// Redirect to the tag management page.
header('Location: ' . BASEURL . '/managetags/');
// Redirect to a clean page
if (Registry::get('user')->isAdmin())
header('Location: ' . BASEURL . '/managetags/');
else
header('Location: ' . BASEURL . '/edittag/?id=' . $id_tag);
exit;
}
}

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',
@@ -129,13 +129,13 @@ class EditUser extends HTMLController
// Anything missing?
if (!empty($form->getMissing()))
return $formview->adopt(new Alert('Some data missing', 'Please fill out the following fields: ' . implode(', ', $form->getMissing()), 'error'));
return $formview->adopt(new Alert('Some data missing', 'Please fill out the following fields: ' . implode(', ', $form->getMissing()), 'danger'));
$data = $form->getData();
// Just to be on the safe side.
$data['first_name'] = htmlentities(trim($data['first_name']));
$data['surname'] = htmlentities(trim($data['surname']));
$data['first_name'] = htmlspecialchars(trim($data['first_name']));
$data['surname'] = htmlspecialchars(trim($data['surname']));
$data['emailaddress'] = trim($data['emailaddress']);
// Make sure there's a slug.
@@ -150,18 +150,18 @@ class EditUser extends HTMLController
// If it looks like an e-mail address...
if (!empty($data['emailaddress']) && !preg_match('~^[^ ]+@[^ ]+\.[a-z]+$~', $data['emailaddress']))
return $formview->adopt(new Alert('Email addresses invalid', 'The email address you entered is not a valid email address.', 'error'));
return $formview->adopt(new Alert('Email addresses invalid', 'The email address you entered is not a valid email address.', 'danger'));
// Check whether email address is already linked to an account in the database -- just not to the account we happen to be editing, of course.
elseif (!empty($data['emailaddress']) && Member::exists($data['emailaddress']) && !($id_user && $user->getEmailAddress() == $data['emailaddress']))
return $formview->adopt(new Alert('Email address already in use', 'Another account is already using the e-mail address you entered.', 'error'));
return $formview->adopt(new Alert('Email address already in use', 'Another account is already using the e-mail address you entered.', 'danger'));
// Setting passwords? We'll need two!
if (!$id_user || !empty($data['password1']) && !empty($data['password2']))
{
if (strlen($data['password1']) < 6 || !preg_match('~[^A-z]~', $data['password1']))
return $formview->adopt(new Alert('Password not acceptable', 'Please fill in a password that is at least six characters long and contains at least one non-alphabetic character (e.g. a number or symbol).', 'error'));
return $formview->adopt(new Alert('Password not acceptable', 'Please fill in a password that is at least six characters long and contains at least one non-alphabetic character (e.g. a number or symbol).', 'danger'));
elseif ($data['password1'] !== $data['password2'])
return $formview->adopt(new Alert('Passwords do not match', 'The passwords you entered do not match. Please try again.', 'error'));
return $formview->adopt(new Alert('Passwords do not match', 'The passwords you entered do not match. Please try again.', 'danger'));
else
$data['password'] = $data['password1'];
@@ -173,7 +173,7 @@ class EditUser extends HTMLController
{
$return = Member::createNew($data);
if ($return === false)
return $formview->adopt(new Alert('Cannot create this user', 'Something went wrong while creating the user...', 'error'));
return $formview->adopt(new Alert('Cannot create this user', 'Something went wrong while creating the user...', 'danger'));
if (isset($_POST['submit_and_new']))
{

View File

@@ -12,7 +12,6 @@
abstract class HTMLController
{
protected $page;
protected $admin_bar;
public function __construct($title)
{
@@ -22,8 +21,6 @@ abstract class HTMLController
if (Registry::get('user')->isAdmin())
{
$this->page->appendStylesheet(BASEURL . '/css/admin.css');
$this->admin_bar = new AdminBar();
$this->page->adopt($this->admin_bar);
}
}

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']));
@@ -44,7 +46,7 @@ class Login extends HTMLController
parent::__construct('Log in - ' . SITE_TITLE);
$form = new LogInForm('Log in');
if ($login_error)
$form->adopt(new Alert('', 'Invalid email address or password.', 'error'));
$form->adopt(new Alert('', 'Invalid email address or password.', 'danger'));
// Tried anything? Be helpful, at least.
if (isset($_POST['emailaddress']))

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' => 'floatright',
'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,51 +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' => 'floatleft',
'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';
$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' => $rows,
'order' => $order,
'direction' => ($direction == 'up' ? 'up' : 'down'),
];
'get_data' => function($offset, $limit, $order, $direction) {
return Tag::getOffset($offset, $limit, $order, $direction);
},
'get_count' => function() {
return 9999;
return Tag::getCount(false, 'Album');
}
];
@@ -106,42 +70,4 @@ class ManageAlbums extends HTMLController
parent::__construct('Album management - Page ' . $table->getCurrentPage() .' - ' . SITE_TITLE);
$this->page->adopt(new TabularData($table));
}
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

@@ -14,10 +14,57 @@ class ManageAssets extends HTMLController
if (!Registry::get('user')->isAdmin())
throw new NotAllowedException();
if (isset($_POST['deleteChecked'], $_POST['delete']) && Session::validateSession())
$this->handleAssetDeletion();
Session::resetSessionToken();
$options = [
'form' => [
'action' => BASEURL . '/manageassets/?' . Session::getSessionTokenKey() . '=' . Session::getSessionToken(),
'method' => 'post',
'is_embed' => true,
'controls' => [
'deleteChecked' => [
'type' => 'submit',
'caption' => 'Delete checked',
'class' => 'btn-danger',
'onclick' => 'return confirm(\'Are you sure you want to delete these items?\')',
],
],
],
'columns' => [
'checkbox' => [
'header' => '<input type="checkbox" id="selectall">',
'is_sortable' => false,
'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',
'header' => 'ID',
@@ -32,69 +79,64 @@ 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',
],
'title' => [
'header' => 'Title',
'id_user_uploaded' => [
'header' => 'User uploaded',
'is_sortable' => true,
'parse' => [
'type' => 'value',
'link' => BASEURL . '/editasset/?id={ID_ASSET}',
'data' => 'title',
],
'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' => 'pull_left',
'base_url' => BASEURL . '/manageassets/',
'get_data' => function($offset = 0, $limit = 30, $order = '', $direction = 'down') {
if (!in_array($order, ['id_asset', 'title', 'subdir', 'filename']))
$order = 'id_asset';
$data = Registry::get('db')->queryAssocs('
SELECT id_asset, title, subdir, filename, image_width, image_height
FROM assets
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',
];
$table = new GenericTable($options);
parent::__construct('Asset management - Page ' . $table->getCurrentPage() .'');
$this->page->adopt(new TabularData($table));
parent::__construct('Asset management - Page ' . $table->getCurrentPage());
$wrapper = new AssetManagementWrapper();
$this->page->adopt($wrapper);
$wrapper->adopt(new TabularData($table));
}
private function handleAssetDeletion()
{
if (!isset($_POST['delete']) || !is_array($_POST['delete']))
throw new UnexpectedValueException();
foreach ($_POST['delete'] as $id_asset)
{
$asset = Asset::fromId($id_asset);
$asset->delete();
}
header('Location: ' . BASEURL . '/manageassets/');
exit;
}
}

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,35 +29,32 @@ class ManageErrors extends HTMLController
'form' => [
'action' => BASEURL . '/manageerrors/?' . Session::getSessionTokenKey() . '=' . Session::getSessionToken(),
'method' => 'post',
'class' => 'floatright',
'buttons' => [
'flush' => [
'controls' => [
'clear' => [
'type' => 'submit',
'caption' => 'Delete all',
'class' => 'btn-danger',
],
],
],
'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',
@@ -70,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,
@@ -88,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' => 'floatleft',
'base_url' => BASEURL . '/manageerrors/',
'get_count' => 'ErrorLog::getCount',
'get_data' => function($offset = 0, $limit = 20, $order = '', $direction = 'down') {
if (!in_array($order, ['id_entry', 'file', 'line', 'time', 'ipaddress', 'id_user']))
$order = 'id_entry';
$data = Registry::get('db')->queryAssocs('
SELECT *
FROM log_errors
ORDER BY {raw:order}
LIMIT {int:offset}, {int:limit}',
[
'order' => $order . ($direction === 'up' ? ' ASC' : ' DESC'),
'offset' => $offset,
'limit' => $limit,
]);
return [
'rows' => $data,
'order' => $order,
'direction' => $direction,
];
},
'get_data' => 'ErrorLog::getOffset',
];
$error_log = new GenericTable($options);

View File

@@ -14,12 +14,13 @@ class ManageTags extends HTMLController
if (!Registry::get('user')->isAdmin())
throw new NotAllowedException();
Session::resetSessionToken();
$options = [
'form' => [
'action' => BASEURL . '/edittag/',
'method' => 'get',
'class' => 'floatright',
'buttons' => [
'controls' => [
'add' => [
'type' => 'submit',
'caption' => 'Add new tag',
@@ -35,23 +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',
],
'kind' => [
'header' => 'Kind',
'id_user_owner' => [
'header' => 'Owning user',
'is_sortable' => true,
'value' => 'kind',
'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',
@@ -59,45 +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' => 'floatleft',
'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 *
FROM tags
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, true);
},
'get_count' => function() {
return Registry::get('db')->queryValue('
SELECT COUNT(*)
FROM tags
WHERE kind != {string:album}',
['album' => 'Album']);
return Tag::getCount(false, null, true);
}
];

View File

@@ -14,12 +14,13 @@ class ManageUsers extends HTMLController
if (!Registry::get('user')->isAdmin())
throw new NotAllowedException();
Session::resetSessionToken();
$options = [
'form' => [
'action' => BASEURL . '/edituser/',
'method' => 'get',
'class' => 'floatright',
'buttons' => [
'controls' => [
'add' => [
'type' => 'submit',
'caption' => 'Add new user',
@@ -35,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',
@@ -62,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,
@@ -80,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' => 'floatleft',
'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

@@ -57,7 +57,7 @@ class ProvideAutoSuggest extends JSONController
return;
}
$label = htmlentities(trim($_REQUEST['tag']));
$label = htmlspecialchars(trim($_REQUEST['tag']));
$slug = strtr($label, [' ' => '-']);
$tag = Tag::createNew([
'tag' => $label,

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>', 'error'));
}
}
$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.', 'error'));
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

@@ -33,21 +33,20 @@ class UploadMedia extends HTMLController
if (empty($uploaded_file))
continue;
// DIY slug club.
$slug = $tag->slug . '/' . strtr($uploaded_file['name'], [' ' => '-', '--' => '-', '&' => 'and', '=>' => '', "'" => "", ":"=> "", '\\' => '-']);
$asset = Asset::createNew([
'filename_to_copy' => $uploaded_file['tmp_name'],
'preferred_filename' => $uploaded_file['name'],
'preferred_subdir' => $tag->slug,
'slug' => $slug,
]);
$new_ids[] = $asset->getId();
$asset->linkTags([$tag->id_tag]);
$tag->id_asset_thumb = $asset->getId();
$tag->save();
if (empty($tag->id_asset_thumb))
{
$tag->id_asset_thumb = $asset->getId();
$tag->save();
}
}
if (isset($_REQUEST['format']) && $_REQUEST['format'] === 'json')

View File

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

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,74 +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);
// Add an edit button to the admin bar.
if ($user->isAdmin())
$this->admin_bar->appendItem(BASEURL . '/editasset/?id=' . $photo->getId(), 'Edit this photo');
}
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);
}
else if (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');
@@ -100,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;
}
@@ -108,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,80 +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 = [];
// Who contributed to this album?
$contributors = $tag->getContributorList();
if (Registry::get('user')->isLoggedIn())
// Enumerate possible filters
$filters = [];
if (!empty($contributors))
{
$buttons[] = [
'url' => BASEURL . '/download/?tag=' . $id_tag,
'caption' => 'Download this album',
];
$filters[''] = ['id_user' => null, 'label' => '', 'caption' => 'All photos',
'link' => $tag->getUrl()];
$buttons[] = [
'url' => BASEURL . '/uploadmedia/?tag=' . $id_tag,
'caption' => 'Upload new photos here',
];
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'],
];
}
}
if (Registry::get('user')->isAdmin())
$buttons[] = [
'url' => BASEURL . '/addalbum/?tag=' . $id_tag,
'caption' => 'Create new subalbum here',
];
// Enough actions for a button box?
if (!empty($buttons))
$this->page->adopt(new AlbumButtonBox($buttons));
// 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.
@@ -108,23 +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%/',
'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 Pagination($index));
$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,
@@ -156,16 +169,124 @@ class ViewPhotoAlbum extends HTMLController
'id_tag' => $album['id_tag'],
'caption' => $album['tag'],
'link' => BASEURL . '/' . $album['slug'] . '/',
'thumbnail' => !empty($album['id_asset_thumb']) ? $assets[$album['id_asset_thumb']]->getImage() : null,
'thumbnail' => !empty($album['id_asset_thumb']) && isset($assets[$album['id_asset_thumb']])
? $assets[$album['id_asset_thumb']]->getImage() : null,
];
}
return $albums;
}
public function __destruct()
private function getAlbumButtons(Tag $tag, $active_filter)
{
if (isset($this->iterator))
$this->iterator->clean();
$buttons = [];
$user = Registry::get('user');
if ($user->isLoggedIn())
{
$suffix = !empty($active_filter) ? '&by=' . $active_filter : '';
$buttons[] = [
'url' => BASEURL . '/download/?tag=' . $tag->id_tag . $suffix,
'caption' => 'Download album',
];
}
if ($tag->id_parent != 0)
{
if ($tag->kind === 'Album')
{
$buttons[] = [
'url' => BASEURL . '/uploadmedia/?tag=' . $tag->id_tag,
'caption' => 'Upload photos here',
];
}
if ($user->isAdmin())
{
if ($tag->kind === 'Album')
{
$buttons[] = [
'url' => BASEURL . '/editalbum/?id=' . $tag->id_tag,
'caption' => 'Edit album',
];
}
elseif ($tag->kind === 'Person')
{
$buttons[] = [
'url' => BASEURL . '/edittag/?id=' . $tag->id_tag,
'caption' => 'Edit tag',
];
}
}
}
if ($user->isAdmin() && (!isset($tag) || $tag->kind === 'Album'))
{
$buttons[] = [
'url' => BASEURL . '/addalbum/?tag=' . $tag->id_tag,
'caption' => 'Create subalbum',
];
}
return $buttons;
}
private function getEditMenuItems($url_suffix)
{
$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,17 +46,12 @@ 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-around justify-content-lg-center',
]);
$this->page->adopt(new Pagination($index));
$this->page->adopt(new PageIndexWidget($index));
}
// Set the canonical url.
$this->page->setCanonicalUrl(BASEURL . '/timeline/');
}
public function __destruct()
{
if (isset($this->iterator))
$this->iterator->clean();
}
}

View File

@@ -1,319 +0,0 @@
<?php
/*****************************************************************************
* import_albums.php
* Imports albums from a Gallery 3 database.
*
* Kabuki CMS (C) 2013-2016, Aaron van Geffen
*****************************************************************************/
// Include the project's configuration.
require_once 'config.php';
// Set up the autoloader.
require_once 'vendor/autoload.php';
// Initialise the database.
$db = new Database(DB_SERVER, DB_USER, DB_PASS, DB_NAME);
$pdb = new Database(DB_SERVER, DB_USER, DB_PASS, "hashru_gallery");
Registry::set('db', $db);
// Do some authentication checks.
Session::start();
Registry::set('user', Authentication::isLoggedIn() ? Member::fromId($_SESSION['user_id']) : new Guest());
// Enable debugging.
//set_error_handler('ErrorHandler::handleError');
ini_set("display_errors", DEBUG ? "On" : "Off");
/*******************************
* STEP 0: USERS
*******************************/
$num_users = $pdb->queryValue('
SELECT COUNT(*)
FROM users');
echo $num_users, ' users to import.', "\n";
$rs_users = $pdb->query('
SELECT id, name, full_name, password, last_login, email, admin
FROM users
WHERE id > 1
ORDER BY id ASC');
$old_user_id_to_new_user_id = [];
while ($user = $pdb->fetch_assoc($rs_users))
{
// Check whether a user already exists for this e-mail address.
if (!($id_user = Authentication::getUserId($user['email'])))
{
$bool = $db->insert('insert', 'users', [
'first_name' => 'string-30',
'surname' => 'string-60',
'slug' => 'string-90',
'emailaddress' => 'string-255',
'password_hash' => 'string-255',
'creation_time' => 'int',
'last_action_time' => 'int',
'ip_address' => 'string-15',
'is_admin' => 'int',
], [
'first_name' => substr($user['full_name'], 0, strpos($user['full_name'], ' ')),
'surname' => substr($user['full_name'], strpos($user['full_name'], ' ') + 1),
'slug' => $user['name'],
'emailaddress' => $user['email'],
'password_hash' => $user['password'],
'creation_time' => 0,
'last_action_time' => $user['last_login'],
'ip_address' => '0.0.0.0',
'is_admin' => $user['admin'],
], ['id_user']);
if ($bool)
$id_user = $db->insert_id();
else
die("User creation failed!");
}
$old_user_id_to_new_user_id[$user['id']] = $id_user;
}
$pdb->free_result($rs_users);
/*******************************
* STEP 1: ALBUMS
*******************************/
$num_albums = $pdb->queryValue('
SELECT COUNT(*)
FROM items
WHERE type = {string:album}
ORDER BY id ASC',
['album' => 'album']);
echo $num_albums, ' albums to import.', "\n";
$albums = $pdb->query('
SELECT id, album_cover_item_id, parent_id, title, description, relative_path_cache, relative_url_cache
FROM items
WHERE type = {string:album}
ORDER BY id ASC',
['album' => 'album']);
$tags = [];
$old_album_id_to_new_tag_id = [];
$dirnames_by_old_album_id = [];
$old_thumb_id_by_tag_id = [];
while ($album = $pdb->fetch_assoc($albums))
{
$tag = Tag::createNew([
'tag' => $album['title'],
'slug' => $album['relative_url_cache'],
'kind' => 'Album',
'description' => $album['description'],
]);
if (!empty($album['parent_id']))
$parent_to_set[$tag->id_tag] = $album['parent_id'];
$tags[$tag->id_tag] = $tag;
$old_album_id_to_new_tag_id[$album['id']] = $tag->id_tag;
$dirnames_by_old_album_id[$album['id']] = str_replace('#', '', urldecode($album['relative_path_cache']));
$old_thumb_id_by_tag_id[$tag->id_tag] = $album['album_cover_item_id'];
}
$pdb->free_result($albums);
foreach ($parent_to_set as $id_tag => $old_album_id)
{
$id_parent = $old_album_id_to_new_tag_id[$old_album_id];
$db->query('
UPDATE tags
SET id_parent = ' . $id_parent . '
WHERE id_tag = ' . $id_tag);
}
unset($parent_to_set);
/*******************************
* STEP 2: PHOTOS
*******************************/
$num_photos = $pdb->queryValue('
SELECT COUNT(*)
FROM items
WHERE type = {string:photo}',
['photo' => "photo"]);
echo $num_photos, " photos to import.\n";
$old_photo_id_to_asset_id = [];
for ($i = 0; $i < $num_photos; $i += 50)
{
echo 'Offset ' . $i . "...\n";
$photos = $pdb->query('
SELECT id, owner_id, parent_id, captured, created, name, title, description, relative_url_cache, width, height, mime_type, weight
FROM items
WHERE type = {string:photo}
ORDER BY id ASC
LIMIT ' . $i . ', 50',
['photo' => 'photo']);
while ($photo = $pdb->fetch_assoc($photos))
{
$res = $db->query('
INSERT INTO assets
(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' => $old_user_id_to_new_user_id[$photo['owner_id']],
'subdir' => $dirnames_by_old_album_id[$photo['parent_id']],
'filename' => str_replace('#', '', $photo['name']),
'title' => $photo['title'],
'slug' => str_replace('#', '', urldecode($photo['relative_url_cache'])),
'mimetype' => $photo['mime_type'],
'image_width' => !empty($photo['width']) ? $photo['width'] : 'NULL',
'image_height' => !empty($photo['height']) ? $photo['height'] : 'NULL',
'date_captured' => !empty($photo['captured']) ? $photo['captured'] : $photo['created'],
'priority' => !empty($photo['weight']) ? (int) $photo['weight'] : 0,
]);
$id_asset = $db->insert_id();
$old_photo_id_to_asset_id[$photo['id']] = $id_asset;
// Link to album.
$db->query('
INSERT INTO assets_tags
(id_asset, id_tag)
VALUES
({int:id_asset}, {int:id_tag})',
[
'id_asset' => $id_asset,
'id_tag' => $old_album_id_to_new_tag_id[$photo['parent_id']],
]);
}
}
/*******************************
* STEP 3: TAGS
*******************************/
$num_tags = $pdb->queryValue('
SELECT COUNT(*)
FROM tags');
echo $num_tags, " tags to import.\n";
$rs_tags = $pdb->query('
SELECT id, name, count
FROM tags');
$old_tag_id_to_new_tag_id = [];
while ($person = $pdb->fetch_assoc($rs_tags))
{
$tag = Tag::createNew([
'tag' => $person['name'],
'slug' => $person['name'],
'kind' => 'Person',
'description' => '',
'count' => $person['count'],
]);
$tags[$tag->id_tag] = $tag;
$old_tag_id_to_new_tag_id[$person['id']] = $tag->id_tag;
}
$pdb->free_result($rs_tags);
/*******************************
* STEP 4: TAGGED PHOTOS
*******************************/
$num_tagged = $pdb->queryValue('
SELECT COUNT(*)
FROM items_tags
WHERE item_id IN(
SELECT id
FROM items
WHERE type = {string:photo}
)',
['photo' => 'photo']);
echo $num_tagged, " photo tags to import.\n";
$rs_tags = $pdb->query('
SELECT item_id, tag_id
FROM items_tags
WHERE item_id IN(
SELECT id
FROM items
WHERE type = {string:photo}
)',
['photo' => 'photo']);
while ($tag = $pdb->fetch_assoc($rs_tags))
{
if (!isset($old_tag_id_to_new_tag_id[$tag['tag_id']], $old_photo_id_to_asset_id[$tag['item_id']]))
continue;
$id_asset = $old_photo_id_to_asset_id[$tag['item_id']];
$id_tag = $old_tag_id_to_new_tag_id[$tag['tag_id']];
// Link up.
$db->query('
INSERT IGNORE INTO assets_tags
(id_asset, id_tag)
VALUES
({int:id_asset}, {int:id_tag})',
[
'id_asset' => $id_asset,
'id_tag' => $id_tag,
]);
}
$pdb->free_result($rs_tags);
/*******************************
* STEP 5: THUMBNAIL IDS
*******************************/
foreach ($old_thumb_id_by_tag_id as $id_tag => $old_thumb_id)
{
if (!isset($old_photo_id_to_asset_id[$old_thumb_id]))
continue;
$id_asset = $old_photo_id_to_asset_id[$old_thumb_id];
$db->query('
UPDATE tags
SET id_asset_thumb = ' . $id_asset . '
WHERE id_tag = ' . $id_tag);
}
/*******************************
* STEP 6: THUMBNAILS FOR PEOPLE
*******************************/
$db->query('
UPDATE tags AS t
SET id_asset_thumb = (
SELECT id_asset
FROM assets_tags AS a
WHERE a.id_tag = t.id_tag
ORDER BY RAND()
LIMIT 1
)
WHERE kind = {string:person}',
['person' => 'Person']);
/*******************************
* STEP 7: CLEANING UP
*******************************/
Tag::recount();

View File

@@ -1,20 +0,0 @@
#!/bin/bash
# ALBUM UPDATE
# Hashes uit filenames.
find . -name '*#*' -exec rename -v "s/#//" {} \;
# Orientatie-tags goedzetten.
find public/assets/borrel/april-2015/ -type f -exec exiftool -n -Orientation=1 "{}" \;
find public/assets/Eetpartijtjes/ruwinterbbq/ -type f -exec exiftool -n -Orientation=1 "{}" \;
find public/assets/Eetpartijtjes/Tapasavond-oktober-2011/ -type f -exec exiftool -n -Orientation=1 "{}" \;
find public/assets/Eetpartijtjes/Verjaardag-IV-bij-Wally/ -type f -exec exiftool -n -Orientation=1 "{}" \;
find public/assets/Uitstapjes/Final-Symphony-Wuppertal-2013-05-11/ -type f -exec exiftool -n -Orientation=1 "{}" \;
find public/assets/Universiteit/Oude-sneeuwfoto\'s/ -type f -exec exiftool -n -Orientation=1 "{}" \;
find public/assets/Weekenden/Susteren-2012 -type f -exec exiftool -n -Orientation=1 "{}" \;
find public/assets/Weekenden/Susteren-2013 -type f -exec exiftool -n -Orientation=1 "{}" \;
find public/assets/Weekenden/Wijhe-2016/ -type f -exec exiftool -n -Orientation=1 "{}" \;
# Remove backup files.
find public/assets/ -type f -name '*_original' -delete

View File

@@ -1,53 +0,0 @@
<?php
/*****************************************************************************
* migrate_thumbs.php
* Migrates old-style thumbnails (meta) to new table.
*
* Kabuki CMS (C) 2013-2015, Aaron van Geffen
*****************************************************************************/
// Include the project's configuration.
require_once 'config.php';
// Set up the autoloader.
require_once 'vendor/autoload.php';
// Initialise the database.
$db = new Database(DB_SERVER, DB_USER, DB_PASS, DB_NAME);
Registry::set('db', $db);
// Do some authentication checks.
Session::start();
Registry::set('user', Authentication::isLoggedIn() ? Member::fromId($_SESSION['user_id']) : new Guest());
$res = $db->query('
SELECT id_asset, variable, value
FROM assets_meta
WHERE variable LIKE {string:thumbs}',
['thumbs' => 'thumb_%']);
while ($row = $db->fetch_assoc($res))
{
if (!preg_match('~^thumb_(?<width>\d+)x(?<height>\d+)(?:_(?<mode>c[best]?))?$~', $row['variable'], $match))
continue;
echo 'Migrating ... ', $row['value'], '(#', $row['id_asset'], ")\r";
$db->insert('replace', 'assets_thumbs', [
'id_asset' => 'int',
'width' => 'int',
'height' => 'int',
'mode' => 'string-3',
'filename' => 'string-255',
], [
'id_asset' => $row['id_asset'],
'width' => $match['width'],
'height' => $match['height'],
'mode' => $match['mode'] ?? '',
'filename' => $row['value'],
]);
}
echo "\nDone\n";

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`;

61
models/AdminMenu.php Normal file
View File

@@ -0,0 +1,61 @@
<?php
/*****************************************************************************
* AdminMenu.php
* Contains the admin navigation logic.
*
* Kabuki CMS (C) 2013-2023, Aaron van Geffen
*****************************************************************************/
class AdminMenu extends Menu
{
public function __construct()
{
$user = Registry::has('user') ? Registry::get('user') : new Guest();
if (!$user->isAdmin())
return;
$this->items[0] = [
'label' => 'Admin',
'icon' => 'gear',
'badge' => ErrorLog::getCount(),
'subs' => [
[
'uri' => '/managealbums/',
'label' => 'Albums',
],
[
'uri' => '/manageassets/',
'label' => 'Assets',
],
[
'uri' => '/managetags/',
'label' => 'Tags',
],
[
'uri' => '/manageusers/',
'label' => 'Users',
],
[
'uri' => '/manageerrors/',
'label' => 'Errors',
'badge' => ErrorLog::getCount(),
],
],
];
if ($this->items[0]['badge'] == 0)
unset($this->items[0]['badge']);
foreach ($this->items as $i => $item)
{
if (isset($item['uri']))
$this->items[$i]['url'] = BASEURL . $item['uri'];
if (!isset($item['subs']))
continue;
foreach ($item['subs'] as $j => $subitem)
$this->items[$i]['subs'][$j]['url'] = BASEURL . $subitem['uri'];
}
}
}

View File

@@ -8,36 +8,55 @@
class Asset
{
protected $id_asset;
protected $id_user_uploaded;
protected $subdir;
protected $filename;
protected $title;
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)
$this->$attribute = $value;
{
if (property_exists($this, $attribute))
$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,
]);
@@ -50,7 +69,7 @@ class Asset
$row = Registry::get('db')->queryAssoc('
SELECT *
FROM assets
WHERE slug = {string:slug}',
WHERE slug = :slug',
[
'slug' => $slug,
]);
@@ -66,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'],
]);
@@ -75,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')
@@ -102,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'] = [];
@@ -119,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,
@@ -131,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',
'_' => '_',
]);
@@ -149,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 = [];
@@ -183,9 +202,10 @@ class Asset
$new_filename = $preferred_filename;
$destination = ASSETSDIR . '/' . $preferred_subdir . '/' . $preferred_filename;
while (file_exists($destination))
for ($i = 1; file_exists($destination); $i++)
{
$filename = pathinfo($preferred_filename, PATHINFO_FILENAME) . '_' . mt_rand(10, 99);
$suffix = $i;
$filename = pathinfo($preferred_filename, PATHINFO_FILENAME) . ' (' . $suffix . ')';
$extension = pathinfo($preferred_filename, PATHINFO_EXTENSION);
$new_filename = $filename . '.' . $extension;
$destination = dirname($destination) . '/' . $new_filename;
@@ -202,11 +222,14 @@ class Asset
$mimetype = finfo_file($finfo, $destination);
finfo_close($finfo);
// We're going to need the base name a few times...
$basename = pathinfo($new_filename, PATHINFO_FILENAME);
// Do we have a title yet? Otherwise, use the filename.
$title = isset($data['title']) ? $data['title'] : pathinfo($preferred_filename, PATHINFO_FILENAME);
$title = $data['title'] ?? $basename;
// Same with the slug.
$slug = isset($data['slug']) ? $data['slug'] : $preferred_subdir . '/' . pathinfo($preferred_filename, PATHINFO_FILENAME);
$slug = $data['slug'] ?? self::cleanSlug(sprintf('%s/%s', $preferred_subdir, $basename));
// Detected an image?
if (substr($mimetype, 0, 5) == 'image')
@@ -239,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,
@@ -250,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,
]);
@@ -262,8 +285,8 @@ class Asset
return false;
}
$data['id_asset'] = $db->insert_id();
return $return_format == 'object' ? new self($data) : $data;
$data['id_asset'] = $db->insertId();
return $return_format === 'object' ? new self($data) : $data;
}
public function getId()
@@ -281,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;
@@ -291,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.
@@ -364,7 +397,7 @@ class Asset
public function isImage()
{
return substr($this->mimetype, 0, 5) === 'image';
return isset($this->mimetype) && substr($this->mimetype, 0, 5) === 'image';
}
public function getImage()
@@ -375,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!
@@ -394,7 +471,7 @@ class Asset
finfo_close($finfo);
// Detected an image?
if (substr($this->mimetype, 0, 5) == 'image')
if (substr($this->mimetype, 0, 5) === 'image')
{
$image = new Imagick($destination);
$d = $image->getImageGeometry();
@@ -418,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,
]);
}
@@ -450,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),
@@ -482,47 +559,40 @@ class Asset
{
$db = Registry::get('db');
if (!unlink(ASSETSDIR . '/' . $this->subdir . '/' . $this->filename))
return false;
// 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]);
// 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);
$return = $db->query('
DELETE FROM assets
WHERE id_asset = {int:id_asset}',
[
'id_asset' => $this->id_asset,
]);
$rows = $db->query('
// Reset asset ID for tags that use this asset for their thumbnail
$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))
{
@@ -533,6 +603,15 @@ class Asset
}
}
// Finally, delete the actual asset
if (!unlink(ASSETSDIR . '/' . $this->subdir . '/' . $this->filename))
return false;
$return = $db->query('
DELETE FROM assets
WHERE id_asset = :id_asset',
['id_asset' => $this->id_asset]);
return $return;
}
@@ -560,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,
@@ -576,91 +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 <= {datetime:date_captured} AND
a.id_asset != {int:id_asset}
ORDER BY a.date_captured DESC'
: '
FROM assets AS a
WHERE date_captured >= {datetime:date_captured} AND
a.id_asset != {int:id_asset}
ORDER BY date_captured 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 >= {datetime:date_captured} AND
a.id_asset != {int:id_asset}
ORDER BY a.date_captured ASC'
: '
FROM assets AS a
WHERE date_captured <= {datetime:date_captured} AND
a.id_asset != {int:id_asset}
ORDER BY date_captured 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,38 +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 $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;
@@ -40,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')
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 = [
@@ -110,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']))
{
@@ -120,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.
@@ -136,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.
@@ -156,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(
@@ -168,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)
@@ -189,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

@@ -1,61 +0,0 @@
<?php
/*****************************************************************************
* Cache.php
* Contains key class Cache.
*
* Kabuki CMS (C) 2013-2015, Aaron van Geffen
*****************************************************************************/
class Cache
{
public static $hits = 0;
public static $misses = 0;
public static $puts = 0;
public static $removals = 0;
public static function put($key, $value, $ttl = 3600)
{
// If the cache is unavailable, don't bother.
if (!CACHE_ENABLED || !function_exists('apcu_store'))
return false;
// Keep track of the amount of cache puts.
self::$puts++;
// Store the data in serialized form.
return apcu_store(CACHE_KEY_PREFIX . $key, serialize($value), $ttl);
}
// Get some data from the cache.
public static function get($key)
{
// If the cache is unavailable, don't bother.
if (!CACHE_ENABLED || !function_exists('apcu_fetch'))
return false;
// Try to fetch it!
$value = apcu_fetch(CACHE_KEY_PREFIX . $key);
// Were we successful?
if (!empty($value))
{
self::$hits++;
return unserialize($value);
}
// Otherwise, it's a miss.
else
{
self::$misses++;
return null;
}
}
public static function remove($key)
{
if (!CACHE_ENABLED || !function_exists('apcu_delete'))
return false;
self::$removals++;
return apcu_delete(CACHE_KEY_PREFIX . $key);
}
}

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,43 +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 = [];
/**
* 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:utf8}', ['utf8' => 'utf8']);
}
public function getQueryCount()
@@ -51,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;
}
@@ -357,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;
}
@@ -376,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;
}
@@ -395,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;
}
@@ -417,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;
}
@@ -433,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;
}
@@ -452,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;
}
@@ -469,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;
}
@@ -488,7 +401,7 @@ class Database
/**
* This function can be used to insert data into the database in a secure way.
*/
public function insert($method = 'replace', $table, $columns, $data)
public function insert($method, $table, $columns, $data)
{
// With nothing to insert, simply return.
if (empty($data))
@@ -498,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

@@ -8,79 +8,12 @@
class Dispatcher
{
public static function route()
{
$possibleActions = [
'addalbum' => 'EditAlbum',
'albums' => 'ViewPhotoAlbums',
'editalbum' => 'EditAlbum',
'editasset' => 'EditAsset',
'edittag' => 'EditTag',
'edituser' => 'EditUser',
'login' => 'Login',
'logout' => 'Logout',
'managealbums' => 'ManageAlbums',
'manageassets' => 'ManageAssets',
'manageerrors' => 'ManageErrors',
'managetags' => 'ManageTags',
'manageusers' => 'ManageUsers',
'people' => 'ViewPeople',
'resetpassword' => 'ResetPassword',
'suggest' => 'ProvideAutoSuggest',
'timeline' => 'ViewTimeline',
'uploadmedia' => 'UploadMedia',
'download' => 'Download',
];
// Work around PHP's FPM not always providing PATH_INFO.
if (empty($_SERVER['PATH_INFO']) && isset($_SERVER['REQUEST_URI']))
{
if (strpos($_SERVER['REQUEST_URI'], '?') === false)
$_SERVER['PATH_INFO'] = $_SERVER['REQUEST_URI'];
else
$_SERVER['PATH_INFO'] = substr($_SERVER['REQUEST_URI'], 0, strpos($_SERVER['REQUEST_URI'], '?'));
}
// Just showing the album index?
if (empty($_SERVER['PATH_INFO']) || $_SERVER['PATH_INFO'] == '/')
{
return new ViewPhotoAlbum();
}
// Asynchronously generating thumbnails?
elseif (preg_match('~^/thumbnail/(?<id>\d+)/(?<width>\d+)x(?<height>\d+)(?:_(?<mode>c(t|b|s|)))?/?~', $_SERVER['PATH_INFO'], $path))
{
$_GET = array_merge($_GET, $path);
return new GenerateThumbnail();
}
// Look for particular actions...
elseif (preg_match('~^/(?<action>[a-z]+)(?:/page/(?<page>\d+))?/?~', $_SERVER['PATH_INFO'], $path) && isset($possibleActions[$path['action']]))
{
$_GET = array_merge($_GET, $path);
return new $possibleActions[$path['action']]();
}
// An album, person, or any other tag?
elseif (preg_match('~^/(?<tag>.+?)(?:/page/(?<page>\d+))?/?$~', $_SERVER['PATH_INFO'], $path) && Tag::matchSlug($path['tag']))
{
$_GET = array_merge($_GET, $path);
return new ViewPhotoAlbum();
}
// A photo for sure, then, right?
elseif (preg_match('~^/(?<slug>.+?)/?$~', $_SERVER['PATH_INFO'], $path))
{
$_GET = array_merge($_GET, $path);
return new ViewPhoto();
}
// No idea, then?
else
throw new NotFoundException();
}
public static function dispatch()
{
// Let's try to find our bearings!
try
{
$page = self::route();
$page = Router::route();
$page->showContent();
}
// Something wasn't found?
@@ -111,13 +44,26 @@ 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.
*/
public static function kickGuest($title = null, $message = null)
{
$form = new LogInForm('Log in');
$form->adopt(new Alert($title ?? '', $message ?? 'You need to be logged in to view this page.', 'error'));
$form->adopt(new Alert($title ?? '', $message ?? 'You need to be logged in to view this page.', 'danger'));
$form->setRedirectUrl($_SERVER['REQUEST_URI']);
$page = new MainTemplate('Login required');
@@ -127,38 +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 AdminBar());
}
$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

@@ -12,6 +12,7 @@ class EXIF
public $iso = 0;
public $shutter_speed = 0;
public $title = '';
public $software = '';
private function __construct(array $meta)
{
@@ -35,6 +36,7 @@ class EXIF
'iso' => 0,
'shutter_speed' => 0,
'title' => '',
'software' => '',
];
if (!function_exists('exif_read_data'))
@@ -88,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']);
@@ -101,6 +105,9 @@ class EXIF
elseif (!empty($exif['DateTimeDigitized']))
$meta['created_timestamp'] = self::toUnixTime($exif['DateTimeDigitized']);
if (!empty($exif['Software']))
$meta['software'] = $exif['Software'];
return new self($meta);
}

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;
@@ -63,11 +61,11 @@ class ErrorHandler
// Include info on the contents of superglobals.
if (!empty($_SESSION))
$debug_info .= "\nSESSION: " . print_r($_SESSION, true);
$debug_info .= "\nSESSION: " . var_export($_SESSION, true);
if (!empty($_POST))
$debug_info .= "\nPOST: " . print_r($_POST, true);
$debug_info .= "\nPOST: " . var_export($_POST, true);
if (!empty($_GET))
$debug_info .= "\nGET: " . print_r($_GET, true);
$debug_info .= "\nGET: " . var_export($_GET, true);
return $debug_info;
}
@@ -96,12 +94,17 @@ class ErrorHandler
$object = isset($call['class']) ? $call['class'] . $call['type'] : '';
$args = [];
foreach ($call['args'] as $j => $arg)
if (isset($call['args']))
{
if (is_array($arg))
$args[$j] = print_r($arg, true);
elseif (is_object($arg))
$args[$j] = var_dump($arg);
foreach ($call['args'] as $j => $arg)
{
// Only include the class name for objects
if (is_object($arg))
$args[$j] = get_class($arg) . '{}';
// Export everything else -- including arrays
else
$args[$j] = var_export($arg, true);
}
}
$buffer .= '#' . str_pad($i, 3, ' ')
@@ -113,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,
@@ -125,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();
@@ -151,30 +154,30 @@ 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)
{
$page->appendStylesheet(BASEURL . '/css/admin.css');
$page->adopt(new AdminBar());
}
}
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

@@ -3,30 +3,77 @@
* Form.php
* Contains key class Form.
*
* Kabuki CMS (C) 2013-2015, Aaron van Geffen
* Kabuki CMS (C) 2013-2023, Aaron van Geffen
*****************************************************************************/
class Form
{
public $request_method;
public $request_url;
public $content_above;
public $content_below;
private $fields;
private $data;
private $missing;
private $fields = [];
public $before_fields;
public $after_fields;
private $submit_caption;
public $buttons_extra;
private $trim_inputs;
private $data = [];
private $missing = [];
// 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;
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 verify($post)
public function getFields()
{
return $this->fields;
}
public function getData()
{
return $this->data;
}
public function getSubmitButtonCaption()
{
return $this->submit_caption;
}
public function getMissing()
{
return $this->missing;
}
public function setData($data)
{
$this->verify($data, true);
$this->missing = [];
}
public function setFieldAsMissing($field)
{
$this->missing[] = $field;
}
public function verify($post, $initalisation = false)
{
$this->data = [];
$this->missing = [];
@@ -41,30 +88,43 @@ class Form
}
// No data present at all for this field?
if ((!isset($post[$field_id]) || $post[$field_id] == '') && empty($field['is_optional']))
if ((!isset($post[$field_id]) || $post[$field_id] == '') &&
$field['type'] !== 'captcha')
{
$this->missing[] = $field_id;
$this->data[$field_id] = '';
if (empty($field['is_optional']))
$this->missing[] = $field_id;
if ($field['type'] === 'select' && !empty($field['multiple']))
$this->data[$field_id] = [];
else
$this->data[$field_id] = '';
continue;
}
// Verify data for all fields
// Should we trim this?
if ($this->trim_inputs && $field['type'] !== 'captcha' && empty($field['multiple']))
$post[$field_id] = trim($post[$field_id]);
// Using a custom validation function?
if (isset($field['validate']) && is_callable($field['validate']))
{
// Validation functions can clean up the data if passed by reference
$this->data[$field_id] = $post[$field_id];
// Evaluate validation functions as boolean to see if data is missing
if (!$field['validate']($post[$field_id]))
$this->missing[] = $field_id;
continue;
}
// Verify data by field type
switch ($field['type'])
{
case 'select':
case 'radio':
// Skip validation? Dangerous territory!
if (isset($field['verify_options']) && $field['verify_options'] === false)
$this->data[$field_id] = $post[$field_id];
// Check whether selected option is valid.
elseif (isset($post[$field_id]) && !isset($field['options'][$post[$field_id]]))
{
$this->missing[] = $field_id;
$this->data[$field_id] = '';
continue 2;
}
else
$this->data[$field_id] = $post[$field_id];
$this->validateSelect($field_id, $field, $post);
break;
case 'checkbox':
@@ -73,61 +133,22 @@ class Form
break;
case 'color':
// Colors are stored as a string of length 3 or 6 (hex)
if (!isset($post[$field_id]) || (strlen($post[$field_id]) != 3 && strlen($post[$field_id]) != 6))
{
$this->missing[] = $field_id;
$this->data[$field_id] = '';
continue 2;
}
else
$this->data[$field_id] = $post[$field_id];
$this->validateColor($field_id, $field, $post);
break;
case 'file':
// Needs to be verified elsewhere!
// Asset needs to be processed out of POST! This is just a filename.
$this->data[$field_id] = isset($post[$field_id]) ? $post[$field_id] : '';
break;
case 'numeric':
$data = isset($post[$field_id]) ? $post[$field_id] : '';
// Do we need to check bounds?
if (isset($field['min_value']) && is_numeric($data))
{
if (is_float($field['min_value']) && (float) $data < $field['min_value'])
{
$this->missing[] = $field_id;
$this->data[$field_id] = 0.0;
}
elseif (is_int($field['min_value']) && (int) $data < $field['min_value'])
{
$this->missing[] = $field_id;
$this->data[$field_id] = 0;
}
else
$this->data[$field_id] = $data;
}
elseif (isset($field['max_value']) && is_numeric($data))
{
if (is_float($field['max_value']) && (float) $data > $field['max_value'])
{
$this->missing[] = $field_id;
$this->data[$field_id] = 0.0;
}
elseif (is_int($field['max_value']) && (int) $data > $field['max_value'])
{
$this->missing[] = $field_id;
$this->data[$field_id] = 0;
}
else
$this->data[$field_id] = $data;
}
// Does it look numeric?
elseif (is_numeric($data))
{
$this->data[$field_id] = $data;
}
// Let's consider it missing, then.
else
$this->validateNumeric($field_id, $field, $post);
break;
case 'captcha':
if (isset($_POST['g-recaptcha-response']) && !$initalisation)
$this->validateCaptcha($field_id);
elseif (!$initalisation)
{
$this->missing[] = $field_id;
$this->data[$field_id] = 0;
@@ -137,29 +158,200 @@ class Form
case 'text':
case 'textarea':
default:
$this->data[$field_id] = isset($post[$field_id]) ? $post[$field_id] : '';
$this->validateText($field_id, $field, $post);
}
}
}
public function setData($data)
private function validateCaptcha($field_id)
{
$this->verify($data);
$this->missing = [];
$postdata = http_build_query([
'secret' => RECAPTCHA_API_SECRET,
'response' => $_POST['g-recaptcha-response'],
]);
$opts = [
'http' => [
'method' => 'POST',
'header' => 'Content-type: application/x-www-form-urlencoded',
'content' => $postdata,
]
];
$context = stream_context_create($opts);
$result = file_get_contents('https://www.google.com/recaptcha/api/siteverify', false, $context);
$check = json_decode($result);
if ($check->success)
{
$this->data[$field_id] = 1;
}
else
{
$this->data[$field_id] = 0;
$this->missing[] = $field_id;
}
}
public function getFields()
private function validateColor($field_id, array $field, array $post)
{
return $this->fields;
// Colors are stored as a string of length 3 or 6 (hex)
if (!isset($post[$field_id]) || (strlen($post[$field_id]) != 3 && strlen($post[$field_id]) != 6))
{
$this->missing[] = $field_id;
$this->data[$field_id] = '';
}
else
$this->data[$field_id] = $post[$field_id];
}
public function getData()
private function validateNumeric($field_id, array $field, array $post)
{
return $this->data;
$data = isset($post[$field_id]) ? $post[$field_id] : '';
// Sanity check: does this even look numeric?
if (!is_numeric($data))
{
$this->missing[] = $field_id;
$this->data[$field_id] = 0;
return;
}
// Do we need to a minimum bound?
if (isset($field['min_value']))
{
if (is_float($field['min_value']) && (float) $data < $field['min_value'])
{
$this->missing[] = $field_id;
$this->data[$field_id] = 0.0;
}
elseif (is_int($field['min_value']) && (int) $data < $field['min_value'])
{
$this->missing[] = $field_id;
$this->data[$field_id] = 0;
}
}
// What about a maximum bound?
if (isset($field['max_value']))
{
if (is_float($field['max_value']) && (float) $data > $field['max_value'])
{
$this->missing[] = $field_id;
$this->data[$field_id] = 0.0;
}
elseif (is_int($field['max_value']) && (int) $data > $field['max_value'])
{
$this->missing[] = $field_id;
$this->data[$field_id] = 0;
}
}
$this->data[$field_id] = $data;
}
public function getMissing()
private function validateSelect($field_id, array $field, array $post)
{
return $this->missing;
// Skip validation? Dangerous territory!
if (isset($field['verify_options']) && $field['verify_options'] === false)
{
$this->data[$field_id] = $post[$field_id];
return;
}
// Check whether selected option is valid.
if (($field['type'] !== 'select' || empty($field['multiple'])) && empty($field['has_groups']))
{
if (isset($post[$field_id]) && !isset($field['options'][$post[$field_id]]))
{
$this->missing[] = $field_id;
$this->data[$field_id] = '';
return;
}
else
$this->data[$field_id] = $post[$field_id];
}
// Multiple selections involve a bit more work.
elseif (!empty($field['multiple']) && empty($field['has_groups']))
{
$this->data[$field_id] = [];
if (!is_array($post[$field_id]))
{
if (isset($field['options'][$post[$field_id]]))
$this->data[$field_id][] = $post[$field_id];
else
$this->missing[] = $field_id;
return;
}
foreach ($post[$field_id] as $option)
{
if (isset($field['options'][$option]))
$this->data[$field_id][] = $option;
}
if (empty($this->data[$field_id]))
$this->missing[] = $field_id;
}
// Any optgroups involved?
elseif (!empty($field['has_groups']))
{
if (!isset($post[$field_id]))
{
$this->missing[] = $field_id;
$this->data[$field_id] = '';
return;
}
// Expensive: iterate over all groups until the value selected has been found.
foreach ($field['options'] as $label => $options)
{
if (is_array($options))
{
// Consider each of the options as a valid a value.
foreach ($options as $value => $label)
{
if ($post[$field_id] === $value)
{
$this->data[$field_id] = $options;
return;
}
}
}
else
{
// This is an ungrouped value in disguise! Treat it as such.
if ($post[$field_id] === $options)
{
$this->data[$field_id] = $options;
return;
}
else
continue;
}
}
// If we've reached this point, we'll consider the data invalid.
$this->missing[] = $field_id;
$this->data[$field_id] = '';
}
else
{
throw new UnexpectedValueException('Unexpected field configuration in validateSelect!');
}
}
private function validateText($field_id, array $field, array $post)
{
$this->data[$field_id] = isset($post[$field_id]) ? $post[$field_id] : '';
// Trim leading and trailing whitespace?
if (!empty($field['trim']))
$this->data[$field_id] = trim($this->data[$field_id]);
// Is there a length limit to enforce?
if (isset($field['maxlength']) && strlen($post[$field_id]) > $field['maxlength']) {
$post[$field_id] = substr($post[$field_id], 0, $field['maxlength']);
}
}
}

View File

@@ -3,7 +3,7 @@
* GenericTable.php
* Contains key class GenericTable.
*
* Kabuki CMS (C) 2013-2015, Aaron van Geffen
* Kabuki CMS (C) 2013-2023, Aaron van Geffen
*****************************************************************************/
class GenericTable
@@ -15,68 +15,40 @@ class GenericTable
private $title;
private $title_class;
private $tableIsSortable = false;
public $form_above;
public $form_below;
private $table_class;
private $sort_direction;
private $sort_order;
private $base_url;
private $start;
private $items_per_page;
private $recordCount;
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 = 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'] ?? '';
$this->table_class = $options['table_class'] ?? '';
// Got a title?
$this->title = $options['title'] ?? '';
$this->title_class = $options['title_class'] ?? '';
@@ -86,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)
@@ -93,13 +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,
@@ -116,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,
@@ -124,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;
@@ -161,6 +166,11 @@ class GenericTable
return $this->pageIndex;
}
public function getTableClass()
{
return $this->table_class;
}
public function getTitle()
{
return $this->title;
@@ -181,14 +191,21 @@ 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'] ?? '',
'value' => $value,
];
}
@@ -198,66 +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 (!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);
}
}