415 Commits

Author SHA1 Message Date
ea4983e967 FeaturedThumbnailManager: add pager widget; show only 20 thumbs per page 2025-09-24 12:44:05 +02:00
b48c8ea820 EditAlbum: reorder asset loading 2025-09-24 12:32:29 +02:00
c9da46b36f EditAlbum: drop old thumbnail id field entirely 2025-09-24 12:30:22 +02:00
2b8b12e065 Merge branch 'inline-forms' 2025-09-24 12:23:50 +02:00
2af4e865e0 TabularData: take control of juxtapositing pager and form 2025-09-23 15:04:57 +02:00
77fa33730a InlineFormView: combine fields and buttons into one 'controls' array 2025-09-23 14:48:08 +02:00
0274ff5bf4 InlineFormView: remove support for unused 'html_after' property 2025-09-23 14:44:07 +02:00
2dea80b58e InlineFormView: split rendering into smaller methods 2025-09-23 14:42:47 +02:00
2bf78b9f5d InlineFormView: split off from TabularData template 2025-09-23 14:35:40 +02:00
913fb974c7 Fix two more stray queries 2025-09-18 11:10:04 +02:00
92b2cfa391 Merge pull request 'Simplify and clarify Forms and FormViews' (#54) from form-views into master
Reviewed-on: 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
0ec0de4414 Replace deprecated strftime calls 2022-05-07 13:25:19 +02:00
69417c36ed Merge pull request 'EXIF: prefer DateTimeOriginal over DateTimeDigitized' (#27) from exif-date-time-original into master
Reviewed-on: Public/pics#27
2022-02-16 21:56:04 +01:00
f2d8a32e67 EXIF: prefer DateTimeOriginal over DateTimeDigitized 2022-02-16 21:43:55 +01:00
4863561129 Merge pull request 'Refactor generic tables and page index classes' (#26) from refactor-tables into master
Reviewed-on: Public/pics#26
2021-05-17 20:19:18 +02:00
8474d3b2b2 Merge pull request 'Modernise autosuggest code' (#25) from autosuggest into master
Reviewed-on: Public/pics#25
2021-05-17 20:19:05 +02:00
3bf69fd21f Prevent XSS in error log viewer. 2021-03-10 17:40:06 +01:00
237f4005bd Apply htmlspecialchars to basic values. 2021-02-17 22:44:26 +01:00
4bf4641428 Minor refactor of generateColumnHeaders function 2021-02-17 20:45:58 +01:00
ff808ba18d Refactor processing of raw rows and their cell data. 2021-02-17 20:45:22 +01:00
6c662481bc Remove a few obscure, unused features.
* Removed support for row classification. Use of CSS is preferred.
* Removed support for disabling/enabling columns via a property. Unset as needed.
* Removed support for passing and inheriting a cell width by column. Header width suffices.
2021-02-17 20:45:22 +01:00
af73f00701 Restrict access to GenericTable, Pagination class members. 2021-02-17 20:45:21 +01:00
681af07985 Pass builder function for custom pagination link generation.
This makes pagination for GenericTables work properly again.
2021-02-17 20:43:30 +01:00
cba42a9129 Refactor GenericTable to use PageIndex rather than inherit from it
This has on my todo list for years... I'm glad to finally get around to it.
2021-02-17 20:43:30 +01:00
96937b6952 Use null-coalescing operator where appropriate 2021-02-17 20:43:29 +01:00
5c55e45c3c Use splat operator instead of call_user_func_array 2021-02-17 20:43:29 +01:00
70e6001c85 Replace event.keyCode with event.key equivalents. 2021-02-16 15:26:57 +01:00
4402521051 Highlight matching string in autosuggest entries. 2021-02-15 12:14:24 +01:00
889302cd36 Modernise AutoSuggest and TagAutoSuggest classes. 2021-02-15 12:14:23 +01:00
cae5c6e5cf Merge pull request 'Add readme and license files' (#24) from readme into master
Reviewed-on: Public/pics#24
2020-12-30 20:09:04 +01:00
162d14b35f Merge pull request 'Refactor work for Thumbnail class' (#23) from thumbnail-refactor into master
Reviewed-on: Public/pics#23
2020-12-30 20:08:02 +01:00
555c61937b Minor Thumbnail refactors from upstream Kabuki. 2020-12-30 20:09:10 +01:00
d069ddca18 Merge pull request 'Make crop editor usable' (#22) from crop-editor into master
Reviewed-on: Public/pics#22
2020-12-30 20:06:15 +01:00
71b71f8a03 Merge pull request 'Set JPEG compression to 80 for thumbnails' (#21) from thumbnail-quality into master
Reviewed-on: Public/pics#21
2020-12-30 20:05:46 +01:00
2885e24456 Merge pull request 'Prevent blank pages on session errors' (#20) from session-errors into master
Reviewed-on: Public/pics#20
2020-12-30 20:05:27 +01:00
c72e24c0c7 EditAsset: refactor common expression into its own variable. 2020-12-30 15:36:30 +01:00
b8191bf554 EditAsset: handle case where suffix is null. 2020-12-30 15:36:27 +01:00
3594b3d021 Add BSD 3-clause license. 2020-12-30 13:55:30 +01:00
936d3d20db Add a readme file to the repo. 2020-12-30 13:55:16 +01:00
5c4a075231 Constrain aspect ratio conservation to image boundaries as well. 2020-12-30 13:26:31 +01:00
6ddf518294 Grow image and its container with document height. 2020-12-30 13:26:31 +01:00
66a411973a Constrain crop proportions by default, with checkbox to disable. 2020-12-30 13:26:31 +01:00
a83b938f8a Reposition crop boundary when window resizes. 2020-12-30 13:26:31 +01:00
5344378333 Crop editor: interpret 'cs' as centre-sliced crop.
At some point, I started using 'cs' instead of 'cc'. Let's just support both.
2020-12-30 13:26:31 +01:00
8147e2b97d Crop editor: do not allow selections starting outside image bounds. 2020-12-30 13:26:31 +01:00
d562c70667 Improve styling of form elements. 2020-12-30 13:06:44 +01:00
5599ff8d9b Crop editor: use numeric input boxes with appropriate constraints. 2020-12-30 13:05:23 +01:00
e7490e40dd Clean up and force a rebuild of thumbnails when customising a crop region. 2020-12-30 13:05:23 +01:00
6fcc2eb59f EditAsset: generate any pending thumbnails here as well. 2020-12-30 13:05:23 +01:00
b793e05980 Re-assign thumbnail suffix for CROP_MODE_BOUNDARY.
This crop mode was intended to get the '_ce' suffix,
but was inadvertently getting '_c' instead.
2020-12-30 13:05:22 +01:00
340ed84272 Show first available thumbnail instead of a potentially hidden one. 2020-12-30 13:05:22 +01:00
93884e2e93 Fix initial slicing dimensions in CropEditor.setDefaultCrop.
Subtle bug. This has been in for years... :)
2020-12-30 13:05:22 +01:00
2a740d8cef Constrain boundary movement to image canvas. 2020-12-30 13:05:22 +01:00
5e0d4df2f7 Allow moving/dragging the crop boundary.
Currently unconstrained.
2020-12-30 13:05:22 +01:00
e84c4f2b43 Constrain crop selection to image dimensions. 2020-12-30 13:05:22 +01:00
893d31af52 Proper dragging of the crop bounding rectangle/area. 2020-12-30 13:05:22 +01:00
5895f4faa6 Rewrite CropEditor.positionBoundary 2020-12-30 13:05:22 +01:00
8e7a09f3f3 Initial version of crop boundary dragging. 2020-12-30 13:05:22 +01:00
837c92db44 CropEditor: split setDefaultCrop from setInputValues and rename. 2020-12-30 13:05:22 +01:00
c392105814 Refactor crop editor DOM functions. 2020-12-30 13:05:22 +01:00
9d95df81fe WIP: finally implement drag events. 2020-12-30 13:05:22 +01:00
d4cc72304e Use flexbox for crop editor box sizing. 2020-12-30 13:05:21 +01:00
2c68b6a798 Improve crop editor styling. 2020-12-30 13:05:21 +01:00
fd84e1c9f8 Refactor crop editor into a proper class. 2020-12-30 13:05:21 +01:00
8d02662eb3 Set JPEG compression to 80 for thumbnails.
This saves both disk space and bandwidth by compromising a little on quality.
Only thumbnails are affected; full-size images are still saved in full detail.
2020-03-22 22:56:53 +01:00
31f4edc996 Register ErrorHandler before Session start. 2020-03-11 22:38:17 +01:00
a208c0482f Invoke Dispatcher::kickGuest from Session for invalid sessions.
Previously, a NotAllowedException would be thrown if an invalid session
was encountered. However, these exceptions were not caught, and hence
would yield a fatal uncaught exception error.

At this point in Kabuki, the ErrorHandler class has not been registered yet
for error handling purposes. This error is therefore not visible if the PHP
ini directive 'display_errors' is set to 'Off'. As this is the default
production value, the script would fail with a blank page in such cases.
2020-03-11 22:38:15 +01:00
909d50efa8 Merge pull request 'Rework album/tag downloads' (#19) from tag-download into master 2020-03-11 20:04:26 +01:00
bd1ca8d18c Use $_SESSION['current_export'] to prevent simultaneous exports. 2020-03-11 18:57:31 +01:00
c7d3b9c3d1 Disable output buffering so we can enjoy more than ~62MB of photos. 2020-03-11 12:32:44 +01:00
5a51778a6a Clean up tar process handling; make stdout non-blocking. 2020-03-11 12:32:44 +01:00
2bb29d7224 Minor optimisation to Download::getChildAlbumIds. 2020-03-11 12:32:44 +01:00
1b7e83e11e Let tar change working directory to assets directory.
This prevents edge cases where files are not found, while ensuring
the archive does not contain the system directory hierarchy.
2020-03-11 12:32:34 +01:00
354e54a0af Limit album/tag downloading on a user basis.
This removes the limit of downloading albums only; tags are fine, too.

Now using UserFacingException for certain exceptions, as these are
displayed to the user.

Removing the inheritance of HTMLController, as we intend to output binary
data only.
2020-03-11 11:55:17 +01:00
17859b70e9 Fix tag suggestions. 2020-02-25 13:31:30 +01:00
6a7defcdc9 Allow going back to photo directly from Edit Asset screen. 2020-02-25 13:31:12 +01:00
f193b614b7 Port basic asset management page from Kabuki. 2020-02-25 13:31:12 +01:00
12ea378b02 Photo uploader: reduce client memory usage.
To create inline thumbnails locally, the previous version of the photo uploader
used base64 representations of the local files to be uploaded. While this is
effective, it is a little wasteful. When uploading several photos at once, this
could even lead to the page becoming less responsive.

This commit changes the process such that the photos are actually resized on a
canvas, only using the local original as a buffer. Hence, only the resized
photo is retained in memory.
2020-02-08 20:35:26 +01:00
62900e7f81 Correct JSON media type. 2019-09-29 15:00:28 +02:00
c48ba786c1 Refactor leftover old-style arrays into new-style arrays. 2019-09-29 14:47:56 +02:00
3694819d13 Refactor out DummyBox objects from EditUser controller. 2019-09-29 14:38:42 +02:00
d7b68995e8 Fix recounting not working on non-album tags. 2019-09-29 14:20:59 +02:00
5df7ea8371 Remove unused 'best color' algorithm.
This code is used to efficiently determine the most saturated colour in an image,
Kabuki uses this as the highlight colour on a page, but this styling has always
been disabled in HashRU Pics. Hence, we can safely remove the code, freeing up
some CPU cycles in the process. ;-)
2019-09-29 14:18:59 +02:00
7d3ab166c7 Simplify template logic for photo index. 2019-09-29 14:12:30 +02:00
ed6054e6b6 Fix buttons appearing when they shouldn't.
This bug went unnoticed because people who aren't logged in can't actually see this page.
2019-09-29 14:05:45 +02:00
3fc8ccf550 Merge branch 'tag-improvements' of Public/pics into master 2019-03-16 12:12:25 +01:00
6a7c7af7b8 Merge branch 'fix/9' of Public/pics into master 2019-03-16 12:12:13 +01:00
8ec6c227d5 Remove leftover debug statements. 2019-03-09 13:58:37 +01:00
42e5c7fe37 Allow regular users to unlink tags from photos. 2019-03-09 13:48:18 +01:00
05c48be785 Link tags after adding them through autosuggest. 2019-03-09 13:22:32 +01:00
d3cb750874 Default to centre cropping for portrait layout. 2019-03-09 12:50:16 +01:00
20db3561cf Adds the ability to export an album using streaming tar. 2019-03-08 19:52:01 +00:00
768f5ee529 Fixes an error related to using continue within a loop.
https://wiki.php.net/rfc/continue_on_switch_deprecation
2019-03-08 19:52:01 +00:00
16ec547064 Changes the ConfirmDelete page and updates database code.
The ConfirmDelete page now uses parts of the photopage. The
Confirmation dialog is its own template which is based on Alert.

The database now updates the album thumb to the most recent
addition to the album, when the album thumb asset is being deleted.
When there are no pictures left in the album 0 will be set.
2018-07-13 23:00:36 +02:00
e40c05c1f8 Adds a Confirm Delete page and actually delete the assets. 2018-07-07 19:53:46 +00:00
344db6e4c5 Adds a Delete button to the ViewPhotoPage.
The Delete button is non functional yet and is only available for
admins and the photo owner.
2018-07-07 12:19:31 +00:00
fcbbc7106d Fixes the template config for new websites 2018-06-30 17:02:03 +00:00
331193019c Fixes sorting on Tag Id in the ManageTags View. 2018-03-15 21:42:30 +01:00
bcbb74a680 Allow setting URL slug through admin panel. 2018-02-22 20:07:06 +01:00
c6c249787f Pass DIY slug when uploading photos. 2018-02-22 20:02:02 +01:00
068d1dad3e Allow capitals in tag slugs. 2018-02-19 11:55:39 +01:00
f1408ad2ee Allow capitals and slashes in album slugs. 2018-02-19 11:55:39 +01:00
8b73420936 Makes the Reply-To header e-mail address configurable.
also uses the SITE_NAME constant as the sender name.
2018-02-03 20:58:00 +01:00
ee304dd7b9 When generating thumbnails, infer width properly when null is passed. 2018-02-03 20:14:40 +01:00
1def1484cb Backport asynchronous thumbnail generation from Kabuki. 2017-12-20 14:51:23 +01:00
981b652e25 Fix album title box on mobile. 2017-11-22 11:22:38 +01:00
cda7f3115c Fix crop editor. 2017-11-09 17:00:36 +01:00
e439a074a6 Increase tile height for album thumbnails, making them 4:3. 2017-11-09 17:00:36 +01:00
ee9bdd45c0 Leave out labels on photo index pages.
They were pretty much only displaying filenames, so not adding much.
2017-11-09 17:00:36 +01:00
9fe8acc747 Change colour palette into something more befitting #RU. 2017-11-09 17:00:36 +01:00
096cea078c Support 'quick create subalbums' style, too. 2017-11-09 17:00:36 +01:00
2a25434862 Implement basic tag management. 2017-11-09 17:00:36 +01:00
943297900c Implement basic album management. 2017-11-09 17:00:36 +01:00
95e289d82d Introduce an overview of all albums for admins. 2017-11-09 17:00:36 +01:00
1a15e347f2 Merges from upstream Kabuki. 2017-11-09 17:00:36 +01:00
31e1357b47 Show only Person tags in tag management screen. 2017-11-09 17:00:36 +01:00
08cc6b1c77 Updated TODO 2017-11-09 17:00:36 +01:00
1623a430e5 Sort People tags descending; Albums ascending. 2017-11-09 17:00:35 +01:00
9f92ed6d7a JS photo nav: scroll to #photo_frame automatically. 2017-11-09 17:00:35 +01:00
fc02c9b93a Updated todo. 2017-11-09 17:00:35 +01:00
9dfb2649e5 Revert "Properly fix prev/next in set."
This reverts commit 94427a4c5c03b1368356279781bddf6c82527831.
2017-11-09 17:00:35 +01:00
9b2cc5caae Properly fix prev/next in set. 2017-11-09 17:00:35 +01:00
fa5ef75205 Basic styling for (long) tag lists. 2017-11-09 17:00:35 +01:00
f4bcb1e584 Typo: empty string, not 0. 2017-11-09 17:00:35 +01:00
1f7fe35cec Accept tags consisting of only two letters, too. 2017-11-09 17:00:35 +01:00
0a55730696 Allow all users to create and link people tags. 2017-11-09 17:00:35 +01:00
7f5ce1820d Split preview box creation from preview generation. 2017-11-09 17:00:35 +01:00
6c0f6b06e6 Fix IPv4-only remnant. 2017-11-09 17:00:35 +01:00
b66088319b Reply from @aaronweb.net, not @pics.hashru.nl, for now. 2017-11-09 17:00:35 +01:00
2a7b44dfb2 Added favicon. 2017-11-09 17:00:35 +01:00
a85afff188 Re-enable custom error handler. 2017-11-09 17:00:35 +01:00
01bfc66842 Process login_url properly. 2017-11-09 17:00:35 +01:00
104 changed files with 7187 additions and 4351 deletions

11
LICENSE.md Normal file
View File

@@ -0,0 +1,11 @@
Copyright 2016-2021 Stichting HashRU
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

1
README
View File

@@ -1 +0,0 @@
This marks the development repository for the HashRU website.

31
README.md Normal file
View File

@@ -0,0 +1,31 @@
# HashRU Pics
This is the development repository for the HashRU photo website.
The CMS and its modules originate in [Kabuki CMS](https://aaronweb.net/projects/kabuki/), but have been extended and are maintained separately in this repository.
## Requirements
The Kabuki codebase requires the following PHP extensions to be enabled for full operation:
* exif
* imagick (PECL)
* mysqli
## Setup
Copy `config.php.dist` to `config.php` and set-up the constants contained in the file.
## Running
For development purposes, simply run the `server` script provided in the root of this repository.
This will start a PHP development server on `hashru.local:8080`.
For a production environment, please set up a proper PHP-FPM environment instead.
## Contributing
Pull requests are welcome over at the [HashRU Gitea](https://gitea.hashru.nl/Public/pics/pulls).
## License
The HashRU Pics repository is licensed with a BSD 3-clause license, as is Kabuki CMS.

11
TODO.md
View File

@@ -1,4 +1,11 @@
TODO: TODO:
* Taggen door gebruikers * Alleen persoonstags tonen bij foto's.
* Album management
* Gebruikers persoonstags laten verwijderen vanaf fotopagina.
* 'Return to album' knop toevoegen die naar juiste pagina leidt.
* Bij taggen van user: thumbnail setten
* Grid herberekenen; captions weglaten.

View File

@@ -16,16 +16,15 @@ require_once 'vendor/autoload.php';
Registry::set('start', microtime(true)); Registry::set('start', microtime(true));
Registry::set('db', new Database(DB_SERVER, DB_USER, DB_PASS, DB_NAME)); Registry::set('db', new Database(DB_SERVER, DB_USER, DB_PASS, DB_NAME));
// Handle errors our own way.
ErrorHandler::enable();
// Do some authentication checks. // Do some authentication checks.
Session::start(); Session::start();
$user = Authentication::isLoggedIn() ? Member::fromId($_SESSION['user_id']) : new Guest(); $user = Authentication::isLoggedIn() ? Member::fromId($_SESSION['user_id']) : new Guest();
$user->updateAccessTime(); $user->updateAccessTime();
Registry::set('user', $user); Registry::set('user', $user);
// Handle errors our own way.
//set_error_handler('ErrorHandler::handleError');
ini_set("display_errors", DEBUG ? "On" : "Off");
// The real magic starts here! // The real magic starts here!
ob_start(); ob_start();
Dispatcher::dispatch(); Dispatcher::dispatch();

View File

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

View File

@@ -14,6 +14,9 @@ const CACHE_KEY_PREFIX = 'hashru_';
const BASEDIR = __DIR__; const BASEDIR = __DIR__;
const BASEURL = 'https://pics.hashru.nl'; // no trailing / const BASEURL = 'https://pics.hashru.nl'; // no trailing /
// Reply-To e-mail header address
const REPLY_TO_ADDRESS = 'no-reply@my.domain.tld';
// Assets dir and url, where assets are plentiful. (In wwwroot!) // Assets dir and url, where assets are plentiful. (In wwwroot!)
const ASSETSDIR = BASEDIR . '/public/assets'; const ASSETSDIR = BASEDIR . '/public/assets';
const ASSETSURL = BASEURL . '/assets'; const ASSETSURL = BASEURL . '/assets';
@@ -29,5 +32,5 @@ const DB_PASS = '';
const DB_NAME = 'hashru_pics'; const DB_NAME = 'hashru_pics';
const DB_LOG_QUERIES = false; const DB_LOG_QUERIES = false;
const SITE_TITLE = 'HashRU'; const SITE_TITLE = 'HashRU Pics';
const SITE_SLOGAN = 'Nijmeegs Nerdclubje'; const SITE_SLOGAN = 'Nijmeegs Nerdclubje';

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);
}
}
}

133
controllers/Download.php Normal file
View File

@@ -0,0 +1,133 @@
<?php
/*****************************************************************************
* Download.php
* Contains the code to download an album.
*
* Kabuki CMS (C) 2013-2019, Aaron van Geffen
*****************************************************************************/
class Download
{
public function __construct()
{
// Ensure we're logged in at this point.
$user = Registry::get('user');
if (!$user->isLoggedIn())
throw new NotAllowedException();
if (!isset($_GET['tag']))
throw new UserFacingException('No album or tag has been specified for 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, $id_user_uploaded);
exit;
}
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,
'id_user_uploaded' => $id_user_uploaded,
]);
while ($asset = $iterator->next())
$files[] = join(DIRECTORY_SEPARATOR, [$asset->getSubdir(), $asset->getFilename()]);
}
$descriptorspec = [
0 => ['pipe', 'r'], // STDIN
1 => ['pipe', 'w'], // STDOUT
];
// Prevent simultaneous exports.
$_SESSION['current_export'] = $album->id_tag;
// Allow new exports if the connection is terminated unexpectedly (e.g. when a user aborts a download).
register_shutdown_function(function() {
if (isset($_SESSION['current_export']))
unset($_SESSION['current_export']);
});
$command = 'tar -cf - -C ' . escapeshellarg(ASSETSDIR) . ' --null -T -';
$proc = proc_open($command, $descriptorspec, $pipes, ASSETSDIR);
if(!$proc)
throw new UnexpectedValueException('Could not execute TAR command');
if(!$pipes[0])
throw new UnexpectedValueException('Could not open pipe for STDIN');
if(!$pipes[1])
throw new UnexpectedValueException('Could not open pipe for STDOUT');
// 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"');
header('Content-Type: application/octet-stream');
header('Content-Transfer-Encoding: binary');
// Write filenames to include to STDIN, separated by null bytes.
foreach ($files as $file)
fwrite($pipes[0], $file . "\0");
// Close STDIN pipe to start archiving.
fclose($pipes[0]);
// At this point, end output buffering so we can enjoy more than ~62MB of photos.
ob_end_flush();
do
{
// Read STDOUT as `tar` is doing its work.
echo stream_get_contents($pipes[1], 4096);
// Are we still running?
$status = proc_get_status($proc);
}
while (!empty($status) && $status['running']);
// Close STDOUT pipe and clean up process.
fclose($pipes[1]);
proc_close($proc);
// Allow new exports from this point onward.
unset($_SESSION['current_export']);
}
private function getChildAlbumIds($parent_id)
{
$ids = [];
$albums = Tag::getAlbums($parent_id, 0, PHP_INT_MAX);
foreach ($albums as $album)
{
$ids[] = $album['id_tag'];
$ids = array_merge($ids, $this->getChildAlbumIds($album['id_tag']));
}
return $ids;
}
}

249
controllers/EditAlbum.php Normal file
View File

@@ -0,0 +1,249 @@
<?php
/*****************************************************************************
* EditAlbum.php
* Contains the album edit controller.
*
* 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.
if (!Registry::get('user')->isAdmin())
throw new NotAllowedException();
$id_tag = isset($_GET['id']) ? (int) $_GET['id'] : 0;
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')
{
parent::__construct('Add a new album');
$form_title = 'Add a new album';
$this->page->addClass('editalbum');
}
// Deleting one?
elseif (isset($_GET['delete']))
{
// So far so good?
if (Session::validateSession('get') && $album->kind === 'Album' && $album->delete())
{
header('Location: ' . BASEURL . '/managealbums/');
exit;
}
else
throw new Exception('Cannot delete album: an error occured while processing the request.');
}
// Editing one, then, surely.
else
{
if ($album->kind !== 'Album')
throw new Exception('Cannot edit album: not an album.');
parent::__construct('Edit album \'' . $album->tag . '\'');
$form_title = 'Edit album \'' . $album->tag . '\'';
$this->page->addClass('editalbum');
}
// Session checking!
if (empty($_POST))
Session::resetSessionToken();
else
Session::validateSession();
if ($id_tag)
$after_form = '<a href="' . BASEURL . '/editalbum/?id=' . $id_tag . '&delete&' . Session::getSessionTokenKey() . '=' . Session::getSessionToken() . '" class="btn btn-danger" onclick="return confirm(\'Are you sure you want to delete this album? You cannot undo this!\');">Delete album</a>';
elseif (!$id_tag)
$after_form = '<button name="submit_and_new" class="btn">Save and add another</button>';
// 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']);
if ($parentTag->kind === 'Album')
{
$formDefaults = [
'id_parent' => $parentTag->id_tag,
'tag' => 'New Album Title Here',
'slug' => ($parentTag->slug ? $parentTag->slug . '/' : '') . 'NEW_ALBUM_SLUG_HERE',
];
}
}
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.
$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))
{
$this->form->verify($_POST);
// Anything missing?
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 = $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'] = 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.
// Creating a new album?
if (!$id_tag)
{
$data['kind'] = 'Album';
$newTag = Tag::createNew($data);
if ($newTag === false)
return $this->formview->adopt(new Alert('Cannot create this album', 'Something went wrong while creating the album...', 'danger'));
if (isset($_POST['submit_and_new']))
{
header('Location: ' . BASEURL . '/editalbum/?add&tag=' . $data['id_parent']);
exit;
}
}
// Just updating?
else
{
foreach ($data as $key => $value)
$album->$key = $value;
$album->save();
}
// Redirect to the album management page.
header('Location: ' . BASEURL . '/managealbums/');
exit;
}
}
}

View File

@@ -10,10 +10,6 @@ class EditAsset extends HTMLController
{ {
public function __construct() public function __construct()
{ {
// Ensure it's just admins at this point.
if (!Registry::get('user')->isAdmin())
throw new NotAllowedException();
if (empty($_GET['id'])) if (empty($_GET['id']))
throw new Exception('Invalid request.'); throw new Exception('Invalid request.');
@@ -21,8 +17,72 @@ class EditAsset extends HTMLController
if (empty($asset)) if (empty($asset))
throw new NotFoundException('Asset not found'); throw new NotFoundException('Asset not found');
if (isset($_REQUEST['delete'])) // Can we edit this asset?
throw new Exception('Not implemented.'); $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)) if (!empty($_POST))
{ {
@@ -33,18 +93,48 @@ class EditAsset extends HTMLController
} }
// Key info // Key info
if (isset($_POST['title'], $_POST['date_captured'], $_POST['priority'])) if (isset($_POST['title'], $_POST['slug'], $_POST['date_captured'], $_POST['priority']))
{ {
$date_captured = !empty($_POST['date_captured']) ? new DateTime($_POST['date_captured']) : null; $asset->date_captured = !empty($_POST['date_captured']) ?
$asset->setKeyData(htmlentities($_POST['title']), $date_captured, intval($_POST['priority'])); 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 // Handle tags
$new_tags = []; $new_tags = [];
if (isset($_POST['tag']) && is_array($_POST['tag'])) if (isset($_POST['tag']) && is_array($_POST['tag']))
{
foreach ($_POST['tag'] as $id_tag => $bool) foreach ($_POST['tag'] as $id_tag => $bool)
if (is_numeric($id_tag)) if (is_numeric($id_tag))
$new_tags[] = $id_tag; $new_tags[] = $id_tag;
}
$current_tags = array_keys($asset->getTags()); $current_tags = array_keys($asset->getTags());
@@ -76,47 +166,63 @@ class EditAsset extends HTMLController
$image->removeAllThumbnails(); $image->removeAllThumbnails();
} }
} }
elseif (preg_match('~^thumb_(\d+)x(\d+)(_c[best]?)?$~', $_POST['replacement_target'])) elseif (preg_match('~^thumb_(\d+x\d+(?:_c[best]?)?)$~', $_POST['replacement_target'], $match))
{ {
$image = $asset->getImage(); $image = $asset->getImage();
if (($replace_result = $image->replaceThumbnail($_POST['replacement_target'], $_FILES['replacement']['tmp_name'])) !== 0) if (($replace_result = $image->replaceThumbnail($match[1], $_FILES['replacement']['tmp_name'])) !== 0)
throw new Exception('Could not replace thumbnail \'' . $_POST['replacement_target'] . '\' with the uploaded file. Error code: ' . $replace_result); throw new Exception('Could not replace thumbnail \'' . $match[1] . '\' with the uploaded file. Error code: ' . $replace_result);
} }
} }
header('Location: ' . BASEURL . '/editasset/?id=' . $asset->getId()); header('Location: ' . BASEURL . '/editasset/?id=' . $asset->getId());
} }
// Get list of thumbnails $page = new EditAssetForm([
$thumbs = $this->getThumbs($asset); '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); parent::__construct('Edit asset \'' . $asset->getTitle() . '\' (' . $asset->getFilename() . ') - ' . SITE_TITLE);
$this->page->adopt($page); $this->page->adopt($page);
} }
private function getThumbs(Asset $asset) private function getThumbs(Asset $asset)
{ {
$path = $asset->getPath(); if (!$asset->isImage())
return [];
$image = $asset->getImage();
$subdir = $image->getSubdir();
$metadata = $image->getMeta();
$thumb_selectors = $image->getThumbnails();
$thumbs = []; $thumbs = [];
$metadata = $asset->getMeta(); foreach ($thumb_selectors as $selector => $filename)
foreach ($metadata as $key => $meta)
{ {
if (!preg_match('~^thumb_(?<width>\d+)x(?<height>\d+)(?<suffix>_c(?<method>[best]?))?$~', $key, $thumb)) if (!preg_match('~^(?<width>\d+)x(?<height>\d+)(?<suffix>_c(?<method>[best]?))?$~', $selector, $thumb))
continue; continue;
$has_crop_boundary = isset($metadata['crop_' . $thumb['width'] . 'x' . $thumb['height']]); $dimensions = $thumb['width'] . 'x' . $thumb['height'];
$has_custom_image = isset($metadata['custom_' . $thumb['width'] . 'x' . $thumb['height']]);
// Does the thumbnail exist on disk? If not, use an url to generate it.
if (!$filename || !file_exists(THUMBSDIR . '/' . $subdir . '/' . $filename))
$thumb_url = BASEURL . '/thumbnail/' . $image->getId() . '/' . $dimensions . ($thumb['suffix'] ?? '') . '/';
else
$thumb_url = THUMBSURL . '/' . $subdir . '/' . $filename;
$has_crop_boundary = isset($metadata['crop_' . $dimensions]);
$has_custom_image = isset($metadata['custom_' . $dimensions]);
$thumbs[] = [ $thumbs[] = [
'dimensions' => [(int) $thumb['width'], (int) $thumb['height']], 'dimensions' => [(int) $thumb['width'], (int) $thumb['height']],
'cropped' => !$has_custom_image && (!empty($thumb['suffix']) || $has_crop_boundary), 'cropped' => !$has_custom_image && (!empty($thumb['suffix']) || $has_crop_boundary),
'crop_method' => !$has_custom_image && !empty($thumb['method']) ? $thumb['method'] : (!empty($thumb['suffix']) ? 'c' : null), 'crop_method' => !$has_custom_image && !empty($thumb['method']) ? $thumb['method'] : (!empty($thumb['suffix']) ? 'c' : null),
'crop_region' => $has_crop_boundary ? $metadata['crop_' . $thumb['width'] . 'x' . $thumb['height']] : null, 'crop_region' => $has_crop_boundary ? $metadata['crop_' . $dimensions] : null,
'custom_image' => $has_custom_image, 'custom_image' => $has_custom_image,
'filename' => $meta, 'filename' => $filename,
'full_path' => THUMBSDIR . '/' . $path . '/' . $meta, 'url' => $thumb_url,
'url' => THUMBSURL . '/' . $path . '/' . $meta,
'status' => file_exists(THUMBSDIR . '/' . $path . '/' . $meta),
]; ];
} }
@@ -133,18 +239,19 @@ class EditAsset extends HTMLController
$crop_value = $data->crop_width . ',' . $data->crop_height . ',' . $data->source_x . ',' . $data->source_y; $crop_value = $data->crop_width . ',' . $data->crop_height . ',' . $data->source_x . ',' . $data->source_y;
$meta[$crop_key] = $crop_value; $meta[$crop_key] = $crop_value;
// If we uploaded a custom thumbnail, stop considering it such. // If we previously uploaded a custom thumbnail, stop considering it such.
$custom_key = 'custom_' . $data->thumb_width . 'x' . $data->thumb_height; $custom_key = 'custom_' . $data->thumb_width . 'x' . $data->thumb_height;
if (isset($meta[$custom_key])) if (isset($meta[$custom_key]))
{
// TODO: delete from disk
unset($meta[$custom_key]); unset($meta[$custom_key]);
}
// Save meta changes so far.
$image->setMetaData($meta);
// Force a rebuild of related thumbnails. // Force a rebuild of related thumbnails.
$thumb_key = 'thumb_' . $data->thumb_width . 'x' . $data->thumb_height; $image->removeThumbnailsOfSize($data->thumb_width, $data->thumb_height);
foreach ($meta as $meta_key => $meta_value)
if ($meta_key === $thumb_key || strpos($meta_key, $thumb_key . '_') !== false)
unset($meta[$meta_key]);
$image->setMetaData($meta);
$payload = [ $payload = [
'key' => $crop_key, 'key' => $crop_key,

216
controllers/EditTag.php Normal file
View File

@@ -0,0 +1,216 @@
<?php
/*****************************************************************************
* EditTag.php
* Contains the tag edit controller.
*
* Kabuki CMS (C) 2013-2017, Aaron van Geffen
*****************************************************************************/
class EditTag extends HTMLController
{
const THUMBS_PER_PAGE = 20;
public function __construct()
{
$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']))
{
parent::__construct('Add a new tag');
$form_title = 'Add a new tag';
$this->page->addClass('edittag');
}
// Deleting one?
elseif (isset($_GET['delete']))
{
// So far so good?
if (Session::validateSession('get') && $tag->kind !== 'Album' && $tag->delete())
{
header('Location: ' . BASEURL . '/managetags/');
exit;
}
else
throw new Exception('Cannot delete tag: an error occured while processing the request.');
}
// Editing one, then, surely.
else
{
if ($tag->kind === 'Album')
throw new Exception('Cannot edit tag: is actually an album.');
parent::__construct('Edit tag \'' . $tag->tag . '\'');
$form_title = 'Edit tag \'' . $tag->tag . '\'';
$this->page->addClass('edittag');
}
// Session checking!
if (empty($_POST))
Session::resetSessionToken();
else
Session::validateSession();
if ($id_tag)
$after_form = '<a href="' . BASEURL . '/edittag/?id=' . $id_tag . '&delete&' . Session::getSessionTokenKey() . '=' . Session::getSessionToken() . '" class="btn btn-danger" onclick="return confirm(\'Are you sure you want to delete this tag? You cannot undo this!\');">Delete tag</a>';
elseif (!$id_tag)
$after_form = '<button name="submit_and_new" class="btn">Save and add another</button>';
$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.
$form->setData($id_tag ? get_object_vars($tag) : $_POST);
$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()), 'danger'));
$data = $form->getData();
$data['id_parent'] = 0;
// Quick stripping.
$data['slug'] = strtr($data['slug'], [' ' => '-', '--' => '-', '&' => 'and', '=>' => '', "'" => "", ":"=> "", '/' => '-', '\\' => '-']);
// Creating a new tag?
if (!$id_tag)
{
$return = Tag::createNew($data);
if ($return === false)
return $formview->adopt(new Alert('Cannot create this tag', 'Something went wrong while creating the tag...', 'danger'));
if (isset($_POST['submit_and_new']))
{
header('Location: ' . BASEURL . '/edittag/?add');
exit;
}
}
// Just updating?
else
{
foreach ($data as $key => $value)
$tag->$key = $value;
$tag->save();
}
// 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

@@ -24,9 +24,8 @@ class EditUser extends HTMLController
// Adding a user? // Adding a user?
if (isset($_GET['add'])) if (isset($_GET['add']))
{ {
parent::__construct('Add a new user'); $form_title = 'Add a new user';
$view = new DummyBox('Add a new user'); parent::__construct($form_title);
$this->page->adopt($view);
$this->page->addClass('edituser'); $this->page->addClass('edituser');
} }
// Deleting one? // Deleting one?
@@ -34,7 +33,7 @@ class EditUser extends HTMLController
{ {
// Don't be stupid. // Don't be stupid.
if ($current_user->getUserId() == $id_user) 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? // So far so good?
$user = Member::fromId($id_user); $user = Member::fromId($id_user);
@@ -44,15 +43,14 @@ class EditUser extends HTMLController
exit; exit;
} }
else 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. // Editing one, then, surely.
else else
{ {
$user = Member::fromId($id_user); $user = Member::fromId($id_user);
parent::__construct('Edit user \'' . $user->getFullName() . '\''); $form_title = 'Edit user \'' . $user->getFullName() . '\'';
$view = new DummyBox('Edit user \'' . $user->getFullName() . '\''); parent::__construct($form_title);
$this->page->adopt($view);
$this->page->addClass('edituser'); $this->page->addClass('edituser');
} }
@@ -71,7 +69,7 @@ class EditUser extends HTMLController
$form = new Form([ $form = new Form([
'request_url' => BASEURL . '/edituser/?' . ($id_user ? 'id=' . $id_user : 'add'), 'request_url' => BASEURL . '/edituser/?' . ($id_user ? 'id=' . $id_user : 'add'),
'content_below' => $after_form, 'buttons_extra' => $after_form,
'fields' => [ 'fields' => [
'first_name' => [ 'first_name' => [
'type' => 'text', 'type' => 'text',
@@ -122,8 +120,8 @@ class EditUser extends HTMLController
// Create the form, add in default values. // Create the form, add in default values.
$form->setData($id_user ? $user->getProps() : $_POST); $form->setData($id_user ? $user->getProps() : $_POST);
$formview = new FormView($form); $formview = new FormView($form, $form_title);
$view->adopt($formview); $this->page->adopt($formview);
if (!empty($_POST)) if (!empty($_POST))
{ {
@@ -131,13 +129,13 @@ class EditUser extends HTMLController
// Anything missing? // Anything missing?
if (!empty($form->getMissing())) if (!empty($form->getMissing()))
return $formview->adopt(new DummyBox('Some data missing', 'Please fill out the following fields: ' . implode(', ', $form->getMissing()))); return $formview->adopt(new Alert('Some data missing', 'Please fill out the following fields: ' . implode(', ', $form->getMissing()), 'danger'));
$data = $form->getData(); $data = $form->getData();
// Just to be on the safe side. // Just to be on the safe side.
$data['first_name'] = htmlentities(trim($data['first_name'])); $data['first_name'] = htmlspecialchars(trim($data['first_name']));
$data['surname'] = htmlentities(trim($data['surname'])); $data['surname'] = htmlspecialchars(trim($data['surname']));
$data['emailaddress'] = trim($data['emailaddress']); $data['emailaddress'] = trim($data['emailaddress']);
// Make sure there's a slug. // Make sure there's a slug.
@@ -152,18 +150,18 @@ class EditUser extends HTMLController
// If it looks like an e-mail address... // If it looks like an e-mail address...
if (!empty($data['emailaddress']) && !preg_match('~^[^ ]+@[^ ]+\.[a-z]+$~', $data['emailaddress'])) if (!empty($data['emailaddress']) && !preg_match('~^[^ ]+@[^ ]+\.[a-z]+$~', $data['emailaddress']))
return $formview->adopt(new DummyBox('Email addresses invalid', 'The email address you entered is not a valid email address.')); 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. // 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'])) elseif (!empty($data['emailaddress']) && Member::exists($data['emailaddress']) && !($id_user && $user->getEmailAddress() == $data['emailaddress']))
return $formview->adopt(new DummyBox('Email address already in use', 'Another account is already using the e-mail address you entered.')); 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! // Setting passwords? We'll need two!
if (!$id_user || !empty($data['password1']) && !empty($data['password2'])) if (!$id_user || !empty($data['password1']) && !empty($data['password2']))
{ {
if (strlen($data['password1']) < 6 || !preg_match('~[^A-z]~', $data['password1'])) if (strlen($data['password1']) < 6 || !preg_match('~[^A-z]~', $data['password1']))
return $formview->adopt(new DummyBox('Password not acceptable', 'Please fill in a password that is at least six characters long and contains at least one non-alphabetic character (e.g. a number or symbol).')); 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']) elseif ($data['password1'] !== $data['password2'])
return $formview->adopt(new DummyBox('Passwords do not match', 'The passwords you entered do not match. Please try again.')); return $formview->adopt(new Alert('Passwords do not match', 'The passwords you entered do not match. Please try again.', 'danger'));
else else
$data['password'] = $data['password1']; $data['password'] = $data['password1'];
@@ -175,7 +173,7 @@ class EditUser extends HTMLController
{ {
$return = Member::createNew($data); $return = Member::createNew($data);
if ($return === false) if ($return === false)
return $formview->adopt(new DummyBox('Cannot create this user', 'Something went wrong while creating the user...')); return $formview->adopt(new Alert('Cannot create this user', 'Something went wrong while creating the user...', 'danger'));
if (isset($_POST['submit_and_new'])) if (isset($_POST['submit_and_new']))
{ {

View File

@@ -0,0 +1,27 @@
<?php
/*****************************************************************************
* GenerateThumbnail.php
* Contains the asynchronous thumbnail generation controller
*
* Kabuki CMS (C) 2013-2017, Aaron van Geffen
*****************************************************************************/
class GenerateThumbnail extends HTMLController
{
public function __construct()
{
$asset = Asset::fromId($_GET['id']);
if (empty($asset) || !$asset->isImage())
throw new NotFoundException('Image not found');
$image = $asset->getImage();
$crop_mode = isset($_GET['mode']) ? $_GET['mode'] : false;
$url = $image->getThumbnailUrl($_GET['width'], $_GET['height'], $crop_mode, true, true);
if ($url)
{
header('Location: ' . $url);
exit;
}
}
}

View File

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

View File

@@ -3,19 +3,16 @@
* JSONController.php * JSONController.php
* Contains the key JSON controller * Contains the key JSON controller
* *
* Kabuki CMS (C) 2013-2015, Aaron van Geffen * Kabuki CMS (C) 2013-2019, Aaron van Geffen
*****************************************************************************/ *****************************************************************************/
/**
* The abstract class that allows easy creation of json replies.
*/
class JSONController class JSONController
{ {
protected $payload; protected $payload;
public function showContent() public function showContent()
{ {
header('Content-Type: text/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
echo json_encode($this->payload); echo json_encode($this->payload);
} }
} }

View File

@@ -24,14 +24,16 @@ class Login extends HTMLController
if (Authentication::checkPassword($_POST['emailaddress'], $_POST['password'])) if (Authentication::checkPassword($_POST['emailaddress'], $_POST['password']))
{ {
parent::__construct('Login'); 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'])) if (isset($_POST['redirect_url']))
header('Location: ' . base64_decode($_POST['redirect_url'])); header('Location: ' . base64_decode($_POST['redirect_url']));
elseif (isset($_SESSION['login_url'])) elseif (isset($_SESSION['login_url']))
{ {
unset($_SESSION['redirect_url']); header('Location: ' . $_SESSION['login_url']);
header('Location: ' . $_SESSION['redirect_url']); unset($_SESSION['login_url']);
} }
else else
header('Location: ' . BASEURL . '/'); header('Location: ' . BASEURL . '/');
@@ -44,7 +46,7 @@ class Login extends HTMLController
parent::__construct('Log in - ' . SITE_TITLE); parent::__construct('Log in - ' . SITE_TITLE);
$form = new LogInForm('Log in'); $form = new LogInForm('Log in');
if ($login_error) 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. // Tried anything? Be helpful, at least.
if (isset($_POST['emailaddress'])) if (isset($_POST['emailaddress']))

View File

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

View File

@@ -0,0 +1,73 @@
<?php
/*****************************************************************************
* ManageAlbums.php
* Contains the controller for admin album management.
*
* Kabuki CMS (C) 2013-2017, Aaron van Geffen
*****************************************************************************/
class ManageAlbums extends HTMLController
{
public function __construct()
{
// Ensure it's just admins at this point.
if (!Registry::get('user')->isAdmin())
throw new NotAllowedException();
$options = [
'form' => [
'action' => BASEURL . '/editalbum/',
'method' => 'get',
'controls' => [
'add' => [
'type' => 'submit',
'caption' => 'Add new album',
],
],
],
'columns' => [
'id_album' => [
'value' => 'id_tag',
'header' => 'ID',
'is_sortable' => true,
],
'tag' => [
'header' => 'Album',
'is_sortable' => true,
'link' => BASEURL . '/editalbum/?id={ID_TAG}',
'value' => 'tag',
],
'slug' => [
'header' => 'Slug',
'is_sortable' => true,
'link' => BASEURL . '/editalbum/?id={ID_TAG}',
'value' => 'slug',
],
'count' => [
'header' => '# Photos',
'is_sortable' => true,
'value' => 'count',
],
],
'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,
'base_url' => BASEURL . '/managealbums/',
'get_data' => function($offset, $limit, $order, $direction) {
return Tag::getOffset($offset, $limit, $order, $direction, true);
},
'get_count' => function() {
return Tag::getCount(false, 'Album', true);
}
];
$table = new GenericTable($options);
parent::__construct('Album management - Page ' . $table->getCurrentPage() .' - ' . SITE_TITLE);
$this->page->adopt(new TabularData($table));
}
}

View File

@@ -0,0 +1,142 @@
<?php
/*****************************************************************************
* ManageAssets.php
* Contains the asset management controller.
*
* Kabuki CMS (C) 2013-2017, Aaron van Geffen
*****************************************************************************/
class ManageAssets extends HTMLController
{
public function __construct()
{
// Ensure it's just admins at this point.
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',
'is_sortable' => true,
],
'subdir' => [
'value' => 'subdir',
'header' => 'Subdirectory',
'is_sortable' => true,
],
'filename' => [
'value' => 'filename',
'header' => 'Filename',
'is_sortable' => true,
'link' => BASEURL . '/editasset/?id={ID_ASSET}',
'value' => 'filename',
],
'id_user_uploaded' => [
'header' => 'User uploaded',
'is_sortable' => true,
'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,
'format' => function($row) {
if (!empty($row['image_width']))
return $row['image_width'] . ' x ' . $row['image_height'];
else
return 'n/a';
},
],
],
'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,
'base_url' => BASEURL . '/manageassets/',
'get_data' => 'Asset::getOffset',
'get_count' => 'Asset::getCount',
];
$table = new GenericTable($options);
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,11 +14,12 @@ class ManageErrors extends HTMLController
if (!Registry::get('user')->isAdmin()) if (!Registry::get('user')->isAdmin())
throw new NotAllowedException(); throw new NotAllowedException();
// Flushing, are we? // Clearing, are we?
if (isset($_POST['flush']) && Session::validateSession('get')) if (isset($_POST['clear']) && Session::validateSession('get'))
{ {
ErrorLog::flush(); ErrorLog::flush();
header('Location: ' . BASEURL . '/manageerrors/'); header('Location: ' . BASEURL . '/manageerrors/');
exit;
} }
Session::resetSessionToken(); Session::resetSessionToken();
@@ -28,31 +29,32 @@ class ManageErrors extends HTMLController
'form' => [ 'form' => [
'action' => BASEURL . '/manageerrors/?' . Session::getSessionTokenKey() . '=' . Session::getSessionToken(), 'action' => BASEURL . '/manageerrors/?' . Session::getSessionTokenKey() . '=' . Session::getSessionToken(),
'method' => 'post', 'method' => 'post',
'class' => 'floatright', 'controls' => [
'buttons' => [ 'clear' => [
'flush' => [
'type' => 'submit', 'type' => 'submit',
'caption' => 'Delete all', 'caption' => 'Delete all',
'class' => 'btn-danger',
], ],
], ],
], ],
'columns' => [ 'columns' => [
'id' => [ 'id_entry' => [
'value' => 'id_entry', 'value' => 'id_entry',
'header' => '#', 'header' => '#',
'is_sortable' => true, 'is_sortable' => true,
], ],
'message' => [ '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">' . $row['debug_info'] . '</pre></div>' .
'<small><a href="' . BASEURL . $row['request_uri'] . '">' . $row['request_uri'] . '</a></small>';
}
],
'header' => 'Message / URL', 'header' => 'Message / URL',
'is_sortable' => false, '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' => [ 'file' => [
'value' => 'file', 'value' => 'file',
@@ -65,12 +67,10 @@ class ManageErrors extends HTMLController
'is_sortable' => true, 'is_sortable' => true,
], ],
'time' => [ 'time' => [
'parse' => [ 'format' => [
'type' => 'timestamp', 'type' => 'timestamp',
'data' => [
'timestamp' => 'time',
'pattern' => 'long', 'pattern' => 'long',
], 'value' => 'time',
], ],
'header' => 'Time', 'header' => 'Time',
'is_sortable' => true, 'is_sortable' => true,
@@ -83,41 +83,20 @@ class ManageErrors extends HTMLController
'uid' => [ 'uid' => [
'header' => 'UID', 'header' => 'UID',
'is_sortable' => true, 'is_sortable' => true,
'parse' => [ 'link' => BASEURL . '/edituser/?id={ID_USER}',
'link' => BASEURL . '/member/?id={ID_USER}', 'value' => 'id_user',
'data' => 'id_user',
], ],
], ],
], 'default_sort_order' => 'id_entry',
'start' => !empty($_GET['start']) ? (int) $_GET['start'] : 0, 'default_sort_direction' => 'down',
'sort_order' => !empty($_GET['order']) ? $_GET['order'] : '', 'start' => $_GET['start'] ?? 0,
'sort_direction' => !empty($_GET['dir']) ? $_GET['dir'] : '', 'sort_order' => $_GET['order'] ?? '',
'sort_direction' => $_GET['dir'] ?? '',
'no_items_label' => "No errors to display -- we're all good!", 'no_items_label' => "No errors to display -- we're all good!",
'items_per_page' => 20, 'items_per_page' => 20,
'index_class' => 'floatleft',
'base_url' => BASEURL . '/manageerrors/', 'base_url' => BASEURL . '/manageerrors/',
'get_count' => 'ErrorLog::getCount', 'get_count' => 'ErrorLog::getCount',
'get_data' => function($offset = 0, $limit = 20, $order = '', $direction = 'down') { 'get_data' => 'ErrorLog::getOffset',
if (!in_array($order, ['id_entry', 'file', 'line', 'time', 'ipaddress', 'id_user']))
$order = 'id_entry';
$data = Registry::get('db')->queryAssocs('
SELECT *
FROM log_errors
ORDER BY {raw:order}
LIMIT {int:offset}, {int:limit}',
[
'order' => $order . ($direction === 'up' ? ' ASC' : ' DESC'),
'offset' => $offset,
'limit' => $limit,
]);
return [
'rows' => $data,
'order' => $order,
'direction' => $direction,
];
},
]; ];
$error_log = new GenericTable($options); $error_log = new GenericTable($options);

View File

@@ -14,12 +14,21 @@ class ManageTags extends HTMLController
if (!Registry::get('user')->isAdmin()) if (!Registry::get('user')->isAdmin())
throw new NotAllowedException(); throw new NotAllowedException();
if (isset($_REQUEST['create']) && isset($_POST['tag'])) Session::resetSessionToken();
$this->handleTagCreation();
$options = [ $options = [
'form' => [
'action' => BASEURL . '/edittag/',
'method' => 'get',
'controls' => [
'add' => [
'type' => 'submit',
'caption' => 'Add new tag',
],
],
],
'columns' => [ 'columns' => [
'id_post' => [ 'id_tag' => [
'value' => 'id_tag', 'value' => 'id_tag',
'header' => 'ID', 'header' => 'ID',
'is_sortable' => true, 'is_sortable' => true,
@@ -27,23 +36,25 @@ class ManageTags extends HTMLController
'tag' => [ 'tag' => [
'header' => 'Tag', 'header' => 'Tag',
'is_sortable' => true, 'is_sortable' => true,
'parse' => [ 'link' => BASEURL . '/edittag/?id={ID_TAG}',
'link' => BASEURL . '/managetag/?id={ID_TAG}', 'value' => 'tag',
'data' => 'tag',
],
], ],
'slug' => [ 'slug' => [
'header' => 'Slug', 'header' => 'Slug',
'is_sortable' => true, 'is_sortable' => true,
'parse' => [ 'link' => BASEURL . '/edittag/?id={ID_TAG}',
'link' => BASEURL . '/managetag/?id={ID_TAG}', 'value' => 'slug',
'data' => 'slug',
], ],
], 'id_user_owner' => [
'kind' => [ 'header' => 'Owning user',
'header' => 'Kind',
'is_sortable' => true, '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' => [ 'count' => [
'header' => 'Cardinality', 'header' => 'Cardinality',
@@ -51,40 +62,20 @@ class ManageTags extends HTMLController
'value' => 'count', 'value' => 'count',
], ],
], ],
'start' => !empty($_GET['start']) ? (int) $_GET['start'] : 0, 'default_sort_order' => 'tag',
'sort_order' => !empty($_GET['order']) ? $_GET['order'] : null, 'default_sort_direction' => 'up',
'sort_direction' => !empty($_GET['dir']) ? $_GET['dir'] : null, 'start' => $_GET['start'] ?? 0,
'sort_order' => $_GET['order'] ?? '',
'sort_direction' => $_GET['dir'] ?? '',
'title' => 'Manage tags', 'title' => 'Manage tags',
'no_items_label' => 'No tags meet the requirements of the current filter.', 'no_items_label' => 'No tags meet the requirements of the current filter.',
'items_per_page' => 25, 'items_per_page' => 9999,
'base_url' => BASEURL . '/managetags/', 'base_url' => BASEURL . '/managetags/',
'get_data' => function($offset = 0, $limit = 15, $order = '', $direction = 'up') { 'get_data' => function($offset, $limit, $order, $direction) {
if (!in_array($order, ['id_post', 'tag', 'slug', 'kind', 'count'])) return Tag::getOffset($offset, $limit, $order, $direction, false);
$order = 'tag';
if (!in_array($direction, ['up', 'down']))
$direction = 'up';
$data = Registry::get('db')->queryAssocs('
SELECT *
FROM tags
ORDER BY {raw:order}
LIMIT {int:offset}, {int:limit}',
[
'order' => $order . ($direction == 'up' ? ' ASC' : ' DESC'),
'offset' => $offset,
'limit' => $limit,
]);
return [
'rows' => $data,
'order' => $order,
'direction' => ($direction == 'up' ? 'up' : 'down'),
];
}, },
'get_count' => function() { 'get_count' => function() {
return Registry::get('db')->queryValue(' return Tag::getCount(false, null, false);
SELECT COUNT(*)
FROM tags');
} }
]; ];
@@ -92,36 +83,4 @@ class ManageTags extends HTMLController
parent::__construct('Tag management - Page ' . $table->getCurrentPage() .' - ' . SITE_TITLE); parent::__construct('Tag management - Page ' . $table->getCurrentPage() .' - ' . SITE_TITLE);
$this->page->adopt(new TabularData($table)); $this->page->adopt(new TabularData($table));
} }
private function handleTagCreation()
{
header('Content-Type: text/json; charset=utf-8');
// It better not already exist!
if (Tag::exactMatch($_POST['tag']))
{
echo '{"error":"Tag already exists!"}';
exit;
}
$label = htmlentities(trim($_POST['tag']));
$slug = strtr(strtolower($label), [' ' => '-']);
$tag = Tag::createNew([
'tag' => $label,
'slug' => $slug,
]);
// Did we succeed?
if (!$tag)
{
echo '{"error":"Could not create tag."}';
exit;
}
echo json_encode([
'label' => $tag->tag,
'id_tag' => $tag->id_tag,
]);
exit;
}
} }

View File

@@ -14,12 +14,13 @@ class ManageUsers extends HTMLController
if (!Registry::get('user')->isAdmin()) if (!Registry::get('user')->isAdmin())
throw new NotAllowedException(); throw new NotAllowedException();
Session::resetSessionToken();
$options = [ $options = [
'form' => [ 'form' => [
'action' => BASEURL . '/edituser/', 'action' => BASEURL . '/edituser/',
'method' => 'get', 'method' => 'get',
'class' => 'floatright', 'controls' => [
'buttons' => [
'add' => [ 'add' => [
'type' => 'submit', 'type' => 'submit',
'caption' => 'Add new user', 'caption' => 'Add new user',
@@ -35,26 +36,20 @@ class ManageUsers extends HTMLController
'surname' => [ 'surname' => [
'header' => 'Last name', 'header' => 'Last name',
'is_sortable' => true, 'is_sortable' => true,
'parse' => [
'link' => BASEURL . '/edituser/?id={ID_USER}', 'link' => BASEURL . '/edituser/?id={ID_USER}',
'data' => 'surname', 'value' => 'surname',
],
], ],
'first_name' => [ 'first_name' => [
'header' => 'First name', 'header' => 'First name',
'is_sortable' => true, 'is_sortable' => true,
'parse' => [
'link' => BASEURL . '/edituser/?id={ID_USER}', 'link' => BASEURL . '/edituser/?id={ID_USER}',
'data' => 'first_name', 'value' => 'first_name',
],
], ],
'slug' => [ 'slug' => [
'header' => 'Slug', 'header' => 'Slug',
'is_sortable' => true, 'is_sortable' => true,
'parse' => [
'link' => BASEURL . '/edituser/?id={ID_USER}', 'link' => BASEURL . '/edituser/?id={ID_USER}',
'data' => 'slug', 'value' => 'slug',
],
], ],
'emailaddress' => [ 'emailaddress' => [
'value' => 'emailaddress', 'value' => 'emailaddress',
@@ -62,12 +57,11 @@ class ManageUsers extends HTMLController
'is_sortable' => true, 'is_sortable' => true,
], ],
'last_action_time' => [ 'last_action_time' => [
'parse' => [ 'format' => [
'type' => 'timestamp', 'type' => 'timestamp',
'data' => [
'timestamp' => 'last_action_time',
'pattern' => 'long', 'pattern' => 'long',
], 'value' => 'last_action_time',
'if_null' => 'n/a',
], ],
'header' => 'Last activity', 'header' => 'Last activity',
'is_sortable' => true, 'is_sortable' => true,
@@ -80,48 +74,20 @@ class ManageUsers extends HTMLController
'is_admin' => [ 'is_admin' => [
'is_sortable' => true, 'is_sortable' => true,
'header' => 'Admin?', 'header' => 'Admin?',
'parse' => [ 'format' => fn($row) => $row['is_admin'] ? 'yes' : 'no',
'type' => 'function',
'data' => function($row) {
return $row['is_admin'] ? 'yes' : 'no';
}
], ],
], ],
], 'default_sort_order' => 'id_user',
'start' => !empty($_GET['start']) ? (int) $_GET['start'] : 0, 'default_sort_direction' => 'down',
'sort_order' => !empty($_GET['order']) ? $_GET['order'] : '', 'start' => $_GET['start'] ?? 0,
'sort_direction' => !empty($_GET['dir']) ? $_GET['dir'] : '', 'sort_order' => $_GET['order'] ?? '',
'sort_direction' => $_GET['dir'] ?? '',
'title' => 'Manage users', 'title' => 'Manage users',
'no_items_label' => 'No users meet the requirements of the current filter.', 'no_items_label' => 'No users meet the requirements of the current filter.',
'items_per_page' => 15, 'items_per_page' => 30,
'index_class' => 'floatleft',
'base_url' => BASEURL . '/manageusers/', 'base_url' => BASEURL . '/manageusers/',
'get_data' => function($offset = 0, $limit = 15, $order = '', $direction = 'down') { 'get_data' => 'Member::getOffset',
if (!in_array($order, ['id_user', 'surname', 'first_name', 'slug', 'emailaddress', 'last_action_time', 'ip_address', 'is_admin'])) 'get_count' => 'Member::getCount',
$order = 'id_user';
$data = Registry::get('db')->queryAssocs('
SELECT *
FROM users
ORDER BY {raw:order}
LIMIT {int:offset}, {int:limit}',
[
'order' => $order . ($direction == 'up' ? ' ASC' : ' DESC'),
'offset' => $offset,
'limit' => $limit,
]);
return [
'rows' => $data,
'order' => $order,
'direction' => $direction,
];
},
'get_count' => function() {
return Registry::get('db')->queryValue('
SELECT COUNT(*)
FROM users');
}
]; ];
$table = new GenericTable($options); $table = new GenericTable($options);

View File

@@ -14,14 +14,22 @@ class ProvideAutoSuggest extends JSONController
if (!Registry::get('user')->isLoggedIn()) if (!Registry::get('user')->isLoggedIn())
throw new NotAllowedException(); throw new NotAllowedException();
if (!isset($_GET['type'])) if (!isset($_REQUEST['type']))
throw new UnexpectedValueException('Unsupported autosuggest request.'); throw new UnexpectedValueException('Unsupported autosuggest request.');
if ($_GET['type'] === 'tags' && isset($_GET['data'])) if ($_REQUEST['type'] === 'tags' && isset($_REQUEST['data']))
return $this->handleTagSearch();
if ($_REQUEST['type'] === 'createtag' && isset($_REQUEST['tag']))
return $this->handleTagCreation();
}
private function handleTagSearch()
{ {
$data = array_unique(explode(' ', urldecode($_GET['data']))); {
$data = array_unique(explode(' ', urldecode($_REQUEST['data'])));
$data = array_filter($data, function($item) { $data = array_filter($data, function($item) {
return strlen($item) >= 3; return strlen($item) >= 2;
}); });
$this->payload = ['items' => []]; $this->payload = ['items' => []];
@@ -30,12 +38,44 @@ class ProvideAutoSuggest extends JSONController
if (count($data) === 0) if (count($data) === 0)
return; return;
$results = Tag::match($data); $results = Tag::matchPeople($data);
foreach ($results as $id_tag => $tag) foreach ($results as $id_tag => $tag)
$this->payload['items'][] = [ $this->payload['items'][] = [
'label' => $tag, 'label' => $tag['tag'],
'id_tag' => $id_tag, 'id_tag' => $id_tag,
'url' => BASEURL . '/' . $tag['slug'] . '/',
]; ];
} }
} }
private function handleTagCreation()
{
// It better not already exist!
if (Tag::exactMatch($_REQUEST['tag']))
{
$this->payload = ['error' => true, 'msg' => 'Tag already exists!'];
return;
}
$label = htmlspecialchars(trim($_REQUEST['tag']));
$slug = strtr($label, [' ' => '-']);
$tag = Tag::createNew([
'tag' => $label,
'kind' => 'Person',
'slug' => $slug,
]);
// Did we succeed?
if (!$tag)
{
$this->payload = ['error' => true, 'msg' => 'Could not create tag.'];
return;
}
$this->payload = [
'success' => true,
'label' => $tag->tag,
'id_tag' => $tag->id_tag,
];
}
} }

View File

@@ -16,14 +16,65 @@ class ResetPassword extends HTMLController
// Verifying an existing reset key? // Verifying an existing reset key?
if (isset($_GET['step'], $_GET['email'], $_GET['key']) && $_GET['step'] == 2) if (isset($_GET['step'], $_GET['email'], $_GET['key']) && $_GET['step'] == 2)
$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'])))
{
$user = Member::fromEmailAddress($_POST['emailaddress']);
if (!$user)
{
$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']); $email = rawurldecode($_GET['email']);
$id_user = Authentication::getUserid($email); $user = Member::fromEmailAddress($email);
if ($id_user === false) if (!$user)
throw new UserFacingException('Invalid email address. Please make sure you copied the full link in the email you received.'); throw new UserFacingException('Invalid email address. Please make sure you copied the full link in the email you received.');
$key = $_GET['key']; $key = $_GET['key'];
if (!Authentication::checkResetKey($id_user, $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.'); 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); parent::__construct('Reset password - ' . SITE_TITLE);
@@ -42,40 +93,17 @@ class ResetPassword extends HTMLController
// So, are we good to go? // So, are we good to go?
if (empty($missing)) if (empty($missing))
{ {
Authentication::updatePassword($id_user, Authentication::computeHash($_POST['password1'])); 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']; $_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/'); header('Location: ' . BASEURL . '/login/');
exit; exit;
} }
else else
$form->adopt(new Alert('Some fields require your attention', '<ul><li>' . implode('</li><li>', $missing) . '</li></ul>', 'error')); $form->adopt(new Alert('Some fields require your attention', '<ul><li>' . implode('</li><li>', $missing) . '</li></ul>', 'danger'));
}
}
else
{
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'])))
{
$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);
}
} }
} }
} }

View File

@@ -42,9 +42,12 @@ class UploadMedia extends HTMLController
$new_ids[] = $asset->getId(); $new_ids[] = $asset->getId();
$asset->linkTags([$tag->id_tag]); $asset->linkTags([$tag->id_tag]);
if (empty($tag->id_asset_thumb))
{
$tag->id_asset_thumb = $asset->getId(); $tag->id_asset_thumb = $asset->getId();
$tag->save(); $tag->save();
} }
}
if (isset($_REQUEST['format']) && $_REQUEST['format'] === 'json') if (isset($_REQUEST['format']) && $_REQUEST['format'] === 'json')
{ {

View File

@@ -52,8 +52,9 @@ class ViewPeople extends HTMLController
'start' => $start, 'start' => $start,
'base_url' => BASEURL . '/people/', 'base_url' => BASEURL . '/people/',
'page_slug' => 'page/%PAGE%/', '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 . '/' : '')); $this->page->setCanonicalUrl(BASEURL . '/people/' . ($page > 1 ? 'page/' . $page . '/' : ''));
} }

View File

@@ -8,43 +8,119 @@
class ViewPhoto extends HTMLController class ViewPhoto extends HTMLController
{ {
private Image $photo;
public function __construct() public function __construct()
{ {
// Ensure we're logged in at this point. // Ensure we're logged in at this point.
if (!Registry::get('user')->isLoggedIn()) $user = Registry::get('user');
if (!$user->isLoggedIn())
throw new NotAllowedException(); throw new NotAllowedException();
$photo = Asset::fromSlug($_GET['slug']); $photo = Asset::fromSlug($_GET['slug']);
if (empty($photo)) if (empty($photo))
throw new NotFoundException(); throw new NotFoundException();
parent::__construct($photo->getTitle() . ' - ' . SITE_TITLE); $this->photo = $photo->getImage();
$page = new PhotoPage($photo->getImage());
// Exif data? Session::resetSessionToken();
$exif = EXIF::fromFile($photo->getFullPath());
if ($exif) parent::__construct($this->photo->getTitle() . ' - ' . SITE_TITLE);
$page->setExif($exif);
if (!empty($_POST))
$this->handleTagging();
else
$this->handleViewPhoto();
}
private function handleViewPhoto()
{
$page = new PhotoPage($this->photo);
// Any (EXIF) meta data?
$metaData = $this->prepareMetaData();
$page->setMetaData($metaData);
// What tag are we browsing? // What tag are we browsing?
$tag = isset($_GET['in']) ? Tag::fromId($_GET['in']) : null; $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. // Keeping tabs on a filter?
$previous_url = $photo->getUrlForPreviousInSet($id_tag); if (isset($_GET['by']))
if ($previous_url) {
$page->setPreviousPhotoUrl($previous_url); // 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. // Alright, let's run with it then
$next_url = $photo->getUrlForNextInSet($id_tag); $page->setActiveFilter($user->getSlug());
if ($next_url) }
$page->setNextPhotoUrl($next_url);
$this->page->adopt($page); $this->page->adopt($page);
$this->page->setCanonicalUrl($photo->getPageUrl()); $this->page->setCanonicalUrl($this->photo->getPageUrl());
}
// Add an edit button to the admin bar. private function handleTagging()
if (Registry::get('user')->isAdmin()) {
$this->admin_bar->appendItem(BASEURL . '/editasset/?id=' . $photo->getId(), 'Edit this photo'); header('Content-Type: text/json; charset=utf-8');
// Are we tagging a photo?
if (!isset($_POST['id_tag']))
{
echo json_encode(['error' => true, 'msg' => 'Invalid tag request.']);
exit;
}
// We are!
if (!isset($_POST['delete']))
{
$this->photo->linkTags([(int) $_POST['id_tag']]);
echo json_encode(['success' => true]);
exit;
}
// ... deleting, that is.
else
{
$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,71 +26,92 @@ class ViewPhotoAlbum extends HTMLController
$tag = Tag::fromSlug($_GET['tag']); $tag = Tag::fromSlug($_GET['tag']);
$id_tag = $tag->id_tag; $id_tag = $tag->id_tag;
$title = $tag->tag; $title = $tag->tag;
$description = !empty($tag->description) ? $tag->description : ''; $header_box = $this->getHeaderBox($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;';
}
$header_box = new AlbumHeaderBox($title, $description, $back_link, $back_link_title);
} }
// View the album root. // View the album root.
else else
{ {
$id_tag = 1; $id_tag = 1;
$tag = Tag::fromId($id_tag);
$title = 'Albums'; $title = 'Albums';
} }
// What page are we at? // 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)) if (isset($header_box))
$this->page->adopt($header_box); $this->page->adopt($header_box);
// Can we do fancy things here? // Who contributed to this album?
// !!! TODO: permission system? $contributors = $tag->getContributorList();
$buttons = [];
if (Registry::get('user')->isLoggedIn())
$buttons[] = [
'url' => BASEURL . '/uploadmedia/?tag=' . $id_tag,
'caption' => 'Upload new photos here',
];
if (Registry::get('user')->isAdmin())
$buttons[] = [
'url' => BASEURL . '/addalbum/?tag=' . $id_tag,
'caption' => 'Create new subalbum here',
];
// Enough actions for a button box? // Enumerate possible filters
if (!empty($buttons)) $filters = [];
$this->page->adopt(new AlbumButtonBox($buttons)); if (!empty($contributors))
{
$filters[''] = ['id_user' => null, 'label' => '', 'caption' => 'All photos',
'link' => $tag->getUrl()];
foreach ($contributors as $contributor)
{
$filters[$contributor['slug']] = [
'id_user' => $contributor['id_user'],
'label' => $contributor['first_name'],
'caption' => sprintf('By %s (%s photos)',
$contributor['first_name'], $contributor['num_assets']),
'link' => $tag->getUrl() . '?by=' . $contributor['slug'],
];
}
}
// Limit to a particular uploader?
$active_filter = '';
$id_user_uploaded = null;
if (!empty($_GET['by']))
{
if (!isset($filters[$_GET['by']]))
throw new UnexpectedValueException('Invalid filter for this album or tag.');
$active_filter = $_GET['by'];
$id_user_uploaded = $filters[$active_filter]['id_user'];
$filters[$active_filter]['is_active'] = true;
}
// Add an interface to query and modify the album/tag
$buttons = $this->getAlbumButtons($tag, $active_filter);
$button_strip = new AlbumButtonBox($buttons, $filters, $active_filter);
$this->page->adopt($button_strip);
// Fetch subalbums, but only if we're on the first page. // Fetch subalbums, but only if we're on the first page.
if ($page === 1) if ($current_page === 1)
{ {
$albums = $this->getAlbums($id_tag); $albums = $this->getAlbums($id_tag);
$index = new AlbumIndex($albums); $index = new AlbumIndex($albums);
$this->page->adopt($index); $this->page->adopt($index);
} }
// Are we viewing a person tag?
$is_person = $tag->kind === 'Person';
// Load a photo mosaic for the current tag. // Load a photo mosaic for the current tag.
list($mosaic, $total_count) = $this->getPhotoMosaic($id_tag, $page); list($mosaic, $total_count) = $this->getPhotoMosaic($id_tag, $id_user_uploaded, $current_page, !$is_person);
if (isset($mosaic)) if (isset($mosaic))
{ {
$index = new PhotosIndex($mosaic, Registry::get('user')->isAdmin()); $index = new PhotosIndex($mosaic, Registry::get('user')->isAdmin());
$this->page->adopt($index); $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. // Make a page index as needed, while we're at it.
@@ -99,25 +120,26 @@ class ViewPhotoAlbum extends HTMLController
$index = new PageIndex([ $index = new PageIndex([
'recordCount' => $total_count, 'recordCount' => $total_count,
'items_per_page' => self::PER_PAGE, 'items_per_page' => self::PER_PAGE,
'start' => (isset($_GET['page']) ? $_GET['page'] - 1 : 0) * self::PER_PAGE, 'start' => ($current_page - 1) * self::PER_PAGE,
'base_url' => BASEURL . '/' . (isset($_GET['tag']) ? $_GET['tag'] . '/' : ''), 'base_url' => $tag->getUrl(),
'page_slug' => 'page/%PAGE%/', '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. // Set the canonical url.
$this->page->setCanonicalUrl(BASEURL . '/' . (isset($_GET['tag']) ? $_GET['tag'] . '/' : '') . $this->page->setCanonicalUrl($tag->getUrl() . ($current_page > 1 ? 'page/' . $current_page . '/' : ''));
($page > 1 ? 'page/' . $page . '/' : ''));
} }
public function getPhotoMosaic($id_tag, $page) public function getPhotoMosaic($id_tag, $id_user_uploaded, $page, $sort_linear)
{ {
// Create an iterator. // Create an iterator.
list($this->iterator, $total_count) = AssetIterator::getByOptions([ list($this->iterator, $total_count) = AssetIterator::getByOptions([
'id_tag' => $id_tag, 'id_tag' => $id_tag,
'id_user_uploaded' => $id_user_uploaded,
'order' => 'date_captured', 'order' => 'date_captured',
'direction' => $id_tag > 0 ? 'asc' : 'desc', 'direction' => $sort_linear ? 'asc' : 'desc',
'limit' => self::PER_PAGE, 'limit' => self::PER_PAGE,
'page' => $page, 'page' => $page,
], true); ], true);
@@ -147,16 +169,124 @@ class ViewPhotoAlbum extends HTMLController
'id_tag' => $album['id_tag'], 'id_tag' => $album['id_tag'],
'caption' => $album['tag'], 'caption' => $album['tag'],
'link' => BASEURL . '/' . $album['slug'] . '/', '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; return $albums;
} }
public function __destruct() private function getAlbumButtons(Tag $tag, $active_filter)
{ {
if (isset($this->iterator)) $buttons = [];
$this->iterator->clean(); $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, 'start' => (isset($_GET['page']) ? $_GET['page'] - 1 : 0) * self::PER_PAGE,
'base_url' => BASEURL . '/timeline/', 'base_url' => BASEURL . '/timeline/',
'page_slug' => 'page/%PAGE%/', '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. // Set the canonical url.
$this->page->setCanonicalUrl(BASEURL . '/timeline/'); $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

@@ -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,34 +8,55 @@
class Asset class Asset
{ {
protected $id_asset; public $id_asset;
protected $id_user_uploaded; public $id_user_uploaded;
protected $subdir; public $subdir;
protected $filename; public $filename;
protected $title; public $title;
protected $mimetype; public $slug;
protected $image_width; public $mimetype;
protected $image_height; public $image_width;
protected $date_captured; public $image_height;
protected $priority; public $date_captured;
public $priority;
protected $meta; protected $meta;
protected $tags; protected $tags;
protected $thumbnails;
protected function __construct(array $data) public function __construct(array $data)
{ {
foreach ($data as $attribute => $value) foreach ($data as $attribute => $value)
{
if (property_exists($this, $attribute))
$this->$attribute = $value; $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']); $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') public static function fromId($id_asset, $return_format = 'object')
{ {
$row = Registry::get('db')->queryAssoc(' $row = Registry::get('db')->queryAssoc('
SELECT * SELECT *
FROM assets FROM assets
WHERE id_asset = {int:id_asset}', WHERE id_asset = :id_asset',
[ [
'id_asset' => $id_asset, 'id_asset' => $id_asset,
]); ]);
@@ -48,7 +69,7 @@ class Asset
$row = Registry::get('db')->queryAssoc(' $row = Registry::get('db')->queryAssoc('
SELECT * SELECT *
FROM assets FROM assets
WHERE slug = {string:slug}', WHERE slug = :slug',
[ [
'slug' => $slug, 'slug' => $slug,
]); ]);
@@ -58,16 +79,35 @@ class Asset
public static function byRow(array $row, $return_format = 'object') public static function byRow(array $row, $return_format = 'object')
{ {
$db = Registry::get('db');
// Supplement with metadata. // Supplement with metadata.
$row['meta'] = Registry::get('db')->queryPair(' $row['meta'] = $db->queryPair('
SELECT variable, value SELECT variable, value
FROM assets_meta FROM assets_meta
WHERE id_asset = {int:id_asset}', WHERE id_asset = :id_asset',
[ [
'id_asset' => $row['id_asset'], 'id_asset' => $row['id_asset'],
]); ]);
return $return_format == 'object' ? new Asset($row) : $row; // And thumbnails.
$row['thumbnails'] = $db->queryPair('
SELECT
CONCAT(
width, :x, height,
IF(mode != :empty1, CONCAT(:_, mode), :empty2)
) AS selector, filename
FROM assets_thumbs
WHERE id_asset = :id_asset',
[
'id_asset' => $row['id_asset'],
'empty1' => '',
'empty2' => '',
'x' => 'x',
'_' => '_',
]);
return $return_format === 'object' ? new static($row) : $row;
} }
public static function fromIds(array $id_assets, $return_format = 'array') public static function fromIds(array $id_assets, $return_format = 'array')
@@ -80,23 +120,24 @@ class Asset
$res = $db->query(' $res = $db->query('
SELECT * SELECT *
FROM assets FROM assets
WHERE id_asset IN ({array_int:id_assets}) WHERE id_asset IN (@id_assets)
ORDER BY id_asset', ORDER BY id_asset',
[ [
'id_assets' => $id_assets, 'id_assets' => $id_assets,
]); ]);
$assets = []; $assets = [];
while ($asset = $db->fetch_assoc($res)) while ($asset = $db->fetchAssoc($res))
{ {
$assets[$asset['id_asset']] = $asset; $assets[$asset['id_asset']] = $asset;
$assets[$asset['id_asset']]['meta'] = []; $assets[$asset['id_asset']]['meta'] = [];
$assets[$asset['id_asset']]['thumbnails'] = [];
} }
$metas = $db->queryRows(' $metas = $db->queryRows('
SELECT id_asset, variable, value SELECT id_asset, variable, value
FROM assets_meta FROM assets_meta
WHERE id_asset IN ({array_int:id_assets}) WHERE id_asset IN (@id_assets)
ORDER BY id_asset', ORDER BY id_asset',
[ [
'id_assets' => $id_assets, 'id_assets' => $id_assets,
@@ -105,8 +146,30 @@ class Asset
foreach ($metas as $meta) foreach ($metas as $meta)
$assets[$meta[0]]['meta'][$meta[1]] = $meta[2]; $assets[$meta[0]]['meta'][$meta[1]] = $meta[2];
if ($return_format == 'array') $thumbnails = $db->queryRows('
SELECT id_asset,
CONCAT(
width, :x, height,
IF(mode != :empty1, CONCAT(:_, mode), :empty2)
) AS selector, filename
FROM assets_thumbs
WHERE id_asset IN (@id_assets)
ORDER BY id_asset',
[
'id_assets' => $id_assets,
'empty1' => '',
'empty2' => '',
'x' => 'x',
'_' => '_',
]);
foreach ($thumbnails as $thumb)
$assets[$thumb[0]]['thumbnails'][$thumb[1]] = $thumb[2];
if ($return_format === 'array')
{
return $assets; return $assets;
}
else else
{ {
$objects = []; $objects = [];
@@ -116,13 +179,6 @@ class Asset
} }
} }
public static function byPostId($id_post, $return_format = 'object')
{
$db = Registry::get('db');
// !!! TODO
}
public static function createNew(array $data, $return_format = 'object') public static function createNew(array $data, $return_format = 'object')
{ {
// Extract the data array. // Extract the data array.
@@ -146,9 +202,10 @@ class Asset
$new_filename = $preferred_filename; $new_filename = $preferred_filename;
$destination = ASSETSDIR . '/' . $preferred_subdir . '/' . $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); $extension = pathinfo($preferred_filename, PATHINFO_EXTENSION);
$new_filename = $filename . '.' . $extension; $new_filename = $filename . '.' . $extension;
$destination = dirname($destination) . '/' . $new_filename; $destination = dirname($destination) . '/' . $new_filename;
@@ -165,11 +222,14 @@ class Asset
$mimetype = finfo_file($finfo, $destination); $mimetype = finfo_file($finfo, $destination);
finfo_close($finfo); 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. // 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. // 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? // Detected an image?
if (substr($mimetype, 0, 5) == 'image') if (substr($mimetype, 0, 5) == 'image')
@@ -202,10 +262,10 @@ class Asset
INSERT INTO assets INSERT INTO assets
(id_user_uploaded, subdir, filename, title, slug, mimetype, image_width, image_height, date_captured, priority) (id_user_uploaded, subdir, filename, title, slug, mimetype, image_width, image_height, date_captured, priority)
VALUES VALUES
({int:id_user_uploaded}, {string:subdir}, {string:filename}, {string:title}, {string:slug}, {string:mimetype}, (:id_user_uploaded, :subdir, :filename, :title, :slug, :mimetype,
{int:image_width}, {int:image_height}, :image_width, :image_height,
IF({int:date_captured} > 0, FROM_UNIXTIME({int:date_captured}), NULL), ' . (!empty($date_captured) ? 'FROM_UNIXTIME(:date_captured)' : 'NULL') . ',
{int:priority})', :priority)',
[ [
'id_user_uploaded' => isset($id_user) ? $id_user : Registry::get('user')->getUserId(), 'id_user_uploaded' => isset($id_user) ? $id_user : Registry::get('user')->getUserId(),
'subdir' => $preferred_subdir, 'subdir' => $preferred_subdir,
@@ -213,9 +273,9 @@ class Asset
'title' => $title, 'title' => $title,
'slug' => $slug, 'slug' => $slug,
'mimetype' => $mimetype, 'mimetype' => $mimetype,
'image_width' => isset($image_width) ? $image_width : 'NULL', 'image_width' => isset($image_width) ? $image_width : null,
'image_height' => isset($image_height) ? $image_height : 'NULL', 'image_height' => isset($image_height) ? $image_height : null,
'date_captured' => isset($date_captured) ? $date_captured : 'NULL', 'date_captured' => isset($date_captured) ? $date_captured : null,
'priority' => isset($priority) ? (int) $priority : 0, 'priority' => isset($priority) ? (int) $priority : 0,
]); ]);
@@ -225,8 +285,8 @@ class Asset
return false; return false;
} }
$data['id_asset'] = $db->insert_id(); $data['id_asset'] = $db->insertId();
return $return_format == 'object' ? new self($data) : $data; return $return_format === 'object' ? new self($data) : $data;
} }
public function getId() public function getId()
@@ -244,6 +304,16 @@ class Asset
return $this->date_captured; 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() public function getFilename()
{ {
return $this->filename; return $this->filename;
@@ -254,7 +324,7 @@ class Asset
$posts = Registry::get('db')->queryValues(' $posts = Registry::get('db')->queryValues('
SELECT id_post SELECT id_post
FROM posts_assets FROM posts_assets
WHERE id_asset = {int:id_asset}', WHERE id_asset = :id_asset',
['id_asset' => $this->id_asset]); ['id_asset' => $this->id_asset]);
// TODO: fix empty post iterator. // TODO: fix empty post iterator.
@@ -277,7 +347,12 @@ class Asset
return ASSETSDIR . '/' . $this->subdir . '/' . $this->filename; return ASSETSDIR . '/' . $this->subdir . '/' . $this->filename;
} }
public function getPath() public function getSlug()
{
return $this->slug;
}
public function getSubdir()
{ {
return $this->subdir; return $this->subdir;
} }
@@ -322,7 +397,7 @@ class Asset
public function isImage() public function isImage()
{ {
return substr($this->mimetype, 0, 5) === 'image'; return isset($this->mimetype) && substr($this->mimetype, 0, 5) === 'image';
} }
public function getImage() public function getImage()
@@ -333,6 +408,50 @@ class Asset
return new Image(get_object_vars($this)); 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) public function replaceFile($filename)
{ {
// No filename? Abort! // No filename? Abort!
@@ -352,7 +471,7 @@ class Asset
finfo_close($finfo); finfo_close($finfo);
// Detected an image? // Detected an image?
if (substr($this->mimetype, 0, 5) == 'image') if (substr($this->mimetype, 0, 5) === 'image')
{ {
$image = new Imagick($destination); $image = new Imagick($destination);
$d = $image->getImageGeometry(); $d = $image->getImageGeometry();
@@ -376,18 +495,18 @@ class Asset
return Registry::get('db')->query(' return Registry::get('db')->query('
UPDATE assets UPDATE assets
SET SET
mimetype = {string:mimetype}, mimetype = :mimetype,
image_width = {int:image_width}, image_width = :image_width,
image_height = {int:image_height}, image_height = :image_height,
date_captured = {datetime:date_captured}, date_captured = :date_captured,
priority = {int:priority} priority = :priority
WHERE id_asset = {int:id_asset}', WHERE id_asset = :id_asset',
[ [
'id_asset' => $this->id_asset, 'id_asset' => $this->id_asset,
'mimetype' => $this->mimetype, 'mimetype' => $this->mimetype,
'image_width' => isset($this->image_width) ? $this->image_width : 'NULL', 'image_width' => isset($this->image_width) ? $this->image_width : null,
'image_height' => isset($this->image_height) ? $this->image_height : 'NULL', 'image_height' => isset($this->image_height) ? $this->image_height : null,
'date_captured' => isset($this->date_captured) ? $this->date_captured : 'NULL', 'date_captured' => isset($this->date_captured) ? $this->date_captured : null,
'priority' => $this->priority, 'priority' => $this->priority,
]); ]);
} }
@@ -408,8 +527,8 @@ class Asset
if (!empty($to_remove)) if (!empty($to_remove))
$db->query(' $db->query('
DELETE FROM assets_meta DELETE FROM assets_meta
WHERE id_asset = {int:id_asset} AND WHERE id_asset = :id_asset AND
variable IN({array_string:variables})', variable IN(@variables)',
[ [
'id_asset' => $this->id_asset, 'id_asset' => $this->id_asset,
'variables' => array_keys($to_remove), 'variables' => array_keys($to_remove),
@@ -438,12 +557,62 @@ class Asset
public function delete() public function delete()
{ {
return Registry::get('db')->query(' $db = Registry::get('db');
// 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 = :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 = :id_asset',
['id_asset' => $this->id_asset]);
// Delete asset association for these tags
$db->query('
DELETE FROM assets_tags
WHERE id_asset = :id_asset',
['id_asset' => $this->id_asset]);
Tag::recount($recount_tags);
// Reset asset ID for tags that use this asset for their thumbnail
$rows = $db->queryValues('
SELECT id_tag
FROM tags
WHERE id_asset_thumb = :id_asset',
['id_asset' => $this->id_asset]);
if (!empty($rows))
{
foreach ($rows as $row)
{
$tag = Tag::fromId($row['id_tag']);
$tag->resetIdAsset();
}
}
// Finally, delete the actual asset
if (!unlink(ASSETSDIR . '/' . $this->subdir . '/' . $this->filename))
return false;
$return = $db->query('
DELETE FROM assets DELETE FROM assets
WHERE id_asset = {int:id_asset}', WHERE id_asset = :id_asset',
[ ['id_asset' => $this->id_asset]);
'id_asset' => $this->id_asset,
]); return $return;
} }
public function linkTags(array $id_tags) public function linkTags(array $id_tags)
@@ -470,7 +639,7 @@ class Asset
Registry::get('db')->query(' Registry::get('db')->query('
DELETE FROM assets_tags 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_asset' => $this->id_asset,
'id_tags' => $id_tags, 'id_tags' => $id_tags,
@@ -481,94 +650,122 @@ class Asset
public static function getCount() public static function getCount()
{ {
return $db->queryValue(' return Registry::get('db')->queryValue('
SELECT COUNT(*) SELECT COUNT(*)
FROM assets'); FROM assets');
} }
public function setKeyData($title, DateTime $date_captured = null, $priority) public static function getOffset($offset, $limit, $order, $direction)
{ {
$params = [ $order = $order . ($direction == 'up' ? ' ASC' : ' DESC');
'id_asset' => $this->id_asset,
'title' => $title,
'priority' => $priority,
];
if (isset($date_captured)) return Registry::get('db')->queryAssocs('
$params['date_captured'] = $date_captured->format('Y-m-d H:i:s'); 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(' return Registry::get('db')->query('
UPDATE assets UPDATE assets
SET title = {string:title},' . (isset($date_captured) ? ' SET subdir = :subdir,
date_captured = {datetime:date_captured},' : '') . ' filename = :filename,
priority = {int:priority} title = :title,
WHERE id_asset = {int:id_asset}', 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); $params);
}
public function getUrlForPreviousInSet($id_tag = null) if (!$row)
{
$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 false;
}
public function getUrlForNextInSet($id_tag = null)
{
$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'); $obj = self::byRow($row, 'object');
return $obj->getPageUrl() . ($id_tag ? '?in=' . $id_tag : '');
$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;
} }
else
return false; public function getUrlForPreviousInSet(?Tag $tag, $activeFilter)
{
return $this->getUrlForAdjacentInSet('previous', $tag, $activeFilter);
}
public function getUrlForNextInSet(?Tag $tag, $activeFilter)
{
return $this->getUrlForAdjacentInSet('next', $tag, $activeFilter);
} }
} }

View File

@@ -1,36 +1,50 @@
<?php <?php
/***************************************************************************** /*****************************************************************************
* AssetIterator.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 $return_format;
private $res_assets; private $rowCount;
private $res_meta;
protected function __construct($res_assets, $res_meta, $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->direction = $direction;
$this->res_assets = $res_assets;
$this->res_meta = $res_meta;
$this->return_format = $return_format; $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) if (!$row)
return false; return $row;
// Looks up metadata. // Collect metadata
$row['meta'] = []; $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']) if ($meta['id_asset'] != $row['id_asset'])
continue; continue;
@@ -38,40 +52,23 @@ class AssetIterator extends Asset
$row['meta'][$meta['variable']] = $meta['value']; $row['meta'][$meta['variable']] = $meta['value'];
} }
// Reset internal pointer for next asset. // Collect thumbnails
$this->db->data_seek($this->res_meta, 0); $row['thumbnails'] = [];
$this->thumbs_iterator->rewind();
foreach ($this->thumbs_iterator as $thumb)
{
if ($thumb['id_asset'] != $row['id_asset'])
continue;
if ($this->return_format == 'object') $row['thumbnails'][$thumb['selector']] = $thumb['filename'];
}
if ($this->return_format === 'object')
return new Asset($row); return new Asset($row);
else else
return $row; return $row;
} }
public function reset()
{
$this->db->data_seek($this->res_assets, 0);
$this->db->data_seek($this->res_meta, 0);
}
public function clean()
{
if (!$this->res_assets)
return;
$this->db->free_result($this->res_assets);
$this->res_assets = null;
}
public function num()
{
return $this->db->num_rows($this->res_assets);
}
public static function all()
{
return self::getByOptions();
}
public static function getByOptions(array $options = [], $return_count = false, $return_format = 'object') public static function getByOptions(array $options = [], $return_count = false, $return_format = 'object')
{ {
$params = [ $params = [
@@ -94,9 +91,14 @@ class AssetIterator extends Asset
{ {
$params['mime_type'] = $options['mime_type']; $params['mime_type'] = $options['mime_type'];
if (is_array($options['mime_type'])) if (is_array($options['mime_type']))
$where[] = 'a.mimetype IN({array_string:mime_type})'; $where[] = 'a.mimetype IN(@mime_type)';
else 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'])) if (isset($options['id_tag']))
{ {
@@ -104,7 +106,17 @@ class AssetIterator extends Asset
$where[] = 'id_asset IN( $where[] = 'id_asset IN(
SELECT l.id_asset SELECT l.id_asset
FROM assets_tags AS l 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. // Make it valid SQL.
@@ -120,7 +132,7 @@ class AssetIterator extends Asset
FROM assets AS a FROM assets AS a
WHERE ' . $where . ' WHERE ' . $where . '
ORDER BY ' . $order . (!empty($params['limit']) ? ' ORDER BY ' . $order . (!empty($params['limit']) ? '
LIMIT {int:offset}, {int:limit}' : ''), LIMIT :offset, :limit' : ''),
$params); $params);
// Get a resource object for the asset meta. // Get a resource object for the asset meta.
@@ -135,7 +147,30 @@ class AssetIterator extends Asset
ORDER BY id_asset', ORDER BY id_asset',
$params); $params);
$iterator = new self($res_assets, $res_meta, $return_format); // Get a resource object for the asset thumbs.
$res_thumbs = $db->query('
SELECT id_asset, filename,
CONCAT(
width,
:x,
height,
IF(mode != :empty1, CONCAT(:_, mode), :empty2)
) AS selector
FROM assets_thumbs
WHERE id_asset IN(
SELECT id_asset
FROM assets AS a
WHERE ' . $where . '
)
ORDER BY id_asset',
$params + [
'empty1' => '',
'empty2' => '',
'x' => 'x',
'_' => '_',
]);
$iterator = new self($res_assets, $res_meta, $res_thumbs, $return_format, $params['direction']);
// Returning total count, too? // Returning total count, too?
if ($return_count) if ($return_count)
@@ -151,4 +186,39 @@ class AssetIterator extends Asset
else else
return $iterator; 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 class Authentication
{ {
/** const DEFAULT_RESET_TIMEOUT = 30;
* 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;
}
/** /**
* 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(' // Retrieve password hash for user matching the provided emailaddress.
SELECT id_user $password_hash = Registry::get('db')->queryValue('
SELECT password_hash
FROM users FROM users
WHERE emailaddress = {string:emailaddress}', WHERE emailaddress = :emailaddress',
[ [
'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 password_verify($password, $password_hash);
{
return Registry::get('db')->query('
UPDATE users
SET reset_key = {string:key}
WHERE id_user = {int:id}',
[
'id' => $id_user,
'key' => self::newActivationKey(),
]);
} }
public static function checkResetKey($id_user, $reset_key) public static function checkResetKey($id_user, $reset_key)
@@ -61,7 +40,7 @@ class Authentication
$key = Registry::get('db')->queryValue(' $key = Registry::get('db')->queryValue('
SELECT reset_key SELECT reset_key
FROM users FROM users
WHERE id_user = {int:id}', WHERE id_user = :id',
[ [
'id' => $id_user, 'id' => $id_user,
]); ]);
@@ -69,22 +48,55 @@ class Authentication
return $key == $reset_key; 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. * Verifies whether the user is currently logged in.
*/ */
public static function isLoggedIn() public static function isLoggedIn()
{ {
// Check whether the active session matches the current user's environment. if (!isset($_SESSION['user_id']))
if (isset($_SESSION['ip_address'], $_SESSION['user_agent']) && ( return false;
(isset($_SERVER['REMOTE_ADDR']) && $_SESSION['ip_address'] != $_SERVER['REMOTE_ADDR']) ||
(isset($_SERVER['HTTP_USER_AGENT']) && $_SESSION['user_agent'] != $_SERVER['HTTP_USER_AGENT']))) try
{
$exists = Member::fromId($_SESSION['user_id']);
return true;
}
catch (NotFoundException $e)
{ {
session_destroy();
return false; 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; return $string;
} }
/** public static function setResetKey($id_user)
* Checks a password for a given username against the database.
*/
public static function checkPassword($emailaddress, $password)
{ {
// Retrieve password hash for user matching the provided emailaddress. return Registry::get('db')->query('
$password_hash = Registry::get('db')->queryValue(' UPDATE users
SELECT password_hash SET reset_key = :key,
FROM users reset_blocked_until = UNIX_TIMESTAMP() + ' . static::DEFAULT_RESET_TIMEOUT . '
WHERE emailaddress = {string:emailaddress}', 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(' return Registry::get('db')->query('
UPDATE users UPDATE users
SET SET
password_hash = {string:hash}, password_hash = :hash,
reset_key = {string:blank} reset_key = :blank
WHERE id_user = {int:id_user}', WHERE id_user = :id_user',
[ [
'id_user' => $id_user, 'id_user' => $id_user,
'hash' => $hash, 'hash' => $hash,
'blank' => '', '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,149 +0,0 @@
<?php
/*****************************************************************************
* BestColor.php
* Contains key class BestColor.
*
* !!! Licensing?
*****************************************************************************/
class BestColor
{
private $best;
public function __construct(Image $asset)
{
// Set fallback color.
$this->best = ['r' => 204, 'g' => 204, 'b' => 204]; // #cccccc
// We will be needing to read this...
if (!file_exists($asset->getPath()))
return;
// Try the arcane stuff again.
try
{
$image = new Imagick($asset->getPath());
$width = $image->getImageWidth();
$height = $image->getImageHeight();
// Sample six points in the image: four based on the rule of thirds, as well as the horizontal and vertical centre.
$topy = round($height / 3);
$bottomy = round(($height / 3) * 2);
$leftx = round($width / 3);
$rightx = round(($width / 3) * 2);
$centery = round($height / 2);
$centerx = round($width / 2);
// Grab their colours.
$rgb = [
$image->getImagePixelColor($leftx, $topy)->getColor(),
$image->getImagePixelColor($rightx, $topy)->getColor(),
$image->getImagePixelColor($leftx, $bottomy)->getColor(),
$image->getImagePixelColor($rightx, $bottomy)->getColor(),
$image->getImagePixelColor($centerx, $centery)->getColor(),
];
// We won't be needing this anymore, so save us some memory.
$image->clear();
$image->destroy();
}
// In case something does go wrong...
catch (ImagickException $e)
{
// Fall back to default color.
return;
}
// Process rgb values into hsv values
foreach ($rgb as $i => $color)
{
$colors[$i] = $color;
list($colors[$i]['h'], $colors[$i]['s'], $colors[$i]['v']) = self::rgb2hsv($color['r'], $color['g'], $color['b']);
}
// Figure out which color is the best saturated.
$best_saturation = $best_brightness = 0;
$the_best_s = $the_best_v = ['v' => 0];
foreach ($colors as $color)
{
if ($color['s'] > $best_saturation)
{
$best_saturation = $color['s'];
$the_best_s = $color;
}
if ($color['v'] > $best_brightness)
{
$best_brightness = $color['v'];
$the_best_v = $color;
}
}
// Is brightest the same as most saturated?
$this->best = ($the_best_s['v'] >= ($the_best_v['v'] - ($the_best_v['v'] / 2))) ? $the_best_s : $the_best_v;
}
public static function hex2rgb($hex)
{
return sscanf($hex, '%2X%2X%2X');
}
public static function rgb2hex($red, $green, $blue)
{
return sprintf('%02X%02X%02X', $red, $green, $blue);
}
public static function rgb2hsv($r, $g, $b)
{
$max = max($r, $g, $b);
$min = min($r, $g, $b);
$delta = $max - $min;
$v = round(($max / 255) * 100);
$s = ($max != 0) ? (round($delta / $max * 100)) : 0;
if ($s == 0)
{
$h = false;
}
else
{
if ($r == $max)
$h = ($g - $b) / $delta;
elseif ($g == $max)
$h = 2 + ($b - $r) / $delta;
elseif ($b == $max)
$h = 4 + ($r - $g) / $delta;
$h = round($h * 60);
if ($h > 360)
$h = 360;
if ($h < 0)
$h += 360;
}
return [$h, $s, $v];
}
/**
* Get a normal (light) background color as hexadecimal value (without hash prefix).
* @return color string
*/
public function hex()
{
$c = $this->best;
return self::rgb2hex($c['r'], $c['g'], $c['b']);
}
/**
* Get a 50% darker version of the best color as string.
* @param factor, defaults to 0.5
* @param alpha, defaults to 0.7
* @return rgba(r * factor, g * factor, b * factor, alpha)
*/
public function rgba($factor = 0.5, $alpha = 0.7)
{
$c = $this->best;
return 'rgba(' . round($c['r'] * $factor) . ', ' . round($c['g'] * $factor) . ', ' . round($c['b'] * $factor) . ', ' . $alpha . ')';
}
}

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 <?php
/***************************************************************************** /*****************************************************************************
* Database.php * Database.php
* Contains key class Database. * Contains model class Database.
* *
* Adapted from SMF 2.0's DBA (C) 2011 Simple Machines * Kabuki CMS (C) 2013-2025, Aaron van Geffen
* Used under BSD 3-clause license.
*
* Kabuki CMS (C) 2013-2015, Aaron van Geffen
*****************************************************************************/ *****************************************************************************/
/**
* The database model used to communicate with the MySQL server.
*/
class Database class Database
{ {
private $connection; private $connection;
private $query_count = 0; private $query_count = 0;
private $logged_queries = []; private $logged_queries = [];
/** public function __construct($host, $user, $password, $name)
* 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)
{ {
$this->connection = @mysqli_connect($server, $user, $password, $name); try
{
$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. // Give up if we have a connection error.
if (mysqli_connect_error()) catch (PDOException $e)
{ {
header('HTTP/1.1 503 Service Temporarily Unavailable'); 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>'; 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; exit;
} }
$this->query('SET NAMES {string:utf8}', array('utf8' => 'utf8'));
} }
public function getQueryCount() public function getQueryCount()
@@ -51,324 +42,211 @@ 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); return $stmt->fetch(PDO::FETCH_NUM);
}
public function data_seek($result, $row_num)
{
return mysqli_data_seek($result, $row_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); return $stmt->columnCount();
}
/**
* Unescapes a string.
*/
public function unescape_string($string)
{
return stripslashes($string);
}
/**
* Returns the last MySQL error.
*/
public function error()
{
return mysqli_error($this->connection);
}
public function server_info()
{
return mysqli_get_server_info($this->connection);
}
/**
* Selects a database on a given connection.
*/
public function select_db($database)
{
return mysqli_select_db($database, $this->connection);
}
/**
* Returns the amount of rows affected by the previous query.
*/
public function affected_rows()
{
return mysqli_affected_rows($this->connection);
} }
/** /**
* Returns the id of the row created by a previous query. * 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) return $this->connection->beginTransaction();
{
case 'begin':
case 'rollback':
case 'commit':
return @mysqli_query($this->connection, strtoupper($operation));
default:
return false;
}
} }
/** /**
* Function used as a callback for the preg_match function that parses variables into database queries. * 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);
if (!isset($values[$matches[2]]))
trigger_error('The database value you\'re trying to insert does not exist: ' . htmlspecialchars($matches[2]), E_USER_ERROR);
$replacement = $values[$matches[2]];
switch ($matches[1])
{
case 'int':
if ((!is_numeric($replacement) || (string) $replacement !== (string) (int) $replacement) && $replacement !== 'NULL')
trigger_error('Wrong value type sent to the database 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 (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);
}
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))
{
if (empty($replacement))
trigger_error('Database error, given array of string values is empty.', E_USER_ERROR);
foreach ($replacement as $key => $value)
$replacement[$key] = sprintf('\'%1$s\'', mysqli_real_escape_string($connection, $value));
return implode(', ', $replacement);
}
else
trigger_error('Wrong value type sent to the database 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, array('`' => '', '.' => '')) . '`';
break;
case 'raw':
return $replacement;
break;
case 'bool':
case 'boolean':
// In mysql this is a synonym for tinyint(1)
return (bool)$replacement ? 1 : 0;
break;
default:
trigger_error('Undefined type <b>' . $matches[1] . '</b> used in the database query', E_USER_ERROR);
break;
}
} }
/** /**
* Escapes and quotes a string using values passed, and executes the query. * Commit changes in a transaction.
*/ */
public function query($db_string, $db_values = array()) public function commit()
{ {
// One more query.... return $this->connection->commit();
$this->query_count ++;
// Overriding security? This is evil!
$security_override = $db_values === 'security_override' || !empty($db_values['security_override']);
// Please, just use new style queries.
if (strpos($db_string, '\'') !== false && !$security_override)
trigger_error('Hack attempt!', 'Illegal character (\') used in query.', E_USER_ERROR);
if (!$security_override && !empty($db_values))
{
// Set some values for use in the callback function.
$this->db_callback = array($db_values, $this->connection);
// Insert the values passed to this function.
$db_string = preg_replace_callback('~{([a-z_]+)(?::([a-zA-Z0-9_-]+))?}~', array(&$this, 'replacement_callback'), $db_string);
// Save some memory.
$this->db_callback = [];
} }
if (defined("DB_LOG_QUERIES") && DB_LOG_QUERIES) private function expandPlaceholders($db_string, array &$db_values)
$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))); foreach ($db_values as $key => &$value)
trigger_error($this->error() . '<br>' . $clean_sql, E_USER_ERROR); {
if (str_contains($db_string, ':' . $key))
{
if (is_array($value))
{
throw new UnexpectedValueException('Array ' . $key .
' is used as a scalar placeholder. Did you mean to use \'@\' instead?');
} }
return $return; // Prepare date/time values
if (is_a($value, 'DateTime'))
{
$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?');
} }
/** // Create placeholders for all array elements
* Escapes and quotes a string just like db_query, but does not execute the query. $placeholders = array_map(fn($num) => ':' . $key . $num, range(0, count($value) - 1));
* Useful for debugging purposes. $db_string = str_replace('@' . $key, implode(', ', $placeholders), $db_string);
*/ }
public function quote($db_string, $db_values = array()) else
{ {
// Please, just use new style queries. // throw new Exception('Warning: unused key in query: ' . $key);
if (strpos($db_string, '\'') !== false) }
trigger_error('Hack attempt!', 'Illegal character (\') used in query.', E_USER_ERROR); }
// Save some values for use in the callback function.
$this->db_callback = array($db_values, $this->connection);
// Insert the values passed to this function.
$db_string = preg_replace_callback('~{([a-z_]+)(?::([a-zA-Z0-9_-]+))?}~', array(&$this, 'replacement_callback'), $db_string);
// Save some memory.
$this->db_callback = array();
return $db_string; 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 = array()) public function query($db_string, array $db_values = []): PDOStatement
{ {
$res = $this->query($db_string, $db_values); // One more query...
$this->query_count++;
if (!$res || $this->num_rows($res) == 0) // Error out if hardcoded strings are detected
return array(); if (strpos($db_string, '\'') !== false)
throw new UnexpectedValueException('Hack attempt: illegal character (\') used in query.');
$row = $this->fetch_row($res); if (defined('DB_LOG_QUERIES') && DB_LOG_QUERIES)
$this->free_result($res); $this->logged_queries[] = $db_string;
return $row; 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 array of all the rows it returns. * Executes a query, returning an object of the row it returns.
*/ */
public function queryRows($db_string, $db_values = array()) public function queryObject($class, $db_string, $db_values = [])
{ {
$res = $this->query($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 array(); return null;
$rows = array(); $object = $this->fetchObject($res, $class);
while ($row = $this->fetch_row($res)) $this->free($res);
$rows[] = $row;
$this->free_result($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 [];
$rows = [];
while ($object = $this->fetchObject($res, $class))
$rows[] = $object;
$this->free($res);
return $rows; return $rows;
} }
@@ -376,18 +254,53 @@ class Database
/** /**
* Executes a query, returning an array of all the rows it returns. * Executes a query, returning an array of all the rows it returns.
*/ */
public function queryPair($db_string, $db_values = array()) public function queryRow($db_string, array $db_values = [])
{ {
$res = $this->query($db_string, $db_values); $res = $this->query($db_string, $db_values);
if (!$res || $this->num_rows($res) == 0) if ($this->rowCount($res) === 0)
return array(); return [];
$rows = array(); $row = $this->fetchNum($res);
while ($row = $this->fetch_row($res)) $this->free($res);
return $row;
}
/**
* Executes a query, returning an array of all the rows it returns.
*/
public function queryRows($db_string, array $db_values = [])
{
$res = $this->query($db_string, $db_values);
if ($this->rowCount($res) === 0)
return [];
$rows = [];
while ($row = $this->fetchNum($res))
$rows[] = $row;
$this->free($res);
return $rows;
}
/**
* Executes a query, returning an array of all the rows it returns.
*/
public function queryPair($db_string, array $db_values = [])
{
$res = $this->query($db_string, $db_values);
if ($this->rowCount($res) === 0)
return [];
$rows = [];
while ($row = $this->fetchNum($res))
$rows[$row[0]] = $row[1]; $rows[$row[0]] = $row[1];
$this->free_result($res); $this->free($res);
return $rows; return $rows;
} }
@@ -399,17 +312,17 @@ class Database
{ {
$res = $this->query($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 array(); return [];
$rows = array(); $rows = [];
while ($row = $this->fetch_assoc($res)) while ($row = $this->fetchAssoc($res))
{ {
$key_value = reset($row); $key_value = reset($row);
$rows[$key_value] = $row; $rows[$key_value] = $row;
} }
$this->free_result($res); $this->free($res);
return $rows; return $rows;
} }
@@ -417,15 +330,15 @@ class Database
/** /**
* Executes a query, returning an associative array of all the rows it returns. * Executes a query, returning an associative array of all the rows it returns.
*/ */
public function queryAssoc($db_string, $db_values = array()) public function queryAssoc($db_string, array $db_values = [])
{ {
$res = $this->query($db_string, $db_values); $res = $this->query($db_string, $db_values);
if (!$res || $this->num_rows($res) == 0) if ($this->rowCount($res) === 0)
return array(); return [];
$row = $this->fetch_assoc($res); $row = $this->fetchAssoc($res);
$this->free_result($res); $this->free($res);
return $row; return $row;
} }
@@ -433,18 +346,18 @@ class Database
/** /**
* Executes a query, returning an associative array of all the rows it returns. * Executes a query, returning an associative array of all the rows it returns.
*/ */
public function queryAssocs($db_string, $db_values = array(), $connection = null) public function queryAssocs($db_string, array $db_values = [])
{ {
$res = $this->query($db_string, $db_values); $res = $this->query($db_string, $db_values);
if (!$res || $this->num_rows($res) == 0) if ($this->rowCount($res) === 0)
return array(); return [];
$rows = array(); $rows = [];
while ($row = $this->fetch_assoc($res)) while ($row = $this->fetchAssoc($res))
$rows[] = $row; $rows[] = $row;
$this->free_result($res); $this->free($res);
return $rows; return $rows;
} }
@@ -452,16 +365,16 @@ class Database
/** /**
* Executes a query, returning the first value of the first row. * Executes a query, returning the first value of the first row.
*/ */
public function queryValue($db_string, $db_values = array()) public function queryValue($db_string, array $db_values = [])
{ {
$res = $this->query($db_string, $db_values); $res = $this->query($db_string, $db_values);
// If this happens, you're doing it wrong. // If this happens, you're doing it wrong.
if (!$res || $this->num_rows($res) == 0) if ($this->rowCount($res) === 0)
return null; return null;
list($value) = $this->fetch_row($res); list($value) = $this->fetchNum($res);
$this->free_result($res); $this->free($res);
return $value; return $value;
} }
@@ -469,18 +382,18 @@ class Database
/** /**
* Executes a query, returning an array of the first value of each row. * Executes a query, returning an array of the first value of each row.
*/ */
public function queryValues($db_string, $db_values = array()) public function queryValues($db_string, array $db_values = [])
{ {
$res = $this->query($db_string, $db_values); $res = $this->query($db_string, $db_values);
if (!$res || $this->num_rows($res) == 0) if ($this->rowCount($res) === 0)
return array(); return [];
$rows = array(); $rows = [];
while ($row = $this->fetch_row($res)) while ($row = $this->fetchNum($res))
$rows[] = $row[0]; $rows[] = $row[0];
$this->free_result($res); $this->free($res);
return $rows; return $rows;
} }
@@ -488,7 +401,7 @@ class Database
/** /**
* This function can be used to insert data into the database in a secure way. * 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. // With nothing to insert, simply return.
if (empty($data)) if (empty($data))
@@ -496,40 +409,47 @@ class Database
// Inserting data as a single row can be done as a single array. // Inserting data as a single row can be done as a single array.
if (!is_array($data[array_rand($data)])) if (!is_array($data[array_rand($data)]))
$data = array($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 = array();
foreach ($data as $dataRow)
$insertRows[] = $this->quote($insertData, array_combine($indexed_columns, $dataRow));
// Determine the method of insertion. // 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. // What columns are we inserting?
return $this->query(' $columns = array_keys($data[0]);
' . $queryTitle . ' INTO ' . $table . ' (`' . implode('`, `', $indexed_columns) . '`)
VALUES // Start building the query.
' . implode(', $db_string = $method . ' INTO ' . $table . ' (' . implode(',', $columns) . ') VALUES ';
', $insertRows),
array( // Create the mold for a single row insert.
'security_override' => true, $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,68 +8,12 @@
class Dispatcher class Dispatcher
{ {
public static function route()
{
$possibleActions = [
'albums' => 'ViewPhotoAlbums',
'editasset' => 'EditAsset',
'edituser' => 'EditUser',
'login' => 'Login',
'logout' => 'Logout',
'managecomments' => 'ManageComments',
'manageerrors' => 'ManageErrors',
'managetags' => 'ManageTags',
'manageusers' => 'ManageUsers',
'people' => 'ViewPeople',
'resetpassword' => 'ResetPassword',
'suggest' => 'ProvideAutoSuggest',
'timeline' => 'ViewTimeline',
'uploadmedia' => 'UploadMedia',
];
// Work around PHP's FPM not always providing PATH_INFO.
if (empty($_SERVER['PATH_INFO']) && isset($_SERVER['REQUEST_URI']))
{
if (strpos($_SERVER['REQUEST_URI'], '?') === false)
$_SERVER['PATH_INFO'] = $_SERVER['REQUEST_URI'];
else
$_SERVER['PATH_INFO'] = substr($_SERVER['REQUEST_URI'], 0, strpos($_SERVER['REQUEST_URI'], '?'));
}
// Just showing the album index?
if (empty($_SERVER['PATH_INFO']) || $_SERVER['PATH_INFO'] == '/')
{
return new ViewPhotoAlbum();
}
// 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() public static function dispatch()
{ {
// Let's try to find our bearings! // Let's try to find our bearings!
try try
{ {
$page = self::route(); $page = Router::route();
$page->showContent(); $page->showContent();
} }
// Something wasn't found? // Something wasn't found?
@@ -100,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. * Kicks a guest to a login form, redirecting them back to this page upon login.
*/ */
public static function kickGuest() public static function kickGuest($title = null, $message = null)
{ {
$form = new LogInForm('Log in'); $form = new LogInForm('Log in');
$form->adopt(new Alert('', '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']); $form->setRedirectUrl($_SERVER['REQUEST_URI']);
$page = new MainTemplate('Login required'); $page = new MainTemplate('Login required');
@@ -116,38 +73,24 @@ class Dispatcher
exit; exit;
} }
public static function trigger400() private static function trigger400()
{ {
header('HTTP/1.1 400 Bad Request'); http_response_code(400);
$page = new MainTemplate('Bad request'); self::errorPage('Bad request', 'The server does not understand your request.');
$page->adopt(new DummyBox('Bad request', '<p>The server does not understand your request.</p>'));
$page->html_main();
exit; exit;
} }
public static function trigger403() private static function trigger403()
{ {
header('HTTP/1.1 403 Forbidden'); http_response_code(403);
$page = new MainTemplate('Access denied'); self::errorPage('Forbidden', 'You do not have access to this page.');
$page->adopt(new DummyBox('Forbidden', '<p>You do not have access to the page you requested.</p>'));
$page->html_main();
exit; exit;
} }
public static function trigger404() private static function trigger404()
{ {
header('HTTP/1.1 404 Not Found'); http_response_code(404);
$page = new MainTemplate('Page not found'); $page = new ViewErrorPage('Page not found!');
$page->showContent();
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;
} }
} }

View File

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

View File

@@ -14,7 +14,7 @@ class Email
$boundary = uniqid('sr'); $boundary = uniqid('sr');
if (empty($headers)) if (empty($headers))
$headers .= "From: HashRU Pics <no-reply@pics.hashru.nl>\r\n"; $headers .= "From: " . SITE_TITLE . " <" . REPLY_TO_ADDRESS . ">\r\n";
// Set up headers. // Set up headers.
$headers .= "MIME-Version: 1.0\r\n"; $headers .= "MIME-Version: 1.0\r\n";
@@ -44,7 +44,7 @@ class Email
if (DEBUG) if (DEBUG)
return file_put_contents(BASEDIR . '/mail_dumps.txt', "To: \"$addressee\" <$address>\r\n$headers\r\nSubject: $subject\r\n" . self::wrapLines($message), FILE_APPEND); return file_put_contents(BASEDIR . '/mail_dumps.txt', "To: \"$addressee\" <$address>\r\n$headers\r\nSubject: $subject\r\n" . self::wrapLines($message), FILE_APPEND);
else else
return mail("\"$addressee\" <$address>", $subject, $message, $headers, '-fbounces@pics.hashru.nl'); return mail("\"$addressee\" <$address>", $subject, $message, $headers);
} }
public static function wrapLines($body, $maxlength = 80, $break = "\r\n") public static function wrapLines($body, $maxlength = 80, $break = "\r\n")
@@ -69,7 +69,7 @@ class Email
$row = Registry::get('db')->queryAssoc(' $row = Registry::get('db')->queryAssoc('
SELECT first_name, surname, emailaddress, reset_key SELECT first_name, surname, emailaddress, reset_key
FROM users FROM users
WHERE id_user = {int:id_user}', WHERE id_user = :id_user',
[ [
'id_user' => $id_user, 'id_user' => $id_user,
]); ]);

View File

@@ -3,7 +3,7 @@
* ErrorHandler.php * ErrorHandler.php
* Contains key class ErrorHandler. * Contains key class ErrorHandler.
* *
* Kabuki CMS (C) 2013-2016, Aaron van Geffen * Kabuki CMS (C) 2013-2025, Aaron van Geffen
*****************************************************************************/ *****************************************************************************/
class ErrorHandler class ErrorHandler
@@ -47,10 +47,8 @@ class ErrorHandler
// Log the error in the database. // Log the error in the database.
self::logError($error_message, $debug_info, $file, $line); self::logError($error_message, $debug_info, $file, $line);
// Are we considering this fatal? Then display and exit. // Display error and exit.
// !!! TODO: should we consider warnings fatal? self::display($error_message, $file, $line, $debug_info);
if (true) // DEBUG || (!DEBUG && $error_level === E_WARNING || $error_level === E_USER_WARNING))
self::display($file . ' (' . $line . ')<br>' . $error_message, $debug_info);
// If it wasn't a fatal error, well... // If it wasn't a fatal error, well...
self::$handling_error = false; self::$handling_error = false;
@@ -63,11 +61,11 @@ class ErrorHandler
// Include info on the contents of superglobals. // Include info on the contents of superglobals.
if (!empty($_SESSION)) if (!empty($_SESSION))
$debug_info .= "\nSESSION: " . print_r($_SESSION, true); $debug_info .= "\nSESSION: " . var_export($_SESSION, true);
if (!empty($_POST)) if (!empty($_POST))
$debug_info .= "\nPOST: " . print_r($_POST, true); $debug_info .= "\nPOST: " . var_export($_POST, true);
if (!empty($_GET)) if (!empty($_GET))
$debug_info .= "\nGET: " . print_r($_GET, true); $debug_info .= "\nGET: " . var_export($_GET, true);
return $debug_info; return $debug_info;
} }
@@ -96,12 +94,17 @@ class ErrorHandler
$object = isset($call['class']) ? $call['class'] . $call['type'] : ''; $object = isset($call['class']) ? $call['class'] . $call['type'] : '';
$args = []; $args = [];
if (isset($call['args']))
{
foreach ($call['args'] as $j => $arg) foreach ($call['args'] as $j => $arg)
{ {
if (is_array($arg)) // Only include the class name for objects
$args[$j] = print_r($arg, true); if (is_object($arg))
elseif (is_object($arg)) $args[$j] = get_class($arg) . '{}';
$args[$j] = var_dump($arg); // Export everything else -- including arrays
else
$args[$j] = var_export($arg, true);
}
} }
$buffer .= '#' . str_pad($i, 3, ' ') $buffer .= '#' . str_pad($i, 3, ' ')
@@ -113,7 +116,7 @@ class ErrorHandler
} }
// Logs an error into the database. // 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([ if (!ErrorLog::log([
'message' => $error_message, 'message' => $error_message,
@@ -125,15 +128,15 @@ class ErrorHandler
'request_uri' => isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '', 'request_uri' => isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '',
])) ]))
{ {
header('HTTP/1.1 503 Service Temporarily Unavailable'); http_response_code(503);
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>'; 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; exit;
} }
return $error_message; 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(); $is_admin = Registry::has('user') && Registry::get('user')->isAdmin();
@@ -151,30 +154,30 @@ class ErrorHandler
elseif (!$is_sensitive) elseif (!$is_sensitive)
echo json_encode(['error' => $message]); echo json_encode(['error' => $message]);
else 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; exit;
} }
// Initialise the main template to present a nice message to the user. // 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. // Show the error.
$is_admin = Registry::has('user') && Registry::get('user')->isAdmin(); $is_admin = Registry::has('user') && Registry::get('user')->isAdmin();
if (DEBUG || $is_admin) 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! // Let's provide the admin navigation despite it all!
if ($is_admin) if ($is_admin)
{ {
$page->appendStylesheet(BASEURL . '/css/admin.css'); $page->appendStylesheet(BASEURL . '/css/admin.css');
$page->adopt(new AdminBar());
} }
} }
elseif (!$is_sensitive) elseif (!$is_sensitive)
$page->adopt(new DummyBox('An error occured!', '<p>' . $message . '</p>')); $page->adopt(new ErrorPage('An error occurred!', '<p>' . $message . '</p>'));
else 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. // If we got this far, make sure we're not showing stuff twice.
ob_end_clean(); ob_end_clean();

View File

@@ -17,14 +17,14 @@ class ErrorLog
INSERT INTO log_errors INSERT INTO log_errors
(id_user, message, debug_info, file, line, request_uri, time, ip_address) (id_user, message, debug_info, file, line, request_uri, time, ip_address)
VALUES VALUES
({int:id_user}, {string:message}, {string:debug_info}, {string:file}, {int:line}, (:id_user, :message, :debug_info, :file, :line,
{string:request_uri}, CURRENT_TIMESTAMP, {string:ip_address})', :request_uri, CURRENT_TIMESTAMP, :ip_address)',
$data); $data);
} }
public static function flush() 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() public static function getCount()
@@ -33,4 +33,20 @@ class ErrorLog
SELECT COUNT(*) SELECT COUNT(*)
FROM log_errors'); 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 * Form.php
* Contains key class Form. * Contains key class Form.
* *
* Kabuki CMS (C) 2013-2015, Aaron van Geffen * Kabuki CMS (C) 2013-2023, Aaron van Geffen
*****************************************************************************/ *****************************************************************************/
class Form class Form
{ {
public $request_method; public $request_method;
public $request_url; public $request_url;
public $content_above;
public $content_below; private $fields = [];
private $fields; public $before_fields;
private $data; public $after_fields;
private $missing;
private $submit_caption;
public $buttons_extra;
private $trim_inputs;
private $data = [];
private $missing = [];
// NOTE: this class does not verify the completeness of form options. // NOTE: this class does not verify the completeness of form options.
public function __construct($options) public function __construct($options)
{ {
$this->request_method = !empty($options['request_method']) ? $options['request_method'] : 'POST'; static $optionKeys = [
$this->request_url = !empty($options['request_url']) ? $options['request_url'] : BASEURL; 'request_method' => 'POST',
$this->fields = !empty($options['fields']) ? $options['fields'] : []; 'request_url' => BASEURL,
$this->content_below = !empty($options['content_below']) ? $options['content_below'] : null;
$this->content_above = !empty($options['content_above']) ? $options['content_above'] : null; '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->data = [];
$this->missing = []; $this->missing = [];
@@ -41,30 +88,43 @@ class Form
} }
// No data present at all for this field? // 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')
{ {
if (empty($field['is_optional']))
$this->missing[] = $field_id; $this->missing[] = $field_id;
if ($field['type'] === 'select' && !empty($field['multiple']))
$this->data[$field_id] = [];
else
$this->data[$field_id] = ''; $this->data[$field_id] = '';
continue; 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']) switch ($field['type'])
{ {
case 'select': case 'select':
case 'radio': case 'radio':
// Skip validation? Dangerous territory! $this->validateSelect($field_id, $field, $post);
if (isset($field['verify_options']) && $field['verify_options'] === false)
$this->data[$field_id] = $post[$field_id];
// Check whether selected option is valid.
elseif (isset($post[$field_id]) && !isset($field['options'][$post[$field_id]]))
{
$this->missing[] = $field_id;
$this->data[$field_id] = '';
continue;
}
else
$this->data[$field_id] = $post[$field_id];
break; break;
case 'checkbox': case 'checkbox':
@@ -73,25 +133,92 @@ class Form
break; break;
case 'color': case 'color':
$this->validateColor($field_id, $field, $post);
break;
case 'file':
// 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':
$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;
}
break;
case 'text':
case 'textarea':
default:
$this->validateText($field_id, $field, $post);
}
}
}
private function validateCaptcha($field_id)
{
$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;
}
}
private function validateColor($field_id, array $field, array $post)
{
// Colors are stored as a string of length 3 or 6 (hex) // 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)) if (!isset($post[$field_id]) || (strlen($post[$field_id]) != 3 && strlen($post[$field_id]) != 6))
{ {
$this->missing[] = $field_id; $this->missing[] = $field_id;
$this->data[$field_id] = ''; $this->data[$field_id] = '';
continue;
} }
else else
$this->data[$field_id] = $post[$field_id]; $this->data[$field_id] = $post[$field_id];
break; }
case 'file': private function validateNumeric($field_id, array $field, array $post)
// Needs to be verified elsewhere! {
break;
case 'numeric':
$data = isset($post[$field_id]) ? $post[$field_id] : ''; $data = isset($post[$field_id]) ? $post[$field_id] : '';
// Do we need to check bounds?
if (isset($field['min_value']) && is_numeric($data)) // 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']) if (is_float($field['min_value']) && (float) $data < $field['min_value'])
{ {
@@ -103,10 +230,10 @@ class Form
$this->missing[] = $field_id; $this->missing[] = $field_id;
$this->data[$field_id] = 0; $this->data[$field_id] = 0;
} }
else
$this->data[$field_id] = $data;
} }
elseif (isset($field['max_value']) && is_numeric($data))
// What about a maximum bound?
if (isset($field['max_value']))
{ {
if (is_float($field['max_value']) && (float) $data > $field['max_value']) if (is_float($field['max_value']) && (float) $data > $field['max_value'])
{ {
@@ -118,48 +245,113 @@ class Form
$this->missing[] = $field_id; $this->missing[] = $field_id;
$this->data[$field_id] = 0; $this->data[$field_id] = 0;
} }
else }
$this->data[$field_id] = $data; $this->data[$field_id] = $data;
} }
// Does it look numeric?
elseif (is_numeric($data)) private function validateSelect($field_id, array $field, array $post)
{ {
$this->data[$field_id] = $data; // Skip validation? Dangerous territory!
if (isset($field['verify_options']) && $field['verify_options'] === false)
{
$this->data[$field_id] = $post[$field_id];
return;
} }
// Let's consider it missing, then.
else // 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->missing[] = $field_id;
$this->data[$field_id] = 0; $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;
} }
break;
case 'text': foreach ($post[$field_id] as $option)
case 'textarea': {
default: 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] : ''; $this->data[$field_id] = isset($post[$field_id]) ? $post[$field_id] : '';
}
}
}
public function setData($data) // Trim leading and trailing whitespace?
{ if (!empty($field['trim']))
$this->verify($data); $this->data[$field_id] = trim($this->data[$field_id]);
$this->missing = [];
}
public function getFields() // Is there a length limit to enforce?
{ if (isset($field['maxlength']) && strlen($post[$field_id]) > $field['maxlength']) {
return $this->fields; $post[$field_id] = substr($post[$field_id], 0, $field['maxlength']);
} }
public function getData()
{
return $this->data;
}
public function getMissing()
{
return $this->missing;
} }
} }

View File

@@ -3,92 +3,91 @@
* GenericTable.php * GenericTable.php
* Contains key class GenericTable. * Contains key class GenericTable.
* *
* Kabuki CMS (C) 2013-2015, Aaron van Geffen * Kabuki CMS (C) 2013-2023, Aaron van Geffen
*****************************************************************************/ *****************************************************************************/
class GenericTable extends PageIndex class GenericTable
{ {
protected $header = []; private $header = [];
protected $body = []; private $body = [];
protected $page_index = []; private $pageIndex = null;
private $currentPage = 1;
protected $title; private $title;
protected $title_class; private $title_class;
protected $tableIsSortable = false;
protected $recordCount;
protected $needsPageIndex = false;
protected $current_page;
protected $num_pages;
public $form_above; public $form_above;
public $form_below; 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) public function __construct($options)
{ {
// Make sure we're actually sorting on something sortable. $this->initOrder($options);
if (!isset($options['sort_order']) || (!empty($options['sort_order']) && empty($options['columns'][$options['sort_order']]['is_sortable']))) $this->initPagination($options);
$options['sort_order'] = '';
// Order in which direction? $data = $options['get_data']($this->start, $this->items_per_page,
if (!empty($options['sort_direction']) && !in_array($options['sort_direction'], array('up', 'down'))) $this->sort_order, $this->sort_direction);
$options['sort_direction'] = 'up';
// Make sure we know whether we can actually sort on something.
$this->tableIsSortable = !empty($options['base_url']);
// How much stuff do we have?
$this->recordCount = call_user_func_array($options['get_count'], !empty($options['get_count_params']) ? $options['get_count_params'] : array());
// Should we create a page index?
$this->items_per_page = !empty($options['items_per_page']) ? $options['items_per_page'] : 30;
$this->needsPageIndex = !empty($this->items_per_page) && $this->recordCount > $this->items_per_page;
$this->index_class = isset($options['index_class']) ? $options['index_class'] : '';
// Figure out where to start.
$this->start = empty($options['start']) || !is_numeric($options['start']) || $options['start'] < 0 || $options['start'] > $this->recordCount ? 0 : $options['start'];
// Let's bear a few things in mind...
$this->base_url = $options['base_url'];
// This should be set at all times, too.
if (empty($options['no_items_label']))
$options['no_items_label'] = '';
// Gather parameters for the data gather function first.
$parameters = array($this->start, $this->items_per_page, $options['sort_order'], $options['sort_direction']);
if (!empty($options['get_data_params']) && is_array($options['get_data_params']))
$parameters = array_merge($parameters, $options['get_data_params']);
// Okay, let's fetch the data!
$data = call_user_func_array($options['get_data'], $parameters);
// Clean up a bit.
$rows = $data['rows'];
$this->sort_order = $data['order'];
$this->sort_direction = $data['direction'];
unset($data);
// Okay, now for the column headers... // Okay, now for the column headers...
$this->generateColumnHeaders($options); $this->generateColumnHeaders($options);
// Generate a pagination if requested // Should we create a page index?
if ($this->needsPageIndex) if ($this->recordCount > $this->items_per_page)
$this->generatePageIndex(); $this->generatePageIndex($options);
// Not a single row in sight? // Process the data to be shown into rows.
if (empty($rows)) if (!empty($data))
$this->body = $options['no_items_label']; $this->processAllRows($data, $options);
// Otherwise, parse it all!
else else
$this->parseAllRows($rows, $options); $this->body = $options['no_items_label'] ?? '';
$this->table_class = $options['table_class'] ?? '';
// Got a title? // Got a title?
$this->title = isset($options['title']) ? htmlentities($options['title']) : ''; $this->title = $options['title'] ?? '';
$this->title_class = isset($options['title_class']) ? $options['title_class'] : ''; $this->title_class = $options['title_class'] ?? '';
// Maybe even a form or two? // Maybe even a form or two?
$this->form_above = isset($options['form_above']) ? $options['form_above'] : (isset($options['form']) ? $options['form'] : null); $this->form_above = $options['form_above'] ?? $options['form'] ?? null;
$this->form_below = isset($options['form_below']) ? $options['form_below'] : (isset($options['form']) ? $options['form'] : null); $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) private function generateColumnHeaders($options)
@@ -98,110 +97,39 @@ class GenericTable extends PageIndex
if (empty($column['header'])) if (empty($column['header']))
continue; continue;
$header = array( $isSortable = !empty($column['is_sortable']);
$sortDirection = $key == $this->sort_order && $this->sort_direction === 'up' ? 'down' : 'up';
$header = [
'class' => isset($column['class']) ? $column['class'] : '', '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, 'colspan' => !empty($column['header_colspan']) ? $column['header_colspan'] : 1,
'href' => !$this->tableIsSortable || empty($column['is_sortable']) ? '' : $this->getLink($this->start, $key, $key == $this->sort_order && $this->sort_direction == 'up' ? 'down' : 'up'), 'href' => $isSortable ? $this->getHeaderLink($this->start, $key, $sortDirection) : null,
'label' => $column['header'], 'label' => $column['header'],
'scope' => 'col', 'scope' => 'col',
'sort_mode' => $key == $this->sort_order ? $this->sort_direction : null, 'sort_mode' => $key == $this->sort_order ? $this->sort_direction : null,
'width' => !empty($column['header_width']) && is_int($column['header_width']) ? $column['header_width'] : null, 'width' => !empty($column['header_width']) && is_int($column['header_width']) ? $column['header_width'] : null,
); ];
$this->header[] = $header; $this->header[] = $header;
} }
} }
private function parseAllRows($rows, $options) private function generatePageIndex($options)
{ {
// Parse all rows... $this->pageIndex = new PageIndex([
$i = 0; 'base_url' => $this->base_url,
foreach ($rows as $row) 'index_class' => $options['index_class'] ?? '',
{ 'items_per_page' => $this->items_per_page,
$i ++; 'linkBuilder' => [$this, 'getHeaderLink'],
$newRow = array( 'recordCount' => $this->recordCount,
'class' => $i %2 == 1 ? 'odd' : 'even', 'sort_direction' => $this->sort_direction,
'cells' => array(), 'sort_order' => $this->sort_order,
); 'start' => $this->start,
]);
foreach ($options['columns'] as $column)
{
if (isset($column['enabled']) && $column['enabled'] == false)
continue;
// The hard way?
if (isset($column['parse']))
{
if (!isset($column['parse']['type']))
$column['parse']['type'] = 'value';
// Parse the basic value first.
switch ($column['parse']['type'])
{
// value: easy as pie.
default:
case 'value':
$value = $row[$column['parse']['data']];
break;
// sprintf: filling the gaps!
case 'sprintf':
$parameters = array($column['parse']['data']['pattern']);
foreach ($column['parse']['data']['arguments'] as $identifier)
$parameters[] = $row[$identifier];
$value = call_user_func_array('sprintf', $parameters);
break;
// timestamps: let's make them readable!
case 'timestamp':
$pattern = !empty($column['parse']['data']['pattern']) && $column['parse']['data']['pattern'] == 'long' ? '%F %H:%M' : '%H:%M';
if (!is_numeric($row[$column['parse']['data']['timestamp']]))
$timestamp = strtotime($row[$column['parse']['data']['timestamp']]);
else
$timestamp = (int) $row[$column['parse']['data']['timestamp']];
if (isset($column['parse']['data']['if_null']) && $timestamp == 0)
$value = $column['parse']['data']['if_null'];
else
$value = strftime($pattern, $timestamp);
break;
// function: the flexible way!
case 'function':
$value = $column['parse']['data']($row);
break;
} }
// Generate a link, if requested. public function getHeaderLink($start = null, $order = null, $dir = null)
if (!empty($column['parse']['link']))
{
// First, generate the replacement variables.
$keys = array_keys($row);
$values = array_values($row);
foreach ($keys as $keyKey => $keyValue)
$keys[$keyKey] = '{' . strtoupper($keyValue) . '}';
$value = '<a href="' . str_replace($keys, $values, $column['parse']['link']) . '">' . $value . '</a>';
}
}
// The easy way!
else
$value = $row[$column['value']];
// Append the cell to the row.
$newRow['cells'][] = array(
'width' => !empty($column['cell_width']) && is_int($column['cell_width']) ? $column['cell_width'] : null,
'value' => $value,
);
}
// Append the new row in the body.
$this->body[] = $newRow;
}
}
public function getLink($start = null, $order = null, $dir = null)
{ {
if ($start === null) if ($start === null)
$start = $this->start; $start = $this->start;
@@ -218,12 +146,6 @@ class GenericTable extends PageIndex
return $this->start; return $this->start;
} }
public function getArray()
{
// Makes no sense to call it for a table, but inherits from PageIndex due to poor design, sorry.
throw new Exception('Function call is ambiguous.');
}
public function getHeader() public function getHeader()
{ {
return $this->header; return $this->header;
@@ -234,6 +156,21 @@ class GenericTable extends PageIndex
return $this->body; return $this->body;
} }
public function getCurrentPage()
{
return $this->currentPage;
}
public function getPageIndex()
{
return $this->pageIndex;
}
public function getTableClass()
{
return $this->table_class;
}
public function getTitle() public function getTitle()
{ {
return $this->title; return $this->title;
@@ -243,4 +180,82 @@ class GenericTable extends PageIndex
{ {
return $this->title_class; return $this->title_class;
} }
private function processAllRows($rows, $options)
{
foreach ($rows as $i => $row)
{
$newRow = [
'cells' => [],
];
foreach ($options['columns'] as $column)
{
// 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,
];
}
// Append the new row in the body.
$this->body[] = $newRow;
}
}
private function processFormatting($options, $rowData)
{
if ($options['type'] === 'timestamp')
{
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'];
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']];
if (isset($options['if_null']) && $timestamp == 0)
$value = $options['if_null'];
else
$value = date($pattern, $timestamp);
return $value;
}
else
throw ValueError('Unexpected formatter type: ' . $options['type']);
}
private function processLink($template, $value, array $rowData)
{
$href = $this->rowReplacements($template, $rowData);
return '<a href="' . $href . '">' . $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 str_replace($keys, $values, $template);
}
} }

