Compare commits

...

140 Commits

Author SHA1 Message Date
dependabot[bot]
58002f4903 Bump the github-actions group with 2 updates (#3997) 2025-12-02 05:35:16 -07:00
Rotzbua
19a9fafe37 fix(ci): remove outdated travis config (#3864) 2025-12-01 08:23:10 -07:00
Rotzbua
8ad4c006a2 feat(ci): add dependabot to keep GH Actions up to date (#3866)
Limit bot to update only monthly to avoid spam and save resources.
Also group to one PR to limit PR spam.
2025-12-01 08:21:08 -07:00
Andy Miller
52fd9a6e7b update gitignore
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-25 09:47:28 +00:00
Andy Miller
45e6ed941f Fix double execution of preflight checks during self-upgrade 2025-11-25 09:40:53 +00:00
Andy Miller
2c2b2fc2e4 Optimize preflight Monolog checks by skipping vendor directories 2025-11-25 09:40:45 +00:00
Andy Miller
b0301beee3 Fix slow SafeUpgradeServiceTest by optimizing snapshot pruning 2025-11-25 09:40:36 +00:00
Andy Miller
ce6a1b3bcb Ensure file permissions are preserved during safe-upgrade copy operations 2025-11-25 09:40:27 +00:00
Andy Miller
d42adcd593 Fix safe-upgrade snapshot creation (copy vs move) and implement pruning 2025-11-25 09:40:16 +00:00
Andy Miller
bcd93c321b try again
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-24 22:18:43 +00:00
Andy Miller
8bd711f6b1 fixes for versions
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-24 21:42:03 +00:00
Andy Miller
fa707eb7eb vendor updates
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-24 21:38:55 +00:00
Andy Miller
18d285ec36 Merge branch 'develop' of github.com:getgrav/grav into develop 2025-11-24 21:06:07 +00:00
Andy Miller
04c6bdf287 disallow xref/xhref in SVGs
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-24 21:05:53 +00:00
pmoreno.rodriguez
3ddc548d51 Add new Twig filter/function array_group_by for grouping arrays and collections (#3970) 2025-11-23 18:09:12 +00:00
Andy Miller
48343d7714 fix range requests for partial content in Utils::downloads() - Fixes #3990
Signed-off-by: Andy Miller <rhuk@mac.com>

# Conflicts:
#	system/src/Grav/Common/Utils.php
2025-11-23 18:03:28 +00:00
Andy Miller
9c27496cc1 test fixes + major/minor plugin warnings
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-12 13:05:37 +00:00
Andy Miller
fd51d33d3f added configurable snapshot pruning amount
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-11 19:30:05 +00:00
Andy Miller
7304612d3a some installer fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-11 17:55:14 +00:00
Andy Miller
e6025670ea checkout correct version
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-11 15:16:47 +00:00
Andy Miller
92b3d5b1f8 preflight integration for cli
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-11 15:03:48 +00:00
Andy Miller
2ee3ff074c ui things
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-10 19:42:16 +00:00
Andy Miller
4fab5f99bb added back snapshots in Install.php
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-10 19:34:46 +00:00
Andy Miller
1d5d1357b8 simplified safe-upgrade
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-10 11:40:13 +00:00
Andy Miller
eb649c35a3 more simplification
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-09 21:55:01 +00:00
Andy Miller
9b75d96bbf simplify copy/permission process + fix safe-upgrade check
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-09 21:25:34 +00:00
Andy Miller
41d771da7c Merge tag '1.7.50.9' into develop
Release v1.7.50.9

# -----BEGIN PGP SIGNATURE-----
#
# iQIzBAABCAAdFiEEwbiolRD/eEYBHGp5nyzziuvbCuAFAmkQuXIACgkQnyzziuvb
# CuCQiQ/+NvgdESE8Rppn4V+nLkbHL2urMD+2y2TxGqS+hiCg4t3LIJNDPQ16sXoF
# xaWWsoHjcP+JBwktG/wgmYKetZGWEUpRoiu0n8MjfbeZEg69vabNt9GSEBV0x/vl
# 3z1QUd6ZBSg794ZKL8EtEsQlApjqUg8l3VDtp+0jhmXHFjvULYazHR96Pk5jLrH7
# 35VF1d74dCcpJzkZVHqScSpofQkPsOiPe/WqD4j3HP7YaC3GT7ET4xS6bnWVDCOQ
# UYZjj23HAhiuzV5yLcsq3LK0X3jG/cArdvNjzJvYiswtURp/FmxlQ4Yph36esPAH
# os/4L6pFs608fhrJdWQ5/HE+1xf9SKDryRRynADxdMfsqmMJ1L9eS6uqeR6dIoTl
# 79cn/0yf6dipUbhErR3Mgaa/6Z3lo3O2uwJ0oUEEWT1Kqd7if9Bt6akD7HWxEsle
# hAn3NPSvB1O25Xye9gIq03ulkqOEKBQ3kODDXGbVYr227vlO8c/8JjMhXIum8EuI
# PdvXniaTYPPh93AnF7GIjmnZTpT9N9xT8hpyMMfXbEkigOHiBwEG5WFMQcpuoyG5
# vDOBPMVeiz+44tJFWfe+dGTS+kE+3FK3Quy2sfp7uAN/HUHlTlNpRirdcAUzPJDu
# JGy0itWQMuBUnOohlLBa2ytogLr6OOSjUwe0+KrAEAXUpPDzvO8=
# =iopS
# -----END PGP SIGNATURE-----
# gpg: Signature made Sun Nov  9 15:55:30 2025 GMT
# gpg:                using RSA key C1B8A89510FF7846011C6A799F2CF38AEBDB0AE0
# gpg: Good signature from "Andy Miller <rhuk@mac.com>" [ultimate]
2025-11-09 15:55:30 +00:00
Andy Miller
7e3fccce54 Merge branch 'release/1.7.50.9' 2025-11-09 15:55:30 +00:00
Andy Miller
48c6d2eb93 prepare for release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-09 15:55:20 +00:00
Andy Miller
e86820d438 update changelog
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-09 15:54:44 +00:00
Andy Miller
4c324ef4b8 don’t error when trying to force —safe
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-09 15:50:42 +00:00
Andy Miller
a07a1b332a test fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-09 13:11:41 +00:00
Andy Miller
c8204f442a major/minor upgrade warnings
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-09 12:52:04 +00:00
Andy Miller
ba479007ac less confusing messages
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-09 12:07:46 +00:00
Andy Miller
38494b2c1c fall back to safe upgrade
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-08 19:54:20 +00:00
Andy Miller
ba3e0686a6 revert testing repo
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-08 12:17:49 +00:00
Andy Miller
f0ed8e0ea0 some more fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-08 11:43:50 +00:00
Andy Miller
02fbe27efd fix some errors after upgrade
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-08 11:08:45 +00:00
Andy Miller
cfa18a8fd1 mostly working
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-08 11:03:50 +00:00
Andy Miller
89f44631bd Merge branch 'release/1.7.50.8' 2025-11-06 18:56:04 +00:00
Andy Miller
2f2f1e518d Merge tag '1.7.50.8' into develop
Release v1.7.50.8

# -----BEGIN PGP SIGNATURE-----
#
# iQIzBAABCAAdFiEEwbiolRD/eEYBHGp5nyzziuvbCuAFAmkM70QACgkQnyzziuvb
# CuCj0Q/7Bscki0vFiumTFx7GUg/MQ+N29qbAsEoDe/VDoaFn73P/kqioNN6EPMBm
# 3rqo9J679nQWWCfEfXw2KdvzxiP1aHf+KFnQUD4M5sw/wYdJ/SBH5R+CI9/7NNgM
# YC+ZDqf32d9fr/iGH7fdGwzxbt+GWJD+turD/40TMuZUT49LY5gymXE4X8Q7f2qS
# 6aCHLfjo+pZWkcOH34j0jRFUUM20+Tbzyea3Y7cSRVSf/DnkGIhBAhcsHYTc5dez
# b1NLLBLPuYCcOBFxWHDBTaOiYzV8ZlxuKbWOop1y6Ak2Q5fzGhrewAbhGtT6RXOj
# 90BTjeN1M0A9VOGCjdlhoK+zLzjy2BDmEh/qGeXwGg8GXe7eOxeZok4/DQisl3Uz
# nVUj/VZk9czv2QdmsC3D4jPCOSoqzM9lsJ/HSoSAqfxlQf8Li/YwLYYHw2vold3P
# xqv/KO/uoLV30oR3Q/btvZN+//cIFb6wAA9MoASQ0FjRMe4vHQMcRQFyMX3lnHDV
# MTFSJwmEmSwZCQkr+L9Y4QdX0+6YIgJv0WO9sIj29V9VLLD2VGkDWXdBE1ntx54j
# ozCBrTBBydpDlcEgfprecBjObfA+jc80EGg5YFYLNOT8PJQ3/wRMelYZ8DM9/w8D
# nr9pM4l9/CIFG/Qt7JU5VlrNYsvKEBTx3niy6H8bk8x2sLnju1s=
# =qREv
# -----END PGP SIGNATURE-----
# gpg: Signature made Thu Nov  6 18:56:04 2025 GMT
# gpg:                using RSA key C1B8A89510FF7846011C6A799F2CF38AEBDB0AE0
# gpg: Good signature from "Andy Miller <rhuk@mac.com>" [ultimate]
2025-11-06 18:56:04 +00:00
Andy Miller
682109bf3b prepare for release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-06 18:55:56 +00:00
Andy Miller
f420db0eea has some legit uses - this is actually causing problems
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-06 18:36:57 +00:00
Andy Miller
c6764f9815 removed check causing false positives
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-06 18:36:57 +00:00
Andy Miller
a2f944e6c7 Merge branch 'release/1.7.50.7' 2025-11-05 23:39:36 +00:00
Andy Miller
68ff6ae342 Merge tag '1.7.50.7' into develop
Release 1.7.50.7

# -----BEGIN PGP SIGNATURE-----
#
# iQIzBAABCAAdFiEEwbiolRD/eEYBHGp5nyzziuvbCuAFAmkL4DgACgkQnyzziuvb
# CuCoSw//UiX7orbI5Qcel8ZUELHJJDhyq2AGmfg58gvyaUC/VqKY4HdAOGesDNgr
# Qo/FsJ56wKgizXlv1G4k955MZas6oN6IkGTPDl0xjnvYq3Nuxoe3m5zYRAmitgk6
# j8BA2aTdK4erGuxDa9WKJMLOGaen4OrGKIO9WfpuKKs8sP80Z69uPXFnVZIqWPx2
# 5N0yUfo0nnBsigumFBrMbsxLxi56iMRIxpJQ6p7iRRSoXdridO+LVlHlj8l6oWrN
# B1AUYbltUUnX5yJwyoxyktT9kOJ7FEY5OnUw+Cg0hthS07TukymmrrZM+tDjM2Lc
# gKTTH8tQRwao3PFyvoVnMB2Ox+hkqoZMzsivsccQxVNle1wdydvrZ1pwGWh0ynlg
# S42hFEVnpE4o3mbAt2NzCerx5vdjkrUSMBITG6bol2jmyglZx3fy0lNLwMUo9WGV
# djg+yzk8MW/qtTwhGcBr4nfD2bTM9LhSjHHTjanqiWPf+nM19rmhoWQsRI477br4
# Do7XCfvk6/GN6aOfgoJE1DumwMQO+lm23HxzwZ888i3N5mqrk8JixPRt1a1HbMLX
# anoXSlAtqwy4m2Ql1KsTmM8CgLU+ZDsp2uTsTQ9gTpfgoYBTrrZN7v/9Yb2YT9rf
# 1769MwoNQq1Wumnpl7SNv29kcMeq2XZNP1AU0LvtML+vBGMigMA=
# =VlDG
# -----END PGP SIGNATURE-----
# gpg: Signature made Wed Nov  5 23:39:36 2025 GMT
# gpg:                using RSA key C1B8A89510FF7846011C6A799F2CF38AEBDB0AE0
# gpg: Good signature from "Andy Miller <rhuk@mac.com>" [ultimate]
2025-11-05 23:39:36 +00:00
Andy Miller
505fc208bb prepare for release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-05 23:39:25 +00:00
Andy Miller
cd50bd6d63 update clean commant
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-05 23:38:31 +00:00
Andy Miller
0278eb17cb Merge branch 'release/1.7.50.7' 2025-11-05 23:25:26 +00:00
Andy Miller
14fba5170e Merge tag '1.7.50.7' into develop
Release 1.7.50.7

# -----BEGIN PGP SIGNATURE-----
#
# iQIzBAABCAAdFiEEwbiolRD/eEYBHGp5nyzziuvbCuAFAmkL3OYACgkQnyzziuvb
# CuBXag/+Lo9xqsP8XsArdYjyjxAaKFOJikXKEtEwwyYpPgfPFLvX29TJHitqRsJ0
# /Dhd+gSWDzQ1jectvl1Acbffelb3PE95SWLB7pnc/TKlFGLd2JB6p36hb9jO+Moo
# MB9h3X2jTnf0GzxRCBa8Ih+sTvE2ski1DDscavaQ4yyEqpJrkW6hyat6io4ZAAQT
# Th68wgcSBoYRTgt2zbj+W8lOU9sS46rF3sFZn5ysfAl+bH8s+dFSZZMIi4NyhCcB
# O+C3DxQbIvwHO+LT5OPiDHI+2JhSeW/QM5eTD5fqATBSyBjYH55LuOHc8iWOHEyw
# 6oA89Egxxd+EKhwblIOCNvQa0ZOiABefup9oYp+60K/WDtV6lmEFRIttFflyOuwT
# P038XnvitQp1aKL664G4WqjNrOYgSaQvRPUQkDyD9lQQbZ5g7AwsgYztt9d9yNQO
# dXOhwyScK9yMU0roKNYEvknzHCkd8OwXXa4o54xwElRHid/T0FKNPpeEz7LVNLeY
# UKKzWt7AaXTkde/A0JM+OSD+bfRHShGc802dgNaRXuyaCVHPmNtbzluCAJ/YrDh8
# Vby/nBvZVBYLFN2A4pJ/URGb1JN3TpFW8KfMBdfY/PVF/T1kHEDWtOlh2smX/qG2
# YcxsZEmqvo7JwA9wBKgUyhgamE1K/SUSOrv8RgZPNlLD5n1CF30=
# =GlG4
# -----END PGP SIGNATURE-----
# gpg: Signature made Wed Nov  5 23:25:26 2025 GMT
# gpg:                using RSA key C1B8A89510FF7846011C6A799F2CF38AEBDB0AE0
# gpg: Good signature from "Andy Miller <rhuk@mac.com>" [ultimate]
2025-11-05 23:25:26 +00:00
Andy Miller
5af47f0634 prepare for release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-05 23:25:15 +00:00
Andy Miller
6263a34c09 update changelog
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-05 23:24:46 +00:00
Andy Miller
5a6c00f68c ignore .github and .phan folders, fixed path check
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-05 23:23:18 +00:00
Andy Miller
52a8854f6b Merge branch 'release/1.7.50.6' 2025-11-05 21:27:00 +00:00
Andy Miller
945cd6aa8f Merge tag '1.7.50.6' into develop
Release v1.7.50.6

# -----BEGIN PGP SIGNATURE-----
#
# iQIzBAABCAAdFiEEwbiolRD/eEYBHGp5nyzziuvbCuAFAmkLwSQACgkQnyzziuvb
# CuBW1BAAsRPsRLDq2LOVC8pUcHlzJFV+0Yiz3752xchS/4nubg7xO4cjvdkvqtoL
# MOu/s7/DpbEQYP5nGjEQVmo1pug1YnybPprlTGcoT1Swfw0YIrHPnO8WmpPaPYKp
# zKUhkD+n6FNVKA+GhvjXcK7JsB1EbtIC4348uTNKen8zzSSs0EV+k3GhQ0CMxAft
# XgnIHddn4evlJ9MaV1JswJNZs2+BextRd3M4zEdDbvTfnsAZZsb7MOrQSfgl8VbI
# QLAA6ZAQBWlDUkHTMWa2n1ocZAbm12kl8OGfR1hgPjodCQ1eEwE6HQK1Cg4W3M2d
# 6EGI8Iteb5yMTGMa1CN5M9YfPFDM83ngTMYHdAJrztfOD3Bctibg+14oHsdYB5c8
# LbPTxlVIc/9JVyS+cfKtNtCtWLFxzlEs3EMvBaBbFIQze6GxTsw9Dq6GQwqA8CA6
# hCKl4stQwTCCv+UMPB1eEbljAYM5TmVzCndTSTTw6urIyb6to/ITPcofbFzzfwgn
# tFJxTG+p3OFLd1lOp4rxb2UWqNTq1T/TEPtxZD1zijgX+0N4kdgVTY8U1p/GNduF
# m4tXlGzBX+mHPIwkJIGZoJJwH9TmWm/tviRgXNDIM2bHTzY9NmYGnQC7zbv3LzE1
# pGbc2Z11P3faC0xPFlY2nFOBKv+n6hPEGBifPWjJwKiB+s4V35k=
# =/gqY
# -----END PGP SIGNATURE-----
# gpg: Signature made Wed Nov  5 21:27:00 2025 GMT
# gpg:                using RSA key C1B8A89510FF7846011C6A799F2CF38AEBDB0AE0
# gpg: Good signature from "Andy Miller <rhuk@mac.com>" [ultimate]
2025-11-05 21:27:00 +00:00
Andy Miller
070b53180d prepare for release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-05 21:26:47 +00:00
Andy Miller
042b845b8d don’t copy non-upgrade root folders
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-05 21:23:23 +00:00
Andy Miller
84b3a9e68e Merge branch 'release/1.7.50.5' 2025-11-05 18:39:40 +00:00
Andy Miller
70a2e668ec Merge tag '1.7.50.5' into develop
Release v1.7.50.5

# -----BEGIN PGP SIGNATURE-----
#
# iQIzBAABCAAdFiEEwbiolRD/eEYBHGp5nyzziuvbCuAFAmkLmewACgkQnyzziuvb
# CuC3sBAAqBgVbWmZbwB7GsTYphx3teLPIvEz+GG80LL4w97oGnoxi7IhoRKDUqPd
# QAOMV81J7+R8DLS7+WJyGwZtdjc5rNVicyJgH4+A8PLXJwxt5Dsw/uE6DfOWtfTp
# oNBznCgi1ocva5VFUg1ypPAevyfb2pM2XrwWNjPDcwpXFUZhB8SwA6aGQfwQMTHq
# CdAyEw6GNqzA4LN0Ko8jEipquWrqeQterT0UkGy4VNAs222D03vhhU3G5SQ7qDt6
# ExoNdAjhMq5lRhAJavFsO1vb67OaNCoiYk0XN0t8xM8GqBqDu6+Kliecmq2G6i8T
# KlL2W2vM3C69ScsR/vDqa4ITnDxyVUbXVdPhS8zQPymFG/E641O7vaVparPs75bt
# wvTZEcqgt4w2KCHwLkVqtw5khduUT10tBfogfuCRj4FpPMgbwOUMhpjgyRYNMx/K
# PaeZT0GJTI3m+p8oXb+Dph0zAGrqwJZlfxLeLXd9lGMZdvdM5yZob356ppaYKR9H
# tUwGkuolBNxJ40EuqHIptAPvztexPuatD/cu/GPqqBl6zAQByBA7beFEEdKKrvaX
# XGrHMl64wRmE1sxf6sAuFQUnpDJ546cuIry49lNtM48wY08ulpatcq5BkxKb6V4/
# w5LjCMzAdlNiE5Ztrq3taoSHcVGOjqIuOQjtgYvnrX3+yryKHOA=
# =Tb9i
# -----END PGP SIGNATURE-----
# gpg: Signature made Wed Nov  5 18:39:40 2025 GMT
# gpg:                using RSA key C1B8A89510FF7846011C6A799F2CF38AEBDB0AE0
# gpg: Good signature from "Andy Miller <rhuk@mac.com>" [ultimate]
2025-11-05 18:39:40 +00:00
Andy Miller
e04391484e prepare for release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-05 18:39:30 +00:00
Andy Miller
6d72867bef more safeupgrade fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-05 18:30:42 +00:00
Andy Miller
7e8c0e3f6f selfupgrade fix
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-04 14:13:03 +00:00
Andy Miller
4adc7672ac add preflight command and —safe and —legacy for self-upgrade
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-04 14:08:25 +00:00
Andy Miller
dd89d7e25b improved js assets pipline handling to support defer
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-11-03 23:49:20 +00:00
Andy Miller
ce817c1bd1 Merge branch 'release/1.7.50.4' 2025-10-31 19:12:03 +00:00
Andy Miller
918bfc6f2b Merge tag '1.7.50.4' into develop
Release v1.7.50.4

# -----BEGIN PGP SIGNATURE-----
#
# iQIzBAABCAAdFiEEwbiolRD/eEYBHGp5nyzziuvbCuAFAmkFCgMACgkQnyzziuvb
# CuBcMRAAlWFsBs1sXbLrm6x/nX1gwWWZ/PPYvCp7Hl00cylAa4WnaUxTqcTyobld
# LoXwOZARivW7Vxxj1FTFBFtCAx4v2V2UcIJ0EZCMDBPpV6YIoIryggqghO3IMJaH
# H44bnKahRrVQMdfcXtM0A9RZY3zU4HVXe2loybINAGl86TPMINGdfWEf2ePJlmdO
# lrv/ACuBXiF++lHjBqor6uSltHTIPmq0AVEbd3jnvnGF1isikPi2CN5mECf9pd0p
# 74rU3eNhiHCrkj2eN0NsmOkxaaY4Ri20OMgP6OE+sotNy3O47FlHNn2o7pZ3kt4i
# ejRHbsrcqvEEi35+xsXiz2kNFJ7PyHJIgsTLtjrXvVd7dTsJTJEoG3cXuv1ZsPhg
# w0Nd/eWCwuWBLzrU5+/gaaIlSODAdIzJHjW7g2WxhUcHYaKnxqs13hSzaIGgyBzN
# xXMrXSMNoPaXu7z5UI/nEjWfAxJkcc+nJxHHgXICtpMcHAXlgqPT8ZPy23lCqfVg
# qihPI1zpEWq97R8Gl0SH8NQLtgAoz5TfomTlnXSIVtOu6V7htoFErmVSSzyH73iX
# 2BvxRiSWk4y9Vei6LAVkRWZ5WiCKUTTID3VDI98D8FpgzNZaDnUqEJTOgAlHAfxo
# OSIDOmvp9IkRyHnrluuBrRh97iTxC3N24qMRuwUqeb2QHSy472Q=
# =1FsF
# -----END PGP SIGNATURE-----
# gpg: Signature made Fri Oct 31 19:12:03 2025 GMT
# gpg:                using RSA key C1B8A89510FF7846011C6A799F2CF38AEBDB0AE0
# gpg: Good signature from "Andy Miller <rhuk@mac.com>" [ultimate]
2025-10-31 19:12:03 +00:00
Andy Miller
0afbce518d prepare for release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-31 19:11:50 +00:00
Andy Miller
ff0de91bab more fixes for safe upgrade
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-31 19:08:48 +00:00
Andy Miller
2a18c07a64 Merge branch 'release/1.7.50.3' 2025-10-22 21:16:20 -06:00
Andy Miller
16b0b562fb Merge tag '1.7.50.3' into develop
Release v1.7.50.3

# -----BEGIN PGP SIGNATURE-----
#
# iQIzBAABCAAdFiEEwbiolRD/eEYBHGp5nyzziuvbCuAFAmj5ngQACgkQnyzziuvb
# CuCJ+Q//cS9O8FMLU3rQ28HLNj7pkWj4R6XEWMX/OezRKQzCRXgAXDUL4t8L1Psm
# o3p/pm3HUmMhcp00Qo3yQdDktvIa1t3/HY7jN0mPBjmv3dPtnHFgDZC+GPymkxWs
# 1KGH8MWVkQxuBC9FqaxrYN1WonPjj7frR21tpJ72yaGtuGCxmRqQOkU/uiOHr6uE
# IlXsC5Bmlg1LYcbmkDR/fNhahfCBZFcB8u/M3lqTzjpVKZCR/OJFXPvFt8FCsjA8
# i6Z1aXIQU4vdFMnHU9U4ksl7Ftd5pfovVY75yKPhY1Uk3WJkOi/+G7sL72O6lRj9
# 8JNXQoqHPe1MiXe/MsbGmSpJGsUo4/6fl79iJTlI69Y6LOfYGG8zh3PJKItev9lp
# CW8eWmsO6oUb4V3KbMfhyK3TQsVntffN4hB9KnCCfcEiRoLaQyJsMBIM1QpFKh5o
# T4Rz2k5SAtaLqK64clUV0uY6CEA/r/potf0w8VFxQgdBH/aXhLvm9kjcFeWdyyps
# hSMef5spzSX2vHlc919A8YIvXwsH5ZaXBR5ENUnSjLdjiz+RWoaWtrgmAH6LTXnD
# q+aVfx8fu1l/z+fDkTKEz64O358bF43jAvkdRZuq8RWuiE8HOHO5vDnp1suKPoXD
# MygVnzf3FUBzZjwv5PnUS+4ypDoMWlbE0ooL1kxd58o5kZ7Tajs=
# =mDao
# -----END PGP SIGNATURE-----
# gpg: Signature made Wed Oct 22 21:16:20 2025 MDT
# gpg:                using RSA key C1B8A89510FF7846011C6A799F2CF38AEBDB0AE0
# gpg: Good signature from "Andy Miller <rhuk@mac.com>" [ultimate]
2025-10-22 21:16:20 -06:00
Andy Miller
ef48476c88 prepare for release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-22 21:16:07 -06:00
Andy Miller
f73df193ad reset system.yaml to default
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-22 21:15:06 -06:00
Andy Miller
8c1e4252f2 update ignore
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-21 13:59:10 -06:00
Andy Miller
de260489ee remove phpstan file
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-21 13:59:01 -06:00
Andy Miller
1c5c2ac08d Merge tag '1.7.50.2' into develop
Release v1.7.50.2

# -----BEGIN PGP SIGNATURE-----
#
# iQIzBAABCAAdFiEEwbiolRD/eEYBHGp5nyzziuvbCuAFAmj33WQACgkQnyzziuvb
# CuD9iw/9FQH9MTS8wiQnLJWYkFPqzK9fxdIEwawU/p9hYSMd3K0XbdAav3iSsrYV
# 5vBljWRGXgWOyEauBSnajQeL+fFLnpXKZYymsHNu0y7fM76jJ3sSYiTwJ/mtsi9O
# 7UK4mTDkVqlO7Ad+2ujP+B7LrmN25seusxrHzgrG0AG9wS7eCQitXjxuhOLOv671
# VzXfeZJF4Hja+85thv6r2Jco49tXAxafn4n7NC9fv8XypeBrVFryhSwzzahxC36u
# TCIyuusV5VppMYyQWhHpOqNqv5Vl1fusexPAgvS3sJ8XDVBfWv0Sdt9XbsQNhcJ1
# dBdrKOQTsPXL7UrBU2Us+tHnfwklqNkHsKUo/5fwfY9SLKo1Y1wQ7sh61599fiyb
# dUmA78aWy8TrEE7RoL/eUuTo4nCQoB2CaImN2d7kp568VP+xl3uJor5oJ6anQxAQ
# T1MGHg+ViXD4yFiZANjFWB+WpjF8ZSkjIAolZMOhXrAegh+AfYYAnXWx24GwH3oI
# EVNV/l/E5rN0ZY6xT96j0Z2hqb3wVck94h5oIfBtaHeC+gzHTAz/d07HBm2bPhD4
# fnMHS9gpiogSenqrZ+uBVx3i1/ShkfNaMNQEpqubJKpjGT++SY0eXsRxi5DlpHui
# 6lnxz/3j8NqQb9MT9TeVzxrm2v73/WDTUDkp6dUDHxfgCGUw4yw=
# =IG5K
# -----END PGP SIGNATURE-----
# gpg: Signature made Tue Oct 21 13:22:12 2025 MDT
# gpg:                using RSA key C1B8A89510FF7846011C6A799F2CF38AEBDB0AE0
# gpg: Good signature from "Andy Miller <rhuk@mac.com>" [ultimate]
2025-10-21 13:22:12 -06:00
Andy Miller
841259ca2a Merge branch 'release/1.7.50.2' 2025-10-21 13:22:11 -06:00
Andy Miller
20809f3fea prepare for release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-21 13:22:01 -06:00
Andy Miller
1b75df73ef fix for #3966
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-21 13:19:00 -06:00
Andy Miller
a620556e4c Merge branch 'release/1.7.50.1' 2025-10-20 13:38:28 -06:00
Andy Miller
975a2a8dd3 Merge tag '1.7.50.1' into develop
Release 1.7.50.1

# -----BEGIN PGP SIGNATURE-----
#
# iQIzBAABCAAdFiEEwbiolRD/eEYBHGp5nyzziuvbCuAFAmj2j7QACgkQnyzziuvb
# CuC/Iw//SuM31uHMaVCk0aVKXrLQtTHGwmXJq3A6cE+su0ODSXJyp2T/Jj+ydUa3
# vsDp0rK4Dw7YoO1v5upTc1GY+AlGBEIO+6AFzusN/yTz7ihL1hddua2TtjN0sxUQ
# kz8p5CSxQMz87plC1jtTsWRh18J28/g7z76FoBInDninoB8+EKp1Lm30XUsMsrv0
# hU0+DbUI5GO7C8T7bAWGRh+WGh8RwGiNfbiSaUoeDKyT1kFrnOrDN4OtCvUk/Y52
# 2J8sUtEUlwPqNZxa6UtwRf4nLMKKv+6oJMZCL6Sg6bHlhVfpNwlg5LzRQuS0CaIN
# q+crP145IK038RI9DMyq6cfYX574i12iEzqGlrM4kPfpIxHNtCRJT33/b9+g/64j
# 9RT0PPxHBzqHTjp2WmjMzyZkYmPRBORUuYckmxTIT02Fa+H9Rv0pJQQBXvN1UDsk
# qDbfdOS01IIvOIo1Dyj+5EUZDOa1pWIEqs/1HoMHns9txBXIy7qW7OAIsIwvagm/
# ctPfm2lrZ1nPBcW9oEn+SM2mGHj71K6a+TvaeLAqkgd1vnOfJ5vPyNaqtXOpCdnB
# eUjp/ReYgmf0UAoxHWg2pOQjltmOzPLTU9vaULmONs7B+I0Gwc+dDH/k9FlI5ITd
# xU6AKR1ERsu3Oo26jtzhZp+NId5zFn6wPoMx+YKH5RkRimlcVIw=
# =xZ8q
# -----END PGP SIGNATURE-----
# gpg: Signature made Mon Oct 20 13:38:28 2025 MDT
# gpg:                using RSA key C1B8A89510FF7846011C6A799F2CF38AEBDB0AE0
# gpg: Good signature from "Andy Miller <rhuk@mac.com>" [ultimate]
2025-10-20 13:38:28 -06:00
Andy Miller
915991ac6a prepare for release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-20 13:38:14 -06:00
Andy Miller
3fad2a8173 fix for GRAV_ROOT
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-20 13:34:42 -06:00
Andy Miller
06471eb8cf sync with 1.8 changes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-20 13:23:50 -06:00
Andy Miller
71eb774a39 support labels in recovery mode
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-19 21:43:58 -06:00
Andy Miller
65689101ab fix recovery mode
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-19 21:09:16 -06:00
Andy Miller
23d92e6a41 jump into recovery mode
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-19 20:47:34 -06:00
Andy Miller
1982717272 more recovery manage fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-19 18:36:50 -06:00
Andy Miller
e82a0ce8bd more recovery fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-19 18:17:59 -06:00
Andy Miller
38840ff080 recovery/command fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-19 17:54:15 -06:00
Andy Miller
3cf616e609 more fixes for recovery.window and recovery.flag
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-19 17:32:55 -06:00
Andy Miller
ea5ba5dda3 Merge branch 'release/1.7.50' 2025-10-19 16:47:51 -06:00
Andy Miller
f437235eb9 Merge tag '1.7.50' into develop
Release v1.7.50

# -----BEGIN PGP SIGNATURE-----
#
# iQIzBAABCAAdFiEEwbiolRD/eEYBHGp5nyzziuvbCuAFAmj1apcACgkQnyzziuvb
# CuAfWA/8D21MkMWNjirnjc/osndB/SiEkSD2tB1TAIv3099vcGb4n1+OQ4hV0Cr+
# zixeH/bBZJwi1s1nrb6MKnDJMuzOpBOxgfFPWjU3FhG6pBhx7i1YXCH4CvANVFnP
# HNY9mI9PV8ZZ1ymjZF4I3dhOJqKzY9cY1F8RwUTS/iM03cMgrE8V66uMJIYF2Ti7
# d9QcH9ICCOv5en/u26vWRUDkRA+OseSOrc0FMbzMb9x0yqbTDFq4zjOoa6urAwj4
# kMXf8fBdE2N47DkyBr5aEPJqh7fd0xqCfBPQneHciZcBfGGKG4j4PT7IiFq7X6yA
# 4fCJ5+VuSUYP2aSUBugngwTledzh0JfMa0pRP6q6S97HAJwqYJ1eNm3NCI5xplpK
# n4ck/z//06qrYTvxx/ZTLQGGVRvP7OH/Zs1JfYbLb5TNGCTYL18v/t2xeThoGZBo
# vYsv6wXXSlELV5XVCzIsUwsilRgIwa+b2RvEcyEL6JG5QRRvDo4GcBBTHdZ6NVLH
# qkWId9bSlNBYMHuz0AR+LCTrr4KvMwLobLORu2yBP+qtiQ9x3sxapmCJ1iCQE0SZ
# Bxbdd8PPiQ/pANT6snLqM/IXTJcZn4dCGp0yaFkOeNkXOFJAFUGwW9AYQJU3fEHq
# nDu57yI9c0g4GCmnzmcZ2bLvG69C073ciRZIl+f5lGGn2Xp31pM=
# =Va0w
# -----END PGP SIGNATURE-----
# gpg: Signature made Sun Oct 19 16:47:51 2025 MDT
# gpg:                using RSA key C1B8A89510FF7846011C6A799F2CF38AEBDB0AE0
# gpg: Good signature from "Andy Miller <rhuk@mac.com>" [ultimate]
2025-10-19 16:47:51 -06:00
Andy Miller
80b8389432 prepare for release
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-19 16:47:42 -06:00
Andy Miller
5815c8cae5 move recover.flag
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-19 16:03:25 -06:00
Andy Miller
0ac77271cc updated changelog
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-19 14:59:42 -06:00
Andy Miller
269bf78084 ignore unpublished plugins - part 2 2025-10-19 11:04:18 -06:00
Andy Miller
cd5f3842ed ignore unpublished plugins 2025-10-19 10:51:23 -06:00
Andy Miller
997bdfff07 fix test
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-18 19:09:26 -06:00
Andy Miller
da0fbf9dd6 better label handling for snapshots
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-18 19:06:27 -06:00
Andy Miller
6a4ab16529 more restore bin fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-18 18:55:33 -06:00
Andy Miller
c9640d7258 create adhoc snapshot
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-18 18:42:08 -06:00
Andy Miller
7325eb2cfe run / restore feature
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-18 13:48:02 -06:00
Andy Miller
f30cd26956 bin/restore enhancement
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-17 22:18:54 -06:00
Andy Miller
17706d5647 stop cache clearing snapshots
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-17 21:08:08 -06:00
Andy Miller
a0b64b6d88 more refactoring of safe install
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-17 20:47:48 -06:00
Andy Miller
4650bd073e filter out extra folders
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-17 19:54:05 -06:00
Andy Miller
4cab0a7ba1 optimized staged package
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-17 19:33:03 -06:00
Andy Miller
44fd1172b8 more granular install for self upgrade
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-17 18:18:53 -06:00
Andy Miller
920642411c move back to cp instead of mv for snapshots
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-17 17:53:48 -06:00
Andy Miller
2999c06a3a change snapshot storage 2025-10-17 16:49:42 -06:00
Andy Miller
d97b2d70bd logic fixes 2025-10-17 16:18:40 -06:00
Andy Miller
5e7b482972 test fix
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-17 11:34:35 -06:00
Andy Miller
9230a5a40f ingore recovery window
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-17 11:32:38 -06:00
Andy Miller
286b5a5179 fix for binary permissions in CLI 2025-10-17 11:26:43 -06:00
Andy Miller
70d6aec1a7 another fix for safe upgrade 2025-10-17 11:07:17 -06:00
Andy Miller
9dd507b717 route safeupgrade status 2025-10-16 23:31:05 -06:00
Andy Miller
b6a37cfff3 preserver root files 2025-10-16 23:17:34 -06:00
Andy Miller
09aa2fb8fd ensureJobResult
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 21:28:08 -06:00
Andy Miller
3f0b204728 Add new SafeUpgradeRun CLI command
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 17:32:43 -06:00
Andy Miller
f10894fe47 fixes for permission retention
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 15:24:12 -06:00
Andy Miller
b68872e3fd Monolog 3 compatible shim to handle upgrades
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 14:32:05 -06:00
Andy Miller
43126b09e4 fixes for 1.8 upgrades
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 14:19:16 -06:00
Andy Miller
2c4b69f9ec Merge branch 'develop' of github.com:getgrav/grav into develop 2025-10-16 12:01:14 -06:00
Andy Miller
d6cbc263e7 source fix in restore bin + missing dot files after upgrade
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 11:56:40 -06:00
Andy Miller
c56d24c0d7 timelimt on recovery status
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 09:08:53 -06:00
Andy Miller
7192cfe549 synced restore changes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-16 08:09:47 -06:00
Andy Miller
a5c6f1dbe9 Merge branch 'feature/installer-rewrite' into develop 2025-10-15 20:15:29 -06:00
Andy Miller
c8227b38fc standalone grav-restore fixes
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-15 20:14:15 -06:00
Andy Miller
77114ecdd0 grav/restore dedicated binary
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-15 14:20:30 -06:00
Andy Miller
23da92d0ff honor staging_root
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-15 13:49:36 -06:00
Andy Miller
f88c09adca update GRAV_VERSION for testing
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-15 13:12:28 -06:00
Andy Miller
7dd5c8a0ba staging root config option
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-15 13:00:05 -06:00
Andy Miller
cf2ac28be2 bugfixes in safeupgradeservice
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-15 12:50:54 -06:00
Andy Miller
43ddf45057 latest tweak
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-15 12:42:38 -06:00
Andy Miller
57212ec9a5 better plugin checks
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-15 11:24:50 -06:00
Andy Miller
b55e86a8ba force upgrades before updating
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-15 11:00:54 -06:00
Andy Miller
2b1a7d3fb6 upgrade manager fix
Signed-off-by: Andy Miller <rhuk@mac.com>
2025-10-15 10:44:19 -06:00
Andy Miller
250568bae5 initial safeupgrade work 2025-10-15 10:29:26 -06:00
Nakkouch Tarek
75d8356f1b Fixed Twig Sandbox Bypass due to nested expression (#3939) 2025-10-13 13:36:49 -06:00
pmoreno.rodriguez
c82645a42a wordCount Filter for Grav (#3957) 2025-10-13 13:35:33 -06:00
49 changed files with 6579 additions and 404 deletions

12
.github/dependabot.yaml vendored Normal file
View File

@@ -0,0 +1,12 @@
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
groups:
github-actions:
patterns:
- "*"

View File

@@ -15,7 +15,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v6
with:
ref: ${{ github.ref }}
- name: Extract Tag
run: echo "PACKAGE_VERSION=${{ github.ref }}" >> $GITHUB_ENV
@@ -65,7 +67,7 @@ jobs:
runs-on: ubuntu-latest
if: always()
steps:
- uses: technote-space/workflow-conclusion-action@v2
- uses: technote-space/workflow-conclusion-action@v3
- uses: 8398a7/action-slack@v3
with:
status: failure

View File

@@ -19,7 +19,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Setup PHP ${{ matrix.php }}
uses: shivammathur/setup-php@v2

View File

@@ -22,7 +22,7 @@ jobs:
WORKFLOW: "build-skeleton.yml"
AUTH: ":${{secrets.GLOBAL_TOKEN}}"
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v6
- name: Make it rain ☔️
run: |
SKELETONS=`curl -s "${{secrets.SKELETONS_JSON_LIST}}"`

5
.gitignore vendored
View File

@@ -48,3 +48,8 @@ tests/cache/*
tests/error.log
system/templates/testing/*
/user/config/versions.yaml
/system/recovery.window
tmp/*
#needs_fixing.txt
/AGENTS.md
/.claude

View File

@@ -1,96 +0,0 @@
language: php
php:
- '7.1'
- '7.2'
- '7.3'
- '7.4'
branches:
only:
- build_test
notifications:
email:
on_success: never
on_failure: always
slack:
secure: dowksPsxxCxGKT6nis5hUgkp6+ZDAhoqzQHF9rJnx4hx0iEygPhVBs7pKl9yL2jubYJoLs+EXwE7z1dYgDAEJh4BnfrCokCMLpFGcxVxQC/HeAUdSQ2/RtdBYR5PRT75ScaFpqM/SfXXZVtnwVXAw9Z+JC6BjQ9vmn23m51Jw4k=
env:
global:
# Colors!
- TEXTRESET=$(tput sgr0) # reset the foreground colour
- RED=$(tput setaf 1)
- GREEN=$(tput setaf 2)
- YELLOW=$(tput setaf 3)
- BLUE=$(tput setaf 4)
- BOLD=$(tput bold)
# User
- GH_USER="getgrav"
# Paths
- RT_DEVTOOLS=$HOME/devtools
- GOPATH="$HOME/go"
- PATH="$GOPATH/bin:$PATH"
# GH_TOKEN [API Key]
- secure: "NR9pV7YteY9OoPmjDTQG0fDfocVu+tCeiDH1F2GFhXCu71UOIvqWXpOxp0RHkG5GIXdCFHx59yu+ZO275lbaHkbF8+4lVSVrV4RcGn+pIncvxr6iZCVW05dbAxV3H8alK+xYJRGmbyfQl5wIM49WvmuGHZjcmIloS4t/omQ3N+I="
# BB_TOKEN value => "user:pass@"
- secure: "einUtSEkUWy2IrqLXyVjwUU+mwaaoiOXRRVdLBpA3Zye6bZx8cm5h/5AplkPWhM/NmCJoW/MwNZHHkFhlr3mDRov5iOxVmTTYfnXB+I5lxYTSgduOLLErS7mU8hfADpVDU8bHNU44fNGD3UEiG1PD4qQBX4DMlqIFmR20mjs81k="
# GH_API_USER [for curl]
- secure: "AQGcX1B2NrI8ajflY4AimZDNcK2kBA3F6mbtEFQ78NkDoWhMipsQHayWXiSTzRc0YJKvQl2Y16MTwQF4VHzjTAiiZFATgA8J88vQUjIPabi/kKjqSmcLFoaAOAxStQbW6e0z2GiQ6KBMcNF1y5iUuI63xVrBvtKrYX/w5y+ako8="
before_install:
- export TZ=Pacific/Honolulu
- echo $TRAVIS_PHP_VERSION
- echo $TRAVIS_BRANCH
- echo $TRAVIS_PULL_REQUEST
- composer self-update
- if [ $TRAVIS_BRANCH == 'develop' ] || [ $TRAVIS_PULL_REQUEST != 'false' ]; then
composer install --dev --prefer-dist;
fi
- |
if [ $TRAVIS_BRANCH != 'develop' ] && [ $TRAVIS_PHP_VERSION == "7.1" ] && [ $TRAVIS_PULL_REQUEST == "false" ]; then
export TRAVIS_TAG=$(curl -H "Authorization: token ${GH_TOKEN}" --fail -s https://api.github.com/repos/getgrav/grav/releases/latest | grep tag_name | head -n 1 | cut -d '"' -f 4);
eval "$(curl -sL https://raw.githubusercontent.com/travis-ci/gimme/master/gimme | GIMME_GO_VERSION=1.13 bash)";
go get github.com/github-release/github-release;
git clone --quiet --depth=50 --branch=master https://${BB_TOKEN}bitbucket.org/rockettheme/grav-devtools.git $RT_DEVTOOLS &>/dev/null;
if [ ! -z "$TRAVIS_TAG" ]; then
cd ${RT_DEVTOOLS};
./build-grav.sh skeletons.txt;
fi;
fi
before_script:
- phpenv config-rm xdebug.ini
script:
- if [ $TRAVIS_BRANCH == 'develop' ] || [ $TRAVIS_PULL_REQUEST != 'false' ]; then
vendor/bin/codecept run;
fi
- echo "Latest Release Tag - ${TRAVIS_TAG}"
- if [ ! -z "$TRAVIS_TAG" ] && [ $TRAVIS_BRANCH != 'develop' ] && [ $TRAVIS_PHP_VERSION == "7.1" ] && [ $TRAVIS_PULL_REQUEST == "false" ]; then
FILES="$RT_DEVTOOLS/grav-dist/*.zip";
for file in ${FILES[@]}; do
NAME=${file##*/};
if [[ "$NAME" == *"-rc"* ]]; then
REPO="$(echo ${NAME} | rev | cut -f 3- -d "-" | rev)";
else
REPO="$(echo ${NAME} | rev | cut -f 2- -d "-" | rev)";
fi;
if [[ $REPO == 'grav' || $REPO == 'grav-admin' || $REPO == 'grav-update' ]]; then
REPO="grav";
fi;
API="$(curl --fail --user "${GH_API_USER}" -s https://api.github.com/repos/${GH_USER}/${REPO}/releases/latest)";
ASSETS="$(echo "${API}" | node gh-assets.js)";
TAG="$(echo "${API}" | grep tag_name | head -n 1 | cut -d '"' -f 4)";
if [ $REPO == "grav" ]; then
TAG="$TRAVIS_TAG";
fi;
if [ ! -z "$ASSETS" ]; then
for asset in ${ASSETS[@]}; do
asset_id=$(echo ${asset} | cut -d ':' -f 1);
asset_name=$(echo ${asset} | cut -d ':' -f 2);
if [ "${NAME}" == "${asset_name}" ]; then
echo -e "\nAsset ${BOLD}${BLUE}${NAME}${TEXTRESET} already exists in ${YELLOW}${REPO}${TEXTRESET}@${BOLD}${YELLOW}${TAG}${TEXTRESET}... deleting id ${BOLD}${RED}${asset_id}${TEXTRESET}...";
curl -X DELETE --fail --user "${GH_API_USER}" "https://api.github.com/repos/${GH_USER}/${REPO}/releases/assets/${asset_id}";
fi;
done;
fi;
echo "Uploading package ${BOLD}${BLUE}${NAME}${TEXTRESET} to ${YELLOW}${REPO}${TEXTRESET}@${YELLOW}${TAG}${TEXTRESET}";
github-release upload --security-token $GH_TOKEN --user ${GH_USER} --repo $REPO --tag "$TAG" --name "$NAME" --file "$file";
done;
fi

View File

@@ -1,3 +1,80 @@
# v1.7.50.9
## 11/09/2025
1. [](#improved)
* Better error warnings regarding upgrading from 1.7 -> 1.7 vs 1.7 -> 1.8
1. [](#bugfix)
* Fix for update-provided `Install.php` not used if local version called first
* Fix class loading error when trying to use `bin/gpm self-upgrade --safe`
# v1.7.50.8
## 11/06/2025
1. [](#bugfix)
* Removed over zealous safety checks
* Removed .gitattributes which was causing some unintended issues
# v1.7.50.7
## 11/05/2025
1. [](#improved)
* Exclude dev files from exports
* Remove dev file in clean command
1. [](#bugfix)
* Ignore .github and .phan folders during self-upgrade
* Fixed path check in self-upgrade
# v1.7.50.6
## 11/05/2025
1. [](#bugfix)
* Fixed an issue where non-upgradable root-level folders were snapshotted
# v1.7.50.5
## 11/05/2025
1. [](#new)
* Added new `bin/gpm preflight` command
* Added `--safe` and `--legacy` overrides for `bin/gpm self-upgrade` command
1. [](#improved)
* Improved JS assets pipeline handling to support different loading strategies
* More safe-upgrade fixes around safe guarding `/user/` and maintaining permissions better
1. [](#bugfix)
* Fixed a regex issue that corrupted safe-upgrade output
# v1.7.50.4
## 10/31/2025
1. [](#improved)
* More fixes and improvements for safe-uprade process
# v1.7.50.3
## 10/21/2025
1. [](#bugfix)
* Restored `user/config/system.yaml` to 1.7 branch version (testing mode off)
# v1.7.50.2
## 10/21/2025
1. [](#bugfix)
* Fix for `SafeUpgradeService::getLastManifest()` fatal error on upgrade [#3966](https://github.com/getgrav/grav/issues/3966)
# v1.7.50.1
## 10/20/2025
1. [](#bugfix)
* Fix for broken `GRAV_ROOT`
# v1.7.50
## 10/19/2025
1. [](#new)
* Added new **Safe Core Upgrade** process with snapshots for backup and restore, better preflight and postflight checks, as well as exception checking post-install for easy rollback.
* Introduced recovery mode with token-gated UI, plugin quarantine, and CLI rollback support.
* Added `bin/gpm preflight` compatibility scanner and `bin/gpm rollback` utility.
* Added `wordCount` Twig filter [#3957](https://github.com/getgrav/grav/pulls/3957)
# v1.7.49.5
## 09/10/2025

209
bin/build-test-update.php Executable file
View File

@@ -0,0 +1,209 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use Grav\Common\Filesystem\Folder;
require __DIR__ . '/../vendor/autoload.php';
if (!\defined('GRAV_ROOT')) {
\define('GRAV_ROOT', realpath(__DIR__ . '/..') ?: getcwd());
}
if (!\extension_loaded('zip')) {
fwrite(STDERR, "The PHP zip extension is required.\n");
exit(1);
}
$options = getopt('', [
'version:',
'output::',
'port::',
'base-url::',
'serve',
]);
if (!isset($options['version'])) {
fwrite(
STDERR,
"Usage: php bin/build-test-update.php --version=1.7.999 [--output=tmp/test-gpm] [--port=8043] [--base-url=http://127.0.0.1:8043] [--serve]\n"
);
exit(1);
}
$version = trim((string) $options['version']);
if ($version === '') {
fwrite(STDERR, "A non-empty --version value is required.\n");
exit(1);
}
$root = GRAV_ROOT;
$output = $options['output'] ?? $root . '/tmp/test-gpm';
if (!str_starts_with($output, DIRECTORY_SEPARATOR)) {
$output = $root . '/' . ltrim($output, '/');
}
$output = rtrim($output, DIRECTORY_SEPARATOR);
$defaultPort = isset($options['port']) ? (int) $options['port'] : 8043;
$baseUrl = $options['base-url'] ?? sprintf('http://127.0.0.1:%d', $defaultPort);
$serve = array_key_exists('serve', $options);
Folder::create($output);
$downloadName = sprintf('grav-update-%s.zip', $version);
$zipPath = $output . '/' . $downloadName;
$jsonPath = $output . '/grav.json';
$zipPrefix = 'grav-update/';
$excludeDirs = [
'.build',
'.crush',
'.ddev',
'.git',
'.github',
'.gitlab',
'.circleci',
'.idea',
'.vscode',
'.pytest_cache',
'backup',
'cache',
'images',
'logs',
'node_modules',
'tests',
'tmp',
'user',
];
$excludeFiles = [
'.htaccess',
'.DS_Store',
'robots.txt',
];
$directory = new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS);
$filtered = new RecursiveCallbackFilterIterator(
$directory,
function (SplFileInfo $current) use ($root, $excludeDirs, $excludeFiles): bool {
$relative = ltrim(str_replace($root, '', $current->getPathname()), DIRECTORY_SEPARATOR);
$relative = str_replace('\\', '/', $relative);
if ($relative === '') {
return true;
}
if (str_contains($relative, '..')) {
return false;
}
foreach ($excludeDirs as $prefix) {
$prefix = trim($prefix, '/');
if ($prefix === '') {
continue;
}
if ($relative === $prefix || str_starts_with($relative, $prefix . '/')) {
return false;
}
}
if (in_array($current->getFilename(), $excludeFiles, true)) {
return false;
}
return true;
}
);
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException(sprintf('Unable to open archive at %s', $zipPath));
}
$zip->addEmptyDir($zipPrefix);
$iterator = new RecursiveIteratorIterator($filtered, RecursiveIteratorIterator::SELF_FIRST);
/** @var SplFileInfo $fileInfo */
foreach ($iterator as $fileInfo) {
$fullPath = $fileInfo->getPathname();
$relative = ltrim(str_replace($root, '', $fullPath), DIRECTORY_SEPARATOR);
$relative = str_replace('\\', '/', $relative);
$targetPath = $zipPrefix . $relative;
if ($fileInfo->isDir()) {
$zip->addEmptyDir(rtrim($targetPath, '/') . '/');
continue;
}
if ($fileInfo->isLink()) {
$target = readlink($fullPath);
$zip->addFromString($targetPath, $target === false ? '' : $target);
$zip->setExternalAttributesName($targetPath, ZipArchive::OPSYS_UNIX, 0120000 << 16);
continue;
}
$zip->addFile($fullPath, $targetPath);
$perms = @fileperms($fullPath);
if ($perms !== false) {
$zip->setExternalAttributesName($targetPath, ZipArchive::OPSYS_UNIX, ($perms & 0xFFFF) << 16);
}
}
$zip->close();
$size = filesize($zipPath);
$sha256 = hash_file('sha256', $zipPath);
$timestamp = date('c');
$downloadUrl = rtrim($baseUrl, '/') . '/' . rawurlencode($downloadName);
$manifest = [
'version' => $version,
'date' => $timestamp,
'min_php' => '8.3.0',
'assets' => [
'grav-update' => [
'name' => $downloadName,
'slug' => 'grav-update',
'version' => $version,
'date' => $timestamp,
'testing' => false,
'description' => 'Local test update package generated for safe-upgrade validation.',
'download' => $downloadUrl,
'size' => $size,
'checksum' => 'sha256:' . $sha256,
'sha256' => $sha256,
'host' => parse_url($downloadUrl, PHP_URL_HOST),
],
],
'changelog' => [
$version => [
'date' => $timestamp,
'content' => "- Local test update package generated by build-test-update.\n",
],
],
];
file_put_contents($jsonPath, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
$manifestUrl = rtrim($baseUrl, '/') . '/grav.json';
echo "Update package created at: {$zipPath}\n";
echo "Manifest written to: {$jsonPath}\n";
echo "Manifest URL: {$manifestUrl}\n";
echo "Download URL: {$downloadUrl}\n";
echo "Archive size: {$size} bytes\n";
echo "SHA256: {$sha256}\n";
if ($serve) {
$host = parse_url($baseUrl, PHP_URL_HOST) ?: '127.0.0.1';
$port = parse_url($baseUrl, PHP_URL_PORT) ?: $defaultPort;
$command = sprintf('php -S %s:%d -t %s', $host, $port, escapeshellarg($output));
echo "\nServing files using PHP built-in server. Press Ctrl+C to stop.\n";
echo $command . "\n\n";
passthru($command);
}

634
bin/restore Executable file
View File

@@ -0,0 +1,634 @@
#!/usr/bin/env php
<?php
/**
* Grav Snapshot Restore Utility
*
* Lightweight CLI that can list and apply safe-upgrade snapshots without
* bootstrapping the full Grav application (or any plugins).
*/
$root = dirname(__DIR__);
define('GRAV_CLI', true);
define('GRAV_REQUEST_TIME', microtime(true));
if (!file_exists($root . '/vendor/autoload.php')) {
fwrite(STDERR, "Unable to locate vendor/autoload.php. Run composer install first.\n");
exit(1);
}
$autoload = require $root . '/vendor/autoload.php';
if (!file_exists($root . '/index.php')) {
fwrite(STDERR, "FATAL: Must be run from Grav root directory.\n");
exit(1);
}
use Grav\Common\Filesystem\Folder;
use Grav\Common\Recovery\RecoveryManager;
use Grav\Common\Upgrade\SafeUpgradeService;
use Symfony\Component\Yaml\Yaml;
const RESTORE_USAGE = <<<USAGE
Grav Restore Utility
Usage:
bin/restore list [--staging-root=/absolute/path]
Lists all available snapshots (most recent first).
bin/restore apply <snapshot-id> [--staging-root=/absolute/path]
Restores the specified snapshot created by safe-upgrade.
bin/restore remove [<snapshot-id> ...] [--staging-root=/absolute/path]
Deletes one or more snapshots (interactive selection when no id provided).
bin/restore snapshot [--label=\"optional description\"] [--staging-root=/absolute/path]
Creates a manual snapshot of the current Grav core files.
bin/restore recovery [status|clear]
Shows the recovery flag context or clears it.
Options:
--staging-root Overrides the staging directory (defaults to configured value).
--label Optional label to store with the manual snapshot.
Examples:
bin/restore list
bin/restore apply stage-68eff31cc4104
bin/restore apply stage-68eff31cc4104 --staging-root=/var/grav-backups
bin/restore snapshot --label=\"Before plugin install\"
bin/restore recovery status
bin/restore recovery clear
USAGE;
/**
* @param array $args
* @return array{command:string,arguments:array,options:array}
*/
function parseArguments(array $args): array
{
array_shift($args); // remove script name
$command = null;
$arguments = [];
$options = [];
while ($args) {
$arg = array_shift($args);
if (strncmp($arg, '--', 2) === 0) {
$parts = explode('=', substr($arg, 2), 2);
$name = $parts[0] ?? '';
if ($name === '') {
continue;
}
$value = $parts[1] ?? null;
if ($value === null && $args && substr($args[0], 0, 2) !== '--') {
$value = array_shift($args);
}
$options[$name] = $value ?? true;
continue;
}
if (null === $command) {
$command = $arg;
} else {
$arguments[] = $arg;
}
}
if (null === $command) {
$command = 'interactive';
}
return [
'command' => $command,
'arguments' => $arguments,
'options' => $options,
];
}
/**
* @param array $options
* @return SafeUpgradeService
*/
function createUpgradeService(array $options): SafeUpgradeService
{
$serviceOptions = ['root' => GRAV_ROOT];
if (isset($options['staging-root']) && is_string($options['staging-root']) && $options['staging-root'] !== '') {
$serviceOptions['staging_root'] = $options['staging-root'];
}
return new SafeUpgradeService($serviceOptions);
}
/**
* @return list<array{id:string,label:?string,source_version:?string,target_version:?string,created_at:int}>
*/
function loadSnapshots(): array
{
$manifestDir = GRAV_ROOT . '/user/data/upgrades';
if (!is_dir($manifestDir)) {
return [];
}
$files = glob($manifestDir . '/*.json') ?: [];
rsort($files);
$snapshots = [];
foreach ($files as $file) {
$decoded = json_decode(file_get_contents($file) ?: '', true);
if (!is_array($decoded) || empty($decoded['id'])) {
continue;
}
$snapshots[] = [
'id' => $decoded['id'],
'label' => $decoded['label'] ?? null,
'source_version' => $decoded['source_version'] ?? null,
'target_version' => $decoded['target_version'] ?? null,
'created_at' => (int)($decoded['created_at'] ?? 0),
];
}
return $snapshots;
}
/**
* @param list<array{id:string,label:?string,source_version:?string,target_version:?string,created_at:int}> $snapshots
* @return string
*/
function formatSnapshotListLine(array $snapshot): string
{
$restoreVersion = $snapshot['source_version'] ?? $snapshot['target_version'] ?? 'unknown';
$timeLabel = formatSnapshotTimestamp($snapshot['created_at']);
$label = $snapshot['label'] ?? null;
$display = $label ? sprintf('%s [%s]', $label, $snapshot['id']) : $snapshot['id'];
return sprintf('%s (restore to Grav %s, %s)', $display, $restoreVersion, $timeLabel);
}
function formatSnapshotTimestamp(int $timestamp): string
{
if ($timestamp <= 0) {
return 'time unknown';
}
try {
$timezone = resolveTimezone();
$dt = new DateTime('@' . $timestamp);
$dt->setTimezone($timezone);
$formatted = $dt->format('Y-m-d H:i:s T');
} catch (\Throwable $e) {
$formatted = date('Y-m-d H:i:s T', $timestamp);
}
return $formatted . ' (' . formatRelative(time() - $timestamp) . ')';
}
function resolveTimezone(): DateTimeZone
{
static $resolved = null;
if ($resolved instanceof DateTimeZone) {
return $resolved;
}
$timezone = null;
$configFile = GRAV_ROOT . '/user/config/system.yaml';
if (is_file($configFile)) {
try {
$data = Yaml::parse(file_get_contents($configFile) ?: '') ?: [];
if (!empty($data['system']['timezone']) && is_string($data['system']['timezone'])) {
$timezone = $data['system']['timezone'];
}
} catch (\Throwable $e) {
// ignore parse errors, fallback below
}
}
if (!$timezone) {
$timezone = ini_get('date.timezone') ?: 'UTC';
}
try {
$resolved = new DateTimeZone($timezone);
} catch (\Throwable $e) {
$resolved = new DateTimeZone('UTC');
}
return $resolved;
}
function formatRelative(int $seconds): string
{
if ($seconds < 5) {
return 'just now';
}
$negative = $seconds < 0;
$seconds = abs($seconds);
$units = [
31536000 => 'y',
2592000 => 'mo',
604800 => 'w',
86400 => 'd',
3600 => 'h',
60 => 'm',
1 => 's',
];
foreach ($units as $size => $label) {
if ($seconds >= $size) {
$value = (int)floor($seconds / $size);
$suffix = $label === 'mo' ? 'month' : ($label === 'y' ? 'year' : ($label === 'w' ? 'week' : ($label === 'd' ? 'day' : ($label === 'h' ? 'hour' : ($label === 'm' ? 'minute' : 'second')))));
if ($value !== 1) {
$suffix .= 's';
}
$phrase = $value . ' ' . $suffix;
return $negative ? 'in ' . $phrase : $phrase . ' ago';
}
}
return $negative ? 'in 0 seconds' : '0 seconds ago';
}
/**
* @param string $snapshotId
* @param array $options
* @return void
*/
function applySnapshot(string $snapshotId, array $options): void
{
try {
$service = createUpgradeService($options);
$manifest = $service->rollback($snapshotId);
} catch (\Throwable $e) {
fwrite(STDERR, "Restore failed: " . $e->getMessage() . "\n");
exit(1);
}
if (!$manifest) {
fwrite(STDERR, "Snapshot {$snapshotId} not found.\n");
exit(1);
}
$version = $manifest['source_version'] ?? $manifest['target_version'] ?? 'unknown';
echo "Restored snapshot {$snapshotId} (Grav {$version}).\n";
if (!empty($manifest['id'])) {
echo "Snapshot manifest: {$manifest['id']}\n";
}
if (!empty($manifest['backup_path'])) {
echo "Snapshot path: {$manifest['backup_path']}\n";
}
exit(0);
}
/**
* @param array $options
* @return void
*/
function createManualSnapshot(array $options): void
{
$label = null;
if (isset($options['label']) && is_string($options['label'])) {
$label = trim($options['label']);
if ($label === '') {
$label = null;
}
}
try {
$service = createUpgradeService($options);
$manifest = $service->createSnapshot($label);
} catch (\Throwable $e) {
fwrite(STDERR, "Snapshot creation failed: " . $e->getMessage() . "\n");
exit(1);
}
$snapshotId = $manifest['id'] ?? null;
if (!$snapshotId) {
$snapshotId = 'unknown';
}
$version = $manifest['source_version'] ?? $manifest['target_version'] ?? 'unknown';
echo "Created snapshot {$snapshotId} (Grav {$version}).\n";
if ($label) {
echo "Label: {$label}\n";
}
if (!empty($manifest['backup_path'])) {
echo "Snapshot path: {$manifest['backup_path']}\n";
}
exit(0);
}
/**
* @param list<array{id:string,source_version:?string,target_version:?string,created_at:int}> $snapshots
* @return string|null
*/
function promptSnapshotSelection(array $snapshots): ?string
{
echo "Available snapshots:\n";
foreach ($snapshots as $index => $snapshot) {
$line = formatSnapshotListLine($snapshot);
$number = $index + 1;
echo sprintf(" [%d] %s\n", $number, $line);
}
$default = $snapshots[0]['id'];
echo "\nSelect a snapshot to restore [1]: ";
$input = trim((string)fgets(STDIN));
if ($input === '') {
return $default;
}
if (ctype_digit($input)) {
$idx = (int)$input - 1;
if (isset($snapshots[$idx])) {
return $snapshots[$idx]['id'];
}
}
foreach ($snapshots as $snapshot) {
if (strcasecmp($snapshot['id'], $input) === 0) {
return $snapshot['id'];
}
}
echo "Invalid selection. Aborting.\n";
return null;
}
/**
* @param list<array{id:string,source_version:?string,target_version:?string,created_at:int}> $snapshots
* @return array<string>
*/
function promptSnapshotsRemoval(array $snapshots): array
{
echo "Available snapshots:\n";
foreach ($snapshots as $index => $snapshot) {
$line = formatSnapshotListLine($snapshot);
$number = $index + 1;
echo sprintf(" [%d] %s\n", $number, $line);
}
echo "\nSelect snapshots to remove (comma or space separated numbers / ids, 'all' for everything, empty to cancel): ";
$input = trim((string)fgets(STDIN));
if ($input === '') {
return [];
}
$inputLower = strtolower($input);
if ($inputLower === 'all' || $inputLower === '*') {
return array_values(array_unique(array_column($snapshots, 'id')));
}
$tokens = preg_split('/[\\s,]+/', $input, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$selected = [];
foreach ($tokens as $token) {
if (ctype_digit($token)) {
$idx = (int)$token - 1;
if (isset($snapshots[$idx])) {
$selected[] = $snapshots[$idx]['id'];
continue;
}
}
foreach ($snapshots as $snapshot) {
if (strcasecmp($snapshot['id'], $token) === 0) {
$selected[] = $snapshot['id'];
break;
}
}
}
return array_values(array_unique(array_filter($selected)));
}
/**
* @param string $snapshotId
* @return array{success:bool,message:string}
*/
function removeSnapshot(string $snapshotId): array
{
$manifestDir = GRAV_ROOT . '/user/data/upgrades';
$manifestPath = $manifestDir . '/' . $snapshotId . '.json';
if (!is_file($manifestPath)) {
return [
'success' => false,
'message' => "Snapshot {$snapshotId} not found."
];
}
$manifest = json_decode(file_get_contents($manifestPath) ?: '', true);
if (!is_array($manifest)) {
return [
'success' => false,
'message' => "Snapshot {$snapshotId} manifest is invalid."
];
}
$pathsToDelete = [];
foreach (['package_path', 'backup_path'] as $key) {
if (!empty($manifest[$key]) && is_string($manifest[$key])) {
$pathsToDelete[] = $manifest[$key];
}
}
$errors = [];
foreach ($pathsToDelete as $path) {
if (!$path) {
continue;
}
if (!file_exists($path)) {
continue;
}
try {
if (is_dir($path)) {
Folder::delete($path);
} else {
@unlink($path);
}
} catch (\Throwable $e) {
$errors[] = "Unable to remove {$path}: " . $e->getMessage();
}
}
if (!@unlink($manifestPath)) {
$errors[] = "Unable to delete manifest file {$manifestPath}.";
}
if ($errors) {
return [
'success' => false,
'message' => implode(' ', $errors)
];
}
return [
'success' => true,
'message' => "Removed snapshot {$snapshotId}."
];
}
$cli = parseArguments($argv);
$command = $cli['command'];
$arguments = $cli['arguments'];
$options = $cli['options'];
switch ($command) {
case 'interactive':
$snapshots = loadSnapshots();
if (!$snapshots) {
echo "No snapshots found. Run bin/gpm self-upgrade (with safe upgrade enabled) to create one.\n";
exit(0);
}
$selection = promptSnapshotSelection($snapshots);
if (!$selection) {
exit(1);
}
applySnapshot($selection, $options);
break;
case 'list':
$snapshots = loadSnapshots();
if (!$snapshots) {
echo "No snapshots found. Run bin/gpm self-upgrade (with safe upgrade enabled) to create one.\n";
exit(0);
}
echo "Available snapshots:\n";
foreach ($snapshots as $snapshot) {
echo ' - ' . formatSnapshotListLine($snapshot) . "\n";
}
exit(0);
case 'remove':
$snapshots = loadSnapshots();
if (!$snapshots) {
echo "No snapshots found. Nothing to remove.\n";
exit(0);
}
$selectedIds = [];
if ($arguments) {
foreach ($arguments as $arg) {
if (!$arg) {
continue;
}
$selectedIds[] = $arg;
}
} else {
$selectedIds = promptSnapshotsRemoval($snapshots);
if (!$selectedIds) {
echo "No snapshots selected. Aborting.\n";
exit(1);
}
}
$selectedIds = array_values(array_unique($selectedIds));
echo "Snapshots selected for removal:\n";
foreach ($selectedIds as $id) {
echo " - {$id}\n";
}
$autoConfirm = isset($options['yes']) || isset($options['y']);
if (!$autoConfirm) {
echo "\nThis action cannot be undone. Proceed? [y/N] ";
$confirmation = strtolower(trim((string)fgets(STDIN)));
if (!in_array($confirmation, ['y', 'yes'], true)) {
echo "Aborted.\n";
exit(1);
}
}
$success = 0;
foreach ($selectedIds as $id) {
$result = removeSnapshot($id);
echo $result['message'] . "\n";
if ($result['success']) {
$success++;
}
}
exit($success > 0 ? 0 : 1);
case 'apply':
$snapshotId = $arguments[0] ?? null;
if (!$snapshotId) {
echo "Missing snapshot id.\n\n" . RESTORE_USAGE . "\n";
exit(1);
}
applySnapshot($snapshotId, $options);
break;
case 'snapshot':
createManualSnapshot($options);
break;
case 'recovery':
$action = strtolower($arguments[0] ?? 'status');
$manager = new RecoveryManager(GRAV_ROOT);
switch ($action) {
case 'clear':
if ($manager->isActive()) {
$manager->clear();
echo "Recovery flag cleared.\n";
} else {
echo "Recovery mode is not active.\n";
}
exit(0);
case 'status':
if (!$manager->isActive()) {
echo "Recovery mode is not active.\n";
exit(0);
}
$context = $manager->getContext();
if (!$context) {
echo "Recovery flag present but context could not be parsed.\n";
exit(1);
}
$created = isset($context['created_at']) ? date('c', (int)$context['created_at']) : 'unknown';
$token = $context['token'] ?? '(missing)';
$message = $context['message'] ?? '(no message)';
$plugin = $context['plugin'] ?? '(none detected)';
$file = $context['file'] ?? '(unknown file)';
$line = $context['line'] ?? '(unknown line)';
echo "Recovery flag context:\n";
echo " Token: {$token}\n";
echo " Message: {$message}\n";
echo " Plugin: {$plugin}\n";
echo " File: {$file}\n";
echo " Line: {$line}\n";
echo " Created: {$created}\n";
$window = $manager->getUpgradeWindow();
if ($window) {
$expires = isset($window['expires_at']) ? date('c', (int)$window['expires_at']) : 'unknown';
$reason = $window['reason'] ?? '(unknown)';
echo " Window: active ({$reason}, expires {$expires})\n";
} else {
echo " Window: inactive\n";
}
exit(0);
default:
echo "Unknown recovery action: {$action}\n\n" . RESTORE_USAGE . "\n";
exit(1);
}
case 'help':
default:
echo RESTORE_USAGE . "\n";
exit($command === 'help' ? 0 : 1);
}

View File

@@ -26,6 +26,7 @@
"symfony/polyfill-php80": "^1.23",
"symfony/polyfill-php81": "^1.23",
"psr/simple-cache": "^1.0",
"psr/cache": "^1.0",
"psr/http-message": "^1.0",
"psr/http-server-middleware": "^1.0",
"psr/container": "~1.1.0",
@@ -55,7 +56,8 @@
"league/climate": "^3.6",
"miljar/php-exif": "^0.6",
"composer/ca-bundle": "^1.2",
"dragonmantank/cron-expression": "^3.3",
"dragonmantank/cron-expression": "~3.3.0",
"symfony/deprecation-contracts": "^2.2",
"willdurand/negotiation": "^3.0",
"itsgoingd/clockwork": "^5.0",
"symfony/http-client": "^4.4",
@@ -64,13 +66,15 @@
"multiavatar/multiavatar-php": "^1.0"
},
"require-dev": {
"behat/gherkin": "~4.10.0",
"codeception/codeception": "^4.1",
"phpstan/phpstan": "^1.8",
"phpstan/phpstan-deprecation-rules": "^1.0",
"phpunit/php-code-coverage": "~9.2",
"getgrav/markdowndocs": "^2.0",
"codeception/module-asserts": "^1.3",
"codeception/module-phpbrowser": "^1.0"
"codeception/module-phpbrowser": "^1.0",
"doctrine/instantiator": "^1.4"
},
"replace": {
"symfony/polyfill-php72": "*",
@@ -87,7 +91,10 @@
"ext-exif": "Needed to use exif data from images."
},
"config": {
"apcu-autoloader": true
"apcu-autoloader": true,
"audit": {
"block-insecure": false
}
},
"autoload": {
"psr-4": {

295
composer.lock generated
View File

@@ -4,20 +4,20 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "8d681f74b0bd1f5099bb8fbf788ab3eb",
"content-hash": "2d55f03bde4cf99b4790e878792a9ce0",
"packages": [
{
"name": "composer/ca-bundle",
"version": "1.5.8",
"version": "1.5.9",
"source": {
"type": "git",
"url": "https://github.com/composer/ca-bundle.git",
"reference": "719026bb30813accb68271fee7e39552a58e9f65"
"reference": "1905981ee626e6f852448b7aaa978f8666c5bc54"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/ca-bundle/zipball/719026bb30813accb68271fee7e39552a58e9f65",
"reference": "719026bb30813accb68271fee7e39552a58e9f65",
"url": "https://api.github.com/repos/composer/ca-bundle/zipball/1905981ee626e6f852448b7aaa978f8666c5bc54",
"reference": "1905981ee626e6f852448b7aaa978f8666c5bc54",
"shasum": ""
},
"require": {
@@ -64,7 +64,7 @@
"support": {
"irc": "irc://irc.freenode.org/composer",
"issues": "https://github.com/composer/ca-bundle/issues",
"source": "https://github.com/composer/ca-bundle/tree/1.5.8"
"source": "https://github.com/composer/ca-bundle/tree/1.5.9"
},
"funding": [
{
@@ -76,7 +76,7 @@
"type": "github"
}
],
"time": "2025-08-20T18:49:47+00:00"
"time": "2025-11-06T11:46:17+00:00"
},
{
"name": "composer/semver",
@@ -255,6 +255,7 @@
"type": "tidelift"
}
],
"abandoned": true,
"time": "2022-05-20T20:06:54+00:00"
},
{
@@ -377,16 +378,16 @@
},
{
"name": "donatj/phpuseragentparser",
"version": "v1.10.0",
"version": "v1.11.0",
"source": {
"type": "git",
"url": "https://github.com/donatj/PhpUserAgent.git",
"reference": "3ba73057d2a4a275badb88b7708e91e159c40367"
"reference": "c98541c5198bb75564d7db4a8971773bc848361e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/donatj/PhpUserAgent/zipball/3ba73057d2a4a275badb88b7708e91e159c40367",
"reference": "3ba73057d2a4a275badb88b7708e91e159c40367",
"url": "https://api.github.com/repos/donatj/PhpUserAgent/zipball/c98541c5198bb75564d7db4a8971773bc848361e",
"reference": "c98541c5198bb75564d7db4a8971773bc848361e",
"shasum": ""
},
"require": {
@@ -431,7 +432,7 @@
],
"support": {
"issues": "https://github.com/donatj/PhpUserAgent/issues",
"source": "https://github.com/donatj/PhpUserAgent/tree/v1.10.0"
"source": "https://github.com/donatj/PhpUserAgent/tree/v1.11.0"
},
"funding": [
{
@@ -447,20 +448,20 @@
"type": "ko_fi"
}
],
"time": "2024-10-30T15:45:03+00:00"
"time": "2025-09-10T21:58:40+00:00"
},
{
"name": "dragonmantank/cron-expression",
"version": "v3.4.0",
"version": "v3.3.3",
"source": {
"type": "git",
"url": "https://github.com/dragonmantank/cron-expression.git",
"reference": "8c784d071debd117328803d86b2097615b457500"
"reference": "adfb1f505deb6384dc8b39804c5065dd3c8c8c0a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/8c784d071debd117328803d86b2097615b457500",
"reference": "8c784d071debd117328803d86b2097615b457500",
"url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/adfb1f505deb6384dc8b39804c5065dd3c8c8c0a",
"reference": "adfb1f505deb6384dc8b39804c5065dd3c8c8c0a",
"shasum": ""
},
"require": {
@@ -473,14 +474,10 @@
"require-dev": {
"phpstan/extension-installer": "^1.0",
"phpstan/phpstan": "^1.0",
"phpstan/phpstan-webmozart-assert": "^1.0",
"phpunit/phpunit": "^7.0|^8.0|^9.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Cron\\": "src/Cron/"
@@ -504,7 +501,7 @@
],
"support": {
"issues": "https://github.com/dragonmantank/cron-expression/issues",
"source": "https://github.com/dragonmantank/cron-expression/tree/v3.4.0"
"source": "https://github.com/dragonmantank/cron-expression/tree/v3.3.3"
},
"funding": [
{
@@ -512,7 +509,7 @@
"type": "github"
}
],
"time": "2024-10-09T13:47:03+00:00"
"time": "2023-08-10T19:36:49+00:00"
},
{
"name": "erusev/parsedown",
@@ -903,16 +900,16 @@
},
{
"name": "itsgoingd/clockwork",
"version": "v5.3.4",
"version": "v5.3.5",
"source": {
"type": "git",
"url": "https://github.com/itsgoingd/clockwork.git",
"reference": "c27ad77a08a9e58bf0049de46969fa4fe3b506e5"
"reference": "d928483e231f042dbff9258795cb17aadaebc7d0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/itsgoingd/clockwork/zipball/c27ad77a08a9e58bf0049de46969fa4fe3b506e5",
"reference": "c27ad77a08a9e58bf0049de46969fa4fe3b506e5",
"url": "https://api.github.com/repos/itsgoingd/clockwork/zipball/d928483e231f042dbff9258795cb17aadaebc7d0",
"reference": "d928483e231f042dbff9258795cb17aadaebc7d0",
"shasum": ""
},
"require": {
@@ -967,7 +964,7 @@
],
"support": {
"issues": "https://github.com/itsgoingd/clockwork/issues",
"source": "https://github.com/itsgoingd/clockwork/tree/v5.3.4"
"source": "https://github.com/itsgoingd/clockwork/tree/v5.3.5"
},
"funding": [
{
@@ -975,7 +972,7 @@
"type": "github"
}
],
"time": "2025-02-09T15:57:21+00:00"
"time": "2025-09-14T15:34:49+00:00"
},
{
"name": "league/climate",
@@ -2090,16 +2087,16 @@
},
{
"name": "rhukster/dom-sanitizer",
"version": "1.0.7",
"version": "1.0.8",
"source": {
"type": "git",
"url": "https://github.com/rhukster/dom-sanitizer.git",
"reference": "c2a98f27ad742668b254282ccc5581871d0fb601"
"reference": "757e4d6ac03afe9afa4f97cbef453fc5c25f0729"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/rhukster/dom-sanitizer/zipball/c2a98f27ad742668b254282ccc5581871d0fb601",
"reference": "c2a98f27ad742668b254282ccc5581871d0fb601",
"url": "https://api.github.com/repos/rhukster/dom-sanitizer/zipball/757e4d6ac03afe9afa4f97cbef453fc5c25f0729",
"reference": "757e4d6ac03afe9afa4f97cbef453fc5c25f0729",
"shasum": ""
},
"require": {
@@ -2129,9 +2126,9 @@
"description": "A simple but effective DOM/SVG/MathML Sanitizer for PHP 7.4+",
"support": {
"issues": "https://github.com/rhukster/dom-sanitizer/issues",
"source": "https://github.com/rhukster/dom-sanitizer/tree/1.0.7"
"source": "https://github.com/rhukster/dom-sanitizer/tree/1.0.8"
},
"time": "2023-11-06T16:46:48+00:00"
"time": "2024-04-15T08:48:55+00:00"
},
{
"name": "rockettheme/toolbox",
@@ -2426,6 +2423,73 @@
],
"time": "2022-07-20T09:59:04+00:00"
},
{
"name": "symfony/deprecation-contracts",
"version": "v2.5.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
"reference": "605389f2a7e5625f273b53960dc46aeaf9c62918"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/605389f2a7e5625f273b53960dc46aeaf9c62918",
"reference": "605389f2a7e5625f273b53960dc46aeaf9c62918",
"shasum": ""
},
"require": {
"php": ">=7.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "2.5-dev"
}
},
"autoload": {
"files": [
"function.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.4"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-25T14:11:13+00:00"
},
{
"name": "symfony/event-dispatcher",
"version": "v4.4.44",
@@ -3391,28 +3455,28 @@
},
{
"name": "webmozart/assert",
"version": "1.11.0",
"version": "1.12.1",
"source": {
"type": "git",
"url": "https://github.com/webmozarts/assert.git",
"reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991"
"reference": "9be6926d8b485f55b9229203f962b51ed377ba68"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991",
"reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991",
"url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68",
"reference": "9be6926d8b485f55b9229203f962b51ed377ba68",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-date": "*",
"ext-filter": "*",
"php": "^7.2 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<0.12.20",
"vimeo/psalm": "<4.6.1 || 4.6.2"
},
"require-dev": {
"phpunit/phpunit": "^8.5.13"
"suggest": {
"ext-intl": "",
"ext-simplexml": "",
"ext-spl": ""
},
"type": "library",
"extra": {
@@ -3443,9 +3507,9 @@
],
"support": {
"issues": "https://github.com/webmozarts/assert/issues",
"source": "https://github.com/webmozarts/assert/tree/1.11.0"
"source": "https://github.com/webmozarts/assert/tree/1.12.1"
},
"time": "2022-06-03T18:03:27+00:00"
"time": "2025-10-29T15:56:20+00:00"
},
{
"name": "willdurand/negotiation",
@@ -4548,16 +4612,11 @@
},
{
"name": "phpstan/phpstan",
"version": "1.12.28",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan.git",
"reference": "fcf8b71aeab4e1a1131d1783cef97b23a51b87a9"
},
"version": "1.12.32",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/fcf8b71aeab4e1a1131d1783cef97b23a51b87a9",
"reference": "fcf8b71aeab4e1a1131d1783cef97b23a51b87a9",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/2770dcdf5078d0b0d53f94317e06affe88419aa8",
"reference": "2770dcdf5078d0b0d53f94317e06affe88419aa8",
"shasum": ""
},
"require": {
@@ -4602,7 +4661,7 @@
"type": "github"
}
],
"time": "2025-07-17T17:15:39+00:00"
"time": "2025-09-30T10:16:31+00:00"
},
{
"name": "phpstan/phpstan-deprecation-rules",
@@ -4972,16 +5031,16 @@
},
{
"name": "phpunit/phpunit",
"version": "9.6.25",
"version": "9.6.29",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "049c011e01be805202d8eebedef49f769a8ec7b7"
"reference": "9ecfec57835a5581bc888ea7e13b51eb55ab9dd3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/049c011e01be805202d8eebedef49f769a8ec7b7",
"reference": "049c011e01be805202d8eebedef49f769a8ec7b7",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9ecfec57835a5581bc888ea7e13b51eb55ab9dd3",
"reference": "9ecfec57835a5581bc888ea7e13b51eb55ab9dd3",
"shasum": ""
},
"require": {
@@ -5006,7 +5065,7 @@
"sebastian/comparator": "^4.0.9",
"sebastian/diff": "^4.0.6",
"sebastian/environment": "^5.1.5",
"sebastian/exporter": "^4.0.6",
"sebastian/exporter": "^4.0.8",
"sebastian/global-state": "^5.0.8",
"sebastian/object-enumerator": "^4.0.4",
"sebastian/resource-operations": "^3.0.4",
@@ -5055,7 +5114,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.25"
"source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.29"
},
"funding": [
{
@@ -5079,7 +5138,7 @@
"type": "tidelift"
}
],
"time": "2025-08-20T14:38:31+00:00"
"time": "2025-09-24T06:29:11+00:00"
},
{
"name": "psr/http-client",
@@ -5574,16 +5633,16 @@
},
{
"name": "sebastian/exporter",
"version": "4.0.6",
"version": "4.0.8",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/exporter.git",
"reference": "78c00df8f170e02473b682df15bfcdacc3d32d72"
"reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72",
"reference": "78c00df8f170e02473b682df15bfcdacc3d32d72",
"url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c",
"reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c",
"shasum": ""
},
"require": {
@@ -5639,15 +5698,27 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/exporter/issues",
"source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6"
"source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
},
{
"url": "https://liberapay.com/sebastianbergmann",
"type": "liberapay"
},
{
"url": "https://thanks.dev/u/gh/sebastianbergmann",
"type": "thanks_dev"
},
{
"url": "https://tidelift.com/funding/github/packagist/sebastian/exporter",
"type": "tidelift"
}
],
"time": "2024-03-02T06:33:00+00:00"
"time": "2025-09-24T06:03:27+00:00"
},
{
"name": "sebastian/global-state",
@@ -6270,73 +6341,6 @@
],
"time": "2024-09-25T14:11:13+00:00"
},
{
"name": "symfony/deprecation-contracts",
"version": "v2.5.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
"reference": "605389f2a7e5625f273b53960dc46aeaf9c62918"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/605389f2a7e5625f273b53960dc46aeaf9c62918",
"reference": "605389f2a7e5625f273b53960dc46aeaf9c62918",
"shasum": ""
},
"require": {
"php": ">=7.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "2.5-dev"
}
},
"autoload": {
"files": [
"function.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.4"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-25T14:11:13+00:00"
},
{
"name": "symfony/dom-crawler",
"version": "v5.4.48",
@@ -6477,16 +6481,16 @@
},
{
"name": "theseer/tokenizer",
"version": "1.2.3",
"version": "1.3.1",
"source": {
"type": "git",
"url": "https://github.com/theseer/tokenizer.git",
"reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2"
"reference": "b7489ce515e168639d17feec34b8847c326b0b3c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2",
"reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2",
"url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c",
"reference": "b7489ce515e168639d17feec34b8847c326b0b3c",
"shasum": ""
},
"require": {
@@ -6515,7 +6519,7 @@
"description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
"support": {
"issues": "https://github.com/theseer/tokenizer/issues",
"source": "https://github.com/theseer/tokenizer/tree/1.2.3"
"source": "https://github.com/theseer/tokenizer/tree/1.3.1"
},
"funding": [
{
@@ -6523,7 +6527,7 @@
"type": "github"
}
],
"time": "2024-03-03T12:36:25+00:00"
"time": "2025-11-17T20:03:58+00:00"
}
],
"aliases": [],
@@ -6542,8 +6546,5 @@
"ext-gd": "*"
},
"platform-dev": {},
"platform-overrides": {
"php": "7.3.6"
},
"plugin-api-version": "2.6.0"
"plugin-api-version": "2.9.0"
}

View File

@@ -20,6 +20,36 @@ if (PHP_SAPI === 'cli-server') {
}
}
if (PHP_SAPI !== 'cli') {
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
$scriptName = $_SERVER['SCRIPT_NAME'] ?? '';
$path = parse_url($requestUri, PHP_URL_PATH) ?? '/';
$path = str_replace('\\', '/', $path);
$scriptDir = str_replace('\\', '/', dirname($scriptName));
if ($scriptDir && $scriptDir !== '/' && $scriptDir !== '.') {
if (strpos($path, $scriptDir) === 0) {
$path = substr($path, strlen($scriptDir));
$path = $path === '' ? '/' : $path;
}
}
if ($path === '/___safe-upgrade-status') {
$statusEndpoint = __DIR__ . '/user/plugins/admin/safe-upgrade-status.php';
header('Content-Type: application/json; charset=utf-8');
if (is_file($statusEndpoint)) {
require $statusEndpoint;
} else {
http_response_code(404);
echo json_encode([
'status' => 'error',
'message' => 'Safe upgrade status endpoint unavailable.',
]);
}
exit;
}
}
// Ensure vendor libraries exist
$autoload = __DIR__ . '/vendor/autoload.php';
if (!is_file($autoload)) {
@@ -29,6 +59,18 @@ if (!is_file($autoload)) {
// Register the auto-loader.
$loader = require $autoload;
if (!class_exists(\Symfony\Component\ErrorHandler\Exception\FlattenException::class, false) && class_exists(\Symfony\Component\HttpKernel\Exception\FlattenException::class)) {
class_alias(\Symfony\Component\HttpKernel\Exception\FlattenException::class, \Symfony\Component\ErrorHandler\Exception\FlattenException::class);
}
if (!class_exists(\Monolog\Logger::class, false)) {
class_exists(\Monolog\Logger::class);
}
if (defined('Monolog\Logger::API') && \Monolog\Logger::API < 3) {
require_once __DIR__ . '/system/src/Grav/Framework/Compat/Monolog/bootstrap.php';
}
// Set timezone to default, falls back to system if php.ini not set
date_default_timezone_set(@date_default_timezone_get());
@@ -36,6 +78,12 @@ date_default_timezone_set(@date_default_timezone_get());
@ini_set('default_charset', 'UTF-8');
mb_internal_encoding('UTF-8');
$recoveryFlag = __DIR__ . '/user/data/recovery.flag';
if (PHP_SAPI !== 'cli' && is_file($recoveryFlag)) {
require __DIR__ . '/system/recovery.php';
return 0;
}
use Grav\Common\Grav;
use RocketTheme\Toolbox\Event\Event;
@@ -46,6 +94,13 @@ $grav = Grav::instance(array('loader' => $loader));
try {
$grav->process();
} catch (\Error|\Exception $e) {
$grav->fireEvent('onFatalException', new Event(array('exception' => $e)));
$grav->fireEvent('onFatalException', new Event(['exception' => $e]));
if (PHP_SAPI !== 'cli' && is_file($recoveryFlag)) {
require __DIR__ . '/system/recovery.php';
return 0;
}
throw $e;
}

View File

@@ -1598,6 +1598,31 @@ form:
validate:
type: bool
updates_section:
type: section
title: PLUGIN_ADMIN.UPDATES_SECTION
updates.safe_upgrade:
type: toggle
label: PLUGIN_ADMIN.SAFE_UPGRADE
help: PLUGIN_ADMIN.SAFE_UPGRADE_HELP
highlight: 1
default: true
options:
1: PLUGIN_ADMIN.YES
0: PLUGIN_ADMIN.NO
validate:
type: bool
updates.safe_upgrade_snapshot_limit:
type: number
label: PLUGIN_ADMIN.SAFE_UPGRADE_SNAPSHOT_LIMIT
help: PLUGIN_ADMIN.SAFE_UPGRADE_SNAPSHOT_LIMIT_HELP
default: 5
validate:
type: int
min: 0
http_section:
type: section
title: PLUGIN_ADMIN.HTTP_SECTION
@@ -1912,6 +1937,3 @@ form:
#
# pages.type:
# type: hidden

View File

@@ -203,6 +203,10 @@ gpm:
releases: stable # Set to either 'stable' or 'testing'
official_gpm_only: true # By default GPM direct-install will only allow URLs via the official GPM proxy to ensure security
updates:
safe_upgrade: true # Enable guarded staging+rollback pipeline for Grav self-updates
safe_upgrade_snapshot_limit: 5 # Maximum number of safe-upgrade snapshots to retain (0 = unlimited)
http:
method: auto # Either 'curl', 'fopen' or 'auto'. 'auto' will try fopen first and if not available cURL
enable_proxy: true # Enable proxy server configuration

View File

@@ -9,7 +9,7 @@
// Some standard defines
define('GRAV', true);
define('GRAV_VERSION', '1.7.49.5');
define('GRAV_VERSION', '1.7.50.9');
define('GRAV_SCHEMA', '1.7.0_2020-11-20_1');
define('GRAV_TESTING', false);

View File

@@ -10,6 +10,43 @@ if (!defined('GRAV_ROOT')) {
die();
}
// Check if Install class is already loaded (from an older Grav version)
// This happens when upgrading from older versions where the OLD Install class
// was loaded via autoloader before extracting the update package (e.g., via Install::forceSafeUpgrade())
$logInstallerSource = static function ($install, string $source) {
$sourceLabel = $source === 'extracted update package' ? 'update package' : 'existing installation';
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
echo sprintf(" |- Using installer from %s\n", $sourceLabel);
}
};
if (class_exists('Grav\\Installer\\Install', false)) {
// OLD Install class is already loaded. We cannot load the NEW one due to PHP limitations.
// However, we can work around this by:
// 1. Using a different class name for the NEW installer
// 2. Or, accepting that the OLD Install class will run but ensuring it can still upgrade properly
// For now, use the OLD Install class but set its location to this extracted package
// so it processes files from here
$install = Grav\Installer\Install::instance();
// Use reflection to update the location property to point to this package
$reflection = new \ReflectionClass($install);
if ($reflection->hasProperty('location')) {
$locationProp = $reflection->getProperty('location');
$locationProp->setAccessible(true);
$locationProp->setValue($install, __DIR__ . '/..');
}
$logInstallerSource($install, 'existing installation');
return $install;
}
// Normal case: Install class not yet loaded, load the NEW one
require_once __DIR__ . '/src/Grav/Installer/Install.php';
return Grav\Installer\Install::instance();
$install = Grav\Installer\Install::instance();
$logInstallerSource($install, 'extracted update package');
return $install;

View File

@@ -119,3 +119,10 @@ GRAV:
ERROR2: Bad number of elements
ERROR3: The jquery_element should be set into jqCron settings
ERROR4: Unrecognized expression
PLUGIN_ADMIN:
UPDATES_SECTION: Updates
SAFE_UPGRADE: Safe self-upgrade
SAFE_UPGRADE_HELP: When enabled, Grav core updates use staged installation with automatic rollback support.
SAFE_UPGRADE_SNAPSHOT_LIMIT: Safe-upgrade snapshots to keep
SAFE_UPGRADE_SNAPSHOT_LIMIT_HELP: Maximum number of snapshots to retain for safe upgrades (0 disables pruning).

202
system/recovery.php Normal file
View File

@@ -0,0 +1,202 @@
<?php
use Grav\Common\Recovery\RecoveryManager;
use Grav\Common\Upgrade\SafeUpgradeService;
if (!\defined('GRAV_ROOT')) {
\define('GRAV_ROOT', dirname(__DIR__));
}
session_start([
'name' => 'grav-recovery',
'cookie_httponly' => true,
'cookie_secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'cookie_samesite' => 'Lax',
]);
$manager = new RecoveryManager();
$context = $manager->getContext() ?? [];
$token = $context['token'] ?? null;
$authenticated = $token && isset($_SESSION['grav_recovery_authenticated']) && hash_equals($_SESSION['grav_recovery_authenticated'], $token);
$errorMessage = null;
$notice = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'authenticate') {
$provided = trim($_POST['token'] ?? '');
if ($token && hash_equals($token, $provided)) {
$_SESSION['grav_recovery_authenticated'] = $token;
header('Location: ' . $_SERVER['REQUEST_URI']);
exit;
}
$errorMessage = 'Invalid recovery token.';
} elseif ($authenticated) {
$service = new SafeUpgradeService();
try {
if ($action === 'rollback' && !empty($_POST['manifest'])) {
$service->rollback(trim($_POST['manifest']));
$manager->clear();
$_SESSION['grav_recovery_authenticated'] = null;
$notice = 'Rollback complete. Please reload Grav.';
}
if ($action === 'clear-flag') {
$manager->clear();
$_SESSION['grav_recovery_authenticated'] = null;
$notice = 'Recovery flag cleared.';
}
} catch (\Throwable $e) {
$errorMessage = $e->getMessage();
}
} else {
$errorMessage = 'Authentication required.';
}
}
$quarantineFile = GRAV_ROOT . '/user/data/upgrades/quarantine.json';
$quarantine = [];
if (is_file($quarantineFile)) {
$decoded = json_decode(file_get_contents($quarantineFile), true);
if (is_array($decoded)) {
$quarantine = $decoded;
}
}
$manifestDir = GRAV_ROOT . '/user/data/upgrades';
$snapshots = [];
if (is_dir($manifestDir)) {
$files = glob($manifestDir . '/*.json');
if ($files) {
foreach ($files as $file) {
$decoded = json_decode(file_get_contents($file), true);
if (!is_array($decoded)) {
continue;
}
$id = $decoded['id'] ?? pathinfo($file, PATHINFO_FILENAME);
if (!is_string($id) || $id === '' || strncmp($id, 'snapshot-', 9) !== 0) {
continue;
}
$decoded['id'] = $id;
$decoded['file'] = basename($file);
$decoded['created_at'] = (int)($decoded['created_at'] ?? filemtime($file) ?: 0);
$snapshots[] = $decoded;
}
if ($snapshots) {
usort($snapshots, static function (array $a, array $b): int {
return ($b['created_at'] ?? 0) <=> ($a['created_at'] ?? 0);
});
}
}
}
$latestSnapshot = $snapshots[0] ?? null;
header('Content-Type: text/html; charset=utf-8');
?><!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Grav Recovery Mode</title>
<style>
body { font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; margin: 0; padding: 40px; background: #111; color: #eee; }
.panel { max-width: 720px; margin: 0 auto; background: #1d1d1f; padding: 24px 32px; border-radius: 12px; box-shadow: 0 10px 45px rgba(0,0,0,0.4); }
h1 { font-size: 2.5rem; margin-top: 0; color: #fff; display:flex;align-items:center; }
h1 > img {margin-right:1rem;}
code { background: rgba(255,255,255,0.08); padding: 2px 4px; border-radius: 4px; }
form { margin-top: 16px; }
input[type="text"] { width: 100%; padding: 10px; border: 1px solid #333; border-radius: 6px; background: #151517; color: #fff; }
button { margin-top: 12px; padding: 10px 16px; border: 0; border-radius: 6px; cursor: pointer; background: #3c8bff; color: #fff; font-weight: 600; }
button.secondary { background: #444; }
.message { padding: 10px 14px; border-radius: 6px; margin-top: 12px; }
.error { background: rgba(220, 53, 69, 0.15); color: #ffb3b8; }
.notice { background: rgba(25, 135, 84, 0.2); color: #bdf8d4; }
ul { padding-left: 20px; }
li { margin-bottom: 8px; }
.card { border: 1px solid #2a2a2d; border-radius: 8px; padding: 14px 16px; margin-top: 16px; background: #161618; }
small { color: #888; }
</style>
</head>
<body>
<div class="panel">
<h1><img src="system/assets/grav.png">Grav Recovery Mode</h1>
<?php if ($notice): ?>
<div class="message notice"><?php echo htmlspecialchars($notice, ENT_QUOTES, 'UTF-8'); ?></div>
<?php endif; ?>
<?php if ($errorMessage): ?>
<div class="message error"><?php echo htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8'); ?></div>
<?php endif; ?>
<?php if (!$authenticated): ?>
<p>This site is running in recovery mode because Grav detected a fatal error.</p>
<p>Locate the recovery token in <code>user/data/recovery.flag</code> and enter it below.</p>
<form method="post">
<input type="hidden" name="action" value="authenticate">
<label for="token">Recovery token</label>
<input id="token" name="token" type="text" autocomplete="one-time-code" required>
<button type="submit">Unlock Recovery</button>
</form>
<?php else: ?>
<div class="card">
<h2>Failure Details</h2>
<ul>
<li><strong>Message:</strong> <?php echo htmlspecialchars($context['message'] ?? 'Unknown', ENT_QUOTES, 'UTF-8'); ?></li>
<li><strong>File:</strong> <?php echo htmlspecialchars($context['file'] ?? 'n/a', ENT_QUOTES, 'UTF-8'); ?></li>
<li><strong>Line:</strong> <?php echo htmlspecialchars((string)($context['line'] ?? 'n/a'), ENT_QUOTES, 'UTF-8'); ?></li>
<?php if (!empty($context['plugin'])): ?>
<li><strong>Quarantined plugin:</strong> <?php echo htmlspecialchars($context['plugin'], ENT_QUOTES, 'UTF-8'); ?></li>
<?php endif; ?>
</ul>
</div>
<?php if ($quarantine): ?>
<div class="card">
<h3>Quarantined Plugins</h3>
<ul>
<?php foreach ($quarantine as $entry): ?>
<li>
<strong><?php echo htmlspecialchars($entry['slug'], ENT_QUOTES, 'UTF-8'); ?></strong>
<small>(disabled at <?php echo date('c', $entry['disabled_at']); ?>)</small><br>
<?php echo htmlspecialchars($entry['message'] ?? '', ENT_QUOTES, 'UTF-8'); ?>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<div class="card">
<h3>Rollback</h3>
<?php if ($latestSnapshot): ?>
<form method="post">
<input type="hidden" name="action" value="rollback">
<input type="hidden" name="manifest" value="<?php echo htmlspecialchars($latestSnapshot['id'], ENT_QUOTES, 'UTF-8'); ?>">
<p>
Latest snapshot:
<code><?php echo htmlspecialchars($latestSnapshot['id'], ENT_QUOTES, 'UTF-8'); ?></code>
<?php if (!empty($latestSnapshot['label'])): ?>
<br><small><?php echo htmlspecialchars($latestSnapshot['label'], ENT_QUOTES, 'UTF-8'); ?></small>
<?php endif; ?>
— Grav <?php echo htmlspecialchars($latestSnapshot['target_version'] ?? 'unknown', ENT_QUOTES, 'UTF-8'); ?>
<?php if (!empty($latestSnapshot['created_at'])): ?>
<br><small>Created <?php echo htmlspecialchars(date('c', (int)$latestSnapshot['created_at']), ENT_QUOTES, 'UTF-8'); ?></small>
<?php endif; ?>
</p>
<button type="submit" class="secondary">Rollback to Latest Snapshot</button>
</form>
<?php else: ?>
<p>No upgrade snapshots were found.</p>
<?php endif; ?>
</div>
<form method="post">
<input type="hidden" name="action" value="clear-flag">
<button type="submit" class="secondary">Exit Recovery Mode</button>
</form>
<?php endif; ?>
</div>
</body>
</html>

View File

@@ -464,8 +464,18 @@ class Assets extends PropertyObject
if ($this->{$pipeline_enabled} ?? false) {
$options = array_merge($this->pipeline_options, ['timestamp' => $this->timestamp]);
$pipeline = new Pipeline($options);
$pipeline_output = $pipeline->$render_pipeline($pipeline_assets, $group, $attributes);
$grouped_pipeline_assets = $this->splitPipelineAssetsByAttribute($pipeline_assets, 'loading');
foreach ($grouped_pipeline_assets as $pipeline_group) {
if (empty($pipeline_group['assets'])) {
continue;
}
$group_attributes = array_merge($attributes, $pipeline_group['attributes']);
$pipeline = new Pipeline($options);
$pipeline_output .= $pipeline->$render_pipeline($pipeline_group['assets'], $group, $group_attributes);
}
} else {
foreach ($pipeline_assets as $asset) {
$pipeline_output .= $asset->render();
@@ -592,4 +602,71 @@ class Assets extends PropertyObject
return $base_type;
}
/**
* Split pipeline assets into ordered groups based on the value of a given attribute.
*
* This preserves the original order of the assets while ensuring assets that require
* special handling (such as different loading strategies) are rendered separately.
*
* @param array $assets
* @param string $attribute
* @return array<int, array{assets: array, attributes: array}>
*/
protected function splitPipelineAssetsByAttribute(array $assets, string $attribute): array
{
$groups = [];
$currentAssets = [];
$currentValue = null;
$hasCurrentGroup = false;
foreach ($assets as $key => $asset) {
$value = null;
if (method_exists($asset, 'hasNestedProperty')) {
if ($asset->hasNestedProperty($attribute)) {
$value = $asset->getNestedProperty($attribute);
} elseif ($asset->hasNestedProperty('attributes.' . $attribute)) {
$value = $asset->getNestedProperty('attributes.' . $attribute);
}
}
if ($value === null && isset($asset[$attribute])) {
$value = $asset[$attribute];
}
if ($value === '' || $value === false) {
$value = null;
}
if (!$hasCurrentGroup) {
$currentAssets = [$key => $asset];
$currentValue = $value;
$hasCurrentGroup = true;
continue;
}
if ($value === $currentValue) {
$currentAssets[$key] = $asset;
continue;
}
$groups[] = [
'assets' => $currentAssets,
'attributes' => $currentValue !== null ? [$attribute => $currentValue] : []
];
$currentAssets = [$key => $asset];
$currentValue = $value;
}
if ($hasCurrentGroup) {
$groups[] = [
'assets' => $currentAssets,
'attributes' => $currentValue !== null ? [$attribute => $currentValue] : []
];
}
return $groups;
}
}

View File

@@ -550,6 +550,9 @@ class Cache extends Getters
$anything = true;
}
} elseif (is_dir($file)) {
if (basename($file) === 'grav-snapshots') {
continue;
}
if (Folder::delete($file, false)) {
$anything = true;
}

View File

@@ -478,12 +478,22 @@ abstract class Folder
* @return bool
* @throws RuntimeException
*/
public static function rcopy($src, $dest)
public static function rcopy($src, $dest, $preservePermissions = false)
{
// If the src is not a directory do a simple file copy
if (!is_dir($src)) {
copy($src, $dest);
if ($preservePermissions) {
$perm = @fileperms($src);
if ($perm !== false) {
@chmod($dest, $perm & 0777);
}
$mtime = @filemtime($src);
if ($mtime !== false) {
@touch($dest, $mtime);
}
}
return true;
}
@@ -492,14 +502,32 @@ abstract class Folder
static::create($dest);
}
if ($preservePermissions) {
$perm = @fileperms($src);
if ($perm !== false) {
@chmod($dest, $perm & 0777);
}
}
// Open the source directory to read in files
$i = new DirectoryIterator($src);
foreach ($i as $f) {
if ($f->isFile()) {
copy($f->getRealPath(), "{$dest}/" . $f->getFilename());
$target = "{$dest}/" . $f->getFilename();
copy($f->getRealPath(), $target);
if ($preservePermissions) {
$perm = @fileperms($f->getRealPath());
if ($perm !== false) {
@chmod($target, $perm & 0777);
}
$mtime = @filemtime($f->getRealPath());
if ($mtime !== false) {
@touch($target, $mtime);
}
}
} else {
if (!$f->isDot() && $f->isDir()) {
static::rcopy($f->getRealPath(), "{$dest}/{$f}");
static::rcopy($f->getRealPath(), "{$dest}/{$f}", $preservePermissions);
}
}
}

View File

@@ -10,6 +10,7 @@
namespace Grav\Common\GPM;
use Exception;
use Grav\Common\Data\Data;
use Grav\Common\Grav;
use Grav\Common\Filesystem\Folder;
use Grav\Common\HTTP\Response;
@@ -24,6 +25,7 @@ use function count;
use function in_array;
use function is_array;
use function is_object;
use function property_exists;
/**
* Class GPM
@@ -322,6 +324,10 @@ class GPM extends Iterator
continue;
}
if (!$this->isRemotePackagePublished($plugins[$slug])) {
continue;
}
$local_version = $plugin->version ?? 'Unknown';
$remote_version = $plugins[$slug]->version;
@@ -414,6 +420,10 @@ class GPM extends Iterator
continue;
}
if (!$this->isRemotePackagePublished($themes[$slug])) {
continue;
}
$local_version = $plugin->version ?? 'Unknown';
$remote_version = $themes[$slug]->version;
@@ -468,6 +478,42 @@ class GPM extends Iterator
return null;
}
/**
* Determine whether a remote package is marked as published.
*
* Remote package metadata introduced a `published` flag to hide releases that are not yet public.
* Older repository payloads may omit the key, so we default to treating packages as published
* unless the flag is explicitly set to `false`.
*
* @param object|array $package
* @return bool
*/
protected function isRemotePackagePublished($package): bool
{
if (is_object($package) && method_exists($package, 'getData')) {
$data = $package->getData();
if ($data instanceof Data) {
$published = $data->get('published');
return $published !== false;
}
}
if (is_array($package)) {
if (array_key_exists('published', $package)) {
return $package['published'] !== false;
}
return true;
}
$value = null;
if (is_object($package) && property_exists($package, 'published')) {
$value = $package->published;
}
return $value !== false;
}
/**
* Returns true if the package latest release is stable
*

View File

@@ -21,7 +21,6 @@ class GravCore extends AbstractPackageCollection
{
/** @var string */
protected $repository = 'https://getgrav.org/downloads/grav.json';
/** @var array */
private $data;
/** @var string */

View File

@@ -46,6 +46,7 @@ use Grav\Common\Service\SessionServiceProvider;
use Grav\Common\Service\StreamsServiceProvider;
use Grav\Common\Service\TaskServiceProvider;
use Grav\Common\Twig\Twig;
use Grav\Common\Recovery\RecoveryManager;
use Grav\Framework\DI\Container;
use Grav\Framework\Psr7\Response;
use Grav\Framework\RequestHandler\Middlewares\MultipartRequestSupport;
@@ -110,6 +111,7 @@ class Grav extends Container
'scheduler' => Scheduler::class,
'taxonomy' => Taxonomy::class,
'themes' => Themes::class,
'recovery' => RecoveryManager::class,
'twig' => Twig::class,
'uri' => Uri::class,
];

View File

@@ -143,7 +143,31 @@ class Plugins extends Iterator
$instance->setConfig($config);
// Register autoloader.
if (method_exists($instance, 'autoload')) {
$instance->setAutoloader($instance->autoload());
try {
$instance->setAutoloader($instance->autoload());
} catch (\Throwable $e) {
// Log the autoload failure and disable the plugin
$grav['log']->error(
sprintf("Plugin '%s' autoload failed: %s", $instance->name, $e->getMessage())
);
// Disable the plugin to prevent further errors
$config["plugins.{$instance->name}.enabled"] = false;
// If we're in an upgrade window, quarantine the plugin
if (isset($grav['recovery']) && method_exists($grav['recovery'], 'isUpgradeWindowActive')) {
$recovery = $grav['recovery'];
if ($recovery->isUpgradeWindowActive()) {
$recovery->disablePlugin($instance->name, [
'message' => 'Autoloader failed: ' . $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
]);
}
}
continue;
}
}
// Register event listeners.
$events->addSubscriber($instance);

View File

@@ -78,6 +78,9 @@ class InitializeProcessor extends ProcessorBase
// Initialize error handlers.
$this->initializeErrors();
// Register recovery shutdown handler early in the lifecycle.
$this->container['recovery']->registerHandlers();
// Initialize debugger.
$debugger = $this->initializeDebugger();
@@ -145,6 +148,9 @@ class InitializeProcessor extends ProcessorBase
// Disable debugger.
$this->container['debugger']->enabled(false);
// Register recovery handler for CLI commands as well.
$this->container['recovery']->registerHandlers();
// Set timezone, locale.
$this->initializeLocale($config);

View File

@@ -0,0 +1,500 @@
<?php
/**
* @package Grav\Common\Recovery
*
* @copyright Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Common\Recovery;
use Grav\Common\Filesystem\Folder;
use Grav\Common\Grav;
use Grav\Common\Yaml;
use RocketTheme\Toolbox\Event\Event;
use function bin2hex;
use function dirname;
use function file_get_contents;
use function file_put_contents;
use function in_array;
use function is_array;
use function is_file;
use function json_decode;
use function json_encode;
use function max;
use function md5;
use function preg_match;
use function random_bytes;
use function uniqid;
use function time;
use function trim;
use function unlink;
use const E_COMPILE_ERROR;
use const E_CORE_ERROR;
use const E_ERROR;
use const E_PARSE;
use const E_USER_ERROR;
use const GRAV_ROOT;
use const JSON_PRETTY_PRINT;
use const JSON_UNESCAPED_SLASHES;
/**
* Handles recovery flag lifecycle and plugin quarantine during fatal errors.
*/
class RecoveryManager
{
/** @var bool */
private $registered = false;
/** @var string */
private $rootPath;
/** @var string */
private $userPath;
/** @var bool */
private $failureCaptured = false;
/**
* @param mixed $context Container or root path.
*/
public function __construct($context = null)
{
if ($context instanceof \Grav\Common\Grav) {
$root = GRAV_ROOT;
} elseif (is_string($context) && $context !== '') {
$root = $context;
} else {
$root = GRAV_ROOT;
}
$this->rootPath = rtrim($root, DIRECTORY_SEPARATOR);
$this->userPath = $this->rootPath . '/user';
}
/**
* Register shutdown handler to capture fatal errors at runtime.
*
* @return void
*/
public function registerHandlers(): void
{
if ($this->registered) {
return;
}
register_shutdown_function([$this, 'handleShutdown']);
$events = null;
try {
$events = Grav::instance()['events'] ?? null;
} catch (\Throwable $e) {
$events = null;
}
if ($events && method_exists($events, 'addListener')) {
$events->addListener('onFatalException', [$this, 'onFatalException']);
}
$this->registered = true;
}
/**
* Check if recovery mode flag is active.
*
* @return bool
*/
public function isActive(): bool
{
return is_file($this->flagPath());
}
/**
* Remove recovery flag.
*
* @return void
*/
public function clear(): void
{
$flag = $this->flagPath();
if (is_file($flag)) {
@unlink($flag);
}
$this->closeUpgradeWindow();
$this->failureCaptured = false;
}
/**
* Shutdown handler capturing fatal errors.
*
* @return void
*/
public function handleShutdown(): void
{
if ($this->failureCaptured) {
return;
}
$error = $this->resolveLastError();
if (!$error) {
return;
}
$this->processFailure($error);
}
/**
* Handle uncaught exceptions bubbled to the top-level handler.
*
* @param \Throwable $exception
* @return void
*/
public function handleException(\Throwable $exception): void
{
if ($this->failureCaptured) {
return;
}
$error = [
'type' => E_ERROR,
'message' => $exception->getMessage(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
];
$this->processFailure($error);
}
/**
* @param Event $event
* @return void
*/
public function onFatalException(Event $event): void
{
$exception = $event['exception'] ?? null;
if ($exception instanceof \Throwable) {
$this->handleException($exception);
}
}
/**
* Activate recovery mode and record context.
*
* @param array $context
* @return void
*/
public function activate(array $context): void
{
$flag = $this->flagPath();
Folder::create(dirname($flag));
if (empty($context['token'])) {
$context['token'] = $this->generateToken();
}
if (!is_file($flag)) {
file_put_contents($flag, json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
} else {
// Merge context if flag already exists.
$existing = json_decode(file_get_contents($flag), true);
if (is_array($existing)) {
$context = $context + $existing;
if (empty($context['token'])) {
$context['token'] = $this->generateToken();
}
}
file_put_contents($flag, json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
}
}
/**
* @param array $error
* @return void
*/
private function processFailure(array $error): void
{
$type = (int)($error['type'] ?? 0);
if (!$this->isFatal($type)) {
return;
}
$file = $error['file'] ?? '';
$plugin = $this->detectPluginFromPath($file);
$context = [
'created_at' => time(),
'message' => $error['message'] ?? '',
'file' => $file,
'line' => $error['line'] ?? null,
'type' => $type,
'plugin' => $plugin,
];
if (!$this->shouldEnterRecovery($context)) {
return;
}
$this->activate($context);
if ($plugin) {
$this->quarantinePlugin($plugin, $context);
}
$this->failureCaptured = true;
}
/**
* Return last recorded recovery context.
*
* @return array|null
*/
public function getContext(): ?array
{
$flag = $this->flagPath();
if (!is_file($flag)) {
return null;
}
$decoded = json_decode(file_get_contents($flag), true);
return is_array($decoded) ? $decoded : null;
}
/**
* @param string $slug
* @param array $context
* @return void
*/
public function disablePlugin(string $slug, array $context = []): void
{
$context += [
'message' => $context['message'] ?? 'Disabled during upgrade preflight',
'file' => $context['file'] ?? '',
'line' => $context['line'] ?? null,
'created_at' => $context['created_at'] ?? time(),
'plugin' => $context['plugin'] ?? $slug,
];
$this->quarantinePlugin($slug, $context);
}
/**
* @param string $slug
* @param array $context
* @return void
*/
protected function quarantinePlugin(string $slug, array $context): void
{
$slug = trim($slug);
if ($slug === '') {
return;
}
$configPath = $this->userPath . '/config/plugins/' . $slug . '.yaml';
Folder::create(dirname($configPath));
$configuration = is_file($configPath) ? Yaml::parse(file_get_contents($configPath)) : [];
if (!is_array($configuration)) {
$configuration = [];
}
if (($configuration['enabled'] ?? true) === false) {
return;
}
$configuration['enabled'] = false;
$yaml = Yaml::dump($configuration);
file_put_contents($configPath, $yaml);
$quarantineFile = $this->userPath . '/data/upgrades/quarantine.json';
Folder::create(dirname($quarantineFile));
$quarantine = [];
if (is_file($quarantineFile)) {
$decoded = json_decode(file_get_contents($quarantineFile), true);
if (is_array($decoded)) {
$quarantine = $decoded;
}
}
$quarantine[$slug] = [
'slug' => $slug,
'disabled_at' => time(),
'message' => $context['message'] ?? '',
'file' => $context['file'] ?? '',
'line' => $context['line'] ?? null,
];
file_put_contents($quarantineFile, json_encode($quarantine, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
}
/**
* Determine if error type is fatal.
*
* @param int $type
* @return bool
*/
private function isFatal(int $type): bool
{
return in_array($type, [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE, E_USER_ERROR], true);
}
/**
* Attempt to derive plugin slug from file path.
*
* @param string $file
* @return string|null
*/
private function detectPluginFromPath(string $file): ?string
{
if (!$file) {
return null;
}
if (preg_match('#/user/plugins/([^/]+)/#', $file, $matches)) {
return $matches[1] ?? null;
}
return null;
}
/**
* @return string
*/
private function flagPath(): string
{
return $this->userPath . '/data/recovery.flag';
}
/**
* @return string
*/
private function windowPath(): string
{
return $this->userPath . '/data/recovery.window';
}
/**
* @return array|null
*/
private function resolveUpgradeWindow(): ?array
{
$path = $this->windowPath();
if (!is_file($path)) {
return null;
}
$decoded = json_decode(file_get_contents($path), true);
if (!is_array($decoded)) {
@unlink($path);
return null;
}
$expiresAt = (int)($decoded['expires_at'] ?? 0);
if ($expiresAt > 0 && $expiresAt < time()) {
@unlink($path);
return null;
}
return $decoded;
}
/**
* @param array $context
* @return bool
*/
private function shouldEnterRecovery(array $context): bool
{
$window = $this->resolveUpgradeWindow();
if (null === $window) {
return false;
}
$scope = $window['scope'] ?? null;
if ($scope === 'plugin') {
$expected = $window['plugin'] ?? null;
if ($expected && ($context['plugin'] ?? null) !== $expected) {
return false;
}
}
return true;
}
/**
* @return string
*/
protected function generateToken(): string
{
try {
return bin2hex($this->randomBytes(10));
} catch (\Throwable $e) {
return md5(uniqid('grav-recovery', true));
}
}
/**
* @param int $length
* @return string
*/
protected function randomBytes(int $length): string
{
return random_bytes($length);
}
/**
* @return array|null
*/
protected function resolveLastError(): ?array
{
return error_get_last();
}
/**
* Begin an upgrade window; during this window fatal plugin errors may trigger recovery mode.
*
* @param string $reason
* @param array $metadata
* @param int $ttlSeconds
* @return void
*/
public function markUpgradeWindow(string $reason, array $metadata = [], int $ttlSeconds = 604800): void
{
$ttl = max(60, $ttlSeconds);
$createdAt = time();
$payload = $metadata + [
'reason' => $reason,
'created_at' => $createdAt,
'expires_at' => $createdAt + $ttl,
];
$path = $this->windowPath();
Folder::create(dirname($path));
file_put_contents($path, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
$this->failureCaptured = false;
}
/**
* @return bool
*/
public function isUpgradeWindowActive(): bool
{
return $this->resolveUpgradeWindow() !== null;
}
/**
* @return array|null
*/
public function getUpgradeWindow(): ?array
{
return $this->resolveUpgradeWindow();
}
/**
* @return void
*/
public function closeUpgradeWindow(): void
{
$window = $this->windowPath();
if (is_file($window)) {
@unlink($window);
}
}
}

View File

@@ -51,6 +51,7 @@ class Security
{
if (Grav::instance()['config']->get('security.sanitize_svg')) {
$sanitizer = new DOMSanitizer(DOMSanitizer::SVG);
$sanitizer->addDisallowedAttributes(['href', 'xlink:href']);
$sanitized = $sanitizer->sanitize($svg);
if (is_string($sanitized)) {
$svg = $sanitized;
@@ -70,6 +71,7 @@ class Security
{
if (file_exists($file) && Grav::instance()['config']->get('security.sanitize_svg')) {
$sanitizer = new DOMSanitizer(DOMSanitizer::SVG);
$sanitizer->addDisallowedAttributes(['href', 'xlink:href']);
$original_svg = file_get_contents($file);
$clean_svg = $sanitizer->sanitize($original_svg);
@@ -281,7 +283,13 @@ class Security
'twig.safe_functions',
'read_file',
];
$string = preg_replace('/(({{\s*|{%\s*)[^}]*?(' . implode('|', $bad_twig) . ')[^}]*?(\s*}}|\s*%}))/i', '{# $1 #}', $string);
foreach ($bad_twig as $func) {
$string = preg_replace('/\b' . preg_quote($func, '/') . '(\s*\([^)]*\))?\b/i', '{# $1 #}', $string);
}
return $string;
}
}

View File

@@ -140,8 +140,10 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
new TwigFilter('starts_with', [$this, 'startsWithFilter']),
new TwigFilter('truncate', [Utils::class, 'truncate']),
new TwigFilter('truncate_html', [Utils::class, 'truncateHTML']),
new TwigFilter('wordcount', [$this, 'wordCountFilter']),
new TwigFilter('json_decode', [$this, 'jsonDecodeFilter']),
new TwigFilter('array_unique', 'array_unique'),
new TwigFilter('array_group_by', [$this, 'arrayGroupByFilter'], ['needs_environment' => true]),
new TwigFilter('basename', 'basename'),
new TwigFilter('dirname', 'dirname'),
new TwigFilter('print_r', [$this, 'print_r']),
@@ -191,6 +193,7 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
new TwigFunction('array_key_exists', 'array_key_exists'),
new TwigFunction('array_unique', 'array_unique'),
new TwigFunction('array_intersect', [$this, 'arrayIntersectFunc']),
new TwigFunction('array_group_by', [$this, 'arrayGroupByFilter'], ['needs_environment' => true]),
new TwigFunction('array_diff', 'array_diff'),
new TwigFunction('authorize', [$this, 'authorize']),
new TwigFunction('debug', [$this, 'dump'], ['needs_context' => true, 'needs_environment' => true]),
@@ -578,6 +581,62 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
return $str;
}
/**
* Count words in text with improved accuracy for multiple languages
*
* @param string $text The text to count words from
* @param string $locale Optional locale for language-specific counting (default: 'en')
* @return int Number of words
*/
public function wordCountFilter($text, string $locale = 'en'): int
{
if (empty($text)) {
return 0;
}
// Strip HTML tags and decode entities
$cleanText = html_entity_decode(strip_tags($text), ENT_QUOTES, 'UTF-8');
// Remove extra whitespace and normalize
$cleanText = trim(preg_replace('/\s+/', ' ', $cleanText));
if (empty($cleanText)) {
return 0;
}
// Handle different languages
switch (strtolower($locale)) {
case 'zh':
case 'zh-cn':
case 'zh-tw':
case 'chinese':
// Chinese: count characters (excluding spaces and punctuation)
return mb_strlen(preg_replace('/[\s\p{P}]/u', '', $cleanText), 'UTF-8');
case 'ja':
case 'japanese':
// Japanese: count characters (excluding spaces)
return mb_strlen(preg_replace('/\s/', '', $cleanText), 'UTF-8');
case 'ko':
case 'korean':
// Korean: count characters (excluding spaces)
return mb_strlen(preg_replace('/\s/', '', $cleanText), 'UTF-8');
default:
// Western languages: use improved word counting
// Handle contractions, hyphenated words, and numbers better
$words = preg_split('/\s+/', $cleanText, -1, PREG_SPLIT_NO_EMPTY);
// Filter out pure punctuation
$words = array_filter($words, function($word) {
return preg_match('/\w/', $word);
});
return count($words);
}
}
/**
* Get Cron object for a crontab 'at' format
*
@@ -1224,6 +1283,67 @@ class GravExtension extends AbstractExtension implements GlobalsInterface
return json_decode(html_entity_decode($str, ENT_COMPAT | ENT_HTML401, 'UTF-8'), $assoc, $depth, $options);
}
/**
* Group items in an array by the results of a callback function
*
* @param Environment $env The Twig environment
* @param array|\Traversable $array The array or collection to group
* @param string|callable $callback Property name or callable to determine group key
* @return array Grouped array with keys as group identifiers and values as arrays of items
*/
public function arrayGroupByFilter(Environment $env, $array, $callback): array
{
$groups = [];
// Convert to array if it's a Traversable object (like Grav Collections)
if ($array instanceof \Traversable) {
$array = iterator_to_array($array);
}
if (!is_array($array)) {
return [];
}
foreach ($array as $key => $item) {
// If callback is a string, treat it as a property/method name
if (is_string($callback)) {
// Try to get the value using different methods
if (is_array($item) && isset($item[$callback])) {
$groupKey = $item[$callback];
} elseif (is_object($item)) {
if (method_exists($item, $callback)) {
$groupKey = $item->$callback();
} elseif (property_exists($item, $callback)) {
$groupKey = $item->$callback;
} elseif (method_exists($item, '__get')) {
$groupKey = $item->$callback;
} else {
$groupKey = 'undefined';
}
} else {
$groupKey = 'undefined';
}
} else {
// Execute the callback function
try {
$groupKey = call_user_func($callback, $item, $key, $env);
} catch (\Exception $e) {
$groupKey = 'undefined';
}
}
// Initialize group array if it doesn't exist
if (!isset($groups[$groupKey])) {
$groups[$groupKey] = [];
}
// Add item to its group
$groups[$groupKey][] = $item;
}
return $groups;
}
/**
* Used to retrieve a cookie value
*

File diff suppressed because it is too large Load Diff

View File

@@ -693,6 +693,17 @@ abstract class Utils
header('Content-Disposition: attachment; filename="' . ($options['download_name'] ?? $file_parts['basename']) . '"');
}
if ($grav['config']->get('system.cache.enabled')) {
$expires = $options['expires'] ?? $grav['config']->get('system.pages.expires');
if ($expires > 0) {
$expires_date = gmdate('D, d M Y H:i:s T', time() + $expires);
header('Cache-Control: max-age=' . $expires);
header('Expires: ' . $expires_date);
header('Pragma: cache');
}
header('Last-Modified: ' . gmdate('D, d M Y H:i:s T', filemtime($file)));
}
// multipart-download and download resuming support
if (isset($_SERVER['HTTP_RANGE'])) {
[$a, $range] = explode('=', $_SERVER['HTTP_RANGE'], 2);
@@ -705,7 +716,7 @@ abstract class Utils
$range_end = (int)$range_end;
}
$new_length = $range_end - $range + 1;
header('HTTP/1.1 206 Partial Content');
http_response_code(206);
header("Content-Length: {$new_length}");
header("Content-Range: bytes {$range}-{$range_end}/{$size}");
} else {
@@ -714,19 +725,10 @@ abstract class Utils
header('Content-Length: ' . $size);
if ($grav['config']->get('system.cache.enabled')) {
$expires = $options['expires'] ?? $grav['config']->get('system.pages.expires');
if ($expires > 0) {
$expires_date = gmdate('D, d M Y H:i:s T', time() + $expires);
header('Cache-Control: max-age=' . $expires);
header('Expires: ' . $expires_date);
header('Pragma: cache');
}
header('Last-Modified: ' . gmdate('D, d M Y H:i:s T', filemtime($file)));
// Return 304 Not Modified if the file is already cached in the browser
if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) &&
strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) >= filemtime($file)) {
header('HTTP/1.1 304 Not Modified');
strtotime((string) $_SERVER['HTTP_IF_MODIFIED_SINCE']) >= filemtime($file)) {
http_response_code(304);
exit();
}
}

View File

@@ -13,6 +13,8 @@ use Grav\Console\Gpm\DirectInstallCommand;
use Grav\Console\Gpm\IndexCommand;
use Grav\Console\Gpm\InfoCommand;
use Grav\Console\Gpm\InstallCommand;
use Grav\Console\Gpm\PreflightCommand;
use Grav\Console\Gpm\RollbackCommand;
use Grav\Console\Gpm\SelfupgradeCommand;
use Grav\Console\Gpm\UninstallCommand;
use Grav\Console\Gpm\UpdateCommand;
@@ -36,6 +38,8 @@ class GpmApplication extends Application
new UninstallCommand(),
new UpdateCommand(),
new SelfupgradeCommand(),
new PreflightCommand(),
new RollbackCommand(),
new DirectInstallCommand(),
]);
}

View File

@@ -19,6 +19,7 @@ use Grav\Console\Cli\NewProjectCommand;
use Grav\Console\Cli\PageSystemValidatorCommand;
use Grav\Console\Cli\SandboxCommand;
use Grav\Console\Cli\SchedulerCommand;
use Grav\Console\Cli\SafeUpgradeRunCommand;
use Grav\Console\Cli\SecurityCommand;
use Grav\Console\Cli\ServerCommand;
use Grav\Console\Cli\YamlLinterCommand;
@@ -47,6 +48,7 @@ class GravApplication extends Application
new YamlLinterCommand(),
new ServerCommand(),
new PageSystemValidatorCommand(),
new SafeUpgradeRunCommand(),
]);
}
}

View File

@@ -29,6 +29,9 @@ class CleanCommand extends Command
/** @var array */
protected $paths_to_remove = [
'.gitattributes',
'.github/',
'.phan/',
'codeception.yml',
'tests/',
'user/plugins/admin/vendor/bacon/bacon-qr-code/tests',

View File

@@ -0,0 +1,96 @@
<?php
/**
* @package Grav\Console\Cli
*
* Background worker for Safe Upgrade jobs.
*/
namespace Grav\Console\Cli;
use Grav\Console\GravCommand;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Style\SymfonyStyle;
use Throwable;
class SafeUpgradeRunCommand extends GravCommand
{
protected function configure(): void
{
$this
->setName('safe-upgrade:run')
->setDescription('Execute a queued Grav safe-upgrade job')
->addOption(
'job',
null,
InputOption::VALUE_REQUIRED,
'Job identifier to execute'
);
}
protected function serve(): int
{
$input = $this->getInput();
/** @var SymfonyStyle $io */
$io = $this->getIO();
$jobId = $input->getOption('job');
if (!$jobId) {
$io->error('Missing required --job option.');
return 1;
}
if (method_exists($this, 'initializePlugins')) {
$this->initializePlugins();
}
if (!class_exists(\Grav\Plugin\Admin\SafeUpgradeManager::class)) {
$path = GRAV_ROOT . '/user/plugins/admin/classes/plugin/SafeUpgradeManager.php';
if (is_file($path)) {
require_once $path;
}
}
if (!class_exists(\Grav\Plugin\Admin\SafeUpgradeManager::class)) {
$io->error('SafeUpgradeManager is not available. Ensure the Admin plugin is installed.');
return 1;
}
$manager = new \Grav\Plugin\Admin\SafeUpgradeManager();
$manifest = $manager->loadJob($jobId);
if (!$manifest) {
$io->error(sprintf('Safe upgrade job "%s" could not be found.', $jobId));
return 1;
}
$options = $manifest['options'] ?? [];
$manager->updateJob([
'status' => 'running',
'started_at' => $manifest['started_at'] ?? time(),
]);
try {
$operation = $options['operation'] ?? 'upgrade';
if ($operation === 'restore') {
$result = $manager->runRestore($options);
} else {
$result = $manager->run($options);
}
$manager->ensureJobResult($result);
return ($result['status'] ?? null) === 'success' ? 0 : 1;
} catch (Throwable $e) {
$manager->ensureJobResult([
'status' => 'error',
'message' => $e->getMessage(),
]);
$io->error($e->getMessage());
return 1;
}
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace Grav\Console\Gpm;
use Grav\Common\Grav;
use Grav\Common\Upgrade\SafeUpgradeService;
use Grav\Console\GpmCommand;
use Symfony\Component\Console\Input\InputOption;
use function json_encode;
use const JSON_PRETTY_PRINT;
class PreflightCommand extends GpmCommand
{
protected function configure(): void
{
$this
->setName('preflight')
->addOption('json', null, InputOption::VALUE_NONE, 'Output report as JSON')
->setDescription('Run Grav upgrade preflight checks without modifying the installation.');
}
protected function serve(): int
{
$io = $this->getIO();
$service = $this->createSafeUpgradeService();
$report = $service->preflight();
$hasIssues = !empty($report['plugins_pending']) || !empty($report['psr_log_conflicts']) || !empty($report['monolog_conflicts']) || !empty($report['warnings']);
if ($this->getInput()->getOption('json')) {
$io->writeln(json_encode($report, JSON_PRETTY_PRINT));
return $hasIssues ? 2 : 0;
}
$io->title('Grav Upgrade Preflight');
if (!empty($report['warnings'])) {
$io->writeln('<comment>Warnings</comment>');
foreach ($report['warnings'] as $warning) {
$io->writeln(' - ' . $warning);
}
$io->newLine();
}
if (!empty($report['plugins_pending'])) {
$io->writeln('<comment>Packages pending update</comment>');
foreach ($report['plugins_pending'] as $slug => $info) {
$io->writeln(sprintf(' - %s (%s) %s → %s', $slug, $info['type'] ?? 'plugin', $info['current'] ?? 'unknown', $info['available'] ?? 'unknown'));
}
$io->newLine();
}
if (!empty($report['psr_log_conflicts'])) {
$io->writeln('<comment>Potential psr/log conflicts</comment>');
foreach ($report['psr_log_conflicts'] as $slug => $info) {
$io->writeln(sprintf(' - %s (requires psr/log %s)', $slug, $info['requires'] ?? '*'));
}
$io->writeln(' Update the plugin or add "replace": {"psr/log": "*"} to its composer.json and reinstall dependencies.');
$io->newLine();
}
if (!empty($report['monolog_conflicts'])) {
$io->writeln('<comment>Potential Monolog logger conflicts</comment>');
foreach ($report['monolog_conflicts'] as $slug => $entries) {
foreach ($entries as $entry) {
$file = $entry['file'] ?? 'unknown file';
$method = $entry['method'] ?? 'add*';
$io->writeln(sprintf(' - %s (%s in %s)', $slug, $method, $file));
}
}
$io->writeln(' Update the plugin to use PSR-3 style logger calls (e.g. $logger->error()).');
$io->newLine();
}
if (!$hasIssues) {
$io->success('No blocking issues detected.');
} else {
$io->warning('Resolve the findings above before upgrading Grav.');
}
return $hasIssues ? 2 : 0;
}
/**
* @return SafeUpgradeService
*/
protected function createSafeUpgradeService(): SafeUpgradeService
{
$config = null;
try {
$config = Grav::instance()['config'] ?? null;
} catch (\Throwable $e) {
$config = null;
}
return new SafeUpgradeService([
'config' => $config,
]);
}
}

View File

@@ -0,0 +1,135 @@
<?php
namespace Grav\Console\Gpm;
use Grav\Common\Upgrade\SafeUpgradeService;
use Grav\Console\GpmCommand;
use RuntimeException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use function basename;
use function file_get_contents;
use function glob;
use function is_array;
use function json_decode;
use function pathinfo;
use const PATHINFO_FILENAME;
use const GRAV_ROOT;
class RollbackCommand extends GpmCommand
{
/** @var bool */
private $allYes = false;
protected function configure(): void
{
$this
->setName('rollback')
->addArgument('manifest', InputArgument::OPTIONAL, 'Manifest identifier to roll back to. Defaults to the latest snapshot.')
->addOption('list', 'l', InputOption::VALUE_NONE, 'List available snapshots')
->addOption('all-yes', 'y', InputOption::VALUE_NONE, 'Skip confirmation prompts')
->setDescription('Rollback Grav to a previously staged snapshot.');
}
protected function serve(): int
{
$input = $this->getInput();
$io = $this->getIO();
$this->allYes = (bool)$input->getOption('all-yes');
$snapshots = $this->collectSnapshots();
if ($input->getOption('list')) {
if (!$snapshots) {
$io->writeln('No snapshots found.');
return 0;
}
$io->writeln('<info>Available snapshots:</info>');
foreach ($snapshots as $snapshot) {
$io->writeln(sprintf(' - %s (Grav %s)', $snapshot['id'], $snapshot['target_version'] ?? 'unknown'));
}
return 0;
}
if (!$snapshots) {
$io->error('No snapshots available to roll back to.');
return 1;
}
$targetId = $input->getArgument('manifest') ?: $snapshots[0]['id'];
$target = null;
foreach ($snapshots as $snapshot) {
if ($snapshot['id'] === $targetId) {
$target = $snapshot;
break;
}
}
if (!$target) {
$io->error(sprintf('Snapshot %s not found.', $targetId));
return 1;
}
if (!$this->allYes) {
$question = new ConfirmationQuestion(sprintf('Rollback to snapshot %s (Grav %s)? [y|N] ', $target['id'], $target['target_version'] ?? 'unknown'), false);
if (!$io->askQuestion($question)) {
$io->writeln('Rollback aborted.');
return 1;
}
}
$service = $this->createSafeUpgradeService();
try {
$service->rollback($target['id']);
$service->clearRecoveryFlag();
} catch (RuntimeException $e) {
$io->error($e->getMessage());
return 1;
}
$io->success(sprintf('Rolled back to snapshot %s.', $target['id']));
return 0;
}
/**
* @return array<int, array>
*/
protected function collectSnapshots(): array
{
$manifestDir = GRAV_ROOT . '/user/data/upgrades';
$files = glob($manifestDir . '/*.json');
if (!$files) {
return [];
}
rsort($files);
$snapshots = [];
foreach ($files as $file) {
$decoded = json_decode(file_get_contents($file), true);
if (!is_array($decoded)) {
continue;
}
$decoded['id'] = $decoded['id'] ?? pathinfo($file, PATHINFO_FILENAME);
$decoded['file'] = basename($file);
$snapshots[] = $decoded;
}
return $snapshots;
}
/**
* @return SafeUpgradeService
*/
protected function createSafeUpgradeService(): SafeUpgradeService
{
return new SafeUpgradeService();
}
}

View File

@@ -16,14 +16,20 @@ use Grav\Common\GPM\Installer;
use Grav\Common\GPM\Upgrader;
use Grav\Common\Grav;
use Grav\Console\GpmCommand;
// NOTE: SafeUpgradeService removed - no longer used in this file
// Preflight is now handled in Install.php after downloading the package
use Grav\Installer\Install;
use RuntimeException;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Style\SymfonyStyle;
use ZipArchive;
use function date;
use function count;
use function is_callable;
use function strlen;
use function stripos;
/**
* Class SelfupgradeCommand
@@ -41,6 +47,16 @@ class SelfupgradeCommand extends GpmCommand
private $tmp;
/** @var Upgrader */
private $upgrader;
/** @var string|null */
private $lastProgressMessage = null;
/** @var float|null */
private $operationTimerStart = null;
/** @var string|null */
private $currentProgressStage = null;
/** @var float|null */
private $currentStageStartedAt = null;
/** @var array */
private $currentStageExtras = [];
/** @var string */
protected $all_yes;
@@ -82,6 +98,18 @@ class SelfupgradeCommand extends GpmCommand
'Option to set the timeout in seconds when downloading the update (0 for no timeout)',
30
)
->addOption(
'safe',
null,
InputOption::VALUE_NONE,
'Force safe upgrade staging even if disabled in configuration'
)
->addOption(
'legacy',
null,
InputOption::VALUE_NONE,
'Force legacy in-place upgrade even if safe upgrade is enabled'
)
->setDescription('Detects and performs an update of Grav itself when available')
->setHelp('The <info>update</info> command updates Grav itself when a new version is available');
}
@@ -93,147 +121,241 @@ class SelfupgradeCommand extends GpmCommand
{
$input = $this->getInput();
$io = $this->getIO();
$forceSafe = (bool) $input->getOption('safe');
$forceLegacy = (bool) $input->getOption('legacy');
$forcedMode = null;
if (!class_exists(ZipArchive::class)) {
$io->title('GPM Self Upgrade');
$io->error('php-zip extension needs to be enabled!');
if ($forceSafe && $forceLegacy) {
$io->error('Cannot force safe and legacy upgrade modes simultaneously.');
return 1;
}
$this->upgrader = new Upgrader($input->getOption('force'));
$this->all_yes = $input->getOption('all-yes');
$this->overwrite = $input->getOption('overwrite');
$this->timeout = (int) $input->getOption('timeout');
$this->displayGPMRelease();
$update = $this->upgrader->getAssets()['grav-update'];
$local = $this->upgrader->getLocalVersion();
$remote = $this->upgrader->getRemoteVersion();
$release = strftime('%c', strtotime($this->upgrader->getReleaseDate()));
if (!$this->upgrader->meetsRequirements()) {
$io->writeln('<red>ATTENTION:</red>');
$io->writeln(' Grav has increased the minimum PHP requirement.');
$io->writeln(' You are currently running PHP <red>' . phpversion() . '</red>, but PHP <green>' . $this->upgrader->minPHPVersion() . '</green> is required.');
$io->writeln(' Additional information: <white>http://getgrav.org/blog/changing-php-requirements</white>');
$io->newLine();
$io->writeln('Selfupgrade aborted.');
$io->newLine();
return 1;
}
if (!$this->overwrite && !$this->upgrader->isUpgradable()) {
$io->writeln("You are already running the latest version of <green>Grav v{$local}</green>");
$io->writeln("which was released on {$release}");
$config = Grav::instance()['config'];
$schema = $config->get('versions.core.grav.schema');
if ($schema !== GRAV_SCHEMA && version_compare($schema, GRAV_SCHEMA, '<')) {
$io->newLine();
$io->writeln('However post-install scripts have not been run.');
if (!$this->all_yes) {
$question = new ConfirmationQuestion(
'Would you like to run the scripts? [Y|n] ',
true
);
$answer = $io->askQuestion($question);
} else {
$answer = true;
}
if ($answer) {
// Finalize installation.
Install::instance()->finalize();
$io->write(' |- Running post-install scripts... ');
$io->writeln(" '- <green>Success!</green> ");
$io->newLine();
if ($forceSafe || $forceLegacy) {
$forcedMode = $forceSafe ? true : false;
// NOTE: Do not call Install::forceSafeUpgrade() here as it would load the old Install class
// before the upgrade package is extracted, causing a class redeclaration error.
// Instead, we set the config and also use an environment variable as a fallback.
putenv('GRAV_FORCE_SAFE_UPGRADE=' . ($forcedMode ? '1' : '0'));
try {
$grav = Grav::instance();
if ($grav && isset($grav['config'])) {
$grav['config']->set('system.updates.safe_upgrade', $forcedMode);
}
} catch (\Throwable $e) {
// Ignore container bootstrap failures; mode override still applies via env var.
}
return 0;
}
Installer::isValidDestination(GRAV_ROOT . '/system');
if (Installer::IS_LINK === Installer::lastErrorCode()) {
$io->writeln('<red>ATTENTION:</red> Grav is symlinked, cannot upgrade, aborting...');
$io->newLine();
$io->writeln("You are currently running a symbolically linked Grav v{$local}. Latest available is v{$remote}.");
return 1;
}
// not used but preloaded just in case!
new ArrayInput([]);
$io->writeln("Grav v<cyan>{$remote}</cyan> is now available [release date: {$release}].");
$io->writeln('You are currently using v<cyan>' . GRAV_VERSION . '</cyan>.');
if (!$this->all_yes) {
$question = new ConfirmationQuestion(
'Would you like to read the changelog before proceeding? [y|N] ',
false
);
$answer = $io->askQuestion($question);
if ($answer) {
$changelog = $this->upgrader->getChangelog(GRAV_VERSION);
$io->newLine();
foreach ($changelog as $version => $log) {
$title = $version . ' [' . $log['date'] . ']';
$content = preg_replace_callback('/\d\.\s\[\]\(#(.*)\)/', static function ($match) {
return "\n" . ucfirst($match[1]) . ':';
}, $log['content']);
$io->writeln($title);
$io->writeln(str_repeat('-', strlen($title)));
$io->writeln($content);
$io->newLine();
}
$question = new ConfirmationQuestion('Press [ENTER] to continue.', true);
$io->askQuestion($question);
if ($forceSafe) {
$io->note('Safe upgrade staging forced for this run.');
} else {
$io->warning('Legacy in-place upgrade forced for this run.');
}
}
$question = new ConfirmationQuestion('Would you like to upgrade now? [y|N] ', false);
$answer = $io->askQuestion($question);
if (!$answer) {
$io->writeln('Aborting...');
try {
if (!class_exists(ZipArchive::class)) {
$io->title('GPM Self Upgrade');
$io->error('php-zip extension needs to be enabled!');
return 1;
}
}
$io->newLine();
$io->writeln("Preparing to upgrade to v<cyan>{$remote}</cyan>..");
$this->upgrader = new Upgrader($input->getOption('force'));
$this->all_yes = $input->getOption('all-yes');
$this->overwrite = $input->getOption('overwrite');
$this->timeout = (int) $input->getOption('timeout');
$io->write(" |- Downloading upgrade [{$this->formatBytes($update['size'])}]... 0%");
$this->file = $this->download($update);
$this->displayGPMRelease();
$io->write(' |- Installing upgrade... ');
$installation = $this->upgrade();
// NOTE: Preflight checks are now run in Install.php AFTER downloading the package.
// This ensures we use the NEW SafeUpgradeService from the package, not the old one.
// Running preflight here would load the OLD class into memory and prevent the new one from loading.
$update = $this->upgrader->getAssets()['grav-update'];
$local = $this->upgrader->getLocalVersion();
$remote = $this->upgrader->getRemoteVersion();
$release = strftime('%c', strtotime($this->upgrader->getReleaseDate()));
if (!$this->upgrader->meetsRequirements()) {
$io->writeln('<red>ATTENTION:</red>');
$io->writeln(' Grav has increased the minimum PHP requirement.');
$io->writeln(' You are currently running PHP <red>' . phpversion() . '</red>, but PHP <green>' . $this->upgrader->minPHPVersion() . '</green> is required.');
$io->writeln(' Additional information: <white>http://getgrav.org/blog/changing-php-requirements</white>');
$io->newLine();
$io->writeln('Selfupgrade aborted.');
$io->newLine();
return 1;
}
if (!$this->overwrite && !$this->upgrader->isUpgradable()) {
$io->writeln("You are already running the latest version of <green>Grav v{$local}</green>");
$io->writeln("which was released on {$release}");
$config = Grav::instance()['config'];
$schema = $config->get('versions.core.grav.schema');
if ($schema !== GRAV_SCHEMA && version_compare($schema, GRAV_SCHEMA, '<')) {
$io->newLine();
$io->writeln('However post-install scripts have not been run.');
if (!$this->all_yes) {
$question = new ConfirmationQuestion(
'Would you like to run the scripts? [Y|n] ',
true
);
$answer = $io->askQuestion($question);
} else {
$answer = true;
}
if ($answer) {
// Finalize installation.
Install::instance()->finalize();
$io->write(' |- Running post-install scripts... ');
$io->writeln(" |- <green>Success!</green> ");
$io->newLine();
}
}
return 0;
}
Installer::isValidDestination(GRAV_ROOT . '/system');
if (Installer::IS_LINK === Installer::lastErrorCode()) {
$io->writeln('<red>ATTENTION:</red> Grav is symlinked, cannot upgrade, aborting...');
$io->newLine();
$io->writeln("You are currently running a symbolically linked Grav v{$local}. Latest available is v{$remote}.");
return 1;
}
// not used but preloaded just in case!
new ArrayInput([]);
$io->writeln("Grav v<cyan>{$remote}</cyan> is now available [release date: {$release}].");
$io->writeln('You are currently using v<cyan>' . GRAV_VERSION . '</cyan>.');
if (!$this->all_yes) {
$question = new ConfirmationQuestion(
'Would you like to read the changelog before proceeding? [y|N] ',
false
);
$answer = $io->askQuestion($question);
if ($answer) {
$changelog = $this->upgrader->getChangelog(GRAV_VERSION);
$io->newLine();
foreach ($changelog as $version => $log) {
$title = $version . ' [' . $log['date'] . ']';
$content = preg_replace_callback('/\d\.\s\[\]\(#(.*)\)/', static function ($match) {
return "\n" . ucfirst((string) $match[1]) . ':';
}, (string) $log['content']);
$io->writeln($title);
$io->writeln(str_repeat('-', strlen($title)));
$io->writeln($content);
$io->newLine();
}
$question = new ConfirmationQuestion('Press [ENTER] to continue.', true);
$io->askQuestion($question);
}
$question = new ConfirmationQuestion('Would you like to upgrade now? [y|N] ', false);
$answer = $io->askQuestion($question);
if (!$answer) {
$io->writeln('Aborting...');
return 1;
}
}
$error = 0;
if (!$installation) {
$io->writeln(" '- <red>Installation failed or aborted.</red>");
$io->newLine();
$error = 1;
} else {
$io->writeln(" '- <green>Success!</green> ");
$io->newLine();
}
$io->writeln("Preparing to upgrade to v<cyan>{$remote}</cyan>..");
if ($this->tmp && is_dir($this->tmp)) {
Folder::delete($this->tmp);
}
/** @var \Grav\Common\Recovery\RecoveryManager $recovery */
$recovery = Grav::instance()['recovery'];
$recovery->markUpgradeWindow('core-upgrade', [
'scope' => 'core',
'target_version' => $remote,
]);
return $error;
$io->write(" |- Downloading upgrade [{$this->formatBytes($update['size'])}]... 0%");
$this->file = $this->download($update);
$io->write(' |- Installing upgrade... ');
$this->operationTimerStart = microtime(true);
$installation = $this->upgrade();
$error = 0;
if (!$installation) {
$io->writeln(" |- <red>Installation failed or aborted.</red>");
$io->newLine();
$error = 1;
} else {
$io->writeln(" |- <green>Success!</green> ");
$manifest = Install::instance()->getLastManifest();
if (is_array($manifest) && ($manifest['id'] ?? null)) {
$snapshotId = (string) $manifest['id'];
$snapshotTimestamp = isset($manifest['created_at']) ? (int) $manifest['created_at'] : null;
$manifestPath = null;
if (isset($manifest['id'])) {
$manifestPath = 'user/data/upgrades/' . $manifest['id'] . '.json';
}
$metadata = [
'scope' => 'core',
'target_version' => $remote,
'snapshot' => $snapshotId,
];
if (null !== $snapshotTimestamp) {
$metadata['snapshot_created_at'] = $snapshotTimestamp;
}
if ($manifestPath) {
$metadata['snapshot_manifest'] = $manifestPath;
}
$recovery->markUpgradeWindow('core-upgrade', $metadata);
$io->writeln(sprintf(" |- Recovery snapshot: <cyan>%s</cyan>", $snapshotId));
if (null !== $snapshotTimestamp) {
$io->writeln(sprintf(" |- Snapshot captured: <white>%s</white>", date('c', $snapshotTimestamp)));
}
if ($manifestPath) {
$io->writeln(sprintf(" |- Manifest stored at: <white>%s</white>", $manifestPath));
}
} else {
// Ensure recovery window remains active even if manifest could not be resolved.
$recovery->markUpgradeWindow('core-upgrade', [
'scope' => 'core',
'target_version' => $remote,
]);
}
$io->newLine();
// Clear recovery flag - upgrade completed successfully
$recovery->closeUpgradeWindow();
}
if ($this->tmp && is_dir($this->tmp)) {
Folder::delete($this->tmp);
}
return $error;
} finally {
if (null !== $forcedMode) {
// Clean up environment variable
putenv('GRAV_FORCE_SAFE_UPGRADE');
// Only call Install::forceSafeUpgrade if Install class has been loaded
if (class_exists(\Grav\Installer\Install::class, false)) {
Install::forceSafeUpgrade(null);
}
}
}
}
/**
@@ -263,14 +385,200 @@ class SelfupgradeCommand extends GpmCommand
return $this->tmp . DS . $package['name'];
}
/**
* @param array $preflight
* @return bool
*/
protected function handlePreflightReport(array $preflight): bool
{
$io = $this->getIO();
$pending = $preflight['plugins_pending'] ?? [];
$blocking = $preflight['blocking'] ?? [];
$conflicts = $preflight['psr_log_conflicts'] ?? [];
$monologConflicts = $preflight['monolog_conflicts'] ?? [];
$warnings = $preflight['warnings'] ?? [];
$isMajorMinorUpgrade = $preflight['is_major_minor_upgrade'] ?? null;
if ($isMajorMinorUpgrade === null && $this->upgrader) {
$local = $this->upgrader->getLocalVersion();
$remote = $this->upgrader->getRemoteVersion();
$localParts = explode('.', $local);
$remoteParts = explode('.', $remote);
$localMajor = (int)($localParts[0] ?? 0);
$localMinor = (int)($localParts[1] ?? 0);
$remoteMajor = (int)($remoteParts[0] ?? 0);
$remoteMinor = (int)($remoteParts[1] ?? 0);
$isMajorMinorUpgrade = ($localMajor !== $remoteMajor) || ($localMinor !== $remoteMinor);
}
$isMajorMinorUpgrade = (bool)$isMajorMinorUpgrade;
if ($warnings) {
$io->newLine();
$io->writeln('<magenta>Preflight warnings detected:</magenta>');
foreach ($warnings as $warning) {
$io->writeln(' • ' . $warning);
}
}
if ($blocking && empty($pending)) {
$io->newLine();
$io->writeln('<red>Upgrade blocked:</red>');
foreach ($blocking as $reason) {
$io->writeln(' - ' . $reason);
}
return false;
}
if (empty($pending) && empty($conflicts) && empty($monologConflicts)) {
return true;
}
if ($pending && $isMajorMinorUpgrade) {
$local = $this->upgrader ? $this->upgrader->getLocalVersion() : 'unknown';
$remote = $this->upgrader ? $this->upgrader->getRemoteVersion() : 'unknown';
$io->newLine();
$io->writeln('<yellow>The following packages need updating before Grav upgrade:</yellow>');
foreach ($pending as $slug => $info) {
$type = $info['type'] ?? 'plugin';
$current = $info['current'] ?? 'unknown';
$available = $info['available'] ?? 'unknown';
$io->writeln(sprintf(' - %s (%s) %s → %s', $slug, $type, $current, $available));
}
$io->writeln(' For major version upgrades (v' . $local . ' → v' . $remote . '), plugins must be updated to their latest');
$io->writeln(' compatible versions BEFORE upgrading Grav core to ensure compatibility.');
$io->writeln(' Please run `bin/gpm update` to update these packages, then retry self-upgrade.');
$proceed = false;
if (!$this->all_yes) {
$question = new ConfirmationQuestion('Proceed anyway? [y|N] ', false);
$proceed = $io->askQuestion($question);
}
if (!$proceed) {
$io->writeln('Aborting self-upgrade. Run `bin/gpm update` first.');
return false;
}
Install::allowPendingPackageOverride(true);
$io->writeln(' Proceeding despite pending plugin/theme updates.');
}
$handled = $this->handleConflicts(
$conflicts,
static function (SymfonyStyle $io, array $conflicts): void {
$io->newLine();
$io->writeln('<yellow>Potential psr/log incompatibilities:</yellow>');
foreach ($conflicts as $slug => $info) {
$requires = $info['requires'] ?? '*';
$io->writeln(sprintf(' - %s (requires psr/log %s)', $slug, $requires));
}
},
'Update the plugin or add "replace": {"psr/log": "*"} to its composer.json and reinstall dependencies.',
'Aborting self-upgrade. Adjust composer requirements or update affected plugins.',
'Proceeding with potential psr/log incompatibilities still active.',
'Disabled before upgrade because of psr/log conflict'
);
if (!$handled) {
return false;
}
$handledMonolog = $this->handleConflicts(
$monologConflicts,
static function (SymfonyStyle $io, array $conflicts): void {
$io->newLine();
$io->writeln('<yellow>Potential Monolog logger API incompatibilities:</yellow>');
foreach ($conflicts as $slug => $entries) {
foreach ($entries as $entry) {
$file = $entry['file'] ?? 'unknown file';
$method = $entry['method'] ?? 'add*';
$io->writeln(sprintf(' - %s (%s in %s)', $slug, $method, $file));
}
}
},
'Update the plugin to use PSR-3 style logger methods (e.g. $logger->error()) before upgrading.',
'Aborting self-upgrade. Update plugins to remove deprecated Monolog add* calls.',
'Proceeding with potential Monolog API incompatibilities still active.',
'Disabled before upgrade because of Monolog API conflict'
);
if (!$handledMonolog) {
return false;
}
return true;
}
/**
* @param array $conflicts
* @param callable $printer
* @param string $advice
* @param string $abortMessage
* @param string $continueMessage
* @param string $disableNote
* @return bool
*/
private function handleConflicts(array $conflicts, callable $printer, string $advice, string $abortMessage, string $continueMessage, string $disableNote): bool
{
if (empty($conflicts)) {
return true;
}
$io = $this->getIO();
$printer($io, $conflicts);
$io->writeln(' ' . $advice);
$choice = $this->all_yes ? 'abort' : $io->choice(
'How would you like to proceed?',
['disable', 'continue', 'abort'],
'abort'
);
if ($choice === 'abort') {
$io->writeln($abortMessage);
return false;
}
/** @var \Grav\Common\Recovery\RecoveryManager $recovery */
$recovery = Grav::instance()['recovery'];
if ($choice === 'disable') {
foreach (array_keys($conflicts) as $slug) {
$recovery->disablePlugin($slug, ['message' => $disableNote]);
$io->writeln(sprintf(' - Disabled plugin %s.', $slug));
}
$io->writeln('Continuing with conflicted plugins disabled.');
return true;
}
$io->writeln($continueMessage);
return true;
}
/**
* @return bool
*/
private function upgrade(): bool
{
$io = $this->getIO();
$this->lastProgressMessage = null;
$this->upgradeGrav($this->file);
$this->finalizeStageTracking();
$elapsed = null;
if (null !== $this->operationTimerStart) {
$elapsed = microtime(true) - $this->operationTimerStart;
$this->operationTimerStart = null;
}
$errorCode = Installer::lastErrorCode();
if ($errorCode) {
@@ -282,10 +590,16 @@ class SelfupgradeCommand extends GpmCommand
return false;
}
if (null !== $elapsed) {
$io->writeln(sprintf(' |- Safe upgrade staging completed in %s', $this->formatDuration($elapsed)));
}
$io->write("\x0D");
// extra white spaces to clear out the buffer properly
$io->writeln(' |- Installing upgrade... <green>ok</green> ');
$this->ensureExecutablePermissions();
return true;
}
@@ -325,14 +639,32 @@ class SelfupgradeCommand extends GpmCommand
*/
private function upgradeGrav(string $zip): void
{
$io = $this->getIO();
try {
$io->write("\x0D |- Extracting update... ");
$folder = Installer::unZip($zip, $this->tmp . '/zip');
if ($folder === false) {
throw new RuntimeException(Installer::lastErrorMsg());
}
$io->write("\x0D");
$io->writeln(' |- Extracting update... <green>ok</green> ');
$script = $folder . '/system/install.php';
if ((file_exists($script) && $install = include $script) && is_callable($install)) {
if (is_object($install) && method_exists($install, 'setProgressCallback')) {
$install->setProgressCallback(function (string $stage, string $message, ?int $percent = null, array $extra = []) {
$this->handleServiceProgress($stage, $message, $percent);
});
}
if (is_object($install) && method_exists($install, 'generatePreflightReport')) {
$report = $install->generatePreflightReport();
if (!$this->handlePreflightReport($report)) {
Installer::setError('Upgrade aborted due to preflight requirements.');
return;
}
}
$install($zip);
} else {
throw new RuntimeException('Uploaded archive file is not a valid Grav update package');
@@ -341,4 +673,110 @@ class SelfupgradeCommand extends GpmCommand
Installer::setError($e->getMessage());
}
}
private function handleServiceProgress(string $stage, string $message, ?int $percent = null, array $extra = []): void
{
$this->trackStageProgress($stage, $message, $extra);
if ($this->lastProgressMessage === $message) {
return;
}
$this->lastProgressMessage = $message;
$io = $this->getIO();
$suffix = '';
if (null !== $percent) {
$suffix = sprintf(' (%d%%)', $percent);
}
$io->writeln(sprintf(' |- %s%s', $message, $suffix));
}
private function ensureExecutablePermissions(): void
{
$executables = [
'bin/grav',
'bin/plugin',
'bin/gpm',
'bin/restore',
'bin/composer.phar'
];
foreach ($executables as $relative) {
$path = GRAV_ROOT . '/' . $relative;
if (!is_file($path) || is_link($path)) {
continue;
}
$mode = @fileperms($path);
$desired = ($mode & 0777) | 0111;
if (($mode & 0111) !== 0111) {
@chmod($path, $desired);
}
}
}
private function trackStageProgress(string $stage, string $message, array $extra = []): void
{
$now = microtime(true);
if (null !== $this->currentProgressStage && $stage !== $this->currentProgressStage && null !== $this->currentStageStartedAt) {
$elapsed = $now - $this->currentStageStartedAt;
$this->emitStageSummary($this->currentProgressStage, $elapsed, $this->currentStageExtras);
$this->currentStageExtras = [];
}
if ($stage !== $this->currentProgressStage) {
$this->currentProgressStage = $stage;
$this->currentStageStartedAt = $now;
$this->currentStageExtras = [];
}
if (!isset($this->currentStageExtras['label'])) {
$this->currentStageExtras['label'] = $message;
}
if ($extra) {
$this->currentStageExtras = array_merge($this->currentStageExtras, $extra);
}
}
private function finalizeStageTracking(): void
{
if (null !== $this->currentProgressStage && null !== $this->currentStageStartedAt) {
$elapsed = microtime(true) - $this->currentStageStartedAt;
$this->emitStageSummary($this->currentProgressStage, $elapsed, $this->currentStageExtras);
}
$this->currentProgressStage = null;
$this->currentStageStartedAt = null;
$this->currentStageExtras = [];
}
private function emitStageSummary(string $stage, float $seconds, array $extra = []): void
{
$io = $this->getIO();
$label = $extra['label'] ?? ucfirst($stage);
$modeText = '';
if (isset($extra['mode'])) {
$modeText = sprintf(' [%s]', $extra['mode']);
}
$io->writeln(sprintf(' |- %s completed in %s%s', $label, $this->formatDuration($seconds), $modeText));
}
private function formatDuration(float $seconds): string
{
if ($seconds < 1) {
return sprintf('%0.3fs', $seconds);
}
$minutes = (int)floor($seconds / 60);
$remaining = $seconds - ($minutes * 60);
if ($minutes === 0) {
return sprintf('%0.1fs', $remaining);
}
return sprintf('%dm %0.1fs', $minutes, $remaining);
}
}

View File

@@ -12,6 +12,7 @@ namespace Grav\Console\Gpm;
use Grav\Common\GPM\GPM;
use Grav\Common\GPM\Installer;
use Grav\Common\GPM\Upgrader;
use Grav\Common\Grav;
use Grav\Console\GpmCommand;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputArgument;
@@ -116,15 +117,38 @@ class UpdateCommand extends GpmCommand
$local = $this->upgrader->getLocalVersion();
$remote = $this->upgrader->getRemoteVersion();
if ($local !== $remote) {
$io->writeln('<yellow>WARNING</yellow>: A new version of Grav is available. You should update Grav before updating plugins and themes. If you continue without updating Grav, some plugins or themes may stop working.');
$io->newLine();
$question = new ConfirmationQuestion('Continue with the update process? [Y|n] ', true);
$answer = $io->askQuestion($question);
// Determine if this is a major/minor version upgrade by comparing versions
$localParts = explode('.', $local);
$remoteParts = explode('.', $remote);
if (!$answer) {
$io->writeln('<red>Update aborted. Exiting...</red>');
$localMajor = (int)($localParts[0] ?? 0);
$localMinor = (int)($localParts[1] ?? 0);
$remoteMajor = (int)($remoteParts[0] ?? 0);
$remoteMinor = (int)($remoteParts[1] ?? 0);
return 1;
// Check if this is a major/minor version change (e.g., 1.7.x -> 1.8.y)
$isMajorMinorUpgrade = ($localMajor !== $remoteMajor) || ($localMinor !== $remoteMinor);
if ($isMajorMinorUpgrade) {
// For major/minor upgrades (e.g., 1.7.x -> 1.8.y), recommend updating plugins FIRST
$io->writeln('<yellow>WARNING</yellow>: A new major version of Grav is available (v' . $local . ' -> v' . $remote . ').');
$io->writeln('For major version upgrades, you should update plugins and themes to their latest compatible versions BEFORE upgrading Grav core.');
$io->writeln('This ensures plugins have any necessary compatibility fixes for the new Grav version.');
$io->newLine();
$io->writeln('<green>It is recommended to proceed with updating plugins and themes now.</green>');
} else {
// For patch upgrades (e.g., 1.7.45 -> 1.7.46), recommend updating Grav FIRST
$io->writeln('<yellow>WARNING</yellow>: A new version of Grav is available (v' . $local . ' -> v' . $remote . ').');
$io->writeln('You should update Grav before updating plugins and themes. If you continue without updating Grav, some plugins or themes may stop working.');
$io->newLine();
$question = new ConfirmationQuestion('Continue with the update process? [Y|n] ', true);
$answer = $io->askQuestion($question);
if (!$answer) {
$io->writeln('<red>Update aborted. Exiting...</red>');
return 1;
}
}
}
@@ -212,6 +236,10 @@ class UpdateCommand extends GpmCommand
}
}
/** @var \Grav\Common\Recovery\RecoveryManager $recovery */
$recovery = Grav::instance()['recovery'];
$recovery->markUpgradeWindow('package-update', ['scope' => 'core']);
// finally update
$install_command = $this->getApplication()->find('install');

View File

@@ -28,7 +28,7 @@ class GpmCommand extends Command
* @param OutputInterface $output
* @return int
*/
protected function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->setupConsole($input, $output);

View File

@@ -0,0 +1,183 @@
<?php
/**
* Backport of Monolog\Utils providing DEFAULT_JSON_FLAGS for older Monolog versions.
*
* This is a trimmed copy of the Monolog 1.x Utils class with a compatible constant so
* that Grav 1.7 can interoperate with code targeting Monolog 3.
*/
namespace Monolog;
class Utils
{
public const DEFAULT_JSON_FLAGS = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
/**
* @internal
*/
public static function getClass($object)
{
$class = \get_class($object);
return 'c' === $class[0] && 0 === strpos($class, "class@anonymous\0") ? get_parent_class($class).'@anonymous' : $class;
}
/**
* Makes sure if a relative path is passed in it is turned into an absolute path
*
* @param string $streamUrl stream URL or path without protocol
*
* @return string
*/
public static function canonicalizePath($streamUrl)
{
$prefix = '';
if ('file://' === substr($streamUrl, 0, 7)) {
$streamUrl = substr($streamUrl, 7);
$prefix = 'file://';
}
if (false !== strpos($streamUrl, '://')) {
return $streamUrl;
}
if (substr($streamUrl, 0, 1) === '/' || substr($streamUrl, 1, 1) === ':' || substr($streamUrl, 0, 2) === '\\\\') {
return $prefix.$streamUrl;
}
$streamUrl = getcwd() . '/' . $streamUrl;
return $prefix.$streamUrl;
}
/**
* Return the JSON representation of a value
*
* @param mixed $data
* @param int $encodeFlags
* @param bool $ignoreErrors
* @return string
*/
public static function jsonEncode($data, $encodeFlags = null, $ignoreErrors = false)
{
if (null === $encodeFlags) {
$encodeFlags = self::DEFAULT_JSON_FLAGS;
if (defined('JSON_PRESERVE_ZERO_FRACTION')) {
$encodeFlags |= JSON_PRESERVE_ZERO_FRACTION;
}
if (defined('JSON_INVALID_UTF8_SUBSTITUTE')) {
$encodeFlags |= JSON_INVALID_UTF8_SUBSTITUTE;
}
if (defined('JSON_PARTIAL_OUTPUT_ON_ERROR')) {
$encodeFlags |= JSON_PARTIAL_OUTPUT_ON_ERROR;
}
}
if ($ignoreErrors) {
$json = @json_encode($data, $encodeFlags);
if (false === $json) {
return 'null';
}
return $json;
}
$json = json_encode($data, $encodeFlags);
if (false === $json) {
$json = self::handleJsonError(json_last_error(), $data);
}
return $json;
}
/**
* Handle a json_encode failure.
*
* @param int $code
* @param mixed $data
* @param int $encodeFlags
* @return string
*/
public static function handleJsonError($code, $data, $encodeFlags = null)
{
if ($code !== JSON_ERROR_UTF8) {
self::throwEncodeError($code, $data);
}
if (is_string($data)) {
self::detectAndCleanUtf8($data);
} elseif (is_array($data)) {
array_walk_recursive($data, array('Monolog\Utils', 'detectAndCleanUtf8'));
} else {
self::throwEncodeError($code, $data);
}
if (null === $encodeFlags) {
$encodeFlags = self::DEFAULT_JSON_FLAGS;
if (defined('JSON_PRESERVE_ZERO_FRACTION')) {
$encodeFlags |= JSON_PRESERVE_ZERO_FRACTION;
}
if (defined('JSON_INVALID_UTF8_SUBSTITUTE')) {
$encodeFlags |= JSON_INVALID_UTF8_SUBSTITUTE;
}
if (defined('JSON_PARTIAL_OUTPUT_ON_ERROR')) {
$encodeFlags |= JSON_PARTIAL_OUTPUT_ON_ERROR;
}
}
$json = json_encode($data, $encodeFlags);
if ($json === false) {
self::throwEncodeError(json_last_error(), $data);
}
return $json;
}
/**
* @param int $code
* @param mixed $data
* @throws \RuntimeException
*/
private static function throwEncodeError($code, $data)
{
switch ($code) {
case JSON_ERROR_DEPTH:
$msg = 'Maximum stack depth exceeded';
break;
case JSON_ERROR_STATE_MISMATCH:
$msg = 'Underflow or the modes mismatch';
break;
case JSON_ERROR_CTRL_CHAR:
$msg = 'Unexpected control character found';
break;
case JSON_ERROR_UTF8:
$msg = 'Malformed UTF-8 characters, possibly incorrectly encoded';
break;
default:
$msg = 'Unknown error';
}
throw new \RuntimeException('JSON encoding failed: '.$msg.'. Encoding: '.var_export($data, true));
}
/**
* @param mixed $data
*/
public static function detectAndCleanUtf8(&$data)
{
if (is_string($data) && !preg_match('//u', $data)) {
$data = preg_replace_callback(
'/[\x80-\xFF]+/',
function ($m) { return utf8_encode($m[0]); },
$data
);
$data = str_replace(
array('¤', '¦', '¨', '´', '¸', '¼', '½', '¾'),
array('€', 'Š', 'š', 'Ž', 'ž', 'Œ', 'œ', 'Ÿ'),
$data
);
}
}
}

View File

@@ -0,0 +1,28 @@
<?php
/**
* @package Grav\Framework\Compat
*
* Provides lightweight shims for legacy Monolog installations used in Grav 1.7
* so that newer Grav code (targeting Monolog 3) can run without fatal errors.
*/
declare(strict_types=1);
namespace Grav\Framework\Compat\Monolog;
if (!class_exists(\Monolog\Utils::class, false)) {
spl_autoload_register(
static function (string $class): bool {
if ($class === 'Monolog\\Utils') {
require __DIR__ . '/Utils.php';
return true;
}
return false;
},
true,
true
);
}

View File

@@ -12,14 +12,51 @@ namespace Grav\Installer;
use Composer\Autoload\ClassLoader;
use Exception;
use Grav\Common\Cache;
use Grav\Common\Filesystem\Folder;
use Grav\Common\GPM\GPM;
use Grav\Common\GPM\Installer;
use Grav\Common\Grav;
use Grav\Common\Plugins;
use Grav\Common\Yaml;
use RuntimeException;
use Throwable;
use function array_slice;
use function basename;
use function class_exists;
use function count;
use function date;
use function dirname;
use function explode;
use function floor;
use function function_exists;
use function file_get_contents;
use function glob;
use function iterator_to_array;
use function is_dir;
use function is_file;
use function is_link;
use function method_exists;
use function is_string;
use function is_writable;
use function json_encode;
use function json_decode;
use function readlink;
use function array_fill_keys;
use function array_map;
use function array_pad;
use function array_key_exists;
use function rsort;
use function sort;
use function sprintf;
use function strtolower;
use function strpos;
use function preg_match;
use function symlink;
use function time;
use function uniqid;
use function unlink;
use const GRAV_ROOT;
use const JSON_PRETTY_PRINT;
/**
* Grav installer.
@@ -119,8 +156,21 @@ final class Install
/** @var VersionUpdater|null */
private $updater;
/** @var array|null */
private $lastManifest = null;
/** @var static */
private static $instance;
/** @var bool|null */
private static $forceSafeUpgrade = null;
/** @var bool */
private static $allowPendingOverride = false;
/** @var int|null */
private static $snapshotLimit = null;
/** @var callable|null */
private $progressCallback = null;
/** @var array|null */
private $pendingPreflight = null;
/**
* @return static
@@ -134,10 +184,40 @@ final class Install
return self::$instance;
}
/**
* Force safe-upgrade mode independently of system configuration.
*
* @param bool|null $state
* @return void
*/
public static function forceSafeUpgrade(?bool $state = true): void
{
self::$forceSafeUpgrade = $state;
}
private function __construct()
{
}
public static function allowPendingPackageOverride(?bool $state = true): void
{
if ($state === null) {
self::$allowPendingOverride = false;
} else {
self::$allowPendingOverride = (bool)$state;
}
}
private function ensureLocation(): void
{
if (null === $this->location) {
$path = realpath(__DIR__);
if ($path) {
$this->location = dirname($path, 4);
}
}
}
/**
* @param string|null $zip
* @return $this
@@ -186,6 +266,20 @@ ERR;
$this->finalize();
}
public function setProgressCallback(?callable $callback): self
{
$this->progressCallback = $callback;
return $this;
}
private function relayProgress(string $stage, string $message, ?int $percent = null): void
{
if ($this->progressCallback) {
($this->progressCallback)($stage, $message, $percent);
}
}
/**
* NOTE: This method can only be called after $grav['plugins']->init().
*
@@ -209,6 +303,7 @@ ERR;
public function prepare(): void
{
// Locate the new Grav update and the target site from the filesystem.
$this->ensureLocation();
$location = realpath(__DIR__);
$target = realpath(GRAV_ROOT . '/index.php');
@@ -251,6 +346,8 @@ ERR;
throw new RuntimeException('Oops, installer was run without prepare()!', 500);
}
$this->lastManifest = null;
try {
if (null === $this->updater) {
$versions = Versions::instance(USER_DIR . 'config/versions.yaml');
@@ -260,6 +357,31 @@ ERR;
// Update user/config/version.yaml before copying the files to avoid frontend from setting the version schema.
$this->updater->install();
$safeUpgradeRequested = $this->shouldUseSafeUpgrade();
$targetVersion = $this->getVersion();
if (null === $this->pendingPreflight) {
$this->pendingPreflight = $this->runPreflightChecks($targetVersion);
}
if (!empty($this->pendingPreflight['blocking'] ?? [])) {
$this->relayProgress('error', 'Upgrade blocked by preflight checks.', null);
Installer::setError('Upgrade preflight checks failed.');
return;
}
$snapshotManifest = null;
if ($safeUpgradeRequested) {
$snapshotManifest = $this->captureCoreSnapshot($targetVersion);
if ($snapshotManifest) {
$this->relayProgress('snapshot', sprintf('Snapshot %s captured.', $snapshotManifest['id']), 100);
} else {
$this->relayProgress('snapshot', 'Snapshot capture unavailable; continuing without it.', null);
}
}
$progressMessage = $safeUpgradeRequested
? 'Running Grav standard installer (safe mode)...'
: 'Running Grav standard installer...';
$this->relayProgress('installing', $progressMessage, null);
Installer::install(
$this->zip ?? '',
GRAV_ROOT,
@@ -267,8 +389,12 @@ ERR;
$this->location,
!($this->zip && is_file($this->zip))
);
$this->relayProgress('complete', 'Grav standard installer finished.', 100);
} catch (Exception $e) {
Installer::setError($e->getMessage());
} finally {
self::$allowPendingOverride = false;
}
$errorCode = Installer::lastErrorCode();
@@ -280,12 +406,317 @@ ERR;
}
}
/**
* @return bool
*/
private function shouldUseSafeUpgrade(): bool
{
if (null !== self::$forceSafeUpgrade) {
return self::$forceSafeUpgrade;
}
$envValue = getenv('GRAV_FORCE_SAFE_UPGRADE');
if (false !== $envValue && '' !== $envValue) {
return $envValue === '1';
}
try {
$grav = Grav::instance();
if ($grav && isset($grav['config'])) {
$configValue = $grav['config']->get('system.updates.safe_upgrade');
if ($configValue !== null) {
return (bool) $configValue;
}
}
} catch (\Throwable $e) {
// ignore bootstrap failures
}
return false;
}
private function getSafeUpgradeSnapshotLimit(): int
{
if (null !== self::$snapshotLimit) {
return self::$snapshotLimit;
}
$limit = 5;
try {
$grav = Grav::instance();
if ($grav && isset($grav['config'])) {
$configured = $grav['config']->get('system.updates.safe_upgrade_snapshot_limit');
if ($configured !== null) {
$limit = (int)$configured;
}
}
} catch (\Throwable $e) {
// ignore bootstrap failures
}
if ($limit < 0) {
$limit = 0;
}
self::$snapshotLimit = $limit;
return $limit;
}
private function captureCoreSnapshot(string $targetVersion): ?array
{
$entries = $this->collectSnapshotEntries();
if (!$entries) {
return null;
}
$snapshotRoot = $this->resolveSnapshotStore();
if (!$snapshotRoot) {
return null;
}
$snapshotId = 'snapshot-' . date('YmdHis');
$snapshotPath = $snapshotRoot . '/' . $snapshotId;
try {
Folder::create($snapshotPath);
} catch (\Throwable $e) {
error_log('[Grav Upgrade] Unable to create snapshot directory: ' . $e->getMessage());
return null;
}
$total = count($entries);
foreach ($entries as $index => $entry) {
$percent = $total > 0 ? (int)floor((($index + 1) / $total) * 100) : null;
$this->relayProgress('snapshot', sprintf('Snapshotting %s (%d/%d)', $entry, $index + 1, $total), $percent);
$source = GRAV_ROOT . '/' . $entry;
$destination = $snapshotPath . '/' . $entry;
try {
$this->snapshotCopyEntry($source, $destination);
} catch (\Throwable $e) {
error_log('[Grav Upgrade] Snapshot copy failed for ' . $entry . ': ' . $e->getMessage());
return null;
}
}
$manifest = [
'id' => $snapshotId,
'created_at' => time(),
'source_version' => GRAV_VERSION,
'target_version' => $targetVersion,
'php_version' => PHP_VERSION,
'entries' => $entries,
'package_path' => null,
'backup_path' => $snapshotPath,
'operation' => 'upgrade',
'mode' => 'pre-upgrade',
];
$this->persistSnapshotManifest($manifest);
$this->lastManifest = $manifest;
$this->pruneOldSnapshots($snapshotRoot);
return $manifest;
}
private function collectSnapshotEntries(): array
{
$ignores = array_fill_keys($this->ignores, true);
$ignores['user'] = true;
$entries = [];
try {
$iterator = new \DirectoryIterator(GRAV_ROOT);
foreach ($iterator as $item) {
if ($item->isDot()) {
continue;
}
$name = $item->getFilename();
if (isset($ignores[$name])) {
continue;
}
$entries[] = $name;
}
} catch (\Throwable $e) {
error_log('[Grav Upgrade] Unable to enumerate snapshot entries: ' . $e->getMessage());
return [];
}
sort($entries);
return $entries;
}
private function snapshotCopyEntry(string $source, string $destination): void
{
if (is_link($source)) {
$linkTarget = readlink($source);
Folder::create(dirname($destination));
if (is_link($destination) || is_file($destination)) {
@unlink($destination);
}
if ($linkTarget !== false) {
@symlink($linkTarget, $destination);
}
return;
}
if (is_dir($source)) {
Folder::rcopy($source, $destination);
return;
}
Folder::create(dirname($destination));
if (!@copy($source, $destination)) {
throw new RuntimeException(sprintf('Failed to copy file %s during snapshot.', $source));
}
}
private function resolveSnapshotStore(): ?string
{
$candidates = [];
try {
$grav = Grav::instance();
if ($grav && isset($grav['locator'])) {
$path = $grav['locator']->findResource('tmp://grav-snapshots', true, true);
if ($path) {
$candidates[] = $path;
}
}
} catch (\Throwable $e) {
// ignore locator issues
}
$candidates[] = GRAV_ROOT . '/tmp/grav-snapshots';
foreach ($candidates as $candidate) {
if (!$candidate) {
continue;
}
try {
Folder::create($candidate);
} catch (\Throwable $e) {
continue;
}
if (is_dir($candidate) && is_writable($candidate)) {
return rtrim($candidate, '\\/');
}
}
error_log('[Grav Upgrade] Unable to locate writable snapshot directory; skipping snapshot.');
return null;
}
private function persistSnapshotManifest(array $manifest): void
{
$store = GRAV_ROOT . '/user/data/upgrades';
try {
Folder::create($store);
$path = $store . '/' . $manifest['id'] . '.json';
@file_put_contents($path, json_encode($manifest, JSON_PRETTY_PRINT));
} catch (\Throwable $e) {
error_log('[Grav Upgrade] Unable to write snapshot manifest: ' . $e->getMessage());
}
}
private function pruneOldSnapshots(?string $snapshotRoot): void
{
$limit = $this->getSafeUpgradeSnapshotLimit();
if ($limit <= 0) {
return;
}
$manifestDir = GRAV_ROOT . '/user/data/upgrades';
$files = glob($manifestDir . '/*.json');
if (!$files) {
return;
}
rsort($files);
if (count($files) <= $limit) {
return;
}
$obsolete = array_slice($files, $limit);
$removed = 0;
foreach ($obsolete as $manifestPath) {
$manifest = null;
try {
$contents = @file_get_contents($manifestPath);
if ($contents !== false) {
$decoded = json_decode($contents, true);
if (is_array($decoded)) {
$manifest = $decoded;
}
}
} catch (\Throwable $e) {
// ignore malformed manifests
}
$snapshotId = $manifest['id'] ?? basename($manifestPath, '.json');
$backupPath = $manifest['backup_path'] ?? null;
if ($backupPath && is_dir($backupPath)) {
try {
Folder::delete($backupPath);
} catch (\Throwable $e) {
error_log('[Grav Upgrade] Unable to delete snapshot directory ' . $backupPath . ': ' . $e->getMessage());
}
} elseif ($snapshotRoot && $snapshotId) {
$candidate = $snapshotRoot . '/' . $snapshotId;
if (is_dir($candidate)) {
try {
Folder::delete($candidate);
} catch (\Throwable $e) {
error_log('[Grav Upgrade] Unable to delete snapshot directory ' . $candidate . ': ' . $e->getMessage());
}
}
}
if (!@unlink($manifestPath)) {
error_log('[Grav Upgrade] Unable to remove snapshot manifest: ' . $manifestPath);
continue;
}
$removed++;
}
if ($removed > 0) {
$this->relayProgress(
'snapshot',
sprintf(
'Pruned %d old snapshot%s (keeping latest %d).',
$removed,
$removed === 1 ? '' : 's',
$limit
),
null
);
}
}
/**
* @return void
* @throws RuntimeException
*/
public function finalize(): void
{
$start = microtime(true);
$this->relayProgress('finalizing', 'Running postflight tasks...', null);
// Finalize can be run without installing Grav first.
if (null === $this->updater) {
$versions = Versions::instance(USER_DIR . 'config/versions.yaml');
@@ -295,12 +726,17 @@ ERR;
$this->updater->postflight();
$this->ensureExecutablePermissions();
Cache::clearCache('all');
clearstatcache();
if (function_exists('opcache_reset')) {
@opcache_reset();
}
$elapsed = microtime(true) - $start;
$this->relayProgress('finalizing', sprintf('Postflight tasks complete in %.3fs.', $elapsed), null);
}
/**
@@ -392,9 +828,437 @@ ERR;
return $matches[1] ?? '';
}
protected function legacySupport(): void
{
// Support install for Grav 1.6.0 - 1.6.20 by loading the original class from the older version of Grav.
class_exists(\Grav\Console\Cli\CacheCommand::class, true);
}
private function ensureExecutablePermissions(): void
{
$executables = [
'bin/grav',
'bin/plugin',
'bin/gpm',
'bin/restore',
'bin/composer.phar'
];
foreach ($executables as $relative) {
$path = GRAV_ROOT . '/' . $relative;
if (!is_file($path) || is_link($path)) {
continue;
}
$mode = @fileperms($path);
$current = $mode !== false ? ($mode & 0777) : 0644;
if (($current & 0111) === 0111) {
continue;
}
@chmod($path, $current | 0111);
}
}
/**
* @return array|null
*/
public function getLastManifest(): ?array
{
return $this->lastManifest;
}
public function generatePreflightReport(): array
{
$this->ensureLocation();
$version = $this->getVersion();
$report = $this->runPreflightChecks($version ?: GRAV_VERSION);
$this->pendingPreflight = $report;
return $report;
}
public function getPreflightReport(): ?array
{
return $this->pendingPreflight;
}
private function runPreflightChecks(string $targetVersion): array
{
$start = microtime(true);
$this->relayProgress('initializing', 'Running preflight checks...', null);
$report = [
'warnings' => [],
'psr_log_conflicts' => [],
'monolog_conflicts' => [],
'plugins_pending' => [],
'is_major_minor_upgrade' => $this->isMajorMinorUpgrade($targetVersion),
'blocking' => [],
];
$report['plugins_pending'] = $this->detectPendingPackageUpdates();
$report['psr_log_conflicts'] = $this->detectPsrLogConflicts();
$report['monolog_conflicts'] = $this->detectMonologConflicts();
if ($report['plugins_pending']) {
if (self::$allowPendingOverride) {
$report['warnings'][] = 'Pending plugin/theme updates ignored for this upgrade run.';
} elseif ($report['is_major_minor_upgrade']) {
$report['blocking'][] = 'Pending plugin/theme updates detected. Because this is a major Grav upgrade, update them before continuing.';
}
}
$elapsed = microtime(true) - $start;
$this->relayProgress('initializing', sprintf('Preflight checks complete in %.3fs.', $elapsed), null);
return $report;
}
private function isMajorMinorUpgrade(string $targetVersion): bool
{
[$currentMajor, $currentMinor] = array_map('intval', array_pad(explode('.', GRAV_VERSION), 2, 0));
[$targetMajor, $targetMinor] = array_map('intval', array_pad(explode('.', $targetVersion), 2, 0));
return $currentMajor !== $targetMajor || $currentMinor !== $targetMinor;
}
private function detectPendingPackageUpdates(): array
{
$pending = [];
if (!class_exists(GPM::class)) {
return $pending;
}
try {
$gpm = new GPM();
} catch (Throwable $e) {
$this->relayProgress('warning', 'Unable to query GPM: ' . $e->getMessage(), null);
return $pending;
}
$repoPlugins = $this->packagesToArray($gpm->getRepositoryPlugins());
$repoThemes = $this->packagesToArray($gpm->getRepositoryThemes());
$scanRoot = GRAV_ROOT ?: getcwd();
$localPlugins = $this->scanLocalPackageVersions($scanRoot . '/user/plugins');
foreach ($localPlugins as $slug => $version) {
$remote = $repoPlugins[$slug] ?? null;
if (!$this->isGpmPackagePublished($remote)) {
continue;
}
$remoteVersion = $this->resolveRemotePackageVersion($remote);
if (!$remoteVersion || !$version) {
continue;
}
if (!$this->isPluginEnabled($slug)) {
if (str_contains($version, 'dev-')) {
$this->relayProgress('warning', sprintf('Skipping dev plugin %s (%s).', $slug, $version), null);
continue;
}
}
if (version_compare($remoteVersion, $version, '>')) {
$pending[$slug] = [
'type' => 'plugins',
'current' => $version,
'available' => $remoteVersion,
];
}
}
$localThemes = $this->scanLocalPackageVersions($scanRoot . '/user/themes');
foreach ($localThemes as $slug => $version) {
$remote = $repoThemes[$slug] ?? null;
if (!$this->isGpmPackagePublished($remote)) {
if (str_contains($version, 'dev-')) {
$this->relayProgress('warning', sprintf('Skipping dev theme %s (%s).', $slug, $version), null);
continue;
}
}
$remoteVersion = $this->resolveRemotePackageVersion($remote);
if (!$remoteVersion || !$version) {
continue;
}
if (!$this->isThemeEnabled($slug)) {
continue;
}
if (version_compare($remoteVersion, $version, '>')) {
$pending[$slug] = [
'type' => 'themes',
'current' => $version,
'available' => $remoteVersion,
];
}
}
$this->relayProgress('initializing', sprintf('Detected %d updatable packages (including symlinks).', count($pending)), null);
return $pending;
}
private function scanLocalPackageVersions(string $path): array
{
$versions = [];
if (!is_dir($path)) {
return $versions;
}
$entries = glob($path . '/*', GLOB_ONLYDIR) ?: [];
foreach ($entries as $dir) {
$slug = basename($dir);
$version = $this->readBlueprintVersion($dir) ?? $this->readComposerVersion($dir);
if ($version !== null) {
$versions[$slug] = $version;
}
}
return $versions;
}
private function readBlueprintVersion(string $dir): ?string
{
$file = $dir . '/blueprints.yaml';
if (!is_file($file)) {
return null;
}
try {
$contents = @file_get_contents($file);
if ($contents === false) {
return null;
}
$data = Yaml::parse($contents);
if (is_array($data) && isset($data['version'])) {
return (string)$data['version'];
}
} catch (Throwable $e) {
// ignore parse errors
}
return null;
}
private function readComposerVersion(string $dir): ?string
{
$file = $dir . '/composer.json';
if (!is_file($file)) {
return null;
}
$data = json_decode(file_get_contents($file), true);
if (is_array($data) && isset($data['version'])) {
return (string)$data['version'];
}
return null;
}
private function packagesToArray($packages): array
{
if (!$packages) {
return [];
}
if (is_array($packages)) {
return $packages;
}
if ($packages instanceof \Traversable) {
return iterator_to_array($packages, true);
}
return [];
}
private function resolveRemotePackageVersion($package): ?string
{
if (!$package) {
return null;
}
if (is_array($package)) {
return $package['version'] ?? null;
}
if (is_object($package)) {
if (isset($package->version)) {
return (string)$package->version;
}
if (method_exists($package, 'offsetGet')) {
try {
return (string)$package->offsetGet('version');
} catch (Throwable $e) {
return null;
}
}
}
return null;
}
private function detectPsrLogConflicts(): array
{
$conflicts = [];
$pluginRoots = glob(GRAV_ROOT . '/user/plugins/*', GLOB_ONLYDIR) ?: [];
foreach ($pluginRoots as $path) {
$composerFile = $path . '/composer.json';
if (!is_file($composerFile)) {
continue;
}
$json = json_decode(file_get_contents($composerFile), true);
if (!is_array($json)) {
continue;
}
$slug = basename($path);
if (!$this->isPluginEnabled($slug)) {
continue;
}
$rawConstraint = $json['require']['psr/log'] ?? ($json['require-dev']['psr/log'] ?? null);
if (!$rawConstraint) {
continue;
}
$constraint = strtolower((string)$rawConstraint);
$compatible = $constraint === '*'
|| false !== strpos($constraint, '3')
|| false !== strpos($constraint, '4')
|| (false !== strpos($constraint, '>=') && preg_match('/>=\s*3/', $constraint));
if ($compatible) {
continue;
}
$conflicts[$slug] = [
'composer' => $composerFile,
'requires' => $rawConstraint,
];
}
return $conflicts;
}
private function detectMonologConflicts(): array
{
$conflicts = [];
$pluginRoots = glob(GRAV_ROOT . '/user/plugins/*', GLOB_ONLYDIR) ?: [];
$pattern = '/->add(?:Debug|Info|Notice|Warning|Error|Critical|Alert|Emergency)\s*\(/i';
foreach ($pluginRoots as $path) {
$slug = basename($path);
if (!$this->isPluginEnabled($slug)) {
continue;
}
$directory = new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS);
$filter = new \RecursiveCallbackFilterIterator($directory, static function ($current, $key, $iterator) {
// Skip hidden files/dirs (starting with .)
if ($current->getFilename()[0] === '.') {
return false;
}
if ($iterator->hasChildren()) {
// Exclude vendor and node_modules directories
return !in_array($current->getFilename(), ['vendor', 'node_modules'], true);
}
// Only include PHP files
return $current->getExtension() === 'php';
});
$iterator = new \RecursiveIteratorIterator($filter);
foreach ($iterator as $file) {
$contents = @file_get_contents($file->getPathname());
if ($contents === false) {
continue;
}
if (preg_match($pattern, $contents, $match)) {
$relative = str_replace(GRAV_ROOT . '/', '', $file->getPathname());
$conflicts[$slug][] = [
'file' => $relative,
'method' => trim($match[0]),
];
}
}
}
return $conflicts;
}
private function isPluginEnabled(string $slug): bool
{
$configPath = GRAV_ROOT . '/user/config/plugins/' . $slug . '.yaml';
if (is_file($configPath)) {
try {
$contents = @file_get_contents($configPath);
if ($contents !== false) {
$data = Yaml::parse($contents);
if (is_array($data) && array_key_exists('enabled', $data)) {
return (bool)$data['enabled'];
}
}
} catch (Throwable $e) {
// ignore parse errors
}
}
return true;
}
private function isThemeEnabled(string $slug): bool
{
$configPath = GRAV_ROOT . '/user/config/system.yaml';
if (is_file($configPath)) {
try {
$contents = @file_get_contents($configPath);
if ($contents !== false) {
$data = Yaml::parse($contents);
if (is_array($data)) {
$active = $data['pages']['theme'] ?? ($data['system']['pages']['theme'] ?? null);
if ($active !== null) {
return $active === $slug;
}
}
}
} catch (Throwable $e) {
// ignore parse errors
}
}
return true;
}
private function isGpmPackagePublished($package): bool
{
if (is_object($package) && method_exists($package, 'getData')) {
$data = $package->getData();
if ($data instanceof \Grav\Common\Data\Data) {
$published = $data->get('published');
return $published !== false;
}
}
if (is_array($package)) {
if (array_key_exists('published', $package)) {
return $package['published'] !== false;
}
return true;
}
if (is_object($package) && property_exists($package, 'published')) {
return $package->published !== false;
}
return true;
}
}

View File

@@ -0,0 +1,222 @@
<?php
use Grav\Common\Filesystem\Folder;
use Grav\Common\Recovery\RecoveryManager;
use RocketTheme\Toolbox\Event\Event;
class RecoveryManagerTest extends \Codeception\TestCase\Test
{
/** @var string */
private $tmpDir;
protected function _before(): void
{
$this->tmpDir = sys_get_temp_dir() . '/grav-recovery-' . uniqid('', true);
Folder::create($this->tmpDir);
Folder::create($this->tmpDir . '/user');
Folder::create($this->tmpDir . '/user/data');
Folder::create($this->tmpDir . '/system');
}
protected function _after(): void
{
if (is_dir($this->tmpDir)) {
Folder::delete($this->tmpDir);
}
}
public function testHandleShutdownQuarantinesPluginAndCreatesFlag(): void
{
$plugin = $this->tmpDir . '/user/plugins/bad';
Folder::create($plugin);
file_put_contents($plugin . '/plugin.php', '<?php // plugin');
$manager = new class($this->tmpDir) extends RecoveryManager {
protected $error;
public function __construct(string $rootPath)
{
parent::__construct($rootPath);
$this->error = [
'type' => E_ERROR,
'file' => $this->getRootPath() . '/user/plugins/bad/plugin.php',
'message' => 'Fatal failure',
'line' => 42,
];
}
public function getRootPath(): string
{
$prop = new \ReflectionProperty(RecoveryManager::class, 'rootPath');
$prop->setAccessible(true);
return $prop->getValue($this);
}
protected function resolveLastError(): ?array
{
return $this->error;
}
};
$manager->markUpgradeWindow('core-upgrade', ['scope' => 'core']);
$manager->handleShutdown();
$flag = $this->tmpDir . '/user/data/recovery.flag';
self::assertFileExists($flag);
$context = json_decode(file_get_contents($flag), true);
self::assertSame('Fatal failure', $context['message']);
self::assertSame('bad', $context['plugin']);
self::assertNotEmpty($context['token']);
$configFile = $this->tmpDir . '/user/config/plugins/bad.yaml';
self::assertFileExists($configFile);
self::assertStringContainsString('enabled: false', file_get_contents($configFile));
$quarantine = $this->tmpDir . '/user/data/upgrades/quarantine.json';
self::assertFileExists($quarantine);
$decoded = json_decode(file_get_contents($quarantine), true);
self::assertArrayHasKey('bad', $decoded);
}
public function testHandleShutdownCreatesFlagWithoutPlugin(): void
{
$manager = new class($this->tmpDir) extends RecoveryManager {
protected $error;
public function __construct(string $rootPath)
{
parent::__construct($rootPath);
$this->error = [
'type' => E_ERROR,
'file' => $this->getRootPathValue() . '/system/index.php',
'message' => 'Core failure',
'line' => 13,
];
}
protected function resolveLastError(): ?array
{
return $this->error;
}
private function getRootPathValue(): string
{
$prop = new \ReflectionProperty(RecoveryManager::class, 'rootPath');
$prop->setAccessible(true);
return $prop->getValue($this);
}
};
$manager->markUpgradeWindow('core-upgrade', ['scope' => 'core']);
$manager->handleShutdown();
$flag = $this->tmpDir . '/user/data/recovery.flag';
self::assertFileExists($flag);
$context = json_decode(file_get_contents($flag), true);
self::assertArrayHasKey('plugin', $context);
self::assertNull($context['plugin']);
self::assertSame('Core failure', $context['message']);
$quarantine = $this->tmpDir . '/user/data/upgrades/quarantine.json';
self::assertFileDoesNotExist($quarantine);
}
public function testHandleExceptionCreatesFlag(): void
{
$manager = new RecoveryManager($this->tmpDir);
$manager->markUpgradeWindow('core-upgrade', ['scope' => 'core']);
$manager->handleException(new \RuntimeException('Unhandled failure'));
$flag = $this->tmpDir . '/user/data/recovery.flag';
self::assertFileExists($flag);
$context = json_decode(file_get_contents($flag), true);
self::assertSame('Unhandled failure', $context['message']);
self::assertArrayHasKey('plugin', $context);
self::assertNull($context['plugin']);
$manager->clear();
}
public function testOnFatalExceptionDispatchesToHandler(): void
{
$manager = new RecoveryManager($this->tmpDir);
$manager->markUpgradeWindow('core-upgrade', ['scope' => 'core']);
$manager->onFatalException(new Event(['exception' => new \RuntimeException('Event failure')]));
$flag = $this->tmpDir . '/user/data/recovery.flag';
self::assertFileExists($flag);
$context = json_decode(file_get_contents($flag), true);
self::assertSame('Event failure', $context['message']);
$manager->clear();
}
public function testHandleShutdownIgnoresNonFatalErrors(): void
{
$manager = new class($this->tmpDir) extends RecoveryManager {
protected function resolveLastError(): ?array
{
return ['type' => E_USER_WARNING, 'message' => 'Notice'];
}
};
$manager->handleShutdown();
self::assertFileDoesNotExist($this->tmpDir . '/user/data/recovery.flag');
}
public function testClearRemovesFlag(): void
{
$flag = $this->tmpDir . '/user/data/recovery.flag';
file_put_contents($flag, 'flag');
$manager = new RecoveryManager($this->tmpDir);
$manager->clear();
self::assertFileDoesNotExist($flag);
}
public function testGenerateTokenFallbackOnRandomFailure(): void
{
$manager = new class($this->tmpDir) extends RecoveryManager {
protected function randomBytes(int $length): string
{
throw new \RuntimeException('No randomness');
}
};
$manager->activate([]);
$context = $manager->getContext();
self::assertNotEmpty($context['token']);
}
public function testGetContextWithoutFlag(): void
{
$manager = new RecoveryManager($this->tmpDir);
self::assertNull($manager->getContext());
}
public function testDisablePluginRecordsQuarantineWithoutFlag(): void
{
$plugin = $this->tmpDir . '/user/plugins/problem';
Folder::create($plugin);
$manager = new RecoveryManager($this->tmpDir);
$manager->disablePlugin('problem', ['message' => 'Manual disable']);
$flag = $this->tmpDir . '/user/data/recovery.flag';
self::assertFileDoesNotExist($flag);
$configFile = $this->tmpDir . '/user/config/plugins/problem.yaml';
self::assertFileExists($configFile);
self::assertStringContainsString('enabled: false', file_get_contents($configFile));
$quarantine = $this->tmpDir . '/user/data/upgrades/quarantine.json';
self::assertFileExists($quarantine);
$decoded = json_decode(file_get_contents($quarantine), true);
self::assertSame('Manual disable', $decoded['problem']['message']);
}
}

View File

@@ -0,0 +1,246 @@
<?php
use Grav\Common\Filesystem\Folder;
use Grav\Common\Upgrade\SafeUpgradeService;
class SafeUpgradeServiceTest extends \Codeception\TestCase\Test
{
/** @var string */
private $tmpDir;
protected function _before(): void
{
$this->tmpDir = sys_get_temp_dir() . '/grav-safe-upgrade-' . uniqid('', true);
Folder::create($this->tmpDir);
}
protected function _after(): void
{
if (is_dir($this->tmpDir)) {
Folder::delete($this->tmpDir);
}
}
public function testPreflightAggregatesWarnings(): void
{
$service = new class(['root' => $this->tmpDir]) extends SafeUpgradeService {
public $pending = [
'alpha' => ['type' => 'plugins', 'current' => '1.0.0', 'available' => '1.1.0']
];
public $conflicts = [
'beta' => ['requires' => '^1.0']
];
public $monolog = [
'gamma' => [
['file' => 'user/plugins/gamma/gamma.php', 'method' => '->addError(']
]
];
protected function detectPendingPluginUpdates(): array
{
return $this->pending;
}
protected function detectPsrLogConflicts(): array
{
return $this->conflicts;
}
protected function detectMonologConflicts(): array
{
return $this->monolog;
}
};
$result = $service->preflight();
self::assertArrayHasKey('warnings', $result);
self::assertCount(3, $result['warnings']);
self::assertArrayHasKey('alpha', $result['plugins_pending']);
self::assertArrayHasKey('beta', $result['psr_log_conflicts']);
self::assertArrayHasKey('gamma', $result['monolog_conflicts']);
}
public function testPreflightHandlesDetectionFailure(): void
{
$service = new class(['root' => $this->tmpDir]) extends SafeUpgradeService {
protected function detectPendingPluginUpdates(): array
{
throw new RuntimeException('Cannot reach GPM');
}
protected function detectPsrLogConflicts(): array
{
return [];
}
protected function detectMonologConflicts(): array
{
return [];
}
};
$result = $service->preflight();
self::assertSame([], $result['plugins_pending']);
self::assertSame([], $result['psr_log_conflicts']);
self::assertSame([], $result['monolog_conflicts']);
self::assertCount(1, $result['warnings']);
self::assertStringContainsString('Cannot reach GPM', $result['warnings'][0]);
}
public function testPromoteAndRollback(): void
{
[$root, $manifestStore] = $this->prepareLiveEnvironment();
$service = new SafeUpgradeService([
'root' => $root,
'manifest_store' => $manifestStore,
]);
$package = $this->preparePackage();
$manifest = $service->promote($package, '1.8.0', ['backup', 'cache', 'images', 'logs', 'tmp', 'user']);
self::assertFileExists($root . '/system/new.txt');
self::assertFileExists($root . '/ORIGINAL');
$manifestFile = $manifestStore . '/' . $manifest['id'] . '.json';
self::assertFileExists($manifestFile);
$service->rollback($manifest['id']);
self::assertFileExists($root . '/ORIGINAL');
self::assertFileDoesNotExist($root . '/system/new.txt');
self::assertDirectoryExists($manifest['backup_path']);
}
public function testPrunesOldSnapshots(): void
{
[$root, $manifestStore] = $this->prepareLiveEnvironment();
$service = new SafeUpgradeService([
'root' => $root,
'manifest_store' => $manifestStore,
]);
$manifests = [];
for ($i = 0; $i < 6; $i++) {
$package = $this->preparePackage((string)$i);
$manifests[] = $service->promote($package, '1.8.' . $i, ['backup', 'cache', 'images', 'logs', 'tmp', 'user']);
// Ensure subsequent promotions have a marker to restore.
file_put_contents($root . '/ORIGINAL', 'state-' . $i);
}
$files = glob($manifestStore . '/*.json');
self::assertCount(5, $files);
// Verify the oldest one (index 0) is gone
$oldestManifestId = $manifests[0]['id'];
self::assertFileDoesNotExist($manifestStore . '/' . $oldestManifestId . '.json');
self::assertDirectoryDoesNotExist($manifests[0]['backup_path']);
// Verify the newest one (index 5) exists
$newestManifestId = $manifests[5]['id'];
self::assertFileExists($manifestStore . '/' . $newestManifestId . '.json');
self::assertDirectoryExists($manifests[5]['backup_path']);
}
public function testDetectsPsrLogConflictsFromFilesystem(): void
{
[$root] = $this->prepareLiveEnvironment();
$plugin = $root . '/user/plugins/problem';
Folder::create($plugin);
file_put_contents($plugin . '/composer.json', json_encode(['require' => ['psr/log' => '^1.0']], JSON_PRETTY_PRINT));
$service = new SafeUpgradeService([
'root' => $root,
]);
$method = new ReflectionMethod(SafeUpgradeService::class, 'detectPsrLogConflicts');
$method->setAccessible(true);
$conflicts = $method->invoke($service);
self::assertArrayHasKey('problem', $conflicts);
}
public function testDetectsMonologConflictsFromFilesystem(): void
{
[$root] = $this->prepareLiveEnvironment();
$plugin = $root . '/user/plugins/logger';
Folder::create($plugin . '/src');
$code = <<<'PHP'
<?php
class LoggerTest {
public function test(
\Monolog\Logger $logger
) {
$logger->addError('deprecated');
}
}
PHP;
file_put_contents($plugin . '/src/logger.php', $code);
$service = new SafeUpgradeService([
'root' => $root,
]);
$method = new ReflectionMethod(SafeUpgradeService::class, 'detectMonologConflicts');
$method->setAccessible(true);
$conflicts = $method->invoke($service);
self::assertArrayHasKey('logger', $conflicts);
self::assertNotEmpty($conflicts['logger']);
self::assertStringContainsString('addError', $conflicts['logger'][0]['method']);
}
public function testClearRecoveryFlagRemovesFile(): void
{
[$root] = $this->prepareLiveEnvironment();
$flag = $root . '/user/data/recovery.flag';
$window = $root . '/user/data/recovery.window';
Folder::create(dirname($flag));
file_put_contents($flag, 'flag');
Folder::create(dirname($window));
file_put_contents($window, json_encode(['expires_at' => time() + 120]));
$service = new SafeUpgradeService([
'root' => $root,
]);
$service->clearRecoveryFlag();
self::assertFileDoesNotExist($flag);
self::assertFileExists($window);
}
/**
* @return array{0:string,1:string}
*/
private function prepareLiveEnvironment(): array
{
$root = $this->tmpDir . '/root';
$manifestStore = $root . '/user/data/upgrades';
Folder::create($root . '/user/plugins/sample');
Folder::create($root . '/system');
file_put_contents($root . '/system/original.txt', 'original');
file_put_contents($root . '/ORIGINAL', 'original-root');
file_put_contents($root . '/user/plugins/sample/blueprints.yaml', "name: Sample Plugin\nversion: 1.0.0\n");
file_put_contents($root . '/user/plugins/sample/composer.json', json_encode(['require' => ['php' => '^8.0']], JSON_PRETTY_PRINT));
return [$root, $manifestStore];
}
/**
* @param string $suffix
* @return string
*/
private function preparePackage(string $suffix = ''): string
{
$package = $this->tmpDir . '/package-' . uniqid('', true);
Folder::create($package . '/system');
Folder::create($package . '/user');
file_put_contents($package . '/index.php', 'new-release' . $suffix);
file_put_contents($package . '/system/new.txt', 'release' . $suffix);
return $package;
}
}

View File

@@ -0,0 +1,150 @@
<?php
use Grav\Console\Gpm\PreflightCommand;
use Grav\Common\Upgrade\SafeUpgradeService;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Console\Style\SymfonyStyle;
class PreflightCommandTest extends \Codeception\TestCase\Test
{
public function testServeOutputsJsonWhenRequested(): void
{
$service = new StubSafeUpgradeService([
'plugins_pending' => [],
'psr_log_conflicts' => [],
'monolog_conflicts' => [],
'warnings' => []
]);
$command = new TestPreflightCommand($service);
[$style, $output] = $this->injectIo($command, new ArrayInput(['--json' => true]));
$status = $command->runServe();
self::assertSame(0, $status);
$buffer = $output->fetch();
self::assertJson(trim($buffer));
}
public function testServeWarnsWhenIssuesDetected(): void
{
$service = new StubSafeUpgradeService([
'plugins_pending' => ['alpha' => ['type' => 'plugin', 'current' => '1', 'available' => '2']],
'psr_log_conflicts' => ['beta' => ['requires' => '^1']],
'monolog_conflicts' => ['gamma' => [['file' => 'user/plugins/gamma/gamma.php', 'method' => '->addError(']]],
'warnings' => ['pending updates']
]);
$command = new TestPreflightCommand($service);
[$style] = $this->injectIo($command, new ArrayInput([]));
$status = $command->runServe();
self::assertSame(2, $status);
$output = implode("\n", $style->messages);
self::assertStringContainsString('pending updates', $output);
self::assertStringContainsString('beta', $output);
self::assertStringContainsString('gamma', $output);
}
/**
* @param TestPreflightCommand $command
* @param ArrayInput $input
* @return array{0:PreflightMemoryStyle,1:BufferedOutput}
*/
private function injectIo(TestPreflightCommand $command, ArrayInput $input): array
{
$buffer = new BufferedOutput();
$style = new PreflightMemoryStyle($input, $buffer);
$this->setProtectedProperty($command, 'input', $input);
$this->setProtectedProperty($command, 'output', $style);
$input->bind($command->getDefinition());
return [$style, $buffer];
}
private function setProtectedProperty(object $object, string $property, $value): void
{
$ref = new \ReflectionProperty(\Grav\Console\GpmCommand::class, $property);
$ref->setAccessible(true);
$ref->setValue($object, $value);
}
}
class TestPreflightCommand extends PreflightCommand
{
/** @var SafeUpgradeService */
private $service;
public function __construct(SafeUpgradeService $service)
{
parent::__construct();
$this->service = $service;
}
protected function createSafeUpgradeService(): SafeUpgradeService
{
return $this->service;
}
public function runServe(): int
{
return $this->serve();
}
}
class StubSafeUpgradeService extends SafeUpgradeService
{
/** @var array */
private $report;
public function __construct(array $report)
{
$this->report = $report;
parent::__construct([]);
}
public function preflight(?string $targetVersion = null): array
{
return $this->report;
}
}
class PreflightMemoryStyle extends SymfonyStyle
{
/** @var array<int, string> */
public $messages = [];
public function __construct(InputInterface $input, BufferedOutput $output)
{
parent::__construct($input, $output);
}
public function title($message): void
{
$this->messages[] = 'title:' . $message;
parent::title($message);
}
public function writeln($messages, $type = self::OUTPUT_NORMAL): void
{
foreach ((array)$messages as $message) {
$this->messages[] = (string)$message;
}
parent::writeln($messages, $type);
}
public function warning($message): void
{
$this->messages[] = 'warning:' . $message;
parent::warning($message);
}
public function success($message): void
{
$this->messages[] = 'success:' . $message;
parent::success($message);
}
}

View File

@@ -0,0 +1,244 @@
<?php
use Grav\Console\Gpm\RollbackCommand;
use Grav\Common\Upgrade\SafeUpgradeService;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Console\Style\SymfonyStyle;
class RollbackCommandTest extends \Codeception\TestCase\Test
{
public function testListSnapshotsOutputsEntries(): void
{
$service = new StubRollbackService();
$command = new TestRollbackCommand($service);
$command->setSnapshots([
['id' => 'snap-1', 'target_version' => '1.7.49'],
['id' => 'snap-2', 'target_version' => '1.7.50']
]);
[$style] = $this->injectIo($command, new ArrayInput(['--list' => true]));
$status = $command->runServe();
self::assertSame(0, $status);
$output = implode("\n", $style->messages);
self::assertStringContainsString('snap-1', $output);
self::assertStringContainsString('snap-2', $output);
self::assertFalse($service->rollbackCalled);
}
public function testListSnapshotsHandlesAbsence(): void
{
$service = new StubRollbackService();
$command = new TestRollbackCommand($service);
[$style] = $this->injectIo($command, new ArrayInput(['--list' => true]));
$status = $command->runServe();
self::assertSame(0, $status);
self::assertStringContainsString('No snapshots found', implode("\n", $style->messages));
}
public function testRollbackAbortsWhenNoSnapshotsAvailable(): void
{
$service = new StubRollbackService();
$command = new TestRollbackCommand($service);
[$style] = $this->injectIo($command, new ArrayInput([]));
$status = $command->runServe();
self::assertSame(1, $status);
self::assertStringContainsString('No snapshots available', implode("\n", $style->messages));
}
public function testRollbackAbortsWhenSnapshotMissing(): void
{
$service = new StubRollbackService();
$command = new TestRollbackCommand($service);
$command->setSnapshots([
['id' => 'snap-1', 'target_version' => '1.7.49']
]);
[$style] = $this->injectIo($command, new ArrayInput(['manifest' => 'missing']));
$status = $command->runServe();
self::assertSame(1, $status);
self::assertStringContainsString('Snapshot missing not found.', implode("\n", $style->messages));
}
public function testRollbackCancelsWhenUserDeclines(): void
{
$service = new StubRollbackService();
$command = new TestRollbackCommand($service, [false]);
$command->setSnapshots([
['id' => 'snap-1', 'target_version' => '1.7.49']
]);
[$style] = $this->injectIo($command, new ArrayInput([]));
$status = $command->runServe();
self::assertSame(1, $status);
self::assertStringContainsString('Rollback aborted.', implode("\n", $style->messages));
}
public function testRollbackSucceedsAndClearsRecoveryFlag(): void
{
$service = new StubRollbackService();
$command = new TestRollbackCommand($service, [true]);
$command->setSnapshots([
['id' => 'snap-1', 'target_version' => '1.7.49']
]);
$this->setAllYes($command, true);
$this->injectIo($command, new ArrayInput([]));
$status = $command->runServe();
self::assertSame(0, $status);
self::assertTrue($service->rollbackCalled);
self::assertTrue($service->clearFlagCalled);
}
private function setAllYes(RollbackCommand $command, bool $value): void
{
$ref = new \ReflectionProperty(RollbackCommand::class, 'allYes');
$ref->setAccessible(true);
$ref->setValue($command, $value);
}
/**
* @param TestRollbackCommand $command
* @param ArrayInput $input
* @return array{0:RollbackMemoryStyle}
*/
private function injectIo(TestRollbackCommand $command, ArrayInput $input): array
{
$buffer = new BufferedOutput();
$style = new RollbackMemoryStyle($input, $buffer, $command->responses);
$this->setProtectedProperty($command, 'input', $input);
$this->setProtectedProperty($command, 'output', $style);
$input->bind($command->getDefinition());
return [$style];
}
private function setProtectedProperty(object $object, string $property, $value): void
{
$ref = new \ReflectionProperty(\Grav\Console\GpmCommand::class, $property);
$ref->setAccessible(true);
$ref->setValue($object, $value);
}
}
class TestRollbackCommand extends RollbackCommand
{
/** @var SafeUpgradeService */
private $service;
/** @var array<int, array> */
private $snapshots = [];
/** @var array<int, mixed> */
public $responses = [];
public function __construct(SafeUpgradeService $service, array $responses = [])
{
parent::__construct();
$this->service = $service;
$this->responses = $responses;
}
public function setSnapshots(array $snapshots): void
{
$this->snapshots = $snapshots;
}
protected function createSafeUpgradeService(): SafeUpgradeService
{
return $this->service;
}
protected function collectSnapshots(): array
{
return $this->snapshots;
}
public function runServe(): int
{
return $this->serve();
}
}
class StubRollbackService extends SafeUpgradeService
{
public $rollbackCalled = false;
public $clearFlagCalled = false;
public function __construct()
{
parent::__construct([]);
}
public function rollback(?string $id = null): ?array
{
$this->rollbackCalled = true;
return ['id' => $id];
}
public function clearRecoveryFlag(): void
{
$this->clearFlagCalled = true;
}
}
class RollbackMemoryStyle extends SymfonyStyle
{
/** @var array<int, string> */
public $messages = [];
/** @var array<int, mixed> */
private $responses;
public function __construct(InputInterface $input, BufferedOutput $output, array $responses = [])
{
parent::__construct($input, $output);
$this->responses = $responses;
}
public function newLine($count = 1): void
{
for ($i = 0; $i < $count; $i++) {
$this->messages[] = '';
}
parent::newLine($count);
}
public function writeln($messages, $type = self::OUTPUT_NORMAL): void
{
foreach ((array)$messages as $message) {
$this->messages[] = (string)$message;
}
parent::writeln($messages, $type);
}
public function error($message): void
{
$this->messages[] = 'error:' . $message;
parent::error($message);
}
public function success($message): void
{
$this->messages[] = 'success:' . $message;
parent::success($message);
}
public function askQuestion($question)
{
if ($this->responses) {
return array_shift($this->responses);
}
return parent::askQuestion($question);
}
}

View File

@@ -0,0 +1,219 @@
<?php
use Codeception\Util\Fixtures;
use Grav\Console\Gpm\SelfupgradeCommand;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Console\Style\SymfonyStyle;
class SelfupgradeCommandTest extends \Codeception\TestCase\Test
{
public function testHandlePreflightReportSucceedsWithoutIssues(): void
{
$command = new TestSelfupgradeCommand();
[$style] = $this->injectIo($command);
$result = $command->runHandle([
'plugins_pending' => [],
'psr_log_conflicts' => [],
'warnings' => [],
'is_major_minor_upgrade' => false
]);
self::assertTrue($result);
self::assertSame([], $style->messages);
}
public function testHandlePreflightReportFailsWhenPendingEvenWithAllYes(): void
{
$command = new TestSelfupgradeCommand();
[$style] = $this->injectIo($command);
$this->setAllYes($command, true);
$result = $command->runHandle([
'plugins_pending' => ['foo' => ['type' => 'plugin', 'current' => '1', 'available' => '2']],
'psr_log_conflicts' => ['bar' => ['requires' => '^1.0']],
'warnings' => ['pending'],
'is_major_minor_upgrade' => true
]);
self::assertFalse($result);
$output = implode("\n", $style->messages);
self::assertStringContainsString('Run `bin/gpm update` first', $output);
}
public function testHandlePreflightReportAbortsOnPendingWhenDeclined(): void
{
$command = new TestSelfupgradeCommand();
[$style] = $this->injectIo($command, [false]);
$this->setAllYes($command, false);
$result = $command->runHandle([
'plugins_pending' => ['foo' => ['type' => 'plugin', 'current' => '1', 'available' => '2']],
'psr_log_conflicts' => [],
'warnings' => [],
'is_major_minor_upgrade' => true
]);
self::assertFalse($result);
self::assertStringContainsString('Run `bin/gpm update` first', implode("\n", $style->messages));
}
public function testHandlePreflightReportAbortsOnConflictWhenDeclined(): void
{
$command = new TestSelfupgradeCommand();
[$style] = $this->injectIo($command, ['abort']);
$this->setAllYes($command, false);
$result = $command->runHandle([
'plugins_pending' => [],
'psr_log_conflicts' => ['foo' => ['requires' => '^1.0']],
'warnings' => [],
'is_major_minor_upgrade' => false
]);
self::assertFalse($result);
self::assertStringContainsString('Adjust composer requirements', implode("\n", $style->messages));
}
public function testHandlePreflightReportDisablesPluginsWhenRequested(): void
{
$gravFactory = Fixtures::get('grav');
$grav = $gravFactory();
$stub = new class {
public $disabled = [];
public function disablePlugin(string $slug, array $context = []): void
{
$this->disabled[] = $slug;
}
};
$grav['recovery'] = $stub;
$command = new TestSelfupgradeCommand();
[$style] = $this->injectIo($command, ['disable']);
$result = $command->runHandle([
'plugins_pending' => [],
'psr_log_conflicts' => ['foo' => ['requires' => '^1.0']],
'warnings' => [],
'is_major_minor_upgrade' => false
]);
self::assertTrue($result);
self::assertSame(['foo'], $stub->disabled);
$output = implode("\n", $style->messages);
self::assertStringContainsString('Continuing with conflicted plugins disabled.', $output);
}
public function testHandlePreflightReportContinuesWhenRequested(): void
{
$command = new TestSelfupgradeCommand();
[$style] = $this->injectIo($command, ['continue']);
$result = $command->runHandle([
'plugins_pending' => [],
'psr_log_conflicts' => ['foo' => ['requires' => '^1.0']],
'warnings' => [],
'is_major_minor_upgrade' => false
]);
self::assertTrue($result);
$output = implode("\n", $style->messages);
self::assertStringContainsString('Proceeding with potential psr/log incompatibilities still active.', $output);
}
/**
* @param TestSelfupgradeCommand $command
* @param array<int, mixed> $responses
* @return array{0:SelfUpgradeMemoryStyle,1:InputInterface}
*/
private function injectIo(TestSelfupgradeCommand $command, array $responses = []): array
{
$input = new ArrayInput([]);
$buffer = new BufferedOutput();
$style = new SelfUpgradeMemoryStyle($input, $buffer, $responses);
$this->setProtectedProperty($command, 'input', $input);
$this->setProtectedProperty($command, 'output', $style);
$input->bind($command->getDefinition());
return [$style, $input];
}
private function setAllYes(SelfupgradeCommand $command, bool $value): void
{
$ref = new \ReflectionProperty(SelfupgradeCommand::class, 'all_yes');
$ref->setAccessible(true);
$ref->setValue($command, $value);
}
private function setProtectedProperty(object $object, string $property, $value): void
{
$ref = new \ReflectionProperty(\Grav\Console\GpmCommand::class, $property);
$ref->setAccessible(true);
$ref->setValue($object, $value);
}
}
class TestSelfupgradeCommand extends SelfupgradeCommand
{
public function runHandle(array $report): bool
{
return $this->handlePreflightReport($report);
}
}
class SelfUpgradeMemoryStyle extends SymfonyStyle
{
/** @var array<int, string> */
public $messages = [];
/** @var array<int, mixed> */
private $responses;
/**
* @param InputInterface $input
* @param BufferedOutput $output
* @param array<int, mixed> $responses
*/
public function __construct(InputInterface $input, BufferedOutput $output, array $responses = [])
{
parent::__construct($input, $output);
$this->responses = $responses;
}
public function newLine($count = 1): void
{
for ($i = 0; $i < $count; $i++) {
$this->messages[] = '';
}
parent::newLine($count);
}
public function writeln($messages, $type = self::OUTPUT_NORMAL): void
{
foreach ((array)$messages as $message) {
$this->messages[] = (string)$message;
}
parent::writeln($messages, $type);
}
public function askQuestion($question)
{
if ($this->responses) {
return array_shift($this->responses);
}
return parent::askQuestion($question);
}
public function choice($question, array $choices, $default = null, $attempts = null, $errorMessage = 'Invalid value.')
{
if ($this->responses) {
return array_shift($this->responses);
}
return parent::choice($question, $choices, $default, $attempts, $errorMessage);
}
}

0
user/config/media.yaml Normal file
View File