View File

@@ -21,7 +21,7 @@ class Guest extends User
$this->is_guest = true; $this->is_guest = true;
$this->is_admin = false; $this->is_admin = false;
$this->first_name = 'Guest'; $this->first_name = 'Guest';
$this->last_name = ''; $this->surname = '';
} }
public function updateAccessTime() public function updateAccessTime()

View File

@@ -12,17 +12,11 @@ class Image extends Asset
const TYPE_LANDSCAPE = 2; const TYPE_LANDSCAPE = 2;
const TYPE_PORTRAIT = 4; const TYPE_PORTRAIT = 4;
protected function __construct(array $data)
{
foreach ($data as $attribute => $value)
$this->$attribute = $value;
}
public static function fromId($id_asset, $return_format = 'object') public static function fromId($id_asset, $return_format = 'object')
{ {
$asset = parent::fromId($id_asset, 'array'); $asset = parent::fromId($id_asset, 'array');
if ($asset) if ($asset)
return $return_format == 'object' ? new Image($asset) : $asset; return $return_format === 'object' ? new Image($asset) : $asset;
else else
return false; return false;
} }
@@ -34,7 +28,7 @@ class Image extends Asset
$assets = parent::fromIds($id_assets, 'array'); $assets = parent::fromIds($id_assets, 'array');
if ($return_format == 'array') if ($return_format === 'array')
return $assets; return $assets;
else else
{ {
@@ -67,14 +61,33 @@ class Image extends Asset
return EXIF::fromFile($this->getPath()); return EXIF::fromFile($this->getPath());
} }
public function getPath() public function getImageUrls($width = null, $height = null)
{ {
return ASSETSDIR . '/' . $this->subdir . '/' . $this->filename; $image_urls = [];
if (isset($width) || isset($height))
{
$thumbnail = new Thumbnail($this);
$image_urls[1] = $this->getThumbnailUrl($width, $height, false);
// Can we afford to generate double-density thumbnails as well?
if ((!isset($width) || $this->image_width >= $width * 2) &&
(!isset($height) || $this->image_height >= $height * 2))
$image_urls[2] = $this->getThumbnailUrl($width * 2, $height * 2, false);
else
$image_urls[2] = $this->getThumbnailUrl($this->image_width, $this->image_height, true);
}
else
$image_urls[1] = $this->getUrl();
return $image_urls;
} }
public function getUrl() public function getInlineImage($width = null, $height = null, $className = 'inline-image')
{ {
return ASSETSURL . '/' . $this->subdir . '/' . $this->filename; $image_urls = $this->getImageUrls($width, $height);
return '<img class="' . $className . '" src="' . $image_urls[1] . '" alt=""' .
(isset($image_urls[2]) ? ' srcset="' . $image_urls[2] . ' 2x"' : '') . '>';
} }
/** /**
@@ -82,221 +95,17 @@ class Image extends Asset
* @param height: height of the thumbnail. * @param height: height of the thumbnail.
* @param crop: whether and how to crop original image to fit. [false|true|'top'|'center'|'bottom'] * @param crop: whether and how to crop original image to fit. [false|true|'top'|'center'|'bottom']
* @param fit: whether to fit the image to given boundaries [true], or use them merely as an estimation [false]. * @param fit: whether to fit the image to given boundaries [true], or use them merely as an estimation [false].
* @param generate: whether or not to generate a thumbnail if no existing file was found.
*/ */
public function getThumbnailUrl($width, $height, $crop = true, $fit = true) public function getThumbnailUrl($width, $height, $crop = true, $fit = true, $generate = false)
{ {
// First, assert the image's dimensions are properly known in the database. $thumbnail = new Thumbnail($this);
if (!isset($this->image_height, $this->image_width)) return $thumbnail->getUrl($width, $height, $crop, $fit, $generate);
throw new UnexpectedValueException('Image width or height is undefined -- inconsistent database?');
// Inferring width or height?
if (!$height)
$height = ceil($width / $this->image_width * $this->image_height);
elseif (!$width)
$width = ceil($height / $this->image_height * $this->image_width);
// Inferring the height from the original image's ratio?
if (!$fit)
$height = floor($width / ($this->image_width / $this->image_height));
// Assert we have both, now...
if (empty($width) || empty($height))
throw new InvalidArgumentException('Expecting at least either width or height as argument.');
// If we're cropping, verify we're in the right mode.
if ($crop)
{
// If the original image's aspect ratio is much wider, take a slice instead.
if ($this->image_width / $this->image_height > $width / $height)
$crop = 'slice';
// We won't be cropping if the thumbnail is proportional to its original.
if (abs($width / $height - $this->image_width / $this->image_height) <= 0.05)
$crop = false;
} }
// Do we have an exact crop boundary for these dimensions? public function getId()
$crop_selector = "crop_{$width}x{$height}";
if (isset($this->meta[$crop_selector]))
$crop = 'exact';
// Now, do we need to suffix the filename?
if ($crop)
$suffix = '_c' . (is_string($crop) && $crop !== 'center' ? substr($crop, 0, 1) : '');
else
$suffix = '';
// Check whether we already resized this earlier.
$thumb_selector = "thumb_{$width}x{$height}{$suffix}";
if (isset($this->meta[$thumb_selector]) && file_exists(THUMBSDIR . '/' . $this->subdir . '/' . $this->meta[$thumb_selector]))
return THUMBSURL . '/' . $this->subdir . '/' . $this->meta[$thumb_selector];
// Do we have a custom thumbnail on file?
$custom_selector = "custom_{$width}x{$height}";
if (isset($this->meta[$custom_selector]))
{ {
if (file_exists(ASSETSDIR . '/' . $this->subdir . '/' . $this->meta[$custom_selector])) return $this->id_asset;
{
// Copy the custom thumbail to the general thumbnail directory.
copy(ASSETSDIR . '/' . $this->subdir . '/' . $this->meta[$custom_selector],
THUMBSDIR . '/' . $this->subdir . '/' . $this->meta[$custom_selector]);
// Let's remember this for future reference.
$this->meta[$thumb_selector] = $this->meta[$custom_selector];
$this->save();
return THUMBSURL . '/' . $this->subdir . '/' . $this->meta[$custom_selector];
}
else
throw new UnexpectedValueException('Custom thumbnail expected, but missing in file system!');
}
// Let's try some arcane stuff...
try
{
if (!class_exists('Imagick'))
throw new Exception("The PHP module 'imagick' appears to be disabled. Please enable it to use image resampling functions.");
$thumb = new Imagick(ASSETSDIR . '/' . $this->subdir . '/' . $this->filename);
// The image might have some orientation set through EXIF. Let's apply this first.
self::applyRotation($thumb);
// Just resizing? Easy peasy.
if (!$crop)
$thumb->resizeImage($width, $height, Imagick::FILTER_LANCZOS, 1);
// Cropping in the center?
elseif ($crop === true || $crop === 'center')
$thumb->cropThumbnailImage($width, $height);
// Exact cropping? We can do that.
elseif ($crop === 'exact')
{
list($crop_width, $crop_height, $crop_x_pos, $crop_y_pos) = explode(',', $this->meta[$crop_selector]);
$thumb->cropImage($crop_width, $crop_height, $crop_x_pos, $crop_y_pos);
$thumb->resizeImage($width, $height, Imagick::FILTER_LANCZOS, 1);
}
// Advanced cropping? Fun!
else
{
$size = $thumb->getImageGeometry();
// Taking a horizontal slice from the top or bottom of the original image?
if ($crop === 'top' || $crop === 'bottom')
{
$crop_width = $size['width'];
$crop_height = floor($size['width'] / $width * $height);
$target_x = 0;
$target_y = $crop === 'top' ? 0 : $size['height'] - $crop_height;
}
// Otherwise, we're taking a vertical slice from the centre.
else
{
$crop_width = floor($size['height'] / $height * $width);
$crop_height = $size['height'];
$target_x = floor(($size['width'] - $crop_width) / 2);
$target_y = 0;
}
$thumb->cropImage($crop_width, $crop_height, $target_x, $target_y);
$thumb->resizeImage($width, $height, Imagick::FILTER_LANCZOS, 1);
}
// What sort of image is this? Fall back to PNG if we must.
switch ($thumb->getImageFormat())
{
case 'JPEG':
$ext = 'jpg';
$thumb->setImageCompressionQuality(60);
break;
case 'GIF':
$ext = 'gif';
break;
case 'PNG':
default:
$thumb->setFormat('PNG');
$ext = 'png';
break;
}
// So, how do we name this?
$thumbfilename = substr($this->filename, 0, strrpos($this->filename, '.')) . "_{$width}x{$height}{$suffix}.$ext";
// Ensure the thumbnail subdirectory exists.
if (!is_dir(THUMBSDIR . '/' . $this->subdir))
mkdir(THUMBSDIR . '/' . $this->subdir, 0755, true);
// Save it in a public spot.
$thumb->writeImage(THUMBSDIR . '/' . $this->subdir . '/' . $thumbfilename);
$thumb->clear();
$thumb->destroy();
}
// Blast! Curse your sudden but inevitable betrayal!
catch (ImagickException $e)
{
throw new Exception('ImageMagick error occurred while generating thumbnail. Output: ' . $e->getMessage());
}
// Let's remember this for future reference.
$this->meta[$thumb_selector] = $thumbfilename;
$this->save();
// Ah yes, you wanted a URL, didn't you...
return THUMBSURL . '/' . $this->subdir . '/' . $this->meta[$thumb_selector];
}
private static function applyRotation(Imagick $image)
{
switch ($image->getImageOrientation())
{
// Clockwise rotation
case Imagick::ORIENTATION_RIGHTTOP:
$image->rotateImage("#000", 90);
break;
// Counter-clockwise rotation
case Imagick::ORIENTATION_LEFTBOTTOM:
$image->rotateImage("#000", 270);
break;
// Upside down?
case Imagick::ORIENTATION_BOTTOMRIGHT:
$image->rotateImage("#000", 180);
}
// Having rotated the image, make sure the EXIF data is set properly.
$image->setImageOrientation(Imagick::ORIENTATION_TOPLEFT);
}
public function bestColor()
{
// Save some computations if we can.
if (isset($this->meta['best_color']))
return $this->meta['best_color'];
// Find out what colour is most prominent.
$color = new BestColor($this);
$this->meta['best_color'] = $color->hex();
$this->save();
// There's your colour.
return $this->meta['best_color'];
}
public function bestLabelColor()
{
// Save some computations if we can.
if (isset($this->meta['best_color_label']))
return $this->meta['best_color_label'];
// Find out what colour is most prominent.
$color = new BestColor($this);
$this->meta['best_color_label'] = $color->rgba();
$this->save();
// There's your colour.
return $this->meta['best_color_label'];
} }
public function width() public function width()
@@ -309,37 +118,79 @@ class Image extends Asset
return $this->image_height; return $this->image_height;
} }
public function ratio()
{
return $this->image_width / $this->image_height;
}
public function isPanorama() public function isPanorama()
{ {
return $this->image_width / $this->image_height > 2; return $this->ratio() >= 2;
} }
public function isPortrait() public function isPortrait()
{ {
return $this->image_width / $this->image_height < 1; return $this->ratio() < 1;
} }
public function isLandscape() public function isLandscape()
{ {
$ratio = $this->image_width / $this->image_height; $ratio = $this->ratio();
return $ratio >= 1 && $ratio <= 2; return $ratio >= 1 && $ratio <= 2;
} }
public function getType()
{
if ($this->isPortrait())
return self::TYPE_PORTRAIT;
elseif ($this->isPanorama())
return self::TYPE_PANORAMA;
else
return self::TYPE_LANDSCAPE;
}
public function getThumbnails()
{
return $this->thumbnails;
}
public function removeAllThumbnails() public function removeAllThumbnails()
{ {
foreach ($this->meta as $key => $value) foreach ($this->thumbnails as $key => $filename)
{ {
if (substr($key, 0, 6) !== 'thumb_') $thumb_path = THUMBSDIR . '/' . $this->subdir . '/' . $filename;
continue;
$thumb_path = THUMBSDIR . '/' . $this->subdir . '/' . $value;
if (is_file($thumb_path)) if (is_file($thumb_path))
unlink($thumb_path); unlink($thumb_path);
unset($this->meta[$key]);
} }
$this->saveMetaData(); return Registry::get('db')->query('
DELETE FROM assets_thumbs
WHERE id_asset = :id_asset',
['id_asset' => $this->id_asset]);
}
public function removeThumbnailsOfSize($width, $height)
{
foreach ($this->thumbnails as $key => $filename)
{
if (strpos($key, $width . 'x' . $height) !== 0)
continue;
$thumb_path = THUMBSDIR . '/' . $this->subdir . '/' . $filename;
if (is_file($thumb_path))
unlink($thumb_path);
}
return Registry::get('db')->query('
DELETE FROM assets_thumbs
WHERE id_asset = :id_asset AND
width = :width AND
height = :height',
[
'height' => $height,
'id_asset' => $this->id_asset,
'width' => $width,
]);
} }
public function replaceThumbnail($descriptor, $tmp_file) public function replaceThumbnail($descriptor, $tmp_file)
@@ -347,7 +198,7 @@ class Image extends Asset
if (!is_file($tmp_file)) if (!is_file($tmp_file))
return -1; return -1;
if (!isset($this->meta[$descriptor])) if (!isset($this->thumbnails[$descriptor]))
return -2; return -2;
$image = new Imagick($tmp_file); $image = new Imagick($tmp_file);
@@ -355,12 +206,12 @@ class Image extends Asset
unset($image); unset($image);
// Check whether dimensions match. // Check whether dimensions match.
$test_descriptor = 'thumb_' . $d['width'] . 'x' . $d['height']; $test_descriptor = $d['width'] . 'x' . $d['height'];
if ($descriptor !== $test_descriptor && strpos($descriptor, $test_descriptor . '_') === false) if ($descriptor !== $test_descriptor && strpos($descriptor, $test_descriptor . '_') === false)
return -3; return -3;
// Save the custom thumbnail in the assets directory. // Save the custom thumbnail in the assets directory.
$destination = ASSETSDIR . '/' . $this->subdir . '/' . $this->meta[$descriptor]; $destination = ASSETSDIR . '/' . $this->subdir . '/' . $this->thumbnails[$descriptor];
if (file_exists($destination) && !is_writable($destination)) if (file_exists($destination) && !is_writable($destination))
return -4; return -4;
@@ -368,7 +219,7 @@ class Image extends Asset
return -5; return -5;
// Copy it to the thumbnail directory, overwriting the automatically generated one, too. // Copy it to the thumbnail directory, overwriting the automatically generated one, too.
$destination = THUMBSDIR . '/' . $this->subdir . '/' . $this->meta[$descriptor]; $destination = THUMBSDIR . '/' . $this->subdir . '/' . $this->thumbnails[$descriptor];
if (file_exists($destination) && !is_writable($destination)) if (file_exists($destination) && !is_writable($destination))
return -6; return -6;
@@ -376,7 +227,7 @@ class Image extends Asset
return -7; return -7;
// A little bookkeeping // A little bookkeeping
$this->meta['custom_' . $d['width'] . 'x' . $d['height']] = $this->meta[$descriptor]; $this->meta['custom_' . $d['width'] . 'x' . $d['height']] = $this->thumbnails[$descriptor];
$this->saveMetaData(); $this->saveMetaData();
return 0; return 0;
} }

40
models/MainMenu.php Normal file
View File

@@ -0,0 +1,40 @@
<?php
/*****************************************************************************
* MainMenu.php
* Contains the main navigation logic.
*
* Kabuki CMS (C) 2013-2023, Aaron van Geffen
*****************************************************************************/
class MainMenu extends Menu
{
public function __construct()
{
$this->items = [
[
'uri' => '/',
'label' => 'Albums',
],
[
'uri' => '/people/',
'label' => 'People',
],
[
'uri' => '/timeline/',
'label' => 'Timeline',
],
];
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,7 +8,7 @@
class Member extends User class Member extends User
{ {
private function __construct($data) private function __construct($data = [])
{ {
foreach ($data as $key => $value) foreach ($data as $key => $value)
$this->$key = $value; $this->$key = $value;
@@ -18,12 +18,21 @@ class Member extends User
$this->is_admin = $this->is_admin == 1; $this->is_admin = $this->is_admin == 1;
} }
public static function fromEmailAddress($email_address)
{
return Registry::get('db')->queryObject(static::class, '
SELECT *
FROM users
WHERE emailaddress = :email_address',
['email_address' => $email_address]);
}
public static function fromId($id_user) public static function fromId($id_user)
{ {
$row = Registry::get('db')->queryAssoc(' $row = Registry::get('db')->queryAssoc('
SELECT * SELECT *
FROM users FROM users
WHERE id_user = {int:id_user}', WHERE id_user = :id_user',
[ [
'id_user' => $id_user, 'id_user' => $id_user,
]); ]);
@@ -40,7 +49,7 @@ class Member extends User
$row = Registry::get('db')->queryAssoc(' $row = Registry::get('db')->queryAssoc('
SELECT * SELECT *
FROM users FROM users
WHERE slug = {string:slug}', WHERE slug = :slug',
[ [
'slug' => $slug, 'slug' => $slug,
]); ]);
@@ -68,6 +77,7 @@ class Member extends User
'creation_time' => time(), 'creation_time' => time(),
'ip_address' => isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '', 'ip_address' => isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '',
'is_admin' => empty($data['is_admin']) ? 0 : 1, 'is_admin' => empty($data['is_admin']) ? 0 : 1,
'reset_key' => '',
]; ];
if ($error) if ($error)
@@ -81,14 +91,15 @@ class Member extends User
'emailaddress' => 'string-255', 'emailaddress' => 'string-255',
'password_hash' => 'string-255', 'password_hash' => 'string-255',
'creation_time' => 'int', 'creation_time' => 'int',
'ip_address' => 'string-15', 'ip_address' => 'string-45',
'is_admin' => 'int', 'is_admin' => 'int',
'reset_key' => 'string-16'
], $new_user, ['id_user']); ], $new_user, ['id_user']);
if (!$bool) if (!$bool)
return false; return false;
$new_user['id_user'] = $db->insert_id(); $new_user['id_user'] = $db->insertId();
$member = new Member($new_user); $member = new Member($new_user);
return $member; return $member;
@@ -110,16 +121,19 @@ class Member extends User
$this->is_admin = $value == 1 ? 1 : 0; $this->is_admin = $value == 1 ? 1 : 0;
} }
$params = get_object_vars($this);
$params['is_admin'] = $this->is_admin ? 1 : 0;
return Registry::get('db')->query(' return Registry::get('db')->query('
UPDATE users UPDATE users
SET SET
first_name = {string:first_name}, first_name = :first_name,
surname = {string:surname}, surname = :surname,
slug = {string:slug}, slug = :slug,
emailaddress = {string:emailaddress}, emailaddress = :emailaddress,
password_hash = {string:password_hash}, password_hash = :password_hash,
is_admin = {int:is_admin} is_admin = :is_admin
WHERE id_user = {int:id_user}', WHERE id_user = :id_user',
get_object_vars($this)); get_object_vars($this));
} }
@@ -131,7 +145,7 @@ class Member extends User
{ {
return Registry::get('db')->query(' return Registry::get('db')->query('
DELETE FROM users DELETE FROM users
WHERE id_user = {int:id_user}', WHERE id_user = :id_user',
['id_user' => $this->id_user]); ['id_user' => $this->id_user]);
} }
@@ -146,7 +160,7 @@ class Member extends User
$res = Registry::get('db')->queryValue(' $res = Registry::get('db')->queryValue('
SELECT id_user SELECT id_user
FROM users FROM users
WHERE emailaddress = {string:emailaddress}', WHERE emailaddress = :emailaddress',
[ [
'emailaddress' => $emailaddress, 'emailaddress' => $emailaddress,
]); ]);
@@ -162,9 +176,9 @@ class Member extends User
return Registry::get('db')->query(' return Registry::get('db')->query('
UPDATE users UPDATE users
SET SET
last_action_time = {int:now}, last_action_time = :now,
ip_address = {string:ip} ip_address = :ip
WHERE id_user = {int:id}', WHERE id_user = :id',
[ [
'now' => time(), 'now' => time(),
'id' => $this->id_user, 'id' => $this->id_user,
@@ -184,9 +198,36 @@ class Member extends User
FROM users'); FROM users');
} }
public static function getOffset($offset, $limit, $order, $direction)
{
assert(in_array($order, ['id_user', 'surname', 'first_name', 'slug', 'emailaddress', 'last_action_time', 'ip_address', 'is_admin']));
$order = $order . ($direction === 'up' ? ' ASC' : ' DESC');
return Registry::get('db')->queryAssocs('
SELECT *
FROM users
ORDER BY ' . $order . '
LIMIT :offset, :limit',
[
'offset' => $offset,
'limit' => $limit,
]);
}
public function getProps() public function getProps()
{ {
// We should probably phase out the use of this function, or refactor the access levels of member properties... // We should probably phase out the use of this function, or refactor the access levels of member properties...
return get_object_vars($this); return get_object_vars($this);
} }
public static function getMemberMap()
{
return Registry::get('db')->queryPair('
SELECT id_user, CONCAT(first_name, :blank, surname) AS full_name
FROM users
ORDER BY first_name, surname',
[
'blank' => ' ',
]);
}
} }

17
models/Menu.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
/*****************************************************************************
* Menu.php
* Contains all navigational menus.
*
* Kabuki CMS (C) 2013-2023, Aaron van Geffen
*****************************************************************************/
abstract class Menu
{
protected $items = [];
public function getItems()
{
return $this->items;
}
}

View File

@@ -8,26 +8,47 @@
class PageIndex class PageIndex
{ {
protected $page_index = []; private $base_url;
protected $current_page = 0; private $current_page = 1;
protected $items_per_page = 0; private $index_class = 'pagination';
protected $needsPageIndex = false; private $items_per_page = 0;
protected $num_pages = 0; private $linkBuilder;
protected $recordCount = 0; private $needsPageIndex = false;
protected $start = 0; private $num_pages = 1;
protected $sort_order = null; private $page_index = [];
protected $sort_direction = null; private $page_slug = '%AMP%page=%PAGE%';
protected $base_url; private $recordCount = 0;
protected $index_class = 'pagination'; private $sort_direction = null;
protected $page_slug = '%AMP%page=%PAGE%'; private $sort_order = null;
private $start = 0;
public function __construct($options) public function __construct($options)
{ {
foreach ($options as $key => $value) static $neededKeys = ['base_url', 'items_per_page', 'recordCount'];
$this->$key = $value; foreach ($neededKeys as $key)
{
if (!isset($options[$key]))
throw new Exception('PageIndex: argument ' . $key . ' missing in options');
$this->$key = $options[$key];
}
static $optionalKeys = ['index_class', 'linkBuilder', 'page_slug', 'sort_direction', 'sort_order', 'start'];
foreach ($optionalKeys as $key)
if (isset($options[$key]))
$this->$key = $options[$key];
$this->generatePageIndex(); $this->generatePageIndex();
} }
private function buildLink($start = null, $order = null, $dir = null)
{
if (isset($this->linkBuilder))
return call_user_func($this->linkBuilder, $start, $order, $dir);
else
return $this->getLink($start, $order, $dir);
}
protected function generatePageIndex() protected function generatePageIndex()
{ {
/* /*
@@ -42,9 +63,9 @@ class PageIndex
lower current/cont. pgs. center upper lower current/cont. pgs. center upper
*/ */
$this->num_pages = ceil($this->recordCount / $this->items_per_page); $this->num_pages = max(1, ceil($this->recordCount / $this->items_per_page));
$this->current_page = min(ceil($this->start / $this->items_per_page) + 1, $this->num_pages); $this->current_page = min(ceil($this->start / $this->items_per_page) + 1, $this->num_pages);
if ($this->num_pages == 0) if ($this->num_pages <= 1)
{ {
$this->needsPageIndex = false; $this->needsPageIndex = false;
return; return;
@@ -68,7 +89,7 @@ class PageIndex
$this->page_index[$p] = [ $this->page_index[$p] = [
'index' => $p, 'index' => $p,
'is_selected' => $this->current_page == $p, 'is_selected' => $this->current_page == $p,
'href'=> $this->getLink(($p - 1) * $this->items_per_page, $this->sort_order, $this->sort_direction), 'href'=> $this->buildLink(($p - 1) * $this->items_per_page, $this->sort_order, $this->sort_direction),
]; ];
// The center of the page index. // The center of the page index.
@@ -81,7 +102,7 @@ class PageIndex
$this->page_index[$center] = [ $this->page_index[$center] = [
'index' => $center, 'index' => $center,
'is_selected' => $this->current_page == $center, 'is_selected' => $this->current_page == $center,
'href'=> $this->getLink(($center - 1) * $this->items_per_page, $this->sort_order, $this->sort_direction), 'href'=> $this->buildLink(($center - 1) * $this->items_per_page, $this->sort_order, $this->sort_direction),
]; ];
} }
@@ -94,7 +115,7 @@ class PageIndex
$this->page_index[$p] = [ $this->page_index[$p] = [
'index' => $p, 'index' => $p,
'is_selected' => $this->current_page == $p, 'is_selected' => $this->current_page == $p,
'href'=> $this->getLink(($p - 1) * $this->items_per_page, $this->sort_order, $this->sort_direction), 'href'=> $this->buildLink(($p - 1) * $this->items_per_page, $this->sort_order, $this->sort_direction),
]; ];
// The center of the page index. // The center of the page index.
@@ -107,7 +128,7 @@ class PageIndex
$this->page_index[$center] = [ $this->page_index[$center] = [
'index' => $center, 'index' => $center,
'is_selected' => $this->current_page == $center, 'is_selected' => $this->current_page == $center,
'href'=> $this->getLink(($center - 1) * $this->items_per_page, $this->sort_order, $this->sort_direction), 'href'=> $this->buildLink(($center - 1) * $this->items_per_page, $this->sort_order, $this->sort_direction),
]; ];
} }
@@ -120,7 +141,7 @@ class PageIndex
$this->page_index[$p] = [ $this->page_index[$p] = [
'index' => $p, 'index' => $p,
'is_selected' => $this->current_page == $p, 'is_selected' => $this->current_page == $p,
'href'=> $this->getLink(($p - 1) * $this->items_per_page, $this->sort_order, $this->sort_direction), 'href'=> $this->buildLink(($p - 1) * $this->items_per_page, $this->sort_order, $this->sort_direction),
]; ];
// Previous page? // Previous page?
@@ -134,34 +155,25 @@ class PageIndex
public function getLink($start = null, $order = null, $dir = null) public function getLink($start = null, $order = null, $dir = null)
{ {
$url = $this->base_url; $page = !is_string($start) ? ($start / $this->items_per_page) + 1 : $start;
$amp = strpos($this->base_url, '?') ? '&' : '?'; $url = $this->base_url . str_replace('%PAGE%', $page, $this->page_slug);
if (!empty($start)) $urlParams = [];
{
$page = $start !== '%d' ? ($start / $this->items_per_page) + 1 : $start;
$url .= strtr($this->page_slug, ['%PAGE%' => $page, '%AMP%' => $amp]);
$amp = '&';
}
if (!empty($order)) if (!empty($order))
{ $urlParams['order'] = $order;
$url .= $amp . 'order=' . $order;
$amp = '&';
}
if (!empty($dir)) if (!empty($dir))
$urlParams['dir'] = $dir;
if (!empty($urlParams))
{ {
$url .= $amp . 'dir=' . $dir; $queryString = (strpos($uri, '?') !== false ? '&' : '?');
$amp = '&'; $queryString .= http_build_query($urlParams);
$url .= $queryString;
} }
return $url; return $url;
} }
public function getArray()
{
return $this->page_index;
}
public function getPageIndex() public function getPageIndex()
{ {
return $this->page_index; return $this->page_index;

View File

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

View File

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

78
models/Router.php Normal file
View File

@@ -0,0 +1,78 @@
<?php
/*****************************************************************************
* Router.php
* Contains key class Router.
*
* Kabuki CMS (C) 2013-2015, Aaron van Geffen
*****************************************************************************/
class Router
{
public static function route()
{
$possibleActions = [
'accountsettings' => 'AccountSettings',
'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();
}
}

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ class Tag
public $id_tag; public $id_tag;
public $id_parent; public $id_parent;
public $id_asset_thumb; public $id_asset_thumb;
public $id_user_owner;
public $tag; public $tag;
public $slug; public $slug;
public $description; public $description;
@@ -23,6 +24,11 @@ class Tag
$this->$attribute = $value; $this->$attribute = $value;
} }
public function __toString()
{
return $this->tag;
}
public static function fromId($id_tag, $return_format = 'object') public static function fromId($id_tag, $return_format = 'object')
{ {
$db = Registry::get('db'); $db = Registry::get('db');
@@ -30,7 +36,7 @@ class Tag
$row = $db->queryAssoc(' $row = $db->queryAssoc('
SELECT * SELECT *
FROM tags FROM tags
WHERE id_tag = {int:id_tag}', WHERE id_tag = :id_tag',
[ [
'id_tag' => $id_tag, 'id_tag' => $id_tag,
]); ]);
@@ -39,7 +45,7 @@ class Tag
if (empty($row)) if (empty($row))
throw new NotFoundException(); throw new NotFoundException();
return $return_format == 'object' ? new Tag($row) : $row; return $return_format === 'object' ? new Tag($row) : $row;
} }
public static function fromSlug($slug, $return_format = 'object') public static function fromSlug($slug, $return_format = 'object')
@@ -49,7 +55,7 @@ class Tag
$row = $db->queryAssoc(' $row = $db->queryAssoc('
SELECT * SELECT *
FROM tags FROM tags
WHERE slug = {string:slug}', WHERE slug = :slug',
[ [
'slug' => $slug, 'slug' => $slug,
]); ]);
@@ -58,7 +64,7 @@ class Tag
if (empty($row)) if (empty($row))
throw new NotFoundException(); throw new NotFoundException();
return $return_format == 'object' ? new Tag($row) : $row; return $return_format === 'object' ? new Tag($row) : $row;
} }
public static function getAll($limit = 0, $return_format = 'array') public static function getAll($limit = 0, $return_format = 'array')
@@ -67,7 +73,7 @@ class Tag
SELECT * SELECT *
FROM tags FROM tags
ORDER BY ' . ($limit > 0 ? 'count ORDER BY ' . ($limit > 0 ? 'count
LIMIT {int:limit}' : 'tag'), LIMIT :limit' : 'tag'),
[ [
'limit' => $limit, 'limit' => $limit,
]); ]);
@@ -84,7 +90,7 @@ class Tag
}); });
} }
if ($return_format == 'object') if ($return_format === 'object')
{ {
$return = []; $return = [];
foreach ($rows as $row) foreach ($rows as $row)
@@ -95,14 +101,33 @@ class Tag
return $rows; return $rows;
} }
public static function getAllByOwner($id_user_owner)
{
$db = Registry::get('db');
$res = $db->query('
SELECT *
FROM tags
WHERE id_user_owner = :id_user_owner
ORDER BY tag',
[
'id_user_owner' => $id_user_owner,
]);
$objects = [];
while ($row = $db->fetchAssoc($res))
$objects[$row['id_tag']] = new Tag($row);
return $objects;
}
public static function getAlbums($id_parent = 0, $offset = 0, $limit = 24, $return_format = 'array') public static function getAlbums($id_parent = 0, $offset = 0, $limit = 24, $return_format = 'array')
{ {
$rows = Registry::get('db')->queryAssocs(' $rows = Registry::get('db')->queryAssocs('
SELECT * SELECT *
FROM tags FROM tags
WHERE id_parent = {int:id_parent} AND kind = {string:kind} WHERE id_parent = :id_parent AND kind = :kind
ORDER BY tag ASC ORDER BY tag ASC
LIMIT {int:offset}, {int:limit}', LIMIT :offset, :limit',
[ [
'id_parent' => $id_parent, 'id_parent' => $id_parent,
'kind' => 'Album', 'kind' => 'Album',
@@ -110,7 +135,7 @@ class Tag
'limit' => $limit, 'limit' => $limit,
]); ]);
if ($return_format == 'object') if ($return_format === 'object')
{ {
$return = []; $return = [];
foreach ($rows as $row) foreach ($rows as $row)
@@ -121,14 +146,29 @@ class Tag
return $rows; return $rows;
} }
public function getContributorList()
{
return Registry::get('db')->queryPairs('
SELECT u.id_user, u.first_name, u.surname, u.slug, COUNT(*) AS num_assets
FROM assets_tags AS at
LEFT JOIN assets AS a ON at.id_asset = a.id_asset
LEFT JOIN users AS u ON a.id_user_uploaded = u.id_user
WHERE at.id_tag = :id_tag
GROUP BY a.id_user_uploaded
ORDER BY u.first_name, u.surname',
[
'id_tag' => $this->id_tag,
]);
}
public static function getPeople($id_parent = 0, $offset = 0, $limit = 24, $return_format = 'array') public static function getPeople($id_parent = 0, $offset = 0, $limit = 24, $return_format = 'array')
{ {
$rows = Registry::get('db')->queryAssocs(' $rows = Registry::get('db')->queryAssocs('
SELECT * SELECT *
FROM tags FROM tags
WHERE id_parent = {int:id_parent} AND kind = {string:kind} WHERE id_parent = :id_parent AND kind = :kind
ORDER BY tag ASC ORDER BY tag ASC
LIMIT {int:offset}, {int:limit}', LIMIT :offset, :limit',
[ [
'id_parent' => $id_parent, 'id_parent' => $id_parent,
'kind' => 'Person', 'kind' => 'Person',
@@ -136,7 +176,7 @@ class Tag
'limit' => $limit, 'limit' => $limit,
]); ]);
if ($return_format == 'object') if ($return_format === 'object')
{ {
$return = []; $return = [];
foreach ($rows as $row) foreach ($rows as $row)
@@ -155,7 +195,7 @@ class Tag
WHERE id_tag IN( WHERE id_tag IN(
SELECT id_tag SELECT id_tag
FROM assets_tags FROM assets_tags
WHERE id_asset = {int:id_asset} WHERE id_asset = :id_asset
) )
ORDER BY count DESC', ORDER BY count DESC',
[ [
@@ -166,7 +206,7 @@ class Tag
if (empty($rows)) if (empty($rows))
return []; return [];
if ($return_format == 'object') if ($return_format === 'object')
{ {
$return = []; $return = [];
foreach ($rows as $row) foreach ($rows as $row)
@@ -185,7 +225,7 @@ class Tag
WHERE id_tag IN( WHERE id_tag IN(
SELECT id_tag SELECT id_tag
FROM posts_tags FROM posts_tags
WHERE id_post = {int:id_post} WHERE id_post = :id_post
) )
ORDER BY count DESC', ORDER BY count DESC',
[ [
@@ -196,7 +236,7 @@ class Tag
if (empty($rows)) if (empty($rows))
return []; return [];
if ($return_format == 'object') if ($return_format === 'object')
{ {
$return = []; $return = [];
foreach ($rows as $row) foreach ($rows as $row)
@@ -215,7 +255,7 @@ class Tag
FROM `assets_tags` AS at FROM `assets_tags` AS at
WHERE at.id_tag = t.id_tag WHERE at.id_tag = t.id_tag
)' . (!empty($id_tags) ? ' )' . (!empty($id_tags) ? '
WHERE t.id_tag IN({array_int:id_tags})' : ''), WHERE t.id_tag IN(@id_tags)' : ''),
['id_tags' => $id_tags]); ['id_tags' => $id_tags]);
} }
@@ -226,6 +266,9 @@ class Tag
if (!isset($data['id_parent'])) if (!isset($data['id_parent']))
$data['id_parent'] = 0; $data['id_parent'] = 0;
if (!isset($data['description']))
$data['description'] = '';
if (!isset($data['count'])) if (!isset($data['count']))
$data['count'] = 0; $data['count'] = 0;
@@ -233,15 +276,15 @@ class Tag
INSERT IGNORE INTO tags INSERT IGNORE INTO tags
(id_parent, tag, slug, kind, description, count) (id_parent, tag, slug, kind, description, count)
VALUES VALUES
({int:id_parent}, {string:tag}, {string:slug}, {string:kind}, {string:description}, {int:count}) (:id_parent, :tag, :slug, :kind, :description, :count)
ON DUPLICATE KEY UPDATE count = count + 1', ON DUPLICATE KEY UPDATE count = count + 1',
$data); $data);
if (!$res) if (!$res)
trigger_error('Could not create the requested tag.', E_USER_ERROR); throw new Exception('Could not create the requested tag.');
$data['id_tag'] = $db->insert_id(); $data['id_tag'] = $db->insertId();
return $return_format == 'object' ? new Tag($data) : $data; return $return_format === 'object' ? new Tag($data) : $data;
} }
public function getUrl() public function getUrl()
@@ -254,11 +297,15 @@ class Tag
return Registry::get('db')->query(' return Registry::get('db')->query('
UPDATE tags UPDATE tags
SET SET
id_parent = {int:id_parent}, id_parent = :id_parent,
id_asset_thumb = {int:id_asset_thumb}, id_asset_thumb = :id_asset_thumb,' . (isset($this->id_user_owner) ? '
tag = {string:tag}, id_user_owner = :id_user_owner,' : '') . '
count = {int:count} tag = :tag,
WHERE id_tag = {int:id_tag}', slug = :slug,
kind = :kind,
description = :description,
count = :count
WHERE id_tag = :id_tag',
get_object_vars($this)); get_object_vars($this));
} }
@@ -266,9 +313,10 @@ class Tag
{ {
$db = Registry::get('db'); $db = Registry::get('db');
// Unlink any tagged assets
$res = $db->query(' $res = $db->query('
DELETE FROM posts_tags DELETE FROM assets_tags
WHERE id_tag = {int:id_tag}', WHERE id_tag = :id_tag',
[ [
'id_tag' => $this->id_tag, 'id_tag' => $this->id_tag,
]); ]);
@@ -276,14 +324,36 @@ class Tag
if (!$res) if (!$res)
return false; return false;
// Delete the actual tag
return $db->query(' return $db->query('
DELETE FROM tags DELETE FROM tags
WHERE id_tag = {int:id_tag}', WHERE id_tag = :id_tag',
[ [
'id_tag' => $this->id_tag, 'id_tag' => $this->id_tag,
]); ]);
} }
public function resetIdAsset()
{
$db = Registry::get('db');
$new_id = $db->queryValue('
SELECT MAX(id_asset) as new_id
FROM assets_tags
WHERE id_tag = :id_tag',
[
'id_tag' => $this->id_tag,
]);
return $db->query('
UPDATE tags
SET id_asset_thumb = :new_id
WHERE id_tag = :id_tag',
[
'new_id' => $new_id ?? 0,
'id_tag' => $this->id_tag,
]);
}
public static function match($tokens) public static function match($tokens)
{ {
if (!is_array($tokens)) if (!is_array($tokens))
@@ -292,11 +362,28 @@ class Tag
return Registry::get('db')->queryPair(' return Registry::get('db')->queryPair('
SELECT id_tag, tag SELECT id_tag, tag
FROM tags FROM tags
WHERE LOWER(tag) LIKE {string:tokens} WHERE LOWER(tag) LIKE :tokens
ORDER BY tag ASC', ORDER BY tag ASC',
['tokens' => '%' . strtolower(implode('%', $tokens)) . '%']); ['tokens' => '%' . strtolower(implode('%', $tokens)) . '%']);
} }
public static function matchPeople($tokens)
{
if (!is_array($tokens))
$tokens = explode(' ', $tokens);
return Registry::get('db')->queryPairs('
SELECT id_tag, tag, slug
FROM tags
WHERE LOWER(tag) LIKE :tokens AND
kind = :person
ORDER BY tag ASC',
[
'tokens' => '%' . strtolower(implode('%', $tokens)) . '%',
'person' => 'Person',
]);
}
public static function exactMatch($tag) public static function exactMatch($tag)
{ {
if (!is_string($tag)) if (!is_string($tag))
@@ -305,7 +392,7 @@ class Tag
return Registry::get('db')->queryPair(' return Registry::get('db')->queryPair('
SELECT id_tag, tag SELECT id_tag, tag
FROM tags FROM tags
WHERE tag = {string:tag}', WHERE tag = :tag',
['tag' => $tag]); ['tag' => $tag]);
} }
@@ -317,7 +404,7 @@ class Tag
return Registry::get('db')->queryValue(' return Registry::get('db')->queryValue('
SELECT id_tag SELECT id_tag
FROM tags FROM tags
WHERE slug = {string:slug}', WHERE slug = :slug',
['slug' => $slug]); ['slug' => $slug]);
} }
@@ -326,31 +413,103 @@ class Tag
return Registry::get('db')->queryPair(' return Registry::get('db')->queryPair('
SELECT tag, id_tag SELECT tag, id_tag
FROM tags FROM tags
WHERE tag IN ({array_string:tags})', WHERE tag IN (:tags)',
['tags' => $tags]); ['tags' => $tags]);
} }
public static function getCount($only_active = 1, $kind = '') public static function getCount($only_used = true, $kind = '', $isAlbum = false)
{ {
$where = []; $where = [];
if ($only_active) if ($only_used)
$where[] = 'count > 0'; $where[] = 'count > 0';
if (!empty($kind)) if (empty($kind))
$where[] = 'kind = {string:kind}'; $kind = 'Album';
if (!empty($where)) $operator = $isAlbum ? '=' : '!=';
$where = 'WHERE ' . implode(' AND ', $where); $where[] = 'kind ' . $operator . ' :kind';
else $where = implode(' AND ', $where);
$where = '';
return Registry::get('db')->queryValue(' return Registry::get('db')->queryValue('
SELECT COUNT(*) SELECT COUNT(*)
FROM tags ' . $where, FROM tags
['kind' => $kind]); WHERE ' . $where,
[
'kind' => $kind,
]);
} }
public function __toString() public static function getOffset($offset, $limit, $order, $direction, $isAlbum = false)
{ {
return $this->tag; assert(in_array($order, ['id_tag', 'tag', 'slug', 'count']));
$order = $order . ($direction === 'up' ? ' ASC' : ' DESC');
$operator = $isAlbum ? '=' : '!=';
$db = Registry::get('db');
$res = $db->query('
SELECT t.*, u.id_user, u.first_name, u.surname
FROM tags AS t
LEFT JOIN users AS u ON t.id_user_owner = u.id_user
WHERE kind ' . $operator . ' :album
ORDER BY id_parent, ' . $order . '
LIMIT :offset, :limit',
[
'offset' => $offset,
'limit' => $limit,
'album' => 'Album',
]);
$albums_by_parent = [];
while ($row = $db->fetchAssoc($res))
{
if (!isset($albums_by_parent[$row['id_parent']]))
$albums_by_parent[$row['id_parent']] = [];
$albums_by_parent[$row['id_parent']][] = $row + ['children' => []];
}
$albums = self::getChildrenRecursively(0, 0, $albums_by_parent);
$rows = self::flattenChildrenRecursively($albums);
return $rows;
}
private static function getChildrenRecursively($id_parent, $level, &$albums_by_parent)
{
$children = [];
if (!isset($albums_by_parent[$id_parent]))
return $children;
foreach ($albums_by_parent[$id_parent] as $child)
{
if (isset($albums_by_parent[$child['id_tag']]))
$child['children'] = self::getChildrenRecursively($child['id_tag'], $level + 1, $albums_by_parent);
$child['tag'] = ($level ? str_repeat('—', $level * 2) . ' ' : '') . $child['tag'];
$children[] = $child;
}
return $children;
}
private static function flattenChildrenRecursively($albums)
{
if (empty($albums))
return [];
$rows = [];
foreach ($albums as $album)
{
static $headers_to_keep = ['id_tag', 'tag', 'slug', 'count', 'id_user', 'first_name', 'surname'];
$rows[] = array_intersect_key($album, array_flip($headers_to_keep));
if (!empty($album['children']))
{
$children = self::flattenChildrenRecursively($album['children']);
foreach ($children as $child)
$rows[] = array_intersect_key($child, array_flip($headers_to_keep));
}
}
return $rows;
} }
} }

359
models/Thumbnail.php Normal file
View File

@@ -0,0 +1,359 @@
<?php
/*****************************************************************************
* Thumbnail.php
* Contains key class Thumbnail.
*
* Kabuki CMS (C) 2013-2020, Aaron van Geffen
*****************************************************************************/
class Thumbnail
{
private $image;
private $image_meta;
private $thumbnails;
private $properly_initialised;
private $width;
private $height;
private $crop_mode;
private string $filename_suffix;
const CROP_MODE_NONE = 0;
const CROP_MODE_BOUNDARY = 1;
const CROP_MODE_CUSTOM_FILE = 2;
const CROP_MODE_SLICE_TOP = 3;
const CROP_MODE_SLICE_CENTRE = 4;
const CROP_MODE_SLICE_BOTTOM = 5;
public function __construct(Image $image)
{
$this->image = $image;
$this->image_meta = $image->getMeta();
$this->thumbnails = $image->getThumbnails();
}
/**
* @param width: width of the thumbnail.
* @param height: height of the thumbnail.
* @param crop: whether and how to crop original image to fit. [false|true|'top'|'center'|'bottom']
* @param fit: whether to fit the image to given boundaries [true], or use them merely as an estimate [false].
* @param generate: whether or not to generate a thumbnail if no existing file was found.
*/
public function getUrl($width, $height, $crop = true, $fit = true, $generate = false)
{
$this->init($width, $height, $crop, $fit);
// Check whether we've already resized this earlier.
$thumb_selector = $this->width . 'x' . $this->height . $this->filename_suffix;
if (!empty($this->thumbnails[$thumb_selector]))
{
$thumb_filename = $this->image->getSubdir() . '/' . $this->thumbnails[$thumb_selector];
if (file_exists(THUMBSDIR . '/' . $thumb_filename))
return THUMBSURL . '/' . $thumb_filename;
}
// Do we have a custom thumbnail on file?
$custom_selector = 'custom_' . $this->width . 'x' . $this->height;
if (isset($this->image_meta[$custom_selector]))
{
$custom_filename = $this->image->getSubdir() . '/' . $this->image_meta[$custom_selector];
if (file_exists(ASSETSDIR . '/' . $custom_filename))
{
// Copy the custom thumbail to the general thumbnail directory.
copy(ASSETSDIR . '/' . $custom_filename, THUMBSDIR . '/' . $custom_filename);
// Let's remember this for future reference.
$this->markAsGenerated($this->image_meta[$custom_selector]);
return THUMBSURL . '/' . $custom_filename;
}
else
throw new UnexpectedValueException('Custom thumbnail expected, but missing in file system!');
}
// Is this the right moment to generate a thumbnail, then?
if ($generate)
{
if (array_key_exists($thumb_selector, $this->thumbnails))
return $this->generate();
else
throw new Exception("Trying to generate a thumbnail not previously queued by the system\n" .
print_r(func_get_args(), true));
}
// If not, queue it for generation at another time, and return a URL to generate it with.
else
{
$this->markAsQueued();
return BASEURL . '/thumbnail/' . $this->image->getId() . '/' . $thumb_selector . '/';
}
}
/**
* @param width: width of the thumbnail.
* @param height: height of the thumbnail.
* @param crop: whether and how to crop original image to fit. [false|true|'top'|'center'|'bottom']
* @param fit: whether to fit the image to given boundaries [true], or use them merely as an estimate [false].
*/
private function init($width, $height, $crop = true, $fit = true)
{
$this->properly_initialised = false;
// First, assert the image's dimensions are properly known in the database.
if ($this->image->width() === null || $this->image->height() === null)
throw new UnexpectedValueException('Image width or height is undefined -- inconsistent database?');
$this->width = $width;
$this->height = $height;
// Inferring width or height?
if (!$this->height)
$this->height = ceil($this->width / $this->image->ratio());
elseif (!$this->width)
$this->width = ceil($this->height * $this->image->ratio());
// Inferring the height from the original image's ratio?
if (!$fit)
$this->height = floor($this->width / $this->image->ratio());
// Assert we have both, now...
if (empty($this->width) || empty($this->height))
throw new InvalidArgumentException('Expecting at least either width or height as argument.');
// If we're cropping, verify we're in the right mode.
if ($crop)
{
// Do we have an exact crop boundary set for these dimensions?
$crop_selector = 'crop_' . $this->width . 'x' . $this->height;
if (isset($this->image_meta[$crop_selector]))
$this->crop_mode = self::CROP_MODE_BOUNDARY;
// We won't be cropping if the thumbnail is proportional to its original.
elseif (abs($this->ratio() - $this->image->ratio()) <= 0.025)
$this->crop_mode = self::CROP_MODE_NONE;
// If the original image's aspect ratio is much wider, take a slice instead.
elseif ($this->image->ratio() > $this->ratio())
$this->crop_mode = self::CROP_MODE_SLICE_CENTRE;
// Slice from the top?
elseif ($crop === 'top' || $crop === 'ct')
$this->crop_mode = self::CROP_MODE_SLICE_TOP;
// Slice from the bottom?
elseif ($crop === 'bottom' || $crop === 'cb')
$this->crop_mode = self::CROP_MODE_SLICE_BOTTOM;
// Slice from the centre?
elseif ($crop === 'centre' || $crop === 'center' || $crop === 'cs' || $crop === true)
$this->crop_mode = self::CROP_MODE_SLICE_CENTRE;
// Unexpected value? Assume no crop.
else
$this->crop_mode = self::CROP_MODE_NONE;
}
else
$this->crop_mode = self::CROP_MODE_NONE;
// Now, do we need to suffix the filename?
if ($this->crop_mode !== self::CROP_MODE_NONE)
{
$this->filename_suffix = '_c';
if ($this->crop_mode === self::CROP_MODE_SLICE_TOP)
$this->filename_suffix .= 't';
elseif ($this->crop_mode === self::CROP_MODE_SLICE_CENTRE)
$this->filename_suffix .= 's';
elseif ($this->crop_mode === self::CROP_MODE_SLICE_BOTTOM)
$this->filename_suffix .= 'b';
elseif ($this->crop_mode === self::CROP_MODE_BOUNDARY)
$this->filename_suffix .= 'e';
}
else
$this->filename_suffix = '';
$this->properly_initialised = true;
}
private function generate()
{
if (!$this->properly_initialised)
throw new UnexpectedValueException('The thumbnail factory was not intialised before use!');
// Let's try some arcane stuff...
try
{
if (!class_exists('Imagick'))
throw new Exception("The PHP module 'imagick' appears to be disabled. Please enable it to use image resampling functions.");
$thumb = new Imagick(ASSETSDIR . '/' . $this->image->getSubdir() . '/' . $this->image->getFilename());
// The image might have some orientation set through EXIF. Let's apply this first.
self::applyRotation($thumb);
// Just resizing? Easy peasy.
if ($this->crop_mode === self::CROP_MODE_NONE)
$thumb->resizeImage($this->width, $this->height, Imagick::FILTER_LANCZOS, 1);
// // Cropping in the center?
elseif ($this->crop_mode === self::CROP_MODE_SLICE_CENTRE)
$thumb->cropThumbnailImage($this->width, $this->height);
// Exact cropping? We can do that.
elseif ($this->crop_mode === self::CROP_MODE_BOUNDARY)
{
$crop_selector = 'crop_' . $this->width . 'x' . $this->height;
list($crop_width, $crop_height, $crop_x_pos, $crop_y_pos) = explode(',', $this->image_meta[$crop_selector]);
$thumb->cropImage($crop_width, $crop_height, $crop_x_pos, $crop_y_pos);
$thumb->resizeImage($this->width, $this->height, Imagick::FILTER_LANCZOS, 1);
}
// Advanced cropping? Fun!
else
{
$size = $thumb->getImageGeometry();
// Taking a horizontal slice from the top or bottom of the original image?
if ($this->crop_mode === self::CROP_MODE_SLICE_TOP || $this->crop_mode === self::CROP_MODE_SLICE_BOTTOM)
{
$crop_width = $size['width'];
$crop_height = floor($size['width'] / $this->width * $this->height);
$target_x = 0;
$target_y = $this->crop_mode === self::CROP_MODE_SLICE_TOP ? 0 : $size['height'] - $crop_height;
}
// Otherwise, we're taking a vertical slice from the centre.
else
{
$crop_width = floor($size['height'] / $this->height * $this->width);
$crop_height = $size['height'];
$target_x = floor(($size['width'] - $crop_width) / 2);
$target_y = 0;
}
$thumb->cropImage($crop_width, $crop_height, $target_x, $target_y);
$thumb->resizeImage($this->width, $this->height, Imagick::FILTER_LANCZOS, 1);
}
// What sort of image is this? Fall back to PNG if we must.
switch ($thumb->getImageFormat())
{
case 'JPEG':
$ext = 'jpg';
break;
case 'GIF':
$ext = 'gif';
break;
case 'PNG':
default:
$thumb->setFormat('PNG');
$ext = 'png';
break;
}
// So, how do we name this?
$thumb_filename = substr($this->image->getFilename(), 0, strrpos($this->image->getFilename(), '.')) .
'_' . $this->width . 'x' . $this->height . $this->filename_suffix . '.' . $ext;
// Ensure the thumbnail subdirectory exists.
$target_dir = THUMBSDIR . '/' . $this->image->getSubdir();
if (!is_dir($target_dir))
mkdir($target_dir, 0755, true);
if (!is_writable($target_dir))
throw new Exception('Thumbnail directory is not writable!');
// No need to preserve every detail.
$thumb->setImageCompressionQuality(80);
// Save it in a public spot.
$thumb->writeImage($target_dir . '/' . $thumb_filename);
// Let's remember this for future reference...
$this->markAsGenerated($thumb_filename);
$thumb->clear();
$thumb->destroy();
// Finally, return the URL for the generated thumbnail image.
return THUMBSURL . '/' . $this->image->getSubdir() . '/' . $thumb_filename;
}
catch (ImagickException $e)
{
throw new Exception('ImageMagick error occurred while generating thumbnail. Output: ' . $e->getMessage());
}
}
private static function applyRotation(Imagick $image)
{
switch ($image->getImageOrientation())
{
// Clockwise rotation
case Imagick::ORIENTATION_RIGHTTOP:
$image->rotateImage("#000", 90);
break;
// Counter-clockwise rotation
case Imagick::ORIENTATION_LEFTBOTTOM:
$image->rotateImage("#000", 270);
break;
// Upside down?
case Imagick::ORIENTATION_BOTTOMRIGHT:
$image->rotateImage("#000", 180);
}
// Having rotated the image, make sure the EXIF data is set properly.
$image->setImageOrientation(Imagick::ORIENTATION_TOPLEFT);
}
private function ratio()
{
return $this->width / $this->height;
}
private function updateDb($filename)
{
if (!$this->properly_initialised)
throw new UnexpectedValueException('The thumbnail factory was not intialised before use!');
$mode = !empty($this->filename_suffix) ? substr($this->filename_suffix, 1) : '';
$success = Registry::get('db')->insert('replace', 'assets_thumbs', [
'id_asset' => 'int',
'width' => 'int',
'height' => 'int',
'mode' => 'string-3',
'filename' => 'string-255',
], [
'id_asset' => $this->image->getId(),
'width' => $this->width,
'height' => $this->height,
'mode' => $mode,
'filename' => $filename,
]);
if ($success)
{
$thumb_selector = $this->width . 'x' . $this->height . $this->filename_suffix;
$this->thumbnails[$thumb_selector] = $filename ?? null;
// For consistency, write new thumbnail filename to parent Image object.
// TODO: there could still be an inconsistency if multiple objects exists for the same image asset.
$this->image->getThumbnails()[$thumb_selector] = $this->thumbnails[$thumb_selector];
return $success;
}
else
throw new UnexpectedValueException('Thumbnail queuing query failed');
}
private function markAsQueued()
{
$this->updateDb(null);
}
private function markAsGenerated($filename)
{
$this->updateDb($filename);
}
}

View File

@@ -12,17 +12,21 @@
*/ */
abstract class User abstract class User
{ {
protected $id_user; protected int $id_user;
protected $first_name; protected string $first_name;
protected $surname; protected string $surname;
protected $emailaddress; protected string $slug;
protected string $emailaddress;
protected string $password_hash;
protected $creation_time; protected $creation_time;
protected $last_action_time; protected $last_action_time;
protected $ip_address; protected $ip_address;
protected $is_admin; protected $is_admin;
protected $reset_key;
protected $reset_blocked_until;
protected $is_logged; protected bool $is_logged;
protected $is_guest; protected bool $is_guest;
/** /**
* Returns user id. * Returns user id.
@@ -72,6 +76,11 @@ abstract class User
return $this->ip_address; return $this->ip_address;
} }
public function getSlug()
{
return $this->slug;
}
/** /**
* Returns whether user is logged in. * Returns whether user is logged in.
*/ */

59
models/UserMenu.php Normal file
View File

@@ -0,0 +1,59 @@
<?php
/*****************************************************************************
* UserMenu.php
* Contains the user navigation logic.
*
* Kabuki CMS (C) 2013-2023, Aaron van Geffen
*****************************************************************************/
class UserMenu extends Menu
{
public function __construct()
{
$user = Registry::has('user') ? Registry::get('user') : new Guest();
if ($user->isLoggedIn())
{
$this->items[] = [
'label' => $user->getFirstName(),
'icon' => 'person-circle',
'subs' => [
[
'label' => 'Settings',
'uri' => '/accountsettings/',
],
[
'label' => 'Log out',
'uri' => '/logout/',
],
],
];
}
else
{
$this->items[] = [
'label' => 'Log in',
'icon' => 'person-circle',
'uri' => '/login/',
];
}
$this->items[] = [
'label' => 'Home',
'icon' => 'house-door',
'uri' => '/',
];
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

@@ -1,304 +1,60 @@
.admin_box {
margin: 0;
padding: 20px;
background: #fff;
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
overflow: auto;
}
.admin_box h2 {
font: 700 24px "Open Sans", sans-serif;
margin: 0 0 0.2em;
}
.floatleft {
float: left;
}
.floatright {
float: right;
}
/* Admin bar styles
---------------------*/
body {
padding-top: 30px;
}
#admin_bar {
background: #333;
color: #ccc;
left: 0;
position: fixed;
top: 0;
width: 100%;
z-index: 100;
}
#admin_bar ul {
list-style: none;
margin: 0 auto;
max-width: 1280px;
min-width: 900px;
padding: 2px;
width: 95%;
}
#admin_bar ul > li {
display: inline;
border-right: 1px solid #aaa;
}
#admin_bar ul > li:last-child {
border-right: none;
}
#admin_bar li > a {
color: inherit;
display: inline-block;
padding: 4px 6px;
}
#admin_bar li a:hover {
text-decoration: underline;
}
/* (Tag) autosuggest
----------------------*/
#new_tag_container {
display: block;
position: relative;
}
.autosuggest {
background: #fff;
border: 1px solid #ccc;
position: absolute;
top: 29px;
margin: 0;
padding: 0;
}
.autosuggest li {
display: block !important;
padding: 3px;
}
.autosuggest li:hover, .autosuggest li.selected {
background: #CFECF7;
cursor: pointer;
}
/* Edit user screen
---------------------*/
.edituser dt {
clear: left;
float: left;
width: 150px;
}
.edituser dd {
float: left;
margin-bottom: 5px;
}
.edituser form div:last-child {
padding: 1em 0 0;
}
/* Admin widgets
------------------*/
.widget {
background: #fff;
padding: 25px;
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
}
.widget h3 {
margin: 0 0 1em;
font: 400 18px "Raleway", sans-serif;
}
.widget p, .errormsg p {
margin: 0;
}
.widget ul {
margin: 0;
list-style: none;
padding: 0;
}
.widget li {
line-height: 1.7em;
}
/* Edit icon on tiled grids
-----------------------------*/
.tiled_grid div.landscape, .tiled_grid div.portrait, .tiled_grid div.panorama {
position: relative;
}
.tiled_grid div > a.edit {
background: #fff;
border-radius: 3px;
box-shadow: 1px 1px 2px rgba(0,0,0,0.3);
display: none;
left: 20px;
line-height: 1.5;
padding: 5px 10px;
position: absolute;
top: 20px;
}
.tiled_grid div:hover > a.edit {
display: block;
}
/* Crop editor /* Crop editor
----------------*/ ----------------*/
#crop_editor { #crop_editor {
display: flex;
flex-direction: column;
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
height: 100%; height: 100%;
width: 100%; width: 100%;
background: #000; background: rgba(0, 0, 0, 0.8);
z-index: 100; z-index: 100;
color: #fff; color: #fff;
} }
#crop_editor input { #crop_editor .input-group-text {
width: 50px; background-color: rgba(233, 236, 239, 0.5);
background: #555; border-color: rgba(233, 236, 239, 0.5);
color: #fff; color: #fff;
} }
.crop_image_container { #crop_editor input[type=number] {
position: relative; background: #555;
border-color: rgba(233, 236, 239, 0.5);
color: #fff;
width: 85px;
}
#crop_editor input[type=checkbox] {
vertical-align: middle;
} }
.crop_position { .crop_position {
background: rgba(0, 0, 0, 1.0);
border: none;
display: flex;
padding: 5px; padding: 5px;
text-align: center; text-align: center;
} }
.crop_position input, .crop_position .btn { .crop_position input, .crop_position .btn {
margin: 0 5px; margin: 0 5px;
} }
.crop_image_container {
position: relative;
flex-grow: 1;
max-height: calc(100% - 34px);
}
.crop_image_container img { .crop_image_container img {
height: auto; border: 1px solid #000;
width: auto; max-height: 100%;
max-width: 100%; max-width: 100%;
max-height: 700px;
} }
#crop_boundary { #crop_boundary {
border: 1px solid rgba(255, 255, 255, 0.75); border: 1px dashed rgb(255, 255, 255);
background: rgba(255, 255, 255, 0.75); background: rgba(255, 255, 255, 0.4);
cursor: move;
position: absolute; position: absolute;
z-index: 200; z-index: 200;
width: 500px; width: 500px;
height: 300px; height: 300px;
top: 400px; top: 400px;
left: 300px; left: 300px;
filter: invert(100%); /* temp */
}
/* The pagination styles below are based on Bootstrap 2.3.2
-------------------------------------------------------------*/
.table_pagination, .table_form {
margin: 20px 0;
}
.table_pagination ul {
display: inline-block;
margin: 0;
padding: 0;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.table_pagination ul > li {
display: inline;
}
.table_pagination ul > li > a,
.table_pagination ul > li > span {
float: left;
padding: 4px 12px;
line-height: 20px;
text-decoration: none;
background-color: #ffffff;
border: 1px solid #dddddd;
border-left-width: 0;
}
.table_pagination ul > li > a:hover,
.table_pagination ul > li > a:focus,
.table_pagination ul > .active > a,
.table_pagination ul > .active > span {
background-color: #f5f5f5;
}
.table_pagination ul > .active > a,
.table_pagination ul > .active > span {
color: #999999;
cursor: default;
}
.table_pagination ul > .disabled > span,
.table_pagination ul > .disabled > a,
.table_pagination ul > .disabled > a:hover,
.table_pagination ul > .disabled > a:focus {
color: #999999;
cursor: default;
background-color: transparent;
}
.table_pagination ul > li:first-child > a,
.table_pagination ul > li:first-child > span {
border-left-width: 1px;
}
/* The table styles below were taken from Bootstrap 2.3.2
-----------------------------------------------------------*/
table {
max-width: 100%;
background-color: transparent;
border-collapse: collapse;
border-spacing: 0;
}
.table {
width: 100%;
margin-bottom: 20px;
}
.table th,
.table td {
border-top: 1px solid #dddddd;
line-height: 20px;
padding: 8px;
text-align: left;
vertical-align: top;
}
.table th {
font-weight: bold;
}
.table thead th {
vertical-align: bottom;
}
.table caption + thead tr:first-child th,
.table caption + thead tr:first-child td,
.table colgroup + thead tr:first-child th,
.table colgroup + thead tr:first-child td,
.table thead:first-child tr:first-child th,
.table thead:first-child tr:first-child td {
border-top: 0;
}
.table tbody + tbody {
border-top: 2px solid #dddddd;
}
.table .table {
background-color: #ffffff;
}
.table-striped tbody > tr:nth-child(odd) > td,
.table-striped tbody > tr:nth-child(odd) > th {
background-color: #f9f9f9;
}
.table-hover tbody tr:hover > td,
.table-hover tbody tr:hover > th {
background-color: #f5f5f5;
} }

File diff suppressed because it is too large Load Diff

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

10
public/images/nothumb.svg Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 150">
<defs><style>.cls-2{fill:#cc9d9d;}</style></defs>
<g>
<path class="cls-2" d="m221.34,135.39c-13.69,0-27.38-.09-41.07.06-3.43.04-4.94-.61-4.91-4.56.17-27.21.14-54.42.02-81.63-.02-3.41.9-4.57,4.45-4.56,27.69.13,55.38.12,83.07,0,3.36-.01,4.19,1.18,4.18,4.34-.1,27.37-.12,54.73.02,82.1.02,3.73-1.44,4.34-4.69,4.3-13.69-.14-27.38-.06-41.07-.06Zm-.11-27.1c11.37,0,22.74-.1,34.1.06,3.26.05,4.3-.97,4.28-4.25-.14-16.19-.14-32.38,0-48.56.03-3.28-1.01-4.27-4.27-4.25-22.74.12-45.47.12-68.21,0-3.26-.02-4.3.97-4.27,4.25.14,16.19.14,32.38,0,48.56-.03,3.28,1.01,4.3,4.27,4.26,11.37-.16,22.74-.06,34.1-.06Z"/>
<path class="cls-2" d="m271.69,111.12c.4-3.72-.27-8.33-.9-12.95-.4-2.96.59-3.73,3.62-3.01,6.71,1.61,6.75,1.45,8.74-5.81,3.66-13.3,7.37-26.59,10.95-39.91,1.64-6.09,1.55-6.23-4.53-7.87-20.8-5.63-41.65-11.12-62.43-16.82-3.48-.95-5.32-.26-6.11,3.33-.73,3.33-1.85,6.57-2.55,9.9-.71,3.39-3,4.22-5.87,3.73-3.34-.57-2.27-2.94-1.71-5.06,1.7-6.44,3.31-12.91,5.03-19.34.47-1.74.7-3.35,3.66-2.54,27.36,7.52,54.77,14.85,82.2,22.1,2.71.72,3.31,1.43,2.52,4.29-7.26,26.45-14.3,52.97-21.49,79.44-.5,1.84-.24,5.23-3.51,4.25-3.05-.92-8.22.3-7.68-5.77.21-2.32.03-4.67.03-7.96Z"/>
<path class="cls-2" d="m237.89,68.65c3.58,9.04,7.13,18.07,10.74,27.08.87,2.17.4,3.25-2.07,3.25-16.63-.01-33.25,0-49.88-.01-2.63,0-2.8-1.35-1.8-3.33.7-1.39,1.37-2.79,2.07-4.17,2.84-5.69,2.92-5.78,8.04-1.6,1.77,1.44,2.44,1.1,3.45-.67,1.69-2.95,3.7-5.72,5.45-8.64,1.39-2.31,2.67-2.5,4.73-.62,2.11,1.93,3.79,5.97,6.49,5.2,2.2-.63,3.51-4.41,5.19-6.81,2.13-3.04,4.23-6.1,6.37-9.13.15-.21.54-.25,1.23-.54Z"/>
<path class="cls-2" d="m201.38,75.62c-3.33.17-5.32-1.1-5.41-4.73-.09-3.64,1.37-6.17,5.12-6.38,3.38-.19,5.57,1.83,6,5.22.4,3.09-2.39,5.81-5.72,5.89Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -1,14 +1,14 @@
function enableKeyDownNavigation() { function enableKeyDownNavigation() {
document.addEventListener("keydown", function (event) { document.addEventListener("keydown", function (event) {
if (event.keyCode == 37) { if (event.keyCode == 37) {
var target = document.querySelector(".pagination ul > :first-child a"); var target = document.querySelector("ul.pagination > :first-child a");
if (target && target.href) { if (target && target.href) {
event.preventDefault(); event.preventDefault();
document.location.href = target.href; document.location.href = target.href;
} }
} }
else if (event.keyCode == 39) { else if (event.keyCode == 39) {
var target = document.querySelector(".pagination ul > :last-child a"); var target = document.querySelector("ul.pagination > :last-child a");
if (target && target.href) { if (target && target.href) {
event.preventDefault(); event.preventDefault();
document.location.href = target.href; document.location.href = target.href;

View File

@@ -13,8 +13,10 @@ provided that the following conditions are met:
'use strict'; 'use strict';
function AutoSuggest(opt) { class AutoSuggest {
if (typeof opt.inputElement === "undefined" || typeof opt.listElement === "undefined" || typeof opt.baseUrl === "undefined" || typeof opt.appendCallback === "undefined") { constructor(opt) {
if (typeof opt.inputElement === "undefined" || typeof opt.listElement === "undefined" ||
typeof opt.baseUrl === "undefined" || typeof opt.appendCallback === "undefined") {
return; return;
} }
@@ -24,53 +26,46 @@ function AutoSuggest(opt) {
this.appendCallback = opt.appendCallback; this.appendCallback = opt.appendCallback;
this.baseurl = opt.baseUrl; this.baseurl = opt.baseUrl;
var self = this; this.input.addEventListener('keydown', event => this.doSelection(event), false);
this.input.addEventListener('keydown', function(event) { this.input.addEventListener('keyup', event => this.onType(event), false);
self.doSelection(event);
}, false);
this.input.addEventListener('keyup', function(event) {
self.onType(this, event);
}, false);
} }
AutoSuggest.prototype.doSelection = function(event) { doSelection(event) {
if (typeof this.container === "undefined" || this.container.children.length === 0) { if (typeof this.container === "undefined" || this.container.children.length === 0) {
return; return;
} }
switch (event.keyCode) { switch (event.key) {
case 13: // Enter case 'Enter':
event.preventDefault(); event.preventDefault();
this.container.children[this.selectedIndex].click(); this.container.children[this.selectedIndex].click();
break; break;
case 38: // Arrow up case 'ArrowUp':
case 40: // Arrow down case 'ArrowDown':
event.preventDefault(); event.preventDefault();
this.findSelectedElement().className = ''; this.findSelectedElement().className = '';
this.selectedIndex += event.keyCode === 38 ? -1 : 1; this.selectedIndex += event.key === 'ArrowUp' ? -1 : 1;
if (this.selectedIndex < 0) { if (this.selectedIndex < 0) {
this.selectedIndex = this.container.children.length - 1; this.selectedIndex = this.container.children.length - 1;
} else if (this.selectedIndex === this.container.children.length) { } else if (this.selectedIndex === this.container.children.length) {
this.selectedIndex = 0; this.selectedIndex = 0;
} }
var new_el = this.findSelectedElement().className = 'selected'; let new_el = this.findSelectedElement().className = 'selected';
break; break;
} }
}; };
AutoSuggest.prototype.findSelectedElement = function() { findSelectedElement() {
return this.container.children[this.selectedIndex]; return this.container.children[this.selectedIndex];
}; };
AutoSuggest.prototype.onType = function(input, event) { onType(event) {
if (event.keyCode === 13 || event.keyCode === 38 || event.keyCode === 40) { if (['Enter', 'ArrowDown', 'ArrowUp'].indexOf(event.key) !== -1) {
return; return;
} }
var tokens = input.value.split(/\s+/).filter(function(token) { let tokens = event.target.value.split(/\s+/).filter(token => token.length >= 2);
return token.length >= 3;
});
if (tokens.length === 0) { if (tokens.length === 0) {
if (typeof this.container !== "undefined") { if (typeof this.container !== "undefined") {
@@ -79,17 +74,17 @@ AutoSuggest.prototype.onType = function(input, event) {
return false; return false;
} }
var request_uri = this.baseurl + '/suggest/?type=tags&data=' + window.encodeURIComponent(tokens.join(" ")); let request_uri = this.baseurl + '/suggest/?type=tags&data=' + window.encodeURIComponent(tokens.join(" "));
var request = new HttpRequest('get', request_uri, {}, this.onReceive, this); let request = new HttpRequest('get', request_uri, {}, this.onReceive, this);
}; };
AutoSuggest.prototype.onReceive = function(response, self) { onReceive(response, self) {
self.openContainer(); self.openContainer();
self.clearContainer(); self.clearContainer();
self.fillContainer(response); self.fillContainer(response);
}; };
AutoSuggest.prototype.openContainer = function() { openContainer() {
if (this.container) { if (this.container) {
if (!this.container.parentNode) { if (!this.container.parentNode) {
this.input.parentNode.appendChild(this.container); this.input.parentNode.appendChild(this.container);
@@ -103,76 +98,80 @@ AutoSuggest.prototype.openContainer = function() {
return this.container; return this.container;
}; };
AutoSuggest.prototype.clearContainer = function() { clearContainer() {
while (this.container.children.length > 0) { while (this.container.children.length > 0) {
this.container.removeChild(this.container.children[0]); this.container.removeChild(this.container.children[0]);
} }
}; };
AutoSuggest.prototype.clearInput = function() { clearInput() {
this.input.value = ""; this.input.value = "";
this.input.focus(); this.input.focus();
}; };
AutoSuggest.prototype.closeContainer = function() { closeContainer() {
this.container.parentNode.removeChild(this.container); this.container.parentNode.removeChild(this.container);
}; };
AutoSuggest.prototype.fillContainer = function(response) { fillContainer(response) {
var self = this;
this.selectedIndex = 0; this.selectedIndex = 0;
response.items.forEach(function(item, i) {
var node = document.createElement('li'); let query = this.input.value.trim().replace(/[\-\[\]{}()*+?.,\\\/^\$|#]/g, ' ');
var text = document.createTextNode(item.label); let query_tokens = query.split(/ +/).sort((a,b) => a.length - b.length);
response.items.forEach((item, i) => {
let node = document.createElement('li');
node.innerHTML = this.highlightMatches(query_tokens, item.label);
node.jsondata = item; node.jsondata = item;
node.addEventListener('click', function(event) { node.addEventListener('click', event => {
self.appendCallback(this.jsondata); this.appendCallback(node.jsondata);
self.closeContainer(); this.closeContainer();
self.clearInput(); this.clearInput();
}); });
node.appendChild(text); this.container.appendChild(node);
self.container.appendChild(node); if (this.container.children.length === 1) {
if (self.container.children.length === 1) {
node.className = 'selected'; node.className = 'selected';
} }
}); });
}; };
highlightMatches(query_tokens, item) {
let itemTokens = item.split(/ +/);
let queryTokens = new RegExp('(' + query_tokens.join('\|') + ')', 'i');
itemTokens.forEach((token, index) => {
item = item.replace(token, token.replace(queryTokens, ($1, match) => '<strong>' + match + '</strong>'));
});
return item;
};
}
function TagAutoSuggest(opt) { class TagAutoSuggest extends AutoSuggest {
AutoSuggest.prototype.constructor.call(this, opt); constructor(opt) {
super(opt);
this.type = "tags"; this.type = "tags";
} }
TagAutoSuggest.prototype = Object.create(AutoSuggest.prototype); fillContainer(response) {
TagAutoSuggest.prototype.constructor = TagAutoSuggest;
TagAutoSuggest.prototype.fillContainer = function(response) {
if (response.items.length > 0) { if (response.items.length > 0) {
AutoSuggest.prototype.fillContainer.call(this, response); super.fillContainer.call(this, response);
} else { } else {
var node = document.createElement('li') let node = document.createElement('li')
node.innerHTML = "<em>Tag does not exist yet. Create it?</em>"; node.innerHTML = "<em>Tag does not exist yet. Create it?</em>";
var self = this; node.addEventListener('click', event => {
node.addEventListener('click', function(event) { this.createNewTag(response => this.appendCallback(response));
self.createNewTag(function(response) { this.closeContainer();
console.log('Nieuwe tag!!'); this.clearInput();
console.log(response);
self.appendCallback(response);
});
self.closeContainer();
self.clearInput();
}); });
self.container.appendChild(node); this.container.appendChild(node);
this.selectedIndex = 0; this.selectedIndex = 0;
node.className = 'selected'; node.className = 'selected';
} }
}; };
TagAutoSuggest.prototype.createNewTag = function(callback) { createNewTag(callback) {
var request_uri = this.baseurl + '/managetags/?create'; let request_uri = this.baseurl + '/suggest/?type=createtag';
var request = new HttpRequest('post', request_uri, 'tag=' + encodeURIComponent(this.input.value), callback, this); let request = new HttpRequest('post', request_uri, 'tag=' + encodeURIComponent(this.input.value), callback, this);
}
} }

77
public/js/color-modes.js Normal file
View File

@@ -0,0 +1,77 @@
/*!
* Color mode toggler for Bootstrap's docs (https://getbootstrap.com/)
* Copyright 2011-2023 The Bootstrap Authors
* Licensed under the Creative Commons Attribution 3.0 Unported License.
*/
(() => {
'use strict'
const getStoredTheme = () => localStorage.getItem('theme');
const setStoredTheme = theme => localStorage.setItem('theme', theme);
const getPreferredTheme = () => {
const storedTheme = getStoredTheme();
if (storedTheme) {
return storedTheme;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
const setTheme = theme => {
if (theme === 'auto') {
document.documentElement.setAttribute('data-bs-theme', (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'))
} else {
document.documentElement.setAttribute('data-bs-theme', theme);
}
}
setTheme(getPreferredTheme());
const showActiveTheme = (theme, focus = false) => {
const themeSwitcher = document.querySelector('#bd-theme');
if (!themeSwitcher) {
return;
}
const themeSwitcherText = document.querySelector('#bd-theme-text');
const activeThemeIcon = document.querySelector('#theme-icon-active');
const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`);
const activeButtonIcon = btnToActive.querySelector('i.bi').className;
document.querySelectorAll('[data-bs-theme-value]').forEach(element => {
element.classList.remove('active');
});
btnToActive.classList.add('active');
activeThemeIcon.className = activeButtonIcon;
const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})`
if (focus) {
themeSwitcher.focus()
}
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const storedTheme = getStoredTheme()
if (storedTheme !== 'light' && storedTheme !== 'dark') {
setTheme(getPreferredTheme())
}
})
window.addEventListener('DOMContentLoaded', () => {
showActiveTheme(getPreferredTheme())
document.querySelectorAll('[data-bs-theme-value]')
.forEach(toggle => {
toggle.addEventListener('click', () => {
const theme = toggle.getAttribute('data-bs-theme-value')
setStoredTheme(theme)
setTheme(theme)
showActiveTheme(theme, true)
})
})
})
})()

View File

@@ -1,9 +1,10 @@
function CropEditor(opt) { class CropEditor {
constructor(opt) {
this.opt = opt; this.opt = opt;
this.edit_crop_button = document.createElement("span"); this.edit_crop_button = document.createElement("span");
this.edit_crop_button.className = "btn"; this.edit_crop_button.className = "btn btn-light";
this.edit_crop_button.innerHTML = "Edit crop"; this.edit_crop_button.textContent = "Edit crop";
this.edit_crop_button.addEventListener('click', this.show.bind(this)); this.edit_crop_button.addEventListener('click', this.show.bind(this));
this.thumbnail_select = document.getElementById(opt.thumbnail_select_id); this.thumbnail_select = document.getElementById(opt.thumbnail_select_id);
@@ -13,54 +14,87 @@ function CropEditor(opt) {
this.toggleCropButton(); this.toggleCropButton();
} }
CropEditor.prototype.buildContainer = function() { initDOM() {
this.container = document.createElement("div"); this.container = document.createElement("div");
this.container.className = 'container-fluid';
this.container.id = "crop_editor"; this.container.id = "crop_editor";
this.position = document.createElement("div"); this.initPositionForm();
this.position.className = "crop_position"; this.initImageContainer();
this.parent = document.getElementById(this.opt.editor_container_parent_id);
this.parent.appendChild(this.container);
}
initPositionForm() {
this.position = document.createElement("fieldset");
this.position.className = "crop_position flex-row justify-content-center";
this.container.appendChild(this.position); this.container.appendChild(this.position);
var source_x_label = document.createTextNode("Source X:"); const addNumericControl = (label, changeEvent) => {
this.position.appendChild(source_x_label); const column = document.createElement('div');
column.className = 'col-auto';
this.position.appendChild(column);
this.source_x = document.createElement("input"); const group = document.createElement('div');
this.source_x.addEventListener("keyup", this.positionBoundary.bind(this)); group.className = 'input-group';
this.position.appendChild(this.source_x); column.appendChild(group);
var source_y_label = document.createTextNode("Source Y:"); const labelEl = document.createElement("span");
this.position.appendChild(source_y_label); labelEl.className = 'input-group-text';
labelEl.textContent = label;
group.appendChild(labelEl);
this.source_y = document.createElement("input"); const control = document.createElement("input");
this.source_y.addEventListener("keyup", this.positionBoundary.bind(this)); control.className = 'form-control';
this.position.appendChild(this.source_y); control.type = 'number';
control.addEventListener("change", changeEvent);
control.addEventListener("keyup", changeEvent);
group.appendChild(control);
var crop_width_label = document.createTextNode("Crop width:"); return control;
this.position.appendChild(crop_width_label); };
this.crop_width = document.createElement("input"); this.source_x = addNumericControl("Source X:", this.positionBoundary);
this.crop_width.addEventListener("keyup", this.positionBoundary.bind(this)); this.source_y = addNumericControl("Source Y:", this.positionBoundary);
this.position.appendChild(this.crop_width); this.crop_width = addNumericControl("Crop width:", this.positionBoundary);
this.crop_height = addNumericControl("Crop height:", this.positionBoundary);
var crop_height_label = document.createTextNode("Crop height:"); const otherColumn = document.createElement('div');
this.position.appendChild(crop_height_label); otherColumn.className = 'col-auto text-nowrap';
this.position.appendChild(otherColumn);
this.crop_height = document.createElement("input"); const constrainContainer = document.createElement("div");
this.crop_height.addEventListener("keyup", this.positionBoundary.bind(this)); constrainContainer.className = 'form-checkbox d-inline';
this.position.appendChild(this.crop_height); otherColumn.appendChild(constrainContainer);
this.crop_constrain = document.createElement("input");
this.crop_constrain.checked = true;
this.crop_constrain.className = 'form-check-input';
this.crop_constrain.id = 'check_constrain';
this.crop_constrain.type = 'checkbox';
constrainContainer.appendChild(this.crop_constrain);
this.crop_constrain_label = document.createElement("label");
this.crop_constrain_label.className = 'form-check-label';
this.crop_constrain_label.htmlFor = 'check_constrain';
this.crop_constrain_label.textContent = 'Constrain proportions';
constrainContainer.appendChild(this.crop_constrain_label);
this.save_button = document.createElement("span"); this.save_button = document.createElement("span");
this.save_button.className = "btn"; this.save_button.className = "btn btn-light";
this.save_button.innerHTML = "Save"; this.save_button.textContent = "Save";
this.save_button.addEventListener('click', this.save.bind(this)); this.save_button.addEventListener('click', this.save.bind(this));
this.position.appendChild(this.save_button); otherColumn.appendChild(this.save_button);
this.abort_button = document.createElement("span"); this.abort_button = document.createElement("span");
this.abort_button.className = "btn btn-red"; this.abort_button.className = "btn btn-danger";
this.abort_button.innerHTML = "Abort"; this.abort_button.textContent = "Abort";
this.abort_button.addEventListener('click', this.hide.bind(this)); this.abort_button.addEventListener('click', this.hide.bind(this));
this.position.appendChild(this.abort_button); otherColumn.appendChild(this.abort_button);
}
initImageContainer() {
this.image_container = document.createElement("div"); this.image_container = document.createElement("div");
this.image_container.className = "crop_image_container"; this.image_container.className = "crop_image_container";
this.container.appendChild(this.image_container); this.container.appendChild(this.image_container);
@@ -70,74 +104,90 @@ CropEditor.prototype.buildContainer = function() {
this.image_container.appendChild(this.crop_boundary); this.image_container.appendChild(this.crop_boundary);
this.original_image = document.createElement("img"); this.original_image = document.createElement("img");
this.original_image.draggable = false;
this.original_image.id = "original_image"; this.original_image.id = "original_image";
this.original_image.src = this.opt.original_image_src; this.original_image.src = this.opt.original_image_src;
this.image_container.appendChild(this.original_image); this.image_container.appendChild(this.original_image);
}
this.parent = document.getElementById(this.opt.editor_container_parent_id); setDefaultCrop(cropAspectRatio, cropMethod) {
this.parent.appendChild(this.container); let source = this.original_image;
}; let sourceAspectRatio = source.naturalWidth / source.naturalHeight;
CropEditor.prototype.setInputValues = function() {
var current = this.thumbnail_select.options[this.thumbnail_select.selectedIndex].dataset;
if (typeof current.crop_region === "undefined") {
var source_ratio = this.original_image.naturalWidth / this.original_image.naturalHeight,
crop_ratio = current.crop_width / current.crop_height,
min_dim = Math.min(this.original_image.naturalWidth, this.original_image.naturalHeight);
// Cropping from the centre? // Cropping from the centre?
if (current.crop_method === "c") { if (cropMethod === "c" || cropMethod === "s") {
// Crop vertically from the centre, using the entire width. // Crop vertically from the centre, using the entire width.
if (source_ratio < crop_ratio) { if (sourceAspectRatio <= cropAspectRatio) {
this.crop_width.value = this.original_image.naturalWidth; this.crop_width.value = source.naturalWidth;
this.crop_height.value = Math.ceil(this.original_image.naturalWidth / crop_ratio); this.crop_height.value = Math.ceil(source.naturalWidth / cropAspectRatio);
this.source_x.value = 0; this.source_x.value = 0;
this.source_y.value = Math.ceil((this.original_image.naturalHeight - this.crop_height.value) / 2); this.source_y.value = Math.ceil((source.naturalHeight - this.crop_height.value) / 2);
} }
// Crop horizontally from the centre, using the entire height. // Crop horizontally from the centre, using the entire height.
else { else {
this.crop_width.value = Math.ceil(current.crop_width * this.original_image.naturalHeight / current.crop_height); this.crop_width.value = Math.ceil(cropAspectRatio * source.naturalHeight);
this.crop_height.value = this.original_image.naturalHeight; this.crop_height.value = source.naturalHeight;
this.source_x.value = Math.ceil((this.original_image.naturalWidth - this.crop_width.value) / 2); this.source_x.value = Math.ceil((source.naturalWidth - this.crop_width.value) / 2);
this.source_y.value = 0; this.source_y.value = 0;
} }
} }
// Cropping a top or bottom slice? // Cropping a top or bottom slice?
else { else {
// Can we actually take a top or bottom slice from the original image? // Can we actually take a top or bottom slice from the original image?
if (source_ratio < crop_ratio) { if (sourceAspectRatio <= cropAspectRatio) {
this.crop_width.value = this.original_image.naturalWidth; this.crop_width.value = source.naturalWidth;
this.crop_height.value = Math.floor(this.original_image.naturalHeight / crop_ratio); this.crop_height.value = Math.floor(source.naturalWidth / cropAspectRatio);
this.source_x.value = "0"; this.source_x.value = "0";
this.source_y.value = current.crop_method.indexOf("t") !== -1 ? "0" : this.original_image.naturalHeight - this.crop_height.value; this.source_y.value = cropMethod.indexOf("t") !== -1 ? "0" : source.naturalHeight - this.crop_height.value;
} }
// Otherwise, take a vertical slice from the centre. // Otherwise, take a vertical slice from the centre.
else { else {
this.crop_width.value = Math.floor(this.original_image.naturalHeight * crop_ratio); this.crop_width.value = Math.floor(source.naturalHeight * cropAspectRatio);
this.crop_height.value = this.original_image.naturalHeight; this.crop_height.value = source.naturalHeight;
this.source_x.value = Math.floor((this.original_image.naturalWidth - this.crop_width.value) / 2); this.source_x.value = Math.floor((source.naturalWidth - this.crop_width.value) / 2);
this.source_y.value = "0"; this.source_y.value = "0";
} }
} }
}
setPositionFormValues() {
let current = this.thumbnail_select.options[this.thumbnail_select.selectedIndex].dataset;
if (typeof current.crop_region === "undefined") {
let aspectRatio = current.crop_width / current.crop_height;
this.setDefaultCrop(aspectRatio, current.crop_method);
} else { } else {
var region = current.crop_region.split(','); let region = current.crop_region.split(',');
this.crop_width.value = region[0]; this.crop_width.value = region[0];
this.crop_height.value = region[1]; this.crop_height.value = region[1];
this.source_x.value = region[2]; this.source_x.value = region[2];
this.source_y.value = region[3]; this.source_y.value = region[3];
} }
};
CropEditor.prototype.showContainer = function() { this.crop_width.min = 1;
this.container.style.display = "block"; this.crop_height.min = 1;
this.setInputValues(); this.source_x.min = 0;
this.positionBoundary(); this.source_y.min = 0;
let source = this.original_image;
this.crop_width.max = source.naturalWidth;
this.crop_height.max = source.naturalHeight;
this.source_x.max = source.naturalWidth - 1;
this.source_y.max = source.naturalHeight - 1;
this.crop_constrain_label.textContent = `Constrain proportions (${current.crop_width} × ${current.crop_height})`;
} }
CropEditor.prototype.save = function() { showContainer() {
var current = this.thumbnail_select.options[this.thumbnail_select.selectedIndex].dataset; this.container.style.display = '';
var payload = { this.setPositionFormValues();
this.positionBoundary();
this.addEvents();
}
save() {
let current = this.thumbnail_select.options[this.thumbnail_select.selectedIndex].dataset;
let payload = {
thumb_width: current.crop_width, thumb_width: current.crop_width,
thumb_height: current.crop_height, thumb_height: current.crop_height,
crop_method: current.crop_method, crop_method: current.crop_method,
@@ -146,16 +196,16 @@ CropEditor.prototype.save = function() {
source_x: this.source_x.value, source_x: this.source_x.value,
source_y: this.source_y.value source_y: this.source_y.value
}; };
var req = HttpRequest("post", this.parent.action + "?id=" + this.opt.asset_id + "&updatethumb", let req = HttpRequest("post", this.opt.submitUrl + "?id=" + this.opt.asset_id + "&updatethumb",
"data=" + encodeURIComponent(JSON.stringify(payload)), function(response) { "data=" + encodeURIComponent(JSON.stringify(payload)), function(response) {
this.opt.after_save(response); this.opt.after_save(response);
this.hide(); this.hide();
}.bind(this)); }.bind(this));
}; }
CropEditor.prototype.show = function() { show() {
if (typeof this.container === "undefined") { if (typeof this.container === "undefined") {
this.buildContainer(); this.initDOM();
} }
// Defer showing and positioning until image is loaded. // Defer showing and positioning until image is loaded.
@@ -163,56 +213,166 @@ CropEditor.prototype.show = function() {
if (this.original_image.naturalWidth > 0) { if (this.original_image.naturalWidth > 0) {
this.showContainer(); this.showContainer();
} else { } else {
this.original_image.addEventListener("load", function() { this.original_image.addEventListener("load", event => this.showContainer());
this.showContainer(); }
}.bind(this));
} }
};
CropEditor.prototype.hide = function() { hide() {
this.container.style.display = "none"; this.container.style.display = "none";
}; }
CropEditor.prototype.addEvents = function(event) { addEvents(event) {
var drag_target = document.getElementById(opt.drag_target); let cropTarget = this.image_container;
drag_target.addEventListener('dragstart', this.dragStart); cropTarget.addEventListener('mousedown', this.cropSelectionStart.bind(this));
drag_target.addEventListener('drag', this.drag); cropTarget.addEventListener('mousemove', this.cropSelection.bind(this));
drag_target.addEventListener('dragend', this.dragEnd); cropTarget.addEventListener('mouseup', this.cropSelectionEnd.bind(this));
}; // cropTarget.addEventListener('mouseout', this.cropSelectionEnd.bind(this));
CropEditor.prototype.dragStart = function(event) { this.original_image.addEventListener('mousedown', event => {return false});
console.log(event); this.original_image.addEventListener('dragstart', event => {return false});
event.preventDefault();
};
CropEditor.prototype.dragEnd = function(event) { let moveTarget = this.crop_boundary;
console.log(event); moveTarget.addEventListener('mousedown', this.moveSelectionStart.bind(this));
}; moveTarget.addEventListener('mousemove', this.moveSelection.bind(this));
moveTarget.addEventListener('mouseup', this.moveSelectionEnd.bind(this));
CropEditor.prototype.drag = function(event) { window.addEventListener('resize', this.positionBoundary.bind(this));
console.log(event); }
};
CropEditor.prototype.toggleCropButton = function() { cropSelectionStart(event) {
var current = this.thumbnail_select.options[this.thumbnail_select.selectedIndex].dataset; if (this.isMoving) {
return false;
}
let dragStartX = event.x - this.image_container.offsetLeft;
let dragStartY = event.y - this.image_container.offsetTop;
if (dragStartX > this.original_image.clientWidth ||
dragStartY > this.original_image.clientHeight) {
return;
}
this.isDragging = true;
this.dragStartX = dragStartX;
this.dragStartY = dragStartY;
}
cropSelectionEnd(event) {
this.isDragging = false;
this.handleCropSelectionEvent(event);
}
cropSelection(event) {
this.handleCropSelectionEvent(event);
}
getScaleFactor() {
return this.original_image.naturalWidth / this.original_image.clientWidth;
}
handleCropSelectionEvent(event) {
if (!this.isDragging) {
return;
}
this.dragEndX = event.x - this.image_container.offsetLeft;
this.dragEndY = event.y - this.image_container.offsetTop;
let scaleFactor = this.getScaleFactor();
this.source_x.value = Math.ceil(Math.min(this.dragStartX, this.dragEndX) * scaleFactor);
this.source_y.value = Math.ceil(Math.min(this.dragStartY, this.dragEndY) * scaleFactor);
let width = Math.ceil(Math.abs(this.dragEndX - this.dragStartX) * scaleFactor);
this.crop_width.value = Math.min(width, this.original_image.naturalWidth - this.source_x.value);
let height = Math.ceil(Math.abs(this.dragEndY - this.dragStartY) * scaleFactor);
this.crop_height.value = Math.min(height, this.original_image.naturalHeight - this.source_y.value);
if (this.crop_constrain.checked) {
let current = this.thumbnail_select.options[this.thumbnail_select.selectedIndex].dataset;
let currentAspectRatio = parseInt(this.crop_width.value) / parseInt(this.crop_height.value);
let targetAspectRatio = current.crop_width / current.crop_height;
if (Math.abs(currentAspectRatio - targetAspectRatio) > 0.001) {
// Landscape?
if (targetAspectRatio > 1.0) {
let height = Math.ceil(this.crop_width.value / targetAspectRatio);
if (parseInt(this.source_y.value) + height > this.original_image.naturalHeight) {
height = this.original_image.naturalHeight - this.source_y.value;
}
this.crop_width.value = height * targetAspectRatio;
this.crop_height.value = height;
}
// Portrait?
else {
let width = Math.ceil(this.crop_height.value * targetAspectRatio);
if (parseInt(this.source_x.value) + width > this.original_image.naturalWidth) {
width = this.original_image.naturalWidth - this.source_x.value;
}
this.crop_width.value = width;
this.crop_height.value = width / targetAspectRatio;
}
}
}
this.positionBoundary();
}
handleCropMoveEvent(event) {
if (!this.isMoving) {
return;
}
this.dragEndX = event.x - this.crop_boundary.offsetLeft;
this.dragEndY = event.y - this.crop_boundary.offsetTop;
let scaleFactor = this.getScaleFactor();
let x = parseInt(this.source_x.value) + Math.ceil((this.dragEndX - this.dragStartX) * scaleFactor);
if (x + parseInt(this.crop_width.value) > this.original_image.naturalWidth) {
x += this.original_image.naturalWidth - (x + parseInt(this.crop_width.value));
}
this.source_x.value = Math.max(x, 0);
let y = parseInt(this.source_y.value) + Math.ceil((this.dragEndY - this.dragStartY) * scaleFactor);
if (y + parseInt(this.crop_height.value) > this.original_image.naturalHeight) {
y += this.original_image.naturalHeight - (y + parseInt(this.crop_height.value));
}
this.source_y.value = Math.max(y, 0);
this.positionBoundary();
}
moveSelectionStart(event) {
if (this.isDragging) {
return false;
}
this.isMoving = true;
this.dragStartX = event.x - this.crop_boundary.offsetLeft;
this.dragStartY = event.y - this.crop_boundary.offsetTop;
}
moveSelectionEnd(event) {
this.isMoving = false;
this.handleCropMoveEvent(event);
}
moveSelection(event) {
this.handleCropMoveEvent(event);
}
toggleCropButton() {
let current = this.thumbnail_select.options[this.thumbnail_select.selectedIndex].dataset;
this.edit_crop_button.style.display = typeof current.crop_method === "undefined" ? "none" : ""; this.edit_crop_button.style.display = typeof current.crop_method === "undefined" ? "none" : "";
}; }
CropEditor.prototype.positionBoundary = function(event) { positionBoundary(event) {
var source_x = parseInt(this.source_x.value), let scaleFactor = this.getScaleFactor();
source_y = parseInt(this.source_y.value), crop_boundary.style.left = parseInt(this.source_x.value) / scaleFactor + "px";
crop_width = parseInt(this.crop_width.value), crop_boundary.style.top = parseInt(this.source_y.value) / scaleFactor + "px";
crop_height = parseInt(this.crop_height.value), crop_boundary.style.width = parseInt(this.crop_width.value) / scaleFactor + "px";
real_width = this.original_image.naturalWidth, crop_boundary.style.height = parseInt(this.crop_height.value) / scaleFactor + "px";
real_height = this.original_image.naturalHeight, }
scaled_width = this.original_image.clientWidth, }
scaled_height = this.original_image.clientHeight;
var width_scale = scaled_width / real_width,
height_scale = scaled_height / real_height;
crop_boundary.style.left = (this.source_x.value) * width_scale + "px";
crop_boundary.style.top = (this.source_y.value) * height_scale + "px";
crop_boundary.style.width = (this.crop_width.value) * width_scale + "px";
crop_boundary.style.height = (this.crop_height.value) * height_scale + "px";
};

View File

@@ -53,13 +53,13 @@ function enableTouchNavigation() {
var target = document.getElementById("previous_photo").href; var target = document.getElementById("previous_photo").href;
if (target) { if (target) {
event.preventDefault(); event.preventDefault();
document.location.href = target; document.location.href = target + '#photo_frame';
} }
} else { } else {
var target = document.getElementById("next_photo").href; var target = document.getElementById("next_photo").href;
if (target) { if (target) {
event.preventDefault(); event.preventDefault();
document.location.href = target; document.location.href = target + '#photo_frame';
} }
} }
} }

View File

@@ -1,4 +1,5 @@
function UploadQueue(options) { class UploadQueue {
constructor(options) {
this.queue = options.queue_element; this.queue = options.queue_element;
this.preview_area = options.preview_area; this.preview_area = options.preview_area;
this.upload_progress = []; this.upload_progress = [];
@@ -7,143 +8,162 @@ function UploadQueue(options) {
this.addEvents(); this.addEvents();
} }
UploadQueue.prototype.addEvents = function() { addEvents() {
var that = this; this.queue.addEventListener('change', event => {
that.queue.addEventListener('change', function() { this.showSpinner(this.queue, "Generating previews (not uploading yet!)");
that.showSpinner(that.queue, "Generating previews (not uploading yet!)"); this.clearPreviews();
that.clearPreviews(); for (let i = 0; i < this.queue.files.length; i++) {
for (var i = 0; i < that.queue.files.length; i++) { const callback = (i !== this.queue.files.length - 1) ? null : () => {
var callback = (i !== that.queue.files.length - 1) ? null : function() { this.hideSpinner();
that.hideSpinner(); this.submit.disabled = false;
that.submit.disabled = false;
};
that.addPreviewForFile(that.queue.files[i], i, callback);
};
});
that.submit.addEventListener('click', function(e) {
e.preventDefault();
that.process();
});
this.submit.disabled = true;
}; };
UploadQueue.prototype.clearPreviews = function() { if (this.queue.files[0].name.toUpperCase().endsWith(".HEIC")) {
alert('Sorry, the HEIC image format is not supported.\nPlease convert your photos to JPEG before uploading.');
this.hideSpinner();
this.submit.disabled = false;
break;
}
this.addPreviewBoxForQueueSlot(i);
this.addPreviewForFile(this.queue.files[i], i, callback);
};
});
this.submit.addEventListener('click', event => {
event.preventDefault();
this.process();
});
this.submit.disabled = true;
}
clearPreviews() {
this.preview_area.innerHTML = ''; this.preview_area.innerHTML = '';
this.submit.disabled = true; this.submit.disabled = true;
this.current_upload_index = -1; this.current_upload_index = -1;
} }
UploadQueue.prototype.addPreviewForFile = function(file, index, callback) { addPreviewBoxForQueueSlot(index) {
const preview_box = document.createElement('div');
preview_box.id = 'upload_preview_' + index;
this.preview_area.appendChild(preview_box);
}
addPreviewForFile(file, index, callback) {
if (!file) { if (!file) {
return false; return false;
} }
var preview = document.createElement('img'); const preview = document.createElement('canvas');
preview.title = file.name; preview.title = file.name;
preview.style.maxHeight = '150px';
var preview_box = document.createElement('div'); const preview_box = document.getElementById('upload_preview_' + index);
preview_box.id = 'upload_preview_' + index;
preview_box.appendChild(preview); preview_box.appendChild(preview);
var reader = new FileReader(); const reader = new FileReader();
var that = this; reader.addEventListener('load', event => {
var appendMe = function() { const original = document.createElement('img');
preview.src = reader.result; original.src = reader.result;
if (callback) {
preview.addEventListener('load', function() {
callback();
});
}
that.preview_area.appendChild(preview_box);
};
var waitForMe = function() {
var previews = that.preview_area.childNodes;
if (previews.length === 0 || previews[previews.length - 1].id === 'upload_preview_' + (index - 1)) {
appendMe();
} else {
setTimeout(waitForMe, 10);
}
};
reader.addEventListener('load', waitForMe, false);
reader.readAsDataURL(file);
};
UploadQueue.prototype.process = function() { original.addEventListener('load', function() {
// Preparation: make canvas size proportional to the original image.
preview.height = 150;
preview.width = preview.height * (original.width / original.height);
// First pass: resize to 50% on temp canvas.
const temp = document.createElement('canvas'),
tempCtx = temp.getContext('2d');
temp.width = original.width * 0.5;
temp.height = original.height * 0.5;
tempCtx.drawImage(original, 0, 0, temp.width, temp.height);
// Second pass: resize again on temp canvas.
tempCtx.drawImage(temp, 0, 0, temp.width * 0.5, temp.height * 0.5);
// Final pass: resize to desired size on preview canvas.
const context = preview.getContext('2d');
context.drawImage(temp, 0, 0, temp.width * 0.5, temp.height * 0.5,
0, 0, preview.width, preview.height);
if (callback) {
callback();
}
});
}, false);
reader.readAsDataURL(file);
}
process() {
this.showSpinner(this.submit, "Preparing to upload files..."); this.showSpinner(this.submit, "Preparing to upload files...");
if (this.queue.files.length > 0) { if (this.queue.files.length > 0) {
this.submit.disabled = true; this.submit.disabled = true;
this.nextFile(); this.nextFile();
} }
}; }
UploadQueue.prototype.nextFile = function() { nextFile() {
var files = this.queue.files; const files = this.queue.files;
var i = ++this.current_upload_index; const i = ++this.current_upload_index;
if (i === files.length) { if (i === files.length) {
this.hideSpinner(); this.hideSpinner();
} else { } else {
this.setSpinnerLabel("Uploading file " + (i + 1) + " out of " + files.length); this.setSpinnerLabel("Uploading file " + (i + 1) + " out of " + files.length);
this.sendFile(files[i], i, function() { this.sendFile(files[i], i, this.nextFile);
this.nextFile(); }
});
} }
};
UploadQueue.prototype.sendFile = function(file, index, callback) { sendFile(file, index, callback) {
// Prepare the request. const request = new XMLHttpRequest();
var that = this; request.addEventListener('error', event => {
var request = new XMLHttpRequest(); this.updateProgress(index, -1);
request.addEventListener('error', function(event) {
that.updateProgress(index, -1);
}); });
request.addEventListener('progress', function(event) { request.addEventListener('progress', event => {
that.updateProgress(index, event.loaded / event.total); this.updateProgress(index, event.loaded / event.total);
}); });
request.addEventListener('load', function(event) { request.addEventListener('load', event => {
that.updateProgress(index, 1); this.updateProgress(index, 1);
if (request.responseText !== null && request.status === 200) { if (request.responseText !== null && request.status === 200) {
var obj = JSON.parse(request.responseText); const obj = JSON.parse(request.responseText);
if (obj.error) { if (obj.error) {
alert(obj.error); alert(obj.error);
return; return;
} }
else if (callback) { else if (callback) {
callback.call(that, obj); callback.call(this, obj);
} }
} }
}); });
var data = new FormData(); const data = new FormData();
data.append('uploads', file, file.name); data.append('uploads', file, file.name);
request.open('POST', this.upload_url, true); request.open('POST', this.upload_url, true);
request.send(data); request.send(data);
}; }
UploadQueue.prototype.addProgressBar = function(index) { addProgressBar(index) {
if (index in this.upload_progress) { if (index in this.upload_progress) {
return; return;
} }
var progress_container = document.createElement('div'); const progress_container = document.createElement('div');
progress_container.className = 'progress'; progress_container.className = 'progress';
var progress = document.createElement('div'); const progress = document.createElement('div');
progress_container.appendChild(progress); progress_container.appendChild(progress);
var preview_box = document.getElementById('upload_preview_' + index); const preview_box = document.getElementById('upload_preview_' + index);
preview_box.appendChild(progress_container); preview_box.appendChild(progress_container);
this.upload_progress[index] = progress; this.upload_progress[index] = progress;
}; }
UploadQueue.prototype.updateProgress = function(index, progress) { updateProgress(index, progress) {
if (!(index in this.upload_progress)) { if (!(index in this.upload_progress)) {
this.addProgressBar(index); this.addProgressBar(index);
} }
var bar = this.upload_progress[index]; const bar = this.upload_progress[index];
if (progress >= 0) { if (progress >= 0) {
bar.style.width = Math.ceil(progress * 100) + '%'; bar.style.width = Math.ceil(progress * 100) + '%';
@@ -153,9 +173,9 @@ UploadQueue.prototype.updateProgress = function(index, progress) {
bar.className = "error"; bar.className = "error";
} }
} }
}; }
UploadQueue.prototype.showSpinner = function(sibling, label) { showSpinner(sibling, label) {
if (this.spinner) { if (this.spinner) {
return; return;
} }
@@ -170,15 +190,15 @@ UploadQueue.prototype.showSpinner = function(sibling, label) {
this.spinner_label.innerHTML = label; this.spinner_label.innerHTML = label;
sibling.parentNode.appendChild(this.spinner_label); sibling.parentNode.appendChild(this.spinner_label);
} }
}; }
UploadQueue.prototype.setSpinnerLabel = function(label) { setSpinnerLabel(label) {
if (this.spinner_label) { if (this.spinner_label) {
this.spinner_label.innerHTML = label; this.spinner_label.innerHTML = label;
} }
} }
UploadQueue.prototype.hideSpinner = function() { hideSpinner() {
if (this.spinner) { if (this.spinner) {
this.spinner.parentNode.removeChild(this.spinner); this.spinner.parentNode.removeChild(this.spinner);
this.spinner = null; this.spinner = null;
@@ -187,4 +207,5 @@ UploadQueue.prototype.hideSpinner = function() {
this.spinner_label.parentNode.removeChild(this.spinner_label); this.spinner_label.parentNode.removeChild(this.spinner_label);
this.spinner_label = null; this.spinner_label = null;
} }
}; }
}

1
public/vendor Symbolic link
View File

@@ -0,0 +1 @@
../vendor/

0
server Normal file → Executable file
View File

View File

@@ -1,36 +0,0 @@
<?php
/*****************************************************************************
* AdminBar.php
* Defines the AdminBar class.
*
* Kabuki CMS (C) 2013-2015, Aaron van Geffen
*****************************************************************************/
class AdminBar extends SubTemplate
{
private $extra_items = [];
protected function html_content()
{
echo '
<div id="admin_bar">
<ul>
<li><a href="', BASEURL, '/managetags/">Tags</a></li>
<li><a href="', BASEURL, '/manageusers/">Users</a></li>
<li><a href="', BASEURL, '/manageerrors/">Errors [', ErrorLog::getCount(), ']</a></li>';
foreach ($this->extra_items as $item)
echo '
<li><a href="', $item[0], '">', $item[1], '</a></li>';
echo '
<li><a href="', BASEURL, '/logout/">Log out [', Registry::get('user')->getFullName(), ']</a></li>
</ul>
</div>';
}
public function appendItem($url, $caption)
{
$this->extra_items[] = [$url, $caption];
}
}

View File

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

View File

@@ -6,8 +6,13 @@
* Kabuki CMS (C) 2013-2016, Aaron van Geffen * Kabuki CMS (C) 2013-2016, Aaron van Geffen
*****************************************************************************/ *****************************************************************************/
class AlbumHeaderBox extends SubTemplate class AlbumHeaderBox extends Template
{ {
private $back_link_title;
private $back_link;
private $description;
private $title;
public function __construct($title, $description, $back_link, $back_link_title) public function __construct($title, $description, $back_link, $back_link_title)
{ {
$this->title = $title; $this->title = $title;
@@ -16,11 +21,13 @@ class AlbumHeaderBox extends SubTemplate
$this->back_link_title = $back_link_title; $this->back_link_title = $back_link_title;
} }
protected function html_content() public function html_main()
{ {
echo ' echo '
<div class="album_title_box"> <div class="album_title_box">
<a class="back_button" href="', $this->back_link, '" title="', $this->back_link_title, '">&larr;</a> <a class="back_button" href="', $this->back_link, '" title="', $this->back_link_title, '">
<i class="bi bi-arrow-left"></i>
</a>
<div> <div>
<h2>', $this->title, '</h2>'; <h2>', $this->title, '</h2>';

View File

@@ -6,7 +6,7 @@
* Kabuki CMS (C) 2013-2015, Aaron van Geffen * Kabuki CMS (C) 2013-2015, Aaron van Geffen
*****************************************************************************/ *****************************************************************************/
class AlbumIndex extends SubTemplate class AlbumIndex extends Template
{ {
protected $albums; protected $albums;
protected $show_edit_buttons; protected $show_edit_buttons;
@@ -14,7 +14,8 @@ class AlbumIndex extends SubTemplate
protected $row_limit = 1000; protected $row_limit = 1000;
const TILE_WIDTH = 400; const TILE_WIDTH = 400;
const TILE_HEIGHT = 267; const TILE_HEIGHT = 300;
const TILE_RATIO = self::TILE_WIDTH / self::TILE_HEIGHT;
public function __construct(array $albums, $show_edit_buttons = false, $show_labels = true) public function __construct(array $albums, $show_edit_buttons = false, $show_labels = true)
{ {
@@ -23,24 +24,25 @@ class AlbumIndex extends SubTemplate
$this->show_labels = $show_labels; $this->show_labels = $show_labels;
} }
protected function html_content() public function html_main()
{ {
echo ' echo '
<div class="tiled_grid">'; <div class="container album-index">
<div class="row g-5">';
foreach (array_chunk($this->albums, 3) as $photos) foreach ($this->albums as $album)
{ $this->renderAlbum($album);
echo '
<div class="tiled_row">';
foreach ($photos as $album)
{
$color = isset($album['thumbnail']) ? $album['thumbnail']->bestColor() : 'ccc';
if ($color == 'FFFFFF')
$color = 'ccc';
echo ' echo '
<div class="landscape" style="border-color: #', $color, '">'; </div>
</div>';
}
private function renderAlbum(array $album)
{
echo '
<div class="col-md-6 col-xl-4">
<div class="polaroid landscape" style="aspect-ratio: 1.12">';
if ($this->show_edit_buttons) if ($this->show_edit_buttons)
echo ' echo '
@@ -50,11 +52,28 @@ class AlbumIndex extends SubTemplate
<a href="', $album['link'], '">'; <a href="', $album['link'], '">';
if (isset($album['thumbnail'])) if (isset($album['thumbnail']))
{
$thumbs = [];
foreach ([1, 2] as $factor)
$thumbs[$factor] = $album['thumbnail']->getThumbnailUrl(
static::TILE_WIDTH * $factor, static::TILE_HEIGHT * $factor, true, true);
foreach (['normal-photo', 'blur-photo'] as $className)
{
echo ' echo '
<img src="', $album['thumbnail']->getThumbnailUrl(static::TILE_WIDTH, static::TILE_HEIGHT, true, true), '" alt="">'; <img alt="" src="', $thumbs[1], '"' . (isset($thumbs[2]) ?
' srcset="' . $thumbs[2] . ' 2x"' : '') .
' class="', $className, '"' .
' alt="" style="aspect-ratio: ', self::TILE_RATIO, '">';
}
}
else else
{
echo ' echo '
<img src="', BASEURL, '/images/nothumb.png" alt="">'; <img alt="" src="', BASEURL, '/images/nothumb.svg"',
' class="placeholder-image"',
' style="aspect-ratio: ', self::TILE_RATIO, '; object-fit: unset">';
}
if ($this->show_labels) if ($this->show_labels)
echo ' echo '
@@ -62,14 +81,7 @@ class AlbumIndex extends SubTemplate
echo ' echo '
</a> </a>
</div>'; </div>
}
echo '
</div>';
}
echo '
</div>'; </div>';
} }
} }

View File

@@ -6,19 +6,30 @@
* Kabuki CMS (C) 2013-2015, Aaron van Geffen * Kabuki CMS (C) 2013-2015, Aaron van Geffen
*****************************************************************************/ *****************************************************************************/
class Alert extends SubTemplate class Alert extends Template
{ {
private $_type;
private $_message;
private $_title;
public function __construct($title = '', $message = '', $type = 'alert') public function __construct($title = '', $message = '', $type = 'alert')
{ {
$this->_title = $title; $this->_title = $title;
$this->_message = $message; $this->_message = $message;
$this->_type = in_array($type, ['alert', 'error', 'success', 'info']) ? $type : 'alert'; $this->_type = in_array($type, ['success', 'info', 'warning', 'danger']) ? $type : 'info';
} }
protected function html_content() public function html_main()
{ {
echo ' echo '
<div class="alert', $this->_type != 'alert' ? ' alert-' . $this->_type : '', '">', (!empty($this->_title) ? ' <div class="alert', $this->_type !== 'alert' ? ' alert-' . $this->_type : '', '">'
<strong>' . $this->_title . '</strong><br>' : ''), $this->_message, '</div>'; , !empty($this->_title) ? '<strong>' . $this->_title . '</strong><br>' : '', '
', $this->_message,
$this->additional_alert_content(), '
</div>';
}
protected function additional_alert_content()
{
} }
} }

View File

@@ -0,0 +1,36 @@
<?php
/*****************************************************************************
* AssetManagementWrapper.php
* Defines asset management wrapper template.
*
* Kabuki CMS (C) 2013-2015, Aaron van Geffen
*****************************************************************************/
class AssetManagementWrapper extends Template
{
public function html_main()
{
echo '
<form action="" method="post">';
foreach ($this->_subtemplates as $template)
$template->html_main();
echo '
</form>
<script type="text/javascript" defer="defer">
const allAreSelected = () => {
return document.querySelectorAll(".asset_select").length ===
document.querySelectorAll(".asset_select:checked").length;
};
const selectAll = document.getElementById("selectall");
selectAll.addEventListener("change", event => {
const newSelectedState = !allAreSelected();
document.querySelectorAll(".asset_select").forEach(el => {
el.checked = newSelectedState;
});
});
</script>';
}
}

View File

@@ -8,24 +8,26 @@
class DummyBox extends SubTemplate class DummyBox extends SubTemplate
{ {
public function __construct($title = '', $content = '', $class = '') protected $_content;
public function __construct($title = '', $content = '', $class = null)
{ {
$this->_title = $title; parent::__construct($title);
$this->_content = $content; $this->_content = $content;
$this->_class = $class;
if (isset($class))
$this->_class .= $class;
} }
protected function html_content() protected function html_content()
{ {
if ($this->_title)
echo ' echo '
<div class="boxed_content', $this->_class ? ' ' . $this->_class : '', '">', $this->_title ? ' <h2>', $this->_title, '</h2>';
<h2>' . $this->_title . '</h2>' : '', '
', $this->_content; echo $this->_content;
foreach ($this->_subtemplates as $template) foreach ($this->_subtemplates as $template)
$template->html_main(); $template->html_main();
echo '
</div>';
} }
} }

View File

@@ -6,40 +6,49 @@
* Kabuki CMS (C) 2013-2015, Aaron van Geffen * Kabuki CMS (C) 2013-2015, Aaron van Geffen
*****************************************************************************/ *****************************************************************************/
class EditAssetForm extends SubTemplate class EditAssetForm extends Template
{ {
private $allAlbums;
private $asset; private $asset;
private $currentAlbumId;
private $thumbs; private $thumbs;
public function __construct(Asset $asset, array $thumbs = []) public function __construct(array $options)
{ {
$this->asset = $asset; $this->allAlbums = $options['allAlbums'];
$this->thumbs = $thumbs; $this->asset = $options['asset'];
$this->currentAlbumId = $options['currentAlbumId'];
$this->thumbs = $options['thumbs'];
} }
protected function html_content() public function html_main()
{ {
echo ' echo '
<form id="asset_form" action="" method="post" enctype="multipart/form-data"> <form id="asset_form" action="" method="post" enctype="multipart/form-data">
<div class="boxed_content" style="margin-bottom: 2%"> <div class="content-box">
<div style="float: right"> <div class="float-end">
<a class="btn btn-red" href="', BASEURL, '/editasset/?id=', $this->asset->getId(), '&delete">Delete asset</a> <a class="btn btn-danger" href="', $this->asset->getDeleteUrl(), '&',
<input type="submit" value="Save asset data"> Session::getSessionTokenKey(), '=', Session::getSessionToken(),
'" onclick="return confirm(\'Are you sure you want to delete this asset?\');">',
'Delete asset</a>
<a class="btn btn-light" href="', $this->asset->getPageUrl(), '#photo_frame">View asset</a>
<button class="btn btn-primary" type="submit">Save asset data</button>
</div> </div>
<h2>Edit asset \'', $this->asset->getTitle(), '\' (', $this->asset->getFilename(), ')</h2> <h2 class="mb-0">Edit asset \'', $this->asset->getTitle(), '\'</h2>
</div>'; </div>';
$this->section_replace(); $this->section_replace();
echo ' echo '
<div style="float: left; width: 60%; margin-right: 2%">'; <div class="row">
<div class="col-md-8">';
$this->section_key_info(); $this->section_key_info();
$this->section_asset_meta(); $this->section_asset_meta();
echo ' echo '
</div> </div>
<div style="float: left; width: 38%;">'; <div class="col-md-4">';
if (!empty($this->thumbs)) if (!empty($this->thumbs))
$this->section_thumbnails(); $this->section_thumbnails();
@@ -52,6 +61,7 @@ class EditAssetForm extends SubTemplate
$this->section_crop_editor(); $this->section_crop_editor();
echo ' echo '
</div>
</form>'; </form>';
} }
@@ -59,38 +69,74 @@ class EditAssetForm extends SubTemplate
{ {
$date_captured = $this->asset->getDateCaptured(); $date_captured = $this->asset->getDateCaptured();
echo ' echo '
<div class="widget key_info"> <div class="content-box key_info">
<h3>Key info</h3> <h3>Key info</h3>
<dl>
<dt>Title</dt>
<dd><input type="text" name="title" maxlength="255" size="70" value="', $this->asset->getTitle(), '">
<dt>Date captured</dt> <div class="row mb-2">
<dd><input type="text" name="date_captured" size="30" value="', <label class="col-form-label col-sm-3">Album:</label>
$date_captured ? $date_captured->format('Y-m-d H:i:s') : '', '" placeholder="Y-m-d H:i:s"> <div class="col-sm">
<select class="form-select" name="id_album">';
<dt>Display priority</dt> foreach ($this->allAlbums as $id_album => $album)
<dd><input type="number" name="priority" min="0" max="100" step="1" value="', $this->asset->getPriority(), '"> echo '
</dl> <option value="', $id_album, '"',
$this->currentAlbumId == $id_album ? ' selected' : '',
'>', htmlspecialchars($album), '</option>';
echo '
</select>
</div>
</div>
<div class="row mb-2">
<label class="col-form-label col-sm-3">Title (internal):</label>
<div class="col-sm">
<input class="form-control" type="text" name="title" maxlength="255" size="70" value="', $this->asset->getTitle(), '">
</div>
</div>
<div class="row mb-2">
<label class="col-form-label col-sm-3">URL slug:</label>
<div class="col-sm">
<input class="form-control" type="text" name="slug" maxlength="255" size="70" value="', $this->asset->getSlug(), '">
</div>
</div>
<div class="row mb-2">
<label class="col-form-label col-sm-3">Date captured:</label>
<div class="col-sm">
<input class="form-control" type="datetime-local" step="1"
name="date_captured" size="30" placeholder="Y-m-d H:i:s" value="',
$date_captured ? $date_captured->format('Y-m-d H:i:s') : '', '">
</div>
</div>
<div class="row mb-2">
<label class="col-form-label col-sm-3">Display priority:</label>
<div class="col-sm-3">
<input class="form-control" type="number" name="priority" min="0" max="100" step="1" value="', $this->asset->getPriority(), '">
</div>
</div>
</div>'; </div>';
} }
protected function section_linked_tags() protected function section_linked_tags()
{ {
echo ' echo '
<div class="widget linked_tags" style="margin-top: 2%"> <div class="content-box linked_tags">
<h3>Linked tags</h3> <h3>Linked tags</h3>
<ul id="tag_list">'; <ul class="list-unstyled" id="tag_list">';
foreach ($this->asset->getTags() as $tag) foreach ($this->asset->getTags() as $tag)
{
if ($tag->kind === 'Album')
continue;
echo ' echo '
<li> <li>
<input class="tag_check" type="checkbox" name="tag[', $tag->id_tag, ']" id="linked_tag_', $tag->id_tag, '" title="Uncheck to delete" checked> <input class="tag_check" type="checkbox" name="tag[', $tag->id_tag, ']" id="linked_tag_', $tag->id_tag, '" title="Uncheck to delete" checked>
', $tag->tag, ' ', $tag->tag, '
</li>'; </li>';
}
echo ' echo '
<li id="new_tag_container"><input type="text" id="new_tag" placeholder="Type to link a new tag"></li> <li id="new_tag_container"><input class="form-control" type="text" id="new_tag" placeholder="Type to link a new tag"></li>
</ul> </ul>
</div> </div>
<script type="text/javascript" src="', BASEURL, '/js/ajax.js"></script> <script type="text/javascript" src="', BASEURL, '/js/ajax.js"></script>
@@ -131,14 +177,14 @@ class EditAssetForm extends SubTemplate
protected function section_thumbnails() protected function section_thumbnails()
{ {
echo ' echo '
<div class="widget linked_thumbs"> <div class="content-box linked_thumbs">
<h3>Thumbnails</h3> <h3>Thumbnails</h3>
View: <select id="thumbnail_src">'; View: <select class="form-select w-auto d-inline" id="thumbnail_src">';
foreach ($this->thumbs as $thumb) $first = INF;
foreach ($this->thumbs as $i => $thumb)
{ {
if (!$thumb['status']) $first = min($i, $first);
continue;
echo ' echo '
<option data-url="', $thumb['url'], '" data-crop_width="', $thumb['dimensions'][0], '" data-crop_height="', $thumb['dimensions'][1], '"', <option data-url="', $thumb['url'], '" data-crop_width="', $thumb['dimensions'][0], '" data-crop_height="', $thumb['dimensions'][1], '"',
@@ -168,18 +214,16 @@ class EditAssetForm extends SubTemplate
echo ' echo '
</select> </select>
<a id="thumbnail_link" href="', $this->thumbs[0]['url'], '" target="_blank"> <a id="thumbnail_link" href="', $this->thumbs[$first]['url'], '" target="_blank">
<img id="thumbnail" src="', $this->thumbs[0]['url'], '" alt="Thumbnail" style="width: 100%; height: auto;"> <img id="thumbnail" src="', $this->thumbs[$first]['url'], '" alt="Thumbnail" style="width: 100%; height: auto;">
</a> </a>
</div> </div>
<script type="text/javascript"> <script type="text/javascript" defer="defer">
setTimeout(function() { document.getElementById("thumbnail_src").addEventListener("change", event => {
document.getElementById("thumbnail_src").addEventListener("change", function(event) { let selection = event.target.options[event.target.selectedIndex];
var selection = event.target.options[event.target.selectedIndex];
document.getElementById("thumbnail_link").href = selection.dataset.url; document.getElementById("thumbnail_link").href = selection.dataset.url;
document.getElementById("thumbnail").src = selection.dataset.url; document.getElementById("thumbnail").src = selection.dataset.url;
}); });
}, 100);
</script>'; </script>';
} }
@@ -190,71 +234,70 @@ class EditAssetForm extends SubTemplate
echo ' echo '
<script type="text/javascript" src="', BASEURL, '/js/crop_editor.js"></script> <script type="text/javascript" src="', BASEURL, '/js/crop_editor.js"></script>
<script type="text/javascript"> <script type="text/javascript" defer="defer">
setTimeout(function() { let editor = new CropEditor({
var editor = new CropEditor({ submit_url: "', BASEURL, '/editasset/",
original_image_src: "', $this->asset->getUrl(), '", original_image_src: "', $this->asset->getUrl(), '",
editor_container_parent_id: "asset_form", editor_container_parent_id: "asset_form",
thumbnail_select_id: "thumbnail_src", thumbnail_select_id: "thumbnail_src",
drag_target: "drag_target", drag_target: ".crop_image_container",
asset_id: ', $this->asset->getId(), ', asset_id: ', $this->asset->getId(), ',
after_save: function(data) { after_save: function(data) {
// Update thumbnail // Update thumbnail
document.getElementById("thumbnail").src = data.url + "?" + (new Date()).getTime(); document.getElementById("thumbnail").src = data.url + "?" + (new Date()).getTime();
// Update select // Update select
var src = document.getElementById("thumbnail_src"); let src = document.getElementById("thumbnail_src");
src.options[src.selectedIndex].dataset.crop_region = data.value; let option = src.options[src.selectedIndex];
option.dataset.crop_region = data.value;
option.textContent = option.textContent.replace(/top|bottom|centre|slice/, "exact");
// TODO: update meta // TODO: update meta
} }
}); });
}, 100);
</script>'; </script>';
} }
protected function section_asset_meta() protected function section_asset_meta()
{ {
echo ' echo '
<div class="widget asset_meta" style="margin-top: 2%"> <div class="content-box asset_meta mt-2">
<h3>Asset meta data</h3> <h3>Asset meta data</h3>';
<ul>';
$i = -1; $i = 0;
foreach ($this->asset->getMeta() as $key => $meta) foreach ($this->asset->getMeta() as $key => $meta)
{ {
$i++;
echo ' echo '
<li> <div class="input-group">
<input type="text" name="meta_key[', $i, ']" value="', htmlentities($key), '"> <input type="text" class="form-control" name="meta_key[', $i, ']" value="', htmlspecialchars($key), '" placeholder="key">
<input type="text" name="meta_value[', $i, ']" value="', htmlentities($meta), '"> <input type="text" class="form-control" name="meta_value[', $i, ']" value="', htmlspecialchars($meta), '" placeholder="value">
</li>'; </div>';
$i++;
} }
echo ' echo '
<li> <div class="input-group">
<input type="text" name="meta_key[', $i + 1, ']" value=""> <input type="text" class="form-control" name="meta_key[', $i + 1, ']" value="" placeholder="key">
<input type="text" name="meta_value[', $i + 1, ']" value=""> <input type="text" class="form-control" name="meta_value[', $i + 1, ']" value="" placeholder="value">
</li> </div>
</ul> <div class="text-end mt-3">
<p><input type="submit" value="Save metadata"></p> <button class="btn btn-primary" type="submit">Save metadata</button>
</div>
</div>'; </div>';
} }
protected function section_replace() protected function section_replace()
{ {
echo ' echo '
<div class="widget replace_asset" style="margin-bottom: 2%; display: block"> <div class="content-box replace_asset mt-2">
<h3>Replace asset</h3> <h3>Replace asset</h3>
File: <input type="file" name="replacement"> File: <input class="form-control d-inline w-auto" type="file" name="replacement">
Target: <select name="replacement_target"> Target: <select class="form-select d-inline w-auto" name="replacement_target">
<option value="full">master file</option>'; <option value="full">master file</option>';
foreach ($this->thumbs as $thumb) foreach ($this->thumbs as $thumb)
{ {
if (!$thumb['status'])
continue;
echo ' echo '
<option value="thumb_', implode('x', $thumb['dimensions']); <option value="thumb_', implode('x', $thumb['dimensions']);
@@ -278,7 +321,7 @@ class EditAssetForm extends SubTemplate
echo ' crop'; echo ' crop';
} }
elseif ($thumb['custom_image']) elseif ($thumb['custom_image'])
echo ' (custom)'; echo ', custom';
echo ') echo ')
</option>'; </option>';
@@ -286,7 +329,7 @@ class EditAssetForm extends SubTemplate
echo ' echo '
</select> </select>
<input type="submit" value="Save asset"> <button class="btn btn-primary" type="submit">Save asset</button>
</div>'; </div>';
} }
} }

41
templates/ErrorPage.php Normal file
View File

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

View File

@@ -0,0 +1,57 @@
<?php
/*****************************************************************************
* FeaturedThumbnailManager.php
* Contains the featured thumbnail manager template.
*
* Kabuki CMS (C) 2013-2021, Aaron van Geffen
*****************************************************************************/
class FeaturedThumbnailManager extends SubTemplate
{
private $iterator;
private $currentThumbnailId;
public function __construct(AssetIterator $iterator, $currentThumbnailId)
{
$this->iterator = $iterator;
$this->currentThumbnailId = $currentThumbnailId;
}
protected function html_content()
{
echo '
<form action="" method="post">
<div class="row">
<div class="col-lg">
<h2>Select thumbnail</h2>
</div>
<div class="col-lg">';
foreach ($this->_subtemplates as $template)
$template->html_main();
echo '
</div>
<div class="col-lg-auto">
<button class="btn btn-primary" type="submit" name="changeThumbnail">Save thumbnail selection</button>
</div>
</div>
<ul id="featuredThumbnail">';
foreach ($this->iterator as $asset)
{
$image = $asset->getImage();
echo '
<li>
<input class="form-check-input" type="radio" name="featuredThumbnail" value="', $image->getId(), '"',
$this->currentThumbnailId == $image->getId() ? ' checked' : '', '>
<img src="', $image->getThumbnailUrl(150, 100, 'top'), '" alt="" title="', $image->getTitle(), '" onclick="this.parentNode.children[0].checked = true">
</li>';
}
echo '
</ul>
<input type="hidden" name="', Session::getSessionTokenKey(), '" value="', Session::getSessionToken(), '">
</form>';
}
}

View File

@@ -11,19 +11,25 @@ class ForgotPasswordForm extends SubTemplate
protected function html_content() protected function html_content()
{ {
echo ' echo '
<div class="boxed_content"> <h1>Password reset procedure</h1>';
<h2>Password reset procedure</h2>';
foreach ($this->_subtemplates as $template) foreach ($this->_subtemplates as $template)
$template->html_main(); $template->html_main();
echo ' echo '
<p>Please fill in the email address you used to sign up in the form below. You will be sent a reset link to your email address.</p> <p class="mt-3">Please fill in the email address you used to sign up in the form below. We will send a reset link to your email address.</p>
<form class="form-horizontal" action="', BASEURL, '/resetpassword/?step=1" method="post"> <form action="', BASEURL, '/resetpassword/?step=1" method="post">
<label class="control-label" for="field_emailaddress">E-mail address:</label><br> <div class="row">
<input type="text" id="field_emailaddress" name="emailaddress"> <label class="col-sm-2 col-form-label" for="field_emailaddress">E-mail address:</label>
<div class="col-sm-4">
<input type="text" class="form-control" id="field_emailaddress" name="emailaddress">
</div>
</div>
<div class="row mt-3">
<div class="offset-sm-2 col-sm-2">
<button type="submit" class="btn btn-primary">Send mail</button> <button type="submit" class="btn btn-primary">Send mail</button>
</form> </div>
</div>'; </div>
</form>';
} }
} }

View File

@@ -3,159 +3,273 @@
* FormView.php * FormView.php
* Contains the form template. * Contains the form template.
* *
* Kabuki CMS (C) 2013-2015, Aaron van Geffen * Kabuki CMS (C) 2013-2023, Aaron van Geffen
*****************************************************************************/ *****************************************************************************/
class FormView extends SubTemplate class FormView extends SubTemplate
{ {
private $form;
private array $data;
private array $missing;
private $title;
public function __construct(Form $form, $title = '') public function __construct(Form $form, $title = '')
{ {
$this->form = $form;
$this->title = $title; $this->title = $title;
$this->request_url = $form->request_url;
$this->request_method = $form->request_method;
$this->fields = $form->getFields();
$this->missing = $form->getMissing();
$this->data = $form->getData();
$this->content_above = $form->content_above;
$this->content_below = $form->content_below;
} }
protected function html_content($exclude = [], $include = []) protected function html_content()
{ {
if (!empty($this->title)) if (!empty($this->title))
echo ' echo '
<div id="journal_title"> <h1>', $this->title, '</h1>';
<h3>', $this->title, '</h3>
</div>
<div id="inner">';
foreach ($this->_subtemplates as $template) foreach ($this->_subtemplates as $template)
$template->html_main(); $template->html_main();
echo ' echo '
<form action="', $this->request_url, '" method="', $this->request_method, '" enctype="multipart/form-data">'; <form action="', $this->form->request_url, '" method="', $this->form->request_method, '" enctype="multipart/form-data">';
if (isset($this->content_above)) if (isset($this->form->before_fields))
echo $this->content_above; echo $this->form->before_fields;
echo ' $this->missing = $this->form->getMissing();
<dl>'; $this->data = $this->form->getData();
foreach ($this->fields as $field_id => $field) foreach ($this->form->getFields() as $field_id => $field)
{ {
// Either we have a blacklist
if (!empty($exclude) && in_array($field_id, $exclude))
continue;
// ... or a whitelist
elseif (!empty($include) && !in_array($field_id, $include))
continue;
// ... or neither (ha)
$this->renderField($field_id, $field); $this->renderField($field_id, $field);
} }
echo ' if (isset($this->form->after_fields))
</dl> echo $this->form->after_fields;
<input type="hidden" name="', Session::getSessionTokenKey(), '" value="', Session::getSessionToken(), '">
<div style="clear: both">
<button type="submit" class="btn btn-primary">Save information</button>';
if (isset($this->content_below))
echo ' echo '
', $this->content_below; <input type="hidden" name="', Session::getSessionTokenKey(), '" value="', Session::getSessionToken(), '">
<div class="form-group">
<div class="offset-sm-2 col-sm-10">
<button type="submit" name="submit" class="btn btn-primary">', $this->form->getSubmitButtonCaption(), '</button>';
if (isset($this->form->buttons_extra))
echo '
', $this->form->buttons_extra;
echo ' echo '
</div> </div>
</div>
</form>'; </form>';
if (!empty($this->title))
echo '
</div>';
} }
protected function renderField($field_id, $field) protected function renderField($field_id, array $field)
{ {
if (isset($field['before_html'])) if (isset($field['before_html']))
echo '</dl>
', $field['before_html'], '
<dl>';
if ($field['type'] != 'checkbox' && isset($field['label']))
echo ' echo '
<dt class="cont_', $field_id, isset($field['tab_class']) ? ' target target-' . $field['tab_class'] : '', '"', in_array($field_id, $this->missing) ? ' style="color: red"' : '', '>', $field['label'], '</dt>'; ', $field['before_html'];
elseif ($field['type'] == 'checkbox' && isset($field['header']))
echo '
<dt class="cont_', $field_id, isset($field['tab_class']) ? ' target target-' . $field['tab_class'] : '', '"', in_array($field_id, $this->missing) ? ' style="color: red"' : '', '>', $field['header'], '</dt>';
echo ' echo '
<dd class="cont_', $field_id, isset($field['dd_class']) ? ' ' . $field['dd_class'] : '', isset($field['tab_class']) ? ' target target-' . $field['tab_class'] : '', '">'; <div class="row mb-2">';
if (isset($field['before'])) if ($field['type'] !== 'checkbox')
echo $field['before']; {
if (isset($field['label']))
echo '
<label class="col-sm-2 col-form-label" for="', $field_id, '"', in_array($field_id, $this->missing) ? ' style="color: red"' : '', '>', $field['label'], ':</label>
<div class="', isset($field['class']) ? $field['class'] : 'col-sm-6', '">';
else
echo '
<div class="offset-sm-2 ', isset($field['class']) ? $field['class'] : 'col-sm-6', '">';
}
switch ($field['type']) switch ($field['type'])
{ {
case 'select': case 'select':
echo ' $this->renderSelect($field_id, $field);
<select name="', $field_id, '" id="', $field_id, '"', !empty($field['disabled']) ? ' disabled' : '', '>';
if (isset($field['placeholder']))
echo '
<option value="">', $field['placeholder'], '</option>';
foreach ($field['options'] as $value => $option)
echo '
<option value="', $value, '"', $this->data[$field_id] == $value ? ' selected' : '', '>', htmlentities($option), '</option>';
echo '
</select>';
break; break;
case 'radio': case 'radio':
foreach ($field['options'] as $value => $option) $this->renderRadio($field_id, $field);
echo '
<input type="radio" name="', $field_id, '" value="', $value, '"', $this->data[$field_id] == $value ? ' checked' : '', !empty($field['disabled']) ? ' disabled' : '', '> ', htmlentities($option);
break; break;
case 'checkbox': case 'checkbox':
echo ' $this->renderCheckbox($field_id, $field);
<label><input type="checkbox"', $this->data[$field_id] ? ' checked' : '', !empty($field['disabled']) ? ' disabled' : '', ' name="', $field_id, '"> ', htmlentities($field['label']), '</label>';
break; break;
case 'textarea': case 'textarea':
echo ' $this->renderTextArea($field_id, $field);
<textarea name="', $field_id, '" id="', $field_id, '" cols="', isset($field['columns']) ? $field['columns'] : 40, '" rows="', isset($field['rows']) ? $field['rows'] : 4, '"', !empty($field['disabled']) ? ' disabled' : '', '>', $this->data[$field_id], '</textarea>';
break; break;
case 'color': case 'color':
echo ' $this->renderColor($field_id, $field);
<input type="color" name="', $field_id, '" id="', $field_id, '" value="', htmlentities($this->data[$field_id]), '"', !empty($field['disabled']) ? ' disabled' : '', '>';
break; break;
case 'numeric': case 'numeric':
echo ' $this->renderNumeric($field_id, $field);
<input type="number"', isset($field['step']) ? ' step="' . $field['step'] . '"' : '', '" min="', isset($field['min_value']) ? $field['min_value'] : '0', '" max="', isset($field['max_value']) ? $field['max_value'] : '9999', '" name="', $field_id, '" id="', $field_id, '"', isset($field['size']) ? ' size="' . $field['size'] . '"' : '', isset($field['maxlength']) ? ' maxlength="' . $field['maxlength'] . '"' : '', ' value="', htmlentities($this->data[$field_id]), '"', !empty($field['disabled']) ? ' disabled' : '', '>';
break; break;
case 'file': case 'file':
if (!empty($this->data[$field_id])) $this->renderFile($field_id, $field);
echo '<img src="', $this->data[$field_id], '" alt=""><br>'; break;
echo ' case 'captcha':
<input type="file" name="', $field_id, '" id="', $field_id, '"', !empty($field['disabled']) ? ' disabled' : '', '>'; $this->renderCaptcha($field_id, $field);
break; break;
case 'text': case 'text':
case 'password': case 'password':
default: default:
echo ' $this->renderText($field_id, $field);
<input type="', $field['type'], '" name="', $field_id, '" id="', $field_id, '"', isset($field['size']) ? ' size="' . $field['size'] . '"' : '', isset($field['maxlength']) ? ' maxlength="' . $field['maxlength'] . '"' : '', ' value="', htmlentities($this->data[$field_id]), '"', !empty($field['disabled']) ? ' disabled' : '', isset($field['trigger']) ? ' class="trigger-' . $field['trigger'] . '"' : '', '>';
} }
if (isset($field['after'])) if ($field['type'] !== 'checkbox')
echo ' ', $field['after']; echo '
</div>';
echo ' echo '
</dd>'; </div>';
if (isset($field['after_html']))
echo '
', $field['after_html'];
}
private function renderCaptcha($field_id, array $field)
{
echo '
<div class="g-recaptcha" data-sitekey="', RECAPTCHA_API_KEY, '"></div>
<script src="https://www.google.com/recaptcha/api.js"></script>';
}
private function renderCheckbox($field_id, array $field)
{
echo '
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
<input class="form-check-input" type="checkbox"', $this->data[$field_id] ? ' checked' : '', !empty($field['disabled']) ? ' disabled' : '', ' name="', $field_id, '" id="check-', $field_id, '">
<label class="form-check-label" for="check-', $field_id, '">
', $field['label'], '
</label>
</div>
</div>';
}
private function renderColor($field_id, array $field)
{
echo '
<input class="form-control" type="color" name="', $field_id, '" id="', $field_id, '" value="', htmlspecialchars($this->data[$field_id]), '"', !empty($field['disabled']) ? ' disabled' : '', '>';
}
private function renderFile($field_id, array $field)
{
if (!empty($this->data[$field_id]))
echo 'Currently using asset <tt>', $this->data[$field_id], '</tt>. Upload to overwrite.<br>';
echo '
<input class="form-control" type="file" name="', $field_id, '" id="', $field_id, '"', !empty($field['disabled']) ? ' disabled' : '', '>';
}
private function renderNumeric($field_id, array $field)
{
echo '
<input class="form-control" type="number"',
isset($field['step']) ? ' step="' . $field['step'] . '"' : '',
' min="', isset($field['min_value']) ? $field['min_value'] : '0', '"',
' max="', isset($field['max_value']) ? $field['max_value'] : '9999', '"',
' name="', $field_id, '" id="', $field_id, '"',
isset($field['size']) ? ' size="' . $field['size'] . '"' : '',
isset($field['maxlength']) ? ' maxlength="' . $field['maxlength'] . '"' : '',
' value="', htmlspecialchars($this->data[$field_id]), '"',
!empty($field['disabled']) ? ' disabled' : '', '>';
}
private function renderRadio($field_id, array $field)
{
foreach ($field['options'] as $value => $option)
echo '
<div class="form-check">
<input class="form-check-input" type="radio" name="', $field_id, '" id="radio-', $field_id, '-', $value, '" value="', $value, '"', $this->data[$field_id] == $value ? ' checked' : '', !empty($field['disabled']) ? ' disabled' : '', '>
<label class="form-check-label" for="radio-', $field_id, '-', $value, '">
', htmlspecialchars($option), '
</label>
</div>';
}
private function renderSelect($field_id, array $field)
{
echo '
<select class="form-select" name="', $field_id, !empty($field['multiple']) ? '[]' : '',
'" id="', $field_id, '"',
!empty($field['disabled']) ? ' disabled' : '',
!empty($field['multiple']) ? ' multiple' : '',
!empty($field['size']) ? ' size="' . $field['size'] . '"' : '',
'>';
if (isset($field['placeholder']))
echo '
<option value="">', $field['placeholder'], '</option>';
foreach ($field['options'] as $key => $value)
{
if (is_array($value))
{
assert(empty($field['multiple']));
$this->renderSelectOptionGroup($field_id, $key, $value);
}
else
$this->renderSelectOption($field_id, $value, $key, !empty($field['multiple']));
}
echo '
</select>';
}
private function renderSelectOption($field_id, $label, $value, $multiple = false)
{
echo '
<option value="', $value, '"',
!$multiple && $this->data[$field_id] == $value ? ' selected' : '',
$multiple && in_array($value, $this->data[$field_id]) ? ' selected' : '',
'>', htmlspecialchars($label), '</option>';
}
private function renderSelectOptionGroup($field_id, $label, $options)
{
echo '
<optgroup label="', $label, '">';
foreach ($options as $value => $option)
$this->renderSelectOption($field_id, $option, $value);
echo '
</optgroup>';
}
private function renderText($field_id, array $field)
{
echo '
<input class="form-control" ',
'type="', $field['type'], '" ',
'name="', $field_id, '" ',
'id="', $field_id, '"',
isset($field['size']) ? ' size="' . $field['size'] . '"' : '',
isset($field['maxlength']) ? ' maxlength="' . $field['maxlength'] . '"' : '',
isset($this->data[$field_id]) ? ' value="' . htmlspecialchars($this->data[$field_id]) . '"' : '',
isset($field['placeholder']) ? ' placeholder="' . $field['placeholder'] . '"' : '',
!empty($field['disabled']) ? ' disabled' : '',
isset($field['trigger']) ? ' class="trigger-' . $field['trigger'] . '"' : '',
'>';
}
private function renderTextArea($field_id, array $field)
{
echo '
<textarea class="form-control' .
'" name="', $field_id,
'" id="', $field_id,
'" cols="', isset($field['columns']) ? $field['columns'] : 40,
'" rows="', isset($field['rows']) ? $field['rows'] : 4, '"',
isset($field['placeholder']) ? ' placeholder="' . $field['placeholder'] . '"' : '',
'"', !empty($field['disabled']) ? ' disabled' : '',
'>', $this->data[$field_id], '</textarea>';
} }
} }

View File

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

View File

@@ -11,34 +11,44 @@ class LogInForm extends SubTemplate
private $redirect_url = ''; private $redirect_url = '';
private $emailaddress = ''; private $emailaddress = '';
protected $_class = 'content-box container col-lg-6';
public function setRedirectUrl($url) public function setRedirectUrl($url)
{ {
$_SESSION['login_url'] = $url;
$this->redirect_url = $url; $this->redirect_url = $url;
} }
public function setEmail($addr) public function setEmail($addr)
{ {
$this->emailaddress = htmlentities($addr); $this->emailaddress = htmlspecialchars($addr);
} }
protected function html_content() protected function html_content()
{ {
if (!empty($this->_title))
echo ' echo '
<form action="', BASEURL, '/login/" method="post" id="login"> <h1 class="mb-4">Press #RU to continue</h1>';
<h3>Log in</h3>';
if (!empty($this->_subtemplates))
{
foreach ($this->_subtemplates as $template) foreach ($this->_subtemplates as $template)
$template->html_main(); $template->html_main();
}
echo ' echo '
<dl> <form class="mt-4" action="', BASEURL, '/login/" method="post">
<dt><label for="field_emailaddress">E-mail address:</label></dt> <div class="row">
<dd><input type="text" id="field_emailaddress" name="emailaddress" tabindex="1" value="', $this->emailaddress, '" autofocus></dd> <label class="col-sm-3 col-form-label" for="field_emailaddress">E-mail address:</label>
<div class="col-sm">
<dt><label for="field_password">Password:</label></dt> <input type="text" class="form-control" id="field_emailaddress" name="emailaddress" value="', $this->emailaddress, '">
<dd><input type="password" id="field_password" name="password" tabindex="2"></dd> </div>
</dl>'; </div>
<div class="row mt-3">
<label class="col-sm-3 col-form-label" for="field_password">Password:</label>
<div class="col-sm">
<input type="password" class="form-control" id="field_password" name="password">
</div>
</div>';
// Throw in a redirect url if asked for. // Throw in a redirect url if asked for.
if (!empty($this->redirect_url)) if (!empty($this->redirect_url))
@@ -46,9 +56,11 @@ class LogInForm extends SubTemplate
<input type="hidden" name="redirect_url" value="', base64_encode($this->redirect_url), '">'; <input type="hidden" name="redirect_url" value="', base64_encode($this->redirect_url), '">';
echo ' echo '
<a href="', BASEURL, '/resetpassword/">Forgotten your password?</a> <div class="mt-4">
<div class="buttonstrip"> <div class="offset-sm-3 col-sm-9">
<button type="submit" class="btn btn-primary" id="field_login" name="login" tabindex="3">Log in</button> <button type="submit" class="btn btn-primary">Sign in</button>
<a class="btn btn-light" href="', BASEURL, '/resetpassword/" style="margin-left: 1em">Forgotten your password?</a>
</div>
</div> </div>
</form>'; </form>';
} }

97
templates/MainNavBar.php Normal file
View File

@@ -0,0 +1,97 @@
<?php
/*****************************************************************************
* MainNavBar.php
* Contains the primary navigational menu template.
*
* Kabuki CMS (C) 2013-2023, Aaron van Geffen
*****************************************************************************/
class MainNavBar extends NavBar
{
protected $outerMenuId = 'mainNav';
protected $innerMenuId = 'mainNavigation';
protected $ariaLabel = 'Main navigation';
protected $navBarClasses = 'navbar-dark bg-dark sticky-top';
protected $primaryBadgeClasses = 'bg-light text-dark';
protected $secondaryBadgeClasses = 'bg-dark text-light';
public function html_main()
{
// Select a random space invader, with a bias towards the mascot
$rnd = rand(0, 100);
$alt = $rnd > 50 ? ' alt-' . ($rnd % 6 + 1) : '';
$className = $rnd > 5 ? 'space-invader' . $alt : 'nyan-cat';
echo '
<nav id="', $this->outerMenuId, '" class="navbar navbar-expand-lg ', $this->navBarClasses, '" aria-label="', $this->ariaLabel, '">
<div class="container">
<a class="navbar-brand flex-grow-1" href="', BASEURL, '/">
<i class="', $className, '"></i>
HashRU Pics
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#', $this->innerMenuId, '" aria-controls="', $this->innerMenuId, '" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>';
if (Registry::has('user') && Registry::get('user')->isLoggedIn())
{
echo '
<div class="collapse navbar-collapse justify-content-end" id="', $this->innerMenuId, '">
<ul class="navbar-nav mb-2 mb-lg-0">';
$mainMenu = new MainMenu();
$this->renderMenuItems($mainMenu->getItems());
echo '
<li class="nav-divider d-none d-lg-inline"></li>';
$adminMenu = new AdminMenu();
$this->renderMenuItems($adminMenu->getItems());
$userMenu = new UserMenu();
$this->renderMenuItems($userMenu->getItems());
$this->darkModeToggle();
echo '
</ul>
</div>';
}
echo '
</div>
</nav>';
}
private function darkModeToggle()
{
echo '
<li class="nav-item dropdown">
<button class="btn btn-link nav-link py-2 px-0 px-lg-2 dropdown-toggle d-flex align-items-center"
id="bd-theme" type="button" data-bs-toggle="dropdown" data-bs-display="static">
<i id="theme-icon-active" class="bi bi-light"></i>
<span class="d-lg-none ms-2" id="bd-theme-text">Toggle theme</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="light">
<i class="bi bi-sun-fill"></i>
Light
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="dark">
<i class="bi bi-moon-stars-fill"></i>
Dark
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center active" data-bs-theme-value="auto">
<i class="bi bi-circle-half"></i>
Auto
</button>
</li>
</ul>
</li>';
}
}

View File

@@ -25,25 +25,31 @@ class MainTemplate extends Template
echo '<!DOCTYPE html> echo '<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<title>', $this->title, '</title>', !empty($this->canonical_url) ? ' <title>', $this->title, '</title>';
<link rel="canonical" href="' . $this->canonical_url . '">' : '', '
<link type="text/css" rel="stylesheet" href="', BASEURL, '/css/default.css"> if (!empty($this->canonical_url))
<meta name="viewport" content="width=device-width, initial-scale=1"> echo '
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">', !empty($this->css) ? ' <link rel="canonical" href="', $this->canonical_url, '">';
<style type="text/css">' . $this->css . '
</style>' : '', $this->header_html, ' echo '
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">';
echo '
<link rel="stylesheet" href="', BASEURL, '/vendor/twbs/bootstrap/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="', BASEURL, '/vendor/twbs/bootstrap-icons/font/bootstrap-icons.css">
<link type="text/css" rel="stylesheet" href="', BASEURL, '/css/default.css?v2">
<script type="text/javascript" src="', BASEURL, '/js/main.js"></script> <script type="text/javascript" src="', BASEURL, '/js/main.js"></script>
<script type="text/javascript" src="', BASEURL, '/js/color-modes.js"></script>'
, $this->header_html, '
</head> </head>
<body', !empty($this->classes) ? ' class="' . implode(' ', $this->classes) . '"' : '', '> <body', !empty($this->classes) ? ' class="' . implode(' ', $this->classes) . '"' : '', '>
<header> <header>';
<a href="', BASEURL, '/">
<h1 id="logo">#pics</h1> $bar = new MainNavBar();
</a> $bar->html_main();
<ul id="nav">
<li><a href="', BASEURL, '/">albums</a></li> echo '
<li><a href="', BASEURL, '/people/">people</a></li>
<li><a href="', BASEURL, '/timeline/">timeline</a></li>
</ul>
</header> </header>
<div id="wrapper">'; <div id="wrapper">';
@@ -55,12 +61,8 @@ class MainTemplate extends Template
if (Registry::has('user') && Registry::get('user')->isAdmin()) if (Registry::has('user') && Registry::get('user')->isAdmin())
{ {
if (class_exists('Cache'))
echo '
<span class="cache-info">Cache info: ', Cache::$hits, ' hits, ', Cache::$misses, ' misses, ', Cache::$puts, ' puts, ', Cache::$removals, ' removals</span>';
if (Registry::has('start')) if (Registry::has('start'))
echo '<br> echo '
<span class="creation-time">Page creation time: ', sprintf('%1.4f', microtime(true) - Registry::get('start')), ' seconds</span>'; <span class="creation-time">Page creation time: ', sprintf('%1.4f', microtime(true) - Registry::get('start')), ' seconds</span>';
if (Registry::has('db')) if (Registry::has('db'))
@@ -69,7 +71,7 @@ class MainTemplate extends Template
} }
else else
echo ' echo '
<span class="vanity">Powered by <a href="https://aaronweb.net/projects/kabuki/">Kabuki CMS</a></span>'; <span class="vanity">Powered by <a href="https://aaronweb.net/projects/kabuki/" target="_blank">Kabuki CMS</a></span>';
echo ' echo '
</footer> </footer>
@@ -80,15 +82,11 @@ class MainTemplate extends Template
echo '<pre>', strtr($query, "\t", " "), '</pre>'; echo '<pre>', strtr($query, "\t", " "), '</pre>';
echo ' echo '
<script type="text/javascript" src="', BASEURL, '/vendor/twbs/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
</body> </body>
</html>'; </html>';
} }
public function appendCss($css)
{
$this->css .= $css;
}
public function appendHeaderHtml($html) public function appendHeaderHtml($html)
{ {
$this->header_html .= "\n\t\t" . $html; $this->header_html .= "\n\t\t" . $html;

View File

@@ -8,6 +8,8 @@
class MediaUploader extends SubTemplate class MediaUploader extends SubTemplate
{ {
private Tag $tag;
public function __construct(Tag $tag) public function __construct(Tag $tag)
{ {
$this->tag = $tag; $this->tag = $tag;
@@ -16,14 +18,12 @@ class MediaUploader extends SubTemplate
protected function html_content() protected function html_content()
{ {
echo ' echo '
<form action="', BASEURL, '/uploadmedia/?tag=', $this->tag->id_tag, '" class="boxed_content" method="post" enctype="multipart/form-data"> <form action="', BASEURL, '/uploadmedia/?tag=', $this->tag->id_tag, '" method="post" enctype="multipart/form-data">
<h2>Upload new photos to &quot;', $this->tag->tag, '&quot;</h2> <h2>Upload new photos to &quot;', $this->tag->tag, '&quot;</h2>
<div> <div class="input-group">
<h3>Select files</h3> <input class="form-control d-inline" type="file" id="upload_queue" name="uploads[]"
<input type="file" id="upload_queue" name="uploads[]" multiple> accept="image/jpeg" multiple>
</div> <button class="btn btn-primary" name="save" id="photo_submit" type="submit">Upload the lot</button>
<div>
<input name="save" id="photo_submit" type="submit" value="Upload the lot">
</div> </div>
<div id="upload_preview_area"> <div id="upload_preview_area">
</div> </div>

32
templates/MyTagsView.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
/*****************************************************************************
* MyTagsView.php
* Contains the user tag list.
*
* Kabuki CMS (C) 2013-2015, Aaron van Geffen
*****************************************************************************/
class MyTagsView extends SubTemplate
{
private $tags;
public function __construct(array $tags)
{
$this->tags = $tags;
}
protected function html_content()
{
echo '
<h2>Tags you can edit</h2>
<p>You can currently edit the tags below. Click a tag to edit it.</p>
<ul>';
foreach ($this->tags as $tag)
echo '
<li><a href="', BASEURL, '/edittag/?id=', $tag->id_tag, '">', $tag->tag, '</a></li>';
echo '
</ul>';
}
}

61
templates/NavBar.php Normal file
View File

@@ -0,0 +1,61 @@
<?php
/*****************************************************************************
* NavBar.php
* Contains the navigational menu template.
*
* Kabuki CMS (C) 2013-2023, Aaron van Geffen
*****************************************************************************/
abstract class NavBar extends Template
{
protected $primaryBadgeClasses = 'bg-dark text-light';
protected $secondaryBadgeClasses = 'bg-light text-dark';
public function renderMenu(array $items, $navBarClasses = '')
{
echo '
<ul class="navbar-nav ', $navBarClasses, '">';
$this->renderMenuItems($items, $navBarClasses);
echo '
</ul>';
}
public function renderMenuItems(array $items)
{
foreach ($items as $menuId => $item)
{
if (isset($item['icon']))
$item['label'] = '<i class="bi bi-' . $item['icon'] . '"></i> ' . $item['label'];
if (isset($item['badge']))
$item['label'] .= ' <span class="badge ' . $this->primaryBadgeClasses . '">' . $item['badge'] . '</span>';
if (empty($item['subs']))
{
echo '
<li class="nav-item"><a class="nav-link" href="', $item['url'], '">', $item['label'], '</a></li>';
continue;
}
echo '
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="menu', $menuId, '" data-bs-toggle="dropdown" aria-expanded="false">', $item['label'], '</a>
<ul class="dropdown-menu" aria-labelledby="menu', $menuId, '">';
foreach ($item['subs'] as $subitem)
{
if (isset($subitem['badge']))
$subitem['label'] .= ' <span class="badge ' . $this->secondaryBadgeClasses . '">' . $subitem['badge'] . '</span>';
echo '
<li><a class="dropdown-item" href="', $subitem['url'], '">', $subitem['label'], '</a></li>';
}
echo '
</ul>
</li>';
}
}
}

View File

@@ -0,0 +1,82 @@
<?php
/*****************************************************************************
* PageIndexWidget.php
* Contains the template that displays a page index.
*
* Kabuki CMS (C) 2013-2023, Aaron van Geffen
*****************************************************************************/
class PageIndexWidget extends Template
{
private $index;
private string $class;
private static $unique_index_count = 0;
public function __construct(PageIndex $index)
{
$this->index = $index;
$this->class = $index->getPageIndexClass();
}
public function html_main()
{
self::paginate($this->index, $this->class);
}
public static function paginate(PageIndex $index, $class = null)
{
$page_index = $index->getPageIndex();
if (empty($page_index) || count($page_index) == 1)
return;
if (!isset($class))
$class = $index->getPageIndexClass();
echo '
<ul class="pagination', $class ? ' ' . $class : '', '">
<li class="page-item', empty($page_index['previous']) ? ' disabled' : '', '">',
'<a class="page-link"', !empty($page_index['previous']) ? ' href="' . $page_index['previous']['href'] . '"' : '', '>',
'&laquo; previous</a></li>';
$num_wildcards = 0;
foreach ($page_index as $key => $page)
{
if (!is_numeric($key))
continue;
if (!is_array($page))
{
$first_wildcard = $num_wildcards === 0;
$num_wildcards++;
echo '
<li class="page-item page-padding wildcard',
$first_wildcard ? ' first-wildcard' : '',
'" onclick="javascript:promptGoToPage(',
self::$unique_index_count, ')"><a class="page-link">...</a></li>';
}
else
echo '
<li class="page-item page-number', $page['is_selected'] ? ' active" aria-current="page' : '', '">',
'<a class="page-link" href="', $page['href'], '">', $page['index'], '</a></li>';
}
echo '
<li class="page-item', empty($page_index['next']) ? ' disabled' : '', '">',
'<a class="page-link"', !empty($page_index['next']) ? ' href="' . $page_index['next']['href'] . '"' : '', '>',
'next &raquo;</a></li>
</ul>';
if ($num_wildcards)
{
echo '
<script type="text/javascript">
var page_index_', self::$unique_index_count++, ' = {
wildcard_url: "', $index->getLink("%d"), '",
num_pages: ', $index->getNumberOfPages(), ',
per_page: ', $index->getItemsPerPage(), '
};
</script>';
}
}
}

View File

@@ -1,63 +0,0 @@
<?php
/*****************************************************************************
* Pagination.php
* Contains the pagination template.
*
* Kabuki CMS (C) 2013-2016, Aaron van Geffen
*****************************************************************************/
class Pagination extends SubTemplate
{
private $index;
private static $unique_index_count = 0;
public function __construct(PageIndex $index)
{
$this->index = $index;
$this->class = $index->getPageIndexClass();
}
protected function html_content()
{
$index = $this->index->getPageIndex();
echo '
<div class="table_pagination', !empty($this->class) ? ' ' . $this->class : '', '">
<ul>
<li class="first"><', !empty($index['previous']) ? 'a href="' . $index['previous']['href'] . '"' : 'span', '>&laquo; previous</', !empty($index['previous']) ? 'a' : 'span', '></li>';
$num_wildcards = 0;
foreach ($index as $key => $page)
{
if (!is_numeric($key))
continue;
if (!is_array($page))
{
$num_wildcards++;
echo '
<li class="page-padding" onclick="javascript:promptGoToPage(', self::$unique_index_count, ')"><span>...</span></li>';
}
else
echo '
<li class="page-number', $page['is_selected'] ? ' active' : '', '"><a href="', $page['href'], '">', $page['index'], '</a></li>';
}
echo '
<li class="last"><', !empty($index['next']) ? 'a href="' . $index['next']['href'] . '"' : 'span', '>next &raquo;</', !empty($index['next']) ? 'a' : 'span', '></li>
</ul>
</div>';
if ($num_wildcards)
{
echo '
<script type="text/javascript">
var page_index_', self::$unique_index_count++, ' = {
wildcard_url: "', $this->index->getLink("%d"), '",
num_pages: ', $this->index->getNumberOfPages(), ',
per_page: ', $this->index->getItemsPerPage(), '
};
</script>';
}
}
}

View File

@@ -20,27 +20,31 @@ class PasswordResetForm extends SubTemplate
protected function html_content() protected function html_content()
{ {
echo ' echo '
<div class="boxed_content"> <h1 class="mb-4">Password reset procedure</h1>';
<h2>Password reset procedure</h2>';
foreach ($this->_subtemplates as $template) foreach ($this->_subtemplates as $template)
$template->html_main(); $template->html_main();
echo ' echo '
<p>You have successfully confirmed your identify. Please use the form below to set a new password.</p> <p>You have successfully confirmed your identify. Please use the form below to set a new password.</p>
<form class="form-horizontal" action="', BASEURL, '/resetpassword/?step=2&amp;email=', rawurlencode($this->email), '&amp;key=', $this->key, '" method="post"> <form action="', BASEURL, '/resetpassword/?step=2&amp;email=', rawurlencode($this->email), '&amp;key=', $this->key, '" method="post">
<p> <div class="row mt-3">
<label class="control-label" for="field_password1">New password:</label> <label class="col-sm-2 col-form-label" for="field_password1">New password:</label>
<input type="password" id="field_password1" name="password1"> <div class="col-sm-3">
</p> <input type="password" class="form-control" id="field_password1" name="password1">
</div>
<p> </div>
<label class="control-label" for="field_password2">Repeat new password:</label> <div class="row mt-3">
<input type="password" id="field_password2" name="password2"> <label class="col-sm-2 col-form-label" for="field_password2">Repeat new password:</label>
</p> <div class="col-sm-3">
<input type="password" class="form-control" id="field_password2" name="password2">
</div>
</div>
<div class="row mt-3">
<div class="offset-sm-2 col-sm-2">
<button type="submit" class="btn btn-primary">Reset password</button> <button type="submit" class="btn btn-primary">Reset password</button>
</form> </div>
</div>'; </div>
</form>';
} }
} }

View File

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

Some files were not shown because too many files have changed in this diff Show More