diff --git a/.gitignore b/.gitignore index ea85efc..4b9cea0 100644 --- a/.gitignore +++ b/.gitignore @@ -24,8 +24,11 @@ /storage/backups/ /storage/importprice/ /storage/imports/ +/storage/jobs/ /storage/logs/ +/storage/media-trash/ /storage/releases/ +/storage/reports/ /storage/runtime/ /storage/secrets/ /storage/tmp/ diff --git a/PACKAGE-MANIFEST.json b/PACKAGE-MANIFEST.json index dffaf78..af4c335 100644 --- a/PACKAGE-MANIFEST.json +++ b/PACKAGE-MANIFEST.json @@ -2,24 +2,24 @@ "format": "ave-core-package-v2", "product": "AVE.cms", "version": "3.3", - "build": "0.37", + "build": "0.38", "package": "core", - "built_at": "2026-07-30T13:19:22+00:00", + "built_at": "2026-08-05T18:33:56+00:00", "installed_modules": 0, "bundled_optional_modules": [], "optional_after_install": [ "setup/" ], - "core_fingerprint": "ad00f4c5d033c613ea66b4bc56a4b5de126abdf00d79eaa64b0b1bbd1855d0da", + "core_fingerprint": "a8ac3d2be6a3c4b43c7d871c4e5ece44b1641f1fdace69329e296468e1d192d2", "files": { - ".gitignore": "9aa78fb183d01611e3ce9789d244d09ffb862c6ae718438b2132a7edd53a16ec", + ".gitignore": "20d4fb177d47da6944a7a1c2dbf5918fd62766881aa52b00901f9054b1db4b49", ".htaccess": "71c06b7bc41b09c347a562ca72768acaa0ee1ef8388c749fc67956c793d8a826", - "README.md": "7d3f3a226250482eb09aab8188b88c728145a632d7cafe6d742b6003a0ca4d8c", + "README.md": "19b820f1b092780039aae011e133dc6a1e5f1cf213b435e0ff1962b592b9095b", "adminx/.htaccess": "d03ea2d2972af8b7545a2d57c6253f303d15adeb30b775e161d887ca5ff73378", "adminx/Support/AdminLocale.php": "a7fe84d86d65c9f021106172b1789c8bc6221c36b2fe39cf883ca9a505e3e8ad", "adminx/Support/BulkActionExecutor.php": "82859d10eec39a53091ed46604d6819e7111b7d879e5f0f1595c26f8a7ff1a60", "adminx/Support/CodeEditor.php": "51532980b1990e595b796d884c1a8bcc46f77f88344386e5153b4bef4cae973c", - "adminx/Support/GlobalSearchRegistry.php": "7722b193cd60161a29d8fd166c4445294528eac5336b2810a1d9c281347a188e", + "adminx/Support/GlobalSearchRegistry.php": "7f0cb38a76ecb4c71af2891176406b1d46004c7b778e44cd2fce8367407e9da1", "adminx/Support/InterfaceSettings.php": "e42cdc1157d8a55760278ab46d603316039fad0680eadf7c95824984f45f63ea", "adminx/Support/ModuleExtensions.php": "36de2286496c8327ee22d806f98a6bc9521a2d81cd1bb4182122c7925ed8d0a7", "adminx/Support/Notifications.php": "ab08805d571cd3d8a4b2a09fdc6ce96fb4c7b5ec75691322cf8a4e1a142112a3", @@ -28,14 +28,16 @@ "adminx/Support/Roles.php": "6be335dd643317f77bd7c2db92f25895e601fe72818befd62f6aeb3ecac9fce7", "adminx/Support/RouteGuard.php": "e9e20c097c0db354851546585be72bfc71a5d14652871f8bd1160e845d4dab1b", "adminx/Support/SavedViews.php": "4548ca129f270184e61cb670e7f44a1c103099a6a6c2820bed1a11bc18f227c4", - "adminx/Support/SectionHelp.php": "75388bc96c0b43aad13befdf22884401b05361fc1d6dd5d0f1e85e00c09d2bdb", - "adminx/Support/SensitiveRoutes.php": "8d40c429d6abbc1416e3797908672e551d2b3abe64cb1763c6c410e7edb9dbce", + "adminx/Support/SectionHelp.php": "6a5101c1f7b61ea1b94aa4a37985d3a5955aaed8b73537f918f90f7749dfa08b", + "adminx/Support/SensitiveRoutes.php": "d14d809310b43fdb66ec141b0573ec1020aea3720d20269d39f201f411d9f9c5", "adminx/Support/SystemFeatures.php": "f18ada623710f8c3efc6588c037227f6074e75bad9a3820aeef60b4d8d4b20a4", "adminx/Support/SystemHealth.php": "286aee452e90f71ad64437bd39d6dd985d312eb85bbb711490d3a0b631a16aea", "adminx/Support/Twig/AdminConstantExpression.php": "44bb7d2d978e3c873d457ba1ca7b8021c6f5292e1a4f8b74e49348d01dc84b1d", "adminx/Support/Twig/AdminLocaleExtension.php": "1fb6c1d1c35d74fdb4a578cd15b3f19441725db6f11b336dc6948ed982842f3a", "adminx/Support/Twig/AdminLocaleNodeVisitor.php": "ccf387680fe8b0e11bd0fe0d23769be8b7ed9afc363cb9a802db5c4aeb7d405b", "adminx/Support/Twig/AdminTextNode.php": "e51d8d3ee49411f42c8a874cd283f60c1fa0af6506b34568a119ebe2d18d7268", + "adminx/Support/ViewOverrideEditor.php": "c3804e0b399cacd604ad2765885366a4baefe0ee7a441475276fe7d3074dab36", + "adminx/Support/WorkItemContext.php": "d4b87b78ff1f2bdd89d77bc0483ebb46e5c11616635211ead9a43b34568e3a59", "adminx/assets/css/adminkit.css": "15409efc9387120866479c3351a4a291ee7d8207694a84b228fc5d35fc184cbe", "adminx/assets/fonts/NunitoSans-Bold.woff2": "2d48580b7e1be60fa94b8092ebe86471fe0adfe5ca75242a7af2042ac9da825f", "adminx/assets/fonts/NunitoSans-ExtraBold.woff2": "66612ee9d1cdc341c9eec8a43104bbf515e7cc52821558bfc7dfcf33c8cb0cc1", @@ -44,7 +46,7 @@ "adminx/assets/fonts/OpenSans-Regular.woff2": "a26ed465673123933c5510730a7286f49016a40fa94062021d1dc4c9bfeddbd3", "adminx/assets/fonts/OpenSans-SemiBold.woff2": "7d963e1d2f61c11c2e8bd2eb528777b5a55569708d50a7ee7bd097d7fafe6da4", "adminx/assets/img/logo.svg": "685d9bf037b33fbcfc200947d5051c5325762f8ab8a29ac9e1e587076321e463", - "adminx/assets/js/adminx.js": "be65a81e87bb6580c0f3b59514c92627611ad18ba62f090a1600ed5fff20b63e", + "adminx/assets/js/adminx.js": "4303a0a0c89c8e339143e6d1e0269a51b02b1fe72383f83af6a2a8d3b4ee1cf0", "adminx/assets/js/bulk-actions.js": "d7ee80678e6703aff2418a4679ba8e5027e7220cb089848bcced6d9f100b3aae", "adminx/assets/js/command-palette.js": "f62f0bad5ddf2b6e813e7eda094d73df63dce09a6e581025971e8f10430e8d9f", "adminx/assets/js/editor-codemirror.js": "90a04c219e84f5391a40b0d1fc807ecfe4ca582a198bc202bdc5aa6dccec51fa", @@ -139,7 +141,7 @@ "adminx/assets/vendor/tabler/fonts/tabler-icons.woff2": "cce741fcb6c526dfea3c0d48ff486279ac723617cd5b4de2bd5d726e94cef477", "adminx/assets/vendor/tabler/tabler-icons.min.css": "676509f34c06d34b75e4227e4c36105e5d220b458f609fffec247f20a8d72a8d", "adminx/core-permissions.php": "084fbff2f7625f0e1b6a6a15d00fce536212adbdf40f6a7fa2eef34cab54394b", - "adminx/index.php": "212b324ada416cff2da4eb876976411eab53890a7ef60784136ffdf4fa60decf", + "adminx/index.php": "bf4c543d4b32cf24df073318151ae6a7192132897ccbb81089e7c78398247462", "adminx/language/en/client.xml": "de712c66a939fcae607c087e0941dfbf824152a41be85aeae0a0406cc0ef3053", "adminx/language/en/common.xml": "e5f9972786f1d2a8e05f86913b07c361b82811f99cf51bee6d38e9ed275fc995", "adminx/language/en/interface.xml": "80ccb298e1ae8ea31c533832072b99c1eb1369871f6d1b357635aa59622d9d52", @@ -150,19 +152,20 @@ "adminx/language/ru/interface.xml": "03fa6351745d2e47c2b3407da65c8b7b048b8e862409dc9bb934e939b9439c75", "adminx/language/ru/runtime.xml": "3c411a18695af7a63dd0685a8351ac3a51695aea7d1fa2550fb24cc9e3e82be6", "adminx/language/ru/section-help.xml": "e821c8589580e4fb898f6f7352691f81348303edab99c338c942511c9bfdca65", - "adminx/modules/Auth/Controller.php": "bad2d7fe5f7eaf4b7f22661945d95df5a3201498b2920e17de5ede6f9f560686", - "adminx/modules/Auth/language/en/interface.xml": "175f757bdc70cb47353a379a6a2303d4903ae7d446fd9a1362919a35517d11a2", - "adminx/modules/Auth/language/en/runtime.xml": "2a549aa418e35f02d97ce89bc9a88103ac39153526e2c0a11deb689920a3da69", - "adminx/modules/Auth/language/ru/interface.xml": "f8d95a4a8039dabdca7d5f3095feb4904714225b1a285527fd43b0cab65bcf79", - "adminx/modules/Auth/language/ru/runtime.xml": "94f025e2ddd2327ced6aa4740dba80025ae1d71b08f2ce6bd7de9a222277e058", - "adminx/modules/Auth/module.php": "da1ef0924fdaee46a2c1333c43ed05d3afcb02ca16f1994b0eea840a657a4f12", + "adminx/modules/Auth/Controller.php": "55253fc157e5769435108d45b489e0430c3c9ca34842032ac067638654449b0a", + "adminx/modules/Auth/language/en/interface.xml": "f1fff693b755b370504cfb643bd03c44e718a72c421c45237abefc26cffa6bab", + "adminx/modules/Auth/language/en/runtime.xml": "79a073f6546110618937c7138a205e552b1e1f3d3a244eec5a24a335c5ae7004", + "adminx/modules/Auth/language/ru/interface.xml": "b88a7d7ba803f33b65d3cfef971973ce18c66cf46dc65939258f5706a2c89c09", + "adminx/modules/Auth/language/ru/runtime.xml": "ee70e8b6abce1f90db8f416ed9eab7f8e1fb50d8e3d8361c2abba6ccf7f90202", + "adminx/modules/Auth/module.php": "af27a6f74966bd1883daa63222ebb95de0378d6a5284eca4e24f993e595248d1", "adminx/modules/Auth/view/login.twig": "2046a79080fe3f609b88421be5cb62c291402b9717e23b14d8cf93b3c6aa0c18", + "adminx/modules/Auth/view/password.twig": "d7fa6526d3100a04e6bdd322b3478111743330324eb83727c9464e36912faf8e", "adminx/modules/Blocks/Controller.php": "5665327885171e07f87823b473f97e22f416ab896471a25ff7a2dff498a30d22", "adminx/modules/Blocks/Model.php": "69dcdd7488f308349741bff0911066c5f1ceace23216ea408fbc79da4a73bc14", - "adminx/modules/Blocks/Revisions.php": "b0ff697364a5573c221d8a85545970c8f554af2c352bb51ff7e98930d4e7f91a", + "adminx/modules/Blocks/Revisions.php": "9c820024215c0760ba93fbed902b9cbbfdbe402fc9ac514e9908d06fcb794817", "adminx/modules/Blocks/Syntax.php": "9b2eb8d40171633cdbef6cf21545958f801f4983d62176e9256bec8e4763a624", "adminx/modules/Blocks/assets/blocks.css": "13ec7cd2b5a9c2abda0ff2d1419fed50dc1e122edf8ec278e11cb12d75b8ff38", - "adminx/modules/Blocks/assets/blocks.js": "8d6633fa870026c2eb014878bcb985d5807fca8040876364be7b3ca34d8fccb8", + "adminx/modules/Blocks/assets/blocks.js": "22d512d111a1f0dcae0a649bc1d49439c733151e87382f55dd5d5d17863fa61c", "adminx/modules/Blocks/language/en/interface.xml": "6b358b561f81febe1a3284045ff20132737e624a04b2481b51681e258f748e22", "adminx/modules/Blocks/language/en/runtime.xml": "cd76ddcfb5751496dc4cc160dc2c6f38ae5699a3a5c0041b5fb214fa434f04e1", "adminx/modules/Blocks/language/ru/interface.xml": "6048c75a95fb474287cf4903fe1b82f2608741b355134be885eadf511da5b656", @@ -179,9 +182,9 @@ "adminx/modules/Blocks/migrations/010_disable_retired_catalog_endpoints.sql": "c80e95a32c596ed073ca1e15ae027f2e3f813d67867c31926ea5060e3ff5dace", "adminx/modules/Blocks/migrations/011_sysblock_editor_mode.php": "e8c4046116dc2241b1eed3cb677515497fc07c25425628e4d349688df698743d", "adminx/modules/Blocks/migrations/012_merge_visual_blocks.php": "5c9cd6efd66a027134621859da1059b2c403bacb732885d6b405699cbce8fc14", - "adminx/modules/Blocks/module.php": "e196ec71bc17da48f9e1663f74db4a7de8fcd28d0f32a3f36beebd8247656322", + "adminx/modules/Blocks/module.php": "53eb434566d820814f5b25382200b75ed02b0067c066c8f8cb62bee13529b964", "adminx/modules/Blocks/view/index.twig": "39f9225b6a95c65549d0c861afb7db7879ca338c251290653dded6ab7cb715cc", - "adminx/modules/Catalog/Controller.php": "996c5454307133d98a1334584d917b2157f62c2fdc0812683fab93b7ab7f5988", + "adminx/modules/Catalog/Controller.php": "425ebd7b8e54ef659879e7bf8212120feda968a7560e2a614b505be882fed410", "adminx/modules/Catalog/Model.php": "e7c7cffd91754e04dac322c31a64b420739c671079a7f8e7effb550f5019ee13", "adminx/modules/Catalog/assets/catalog-drawer.css": "b3e4f798d7bdf329aa91987ae5be435910ddccfeb8a32211059846ab9dc6b7c4", "adminx/modules/Catalog/assets/catalog.css": "49e7a33fc444f8330806e1cc5cbb0d523b673c64faafd25dbf0fe33067643efd", @@ -193,12 +196,12 @@ "adminx/modules/Catalog/language/ru/interface.xml": "15d8de452eef296375ba40d557ce15ac0363881a7b763b7111c8cc221e9861ff", "adminx/modules/Catalog/language/ru/runtime.xml": "fe59945f4ceb2bf2d85699623369e7e5889613b8cecd8551a390c78023531d14", "adminx/modules/Catalog/migrations/005_add_catalog_purpose.sql": "006ff237e25042a4fa9b9f74d5079741eeee1aa52db0cd996ea4c8b817e6700f", - "adminx/modules/Catalog/module.php": "6d8b0d0bc67d314c3aee6f5813c36d32b6f4bc0de884515e13b059927c3aa9ce", + "adminx/modules/Catalog/module.php": "69f6f46206bec880a6f08329e0b44e35bcf3c7e57ad5e85e3b720b7b4adb7f38", "adminx/modules/Catalog/view/edit.twig": "7fa6f39eec641b23f2191a9df2186a4d4855e50d4b1cc506118a94313cf481a7", "adminx/modules/Catalog/view/index.twig": "754c7c4dc81176e8c99b3d9e8c1fda2f62f03df341c3df1f7393f72d43c6f8e2", "adminx/modules/Console/Controller.php": "af0309a50d7f4366279be8ea12959cf7718a3c68b895a65ec5846554fd6a4857", "adminx/modules/Console/Model.php": "53aad480b8d4e5d68c4e9345d219ea5cbff6d04eee7ce4aa15deab6f00dac1cc", - "adminx/modules/Console/Runner.php": "69d38df0f96d7de1d2fdcaa0e62c25c545854cfc29a26f9f9375059c1465e906", + "adminx/modules/Console/Runner.php": "bd65deeeaea97f4a7ee47826148775e9488a2edac80f5f8640ada6ac96f6b26b", "adminx/modules/Console/assets/console.css": "e2645bf876bfeafc15e6f4e5ad9889adb773aea35ddc42ff151d8ed1f8866eef", "adminx/modules/Console/assets/console.js": "f0f2cec892c4b5cdeb11730bf42e9cf68ff779974338086cdf92618ebcacdb10", "adminx/modules/Console/language/en/client.xml": "02bef78a30b83340eb8968297639b6283dc642236e853bef413e5b7d2ef70315", @@ -208,16 +211,20 @@ "adminx/modules/Console/language/ru/interface.xml": "55f68a6bb3fea3fbaf953482b36593a1f3e585d3a99b6e2da30d6c097915ed08", "adminx/modules/Console/language/ru/runtime.xml": "441ebbfbd7f0e21cef5d298ec5fe314d3236cddd57e05f05fe60eb1ac3d1b8b0", "adminx/modules/Console/migrations/001_create_console_snippets.sql": "70272e04d744eca3d48416d9ffc83d78ad00a02d2a6f68bc388618e350c427e5", - "adminx/modules/Console/module.php": "1ff91297cbdf4a9cf0f83262f44b720d52ff235e59918b0f45544d26a079eff1", + "adminx/modules/Console/module.php": "cb95fd418b5f7970dea53d5bcdd0478bb0560c8b2688a95d5492ab053f324de8", "adminx/modules/Console/view/index.twig": "c64fe9632db751357ed98cf2ddca524001e71200297e1b50f0d5c827483933af", - "adminx/modules/Customers/Controller.php": "ccb32fb836387ab694e74607867ace0274d4bbdcc44566a5bcd2e7d6f1b2c75a", - "adminx/modules/Customers/Model.php": "baa3362be7a06c222f4ebe35862500f69a16785234330b886485e73aee0cfa81", - "adminx/modules/Customers/assets/customers.css": "62e0d5f0430079909cf5b77a761dd4983cd7f2179124257b180e94ff87fd3718", - "adminx/modules/Customers/assets/customers.js": "08399baf41e7762979f54c561b32beebd26ab544cd7bdce70de1079859f0967c", - "adminx/modules/Customers/language/en/client.xml": "21310e86bb84cc1fbaf65fad3f09bb4d058e820d919a9af6ae5e2700c2e8d1b7", + "adminx/modules/Customers/Controller.php": "8f64a15c6bb87eb6ef3535bac2c5364a3bb64ad6d1575eb0ae82c56a0bd04462", + "adminx/modules/Customers/CustomerCenter.php": "02eca82e2d1e2d7998fe6203458ee535421913197e76fb28a6e674231fb989fd", + "adminx/modules/Customers/GlobalSearchProvider.php": "6be85ba385ade05cef1167a6acf146a7e078d176b72db773e0d26d2ab8ea65de", + "adminx/modules/Customers/Model.php": "fb84df79d1ef9b403f16e44100325d26db5c144b6cbc6a69e3425a9a3f29a043", + "adminx/modules/Customers/assets/customers.css": "2542f8ba075ce20f4fa48a966eda5de82b5e574a17f5bebc88727b608eb47bb8", + "adminx/modules/Customers/assets/customers.js": "3a0bc1875dea633b78840b75feb11e903d2b2404080e0f505821156f98a8ffa7", + "adminx/modules/Customers/language/en/center.xml": "0ebf643e8a2e00fdf07a290adfd108d0d7e53781d15c37f44d55564c7476b738", + "adminx/modules/Customers/language/en/client.xml": "6f22d85d37d66aba749b7b4794635fd644a12e9e71e2a5eff66ee3b1c9813e40", "adminx/modules/Customers/language/en/interface.xml": "573a7ca126beeaff77a8865d6114dac0429b588d8061f939c5ed7af316d4070b", "adminx/modules/Customers/language/en/runtime.xml": "c9e6e3c654f97f0a7d047af51e07958a27da21659635f1a04d463070d4a112a3", - "adminx/modules/Customers/language/ru/client.xml": "45b4958eb3dbeebb403440054ad640c4e548c15a0b68c3edacd95abd8b936e61", + "adminx/modules/Customers/language/ru/center.xml": "0e7f08205412ae33c8df6b744fe434d36c9fff1dff153e18dbbe12bf53701f28", + "adminx/modules/Customers/language/ru/client.xml": "592ffa9b57036d44d8be1c71d52b12db0a2f0e20fb310db19209b9e870b8cab9", "adminx/modules/Customers/language/ru/interface.xml": "3a469c1a855f36613b56e3a5e3073579b849663c3639fae753f0b281ffb67e0b", "adminx/modules/Customers/language/ru/runtime.xml": "2733e5d3af9ba28fe9225f1205344773d1876d6c0e6e72e943c71b91f13ba62d", "adminx/modules/Customers/migrations/001_normalize_public_groups.sql": "4cad06703e4d5b810e425ff5f440e7e0bea493d93ee261cd5195cb5c28443698", @@ -226,8 +233,11 @@ "adminx/modules/Customers/migrations/004_materialize_public_auth_schema.sql": "682fd96a76d21e8420c938378832f00691fce80963a798168827995234f765cd", "adminx/modules/Customers/migrations/005_checkout_registration.sql": "4e5ac6d4d5868f9e1c91b896fc03ad0982d5716287101e721a3820d21abe1744", "adminx/modules/Customers/migrations/006_phone_identity.php": "97e8aa457fd8f8ade781a03a98bdc7ee03cac5734782f9e72fb0b76bdbd2cdbb", - "adminx/modules/Customers/module.php": "4db18f68c747765353d6d747ce43e3095067de8e6f76d06c44902e7f4c19c167", - "adminx/modules/Customers/view/index.twig": "bd116c06b95df1a79b08654974d8133b84963f84fdecc3b90d7579221a120413", + "adminx/modules/Customers/migrations/007_customer_center.php": "7adde1ce2b92f24b7f1d1d483aca4304bad918d06ae2f213226eca96dcff344e", + "adminx/modules/Customers/module.php": "a8a567e986129c8960621235c5bb75c03fd19bcae5a0ae4a1065d8d1eb063d21", + "adminx/modules/Customers/view/customer-center-detail.twig": "2239602955041c4eebe704c70e835fd45272f721abd28bf254904901bd77a4eb", + "adminx/modules/Customers/view/customer-center.twig": "223e657861275fbfe482ce624a6216a91e8df893cd1c5fe58a2999606468addf", + "adminx/modules/Customers/view/index.twig": "5b011dab9ca33d66fbd138297875861054477af56f093282d08aaa4c576a7f12", "adminx/modules/Dashboard/Controller.php": "7e6ad0610df7581bb010cee574d8d093c4737af439bd2b0763142f50ddee1c54", "adminx/modules/Dashboard/Widgets.php": "56fc7ba7c1f9e30d7eae8c61524b6441b1c706a2cd56b1db883bb8bff7186eed", "adminx/modules/Dashboard/assets/dashboard.css": "8e28b56cac2e55d9b57304ea6e265fd94a52400674c082fbdd78f1eed1872424", @@ -243,13 +253,13 @@ "adminx/modules/Dashboard/view/widgets/recent-documents.twig": "d03f36f047574f8ce57fdcfd79b26d9b20ca01dba69ce7c71a3e9a277f0105d0", "adminx/modules/Dashboard/view/widgets/shortcuts.twig": "af165425ea7889cdc3edbd72c7f0dd9dc5c4c06486bc4ab37816112cfb696718", "adminx/modules/Dashboard/view/widgets/system-kpis.twig": "52dab540a189443b76811ac4b457a566cf90134366749aa9c7d82e2d23ffdaa3", - "adminx/modules/Database/Backup.php": "cab7b7d1a8ab9d2d18ece85604670458a851665b0af64f7c37d5763cc658c464", + "adminx/modules/Database/Backup.php": "08192cdadd31b0d304393cec761ca78d696d10e7b9585389662fa71f53e8e4c0", "adminx/modules/Database/BackupRestore.php": "1fb34abdec27ed24665ec0749f4ed44167f3881d3477129d5d4de6c7b36d6653", - "adminx/modules/Database/Controller.php": "c3f59beb5874b8959a5a37c49fecc9b6b48581bf20284b73290c44fb91a14363", + "adminx/modules/Database/Controller.php": "87577df32e3cacc36edb57747ac9169d0e32a30b2b2f11dbbc4db18be486e3e6", "adminx/modules/Database/Model.php": "52c23131d07bc798fb2a3489f91ae909985e60d2965e3cd5fbaf5ab441d85477", "adminx/modules/Database/SqlDumpReader.php": "f0b3a501d0afea0ae50d35b43e901844e14d9faec286738956dc6f2b1c5ea4b3", - "adminx/modules/Database/assets/database.css": "aefc5ac895e05190df663d2a7a384430bcbdf2cef92ef1ccd3b819524a20cdbd", - "adminx/modules/Database/assets/database.js": "f54e2756a54eedf5654eee7357a022478988293c9419bb7ad024eb8f69ad8c0a", + "adminx/modules/Database/assets/database.css": "9bbcaefb3c3f3e6230250cf90cb20ffe933f86812ddb8c49d17a3f65097aef5d", + "adminx/modules/Database/assets/database.js": "191c97d3d7586440ca736c5cffc228e4ea9f8915ba61e5a55146f849df7e9d12", "adminx/modules/Database/language/en/client.xml": "4464d65adfd150e597436cade1d36b0c0436654fb4c710218fe2fad117b12369", "adminx/modules/Database/language/en/interface.xml": "da319d6e408fd13179bb4094bff6cab3c72880f691f2e80a3ff13245144014d7", "adminx/modules/Database/language/en/runtime.xml": "180dbaa39dc46cafbd06c7f6d05cb033739d95550337be5a31d50d1332369f4e", @@ -259,28 +269,36 @@ "adminx/modules/Database/migrations/001_mysql_legacy_index_compatibility.php": "e9a021b5643933d6ca000d54bd1a12e339080939aa84fd3ec28d0093a5b8222c", "adminx/modules/Database/migrations/002_module_lifecycle_recovery.php": "779b04c8b35f3d81657848016536a9e3ef70fb76dd3ce5b1ba4b4a72166f7a2d", "adminx/modules/Database/migrations/003_document_fields_text_composite_index.php": "541a8df87dd5fec6946f26dcca88f78a2bf5e09608f9300baa8532202020ffbf", - "adminx/modules/Database/module.php": "61c12758ca1a7b59907c13cd9dbcc39ce771fc671edba665d7b6d4bcf82c9aca", - "adminx/modules/Database/view/index.twig": "566ef7db0a78b98ab1e41750dc364c073735404c62576f7995ddaa4a6d51828a", + "adminx/modules/Database/module.php": "550471695f5bf7ab5045f0b22c891f65b398346fb03f5f0a0cc3bcf3fed379a7", + "adminx/modules/Database/view/index.twig": "4e3d3a8ccdeeb2812d4b6d3ae471c8f162d8ac0e30c5dfcc4847356b4cc063a9", "adminx/modules/Directories/Controller.php": "bae664ccff5a63a215fa1b59b721d0b0a4055ac3633319476d09889ead0d7d27", "adminx/modules/Directories/Model.php": "ead423e3d0a07badf5a773780ee37d92394240fc753c10fad274a3131db7c2ce", "adminx/modules/Directories/assets/directories.css": "6250526e076f3da647c1e696ce965ae2d9d3ddce890b8cf022af4b33aee18e1d", "adminx/modules/Directories/assets/directories.js": "8e0b59128ab92a07731009190716ad3dc14f99ff7ed2bef299e1df645a4b82a0", + "adminx/modules/Directories/language/en/client.xml": "3e4a857ad0fab8667b45b03e25a13db28ed03be9074dc4133f161dc90bd4e292", + "adminx/modules/Directories/language/en/interface.xml": "2e732d6a46e30428587ebe6d97c9ef4311cd4cfed1d9a8235d97d97e79886d6c", + "adminx/modules/Directories/language/ru/client.xml": "446a6b32ca10184998950b0ee536ae41e08571e6d71ae13f75d15aa68dc5b0b9", + "adminx/modules/Directories/language/ru/interface.xml": "a4030ac2f2f1f5ba2ab600c7f82c9e4058adda89dfbc2e5983ded602c26681cb", "adminx/modules/Directories/migrations/001_create_directories.sql": "d1f84fccf75892fc1dcfe6b770112101e233d96d9f4866a08d1977646a690530", - "adminx/modules/Directories/module.php": "562709b6a6ca8bea3e2e05d1dde05af0ce7e627e94d52df3b161349dadeb90da", + "adminx/modules/Directories/module.php": "f6b7f0bfb96d1c2c7aa52c51e60abaa09cdba7a4309d8363394a3a02a8df96a0", "adminx/modules/Directories/view/index.twig": "0bde23bc578b9ffad3c47534c304c0016b661a36588d6d3c9ffb92e2daa388a0", - "adminx/modules/Documents/Controller.php": "8cd40c0e942f7eaef30af8afea990746014df03c4063a69fd6e34019045fd72d", + "adminx/modules/Documents/BulkEditor.php": "6ca33e4ee73f83e043b13363bddb391199a1da902e74dfa7709a10ac32be574b", + "adminx/modules/Documents/Controller.php": "e461ac2766b15194741b0a4ca2c2aafac9edab9b9f602516011dd5168269104e", "adminx/modules/Documents/DocumentHookRunner.php": "7e0cd24ed5791b6d5010e4a05351b709be09b6df1e700a3ff06685b9078b2b1d", + "adminx/modules/Documents/DocumentMediaReplacement.php": "e97d960c852162e655a4389cc5f477f0178597da50e733b00bdf5bcab076c65d", "adminx/modules/Documents/EditConflict.php": "ebc3ebb92c3fc3ccf3909d2ff517014fc888ee20576a520b4979f68bde632b97", - "adminx/modules/Documents/Model.php": "60ad2c0508a6f1ad1e090dc54cadd19ef864fa9657558c5d82291dc30796c15e", - "adminx/modules/Documents/Revisions.php": "5d40ba1ae4c9563e14464f795b054f9d4edb05e02c8a719bd796126054616132", - "adminx/modules/Documents/assets/documents.css": "c7898e92c20f71c155928eb4acb298a3975370269f449960c7576776c9134b9c", - "adminx/modules/Documents/assets/documents.js": "d1af08bd3869764c103cd97880d8d82cba1d233fc4b5dfb01885064713ed6750", + "adminx/modules/Documents/Model.php": "6ac1f3e85c73e5e7c8a531b31f9b72ea1a78ba561355102301e386baf0eafdac", + "adminx/modules/Documents/Revisions.php": "8caef88f7456000b001757cff13393ae36991b33c4c3070c27f3eaa4e8bf6492", + "adminx/modules/Documents/assets/documents.css": "5e9dbbbfbf276ad94a32657dac8a8cf0a5c184e77fb6b1d40e0261876dbe84eb", + "adminx/modules/Documents/assets/documents.js": "b65c2396c0f866e1895d84637875a935ca4a0886378c65068e5b4215b6dd94c8", "adminx/modules/Documents/assets/redirects.js": "95ca395c6b4fd3814da37450c27da351e75f509e26421c975dd8e0ee5f657efd", - "adminx/modules/Documents/language/en/client.xml": "10de693182945b4c5347aafe508c212ba193acbc1384812aa2c802b5298bf908", + "adminx/modules/Documents/language/en/bulk-editor.xml": "b7af92bd151c7899a56cc461f4a09edaa842213cb6e6b4b4363af31aee228e50", + "adminx/modules/Documents/language/en/client.xml": "9ccb6f2aa9a83862abff2c1fa1c6b10760600c3202293fd7e42073c9cfb5fae4", "adminx/modules/Documents/language/en/interface.xml": "f3a30b3c565d2e01c841c843dc529fed9844dc2e095f90b7902008ded7214ed2", "adminx/modules/Documents/language/en/runtime.xml": "7009fa27d67ab168fecd9d9bf182216dfb0397d9c006eb6563ee3c464daa5b5e", "adminx/modules/Documents/language/en/zz-interface.xml": "839ea37d585ff10d7b819a4fcd4bda876c94d51019489cc8d35af15fbc905f2b", - "adminx/modules/Documents/language/ru/client.xml": "14f67aa847553269adb37efa6bb408d866e30f8db0eee92810564e0f7eb67305", + "adminx/modules/Documents/language/ru/bulk-editor.xml": "29500cbda63189ed9a0afdb57b20f998241594b254d0225a776a8be32d108834", + "adminx/modules/Documents/language/ru/client.xml": "3e14cb51de149aca6c3f309ffb3973a47f80f638805325ff0a5cc23dd1025302", "adminx/modules/Documents/language/ru/interface.xml": "718386366f913c590c2bb881f49ad3b7d436de82faeec30655a7c2f9b14d58e0", "adminx/modules/Documents/language/ru/runtime.xml": "6c3e9a2dbf862bef5a76ff0bb600084fcd9d5d1a7f0cbfd02377242b74fcd759", "adminx/modules/Documents/language/ru/zz-interface.xml": "2d819845ff2ae5246203374a685f8a5ad881ba9067166161eb96d5c9271026dd", @@ -300,13 +318,14 @@ "adminx/modules/Documents/migrations/014_document_relation_edges.sql": "3491ade2987535f8db1b3b00ff27f5a2062b02a3a1cb2e707d2752ff446b566c", "adminx/modules/Documents/migrations/015_document_creation_presets.sql": "a123f8867ab6f6346002d08317acac37062c2792913ad14102c81812d45571bd", "adminx/modules/Documents/migrations/016_reconcile_document_content_columns.php": "06671bb26bf29479dce24888fb3712c0d20e2aac5b3d70196604ce4097c875fc", - "adminx/modules/Documents/module.php": "9fd49d13abaa566c76d4d2d7b0e4e2dac2b7d58d1f27e3545400b3a1c900ebfe", + "adminx/modules/Documents/module.php": "36069766b3397735293f9f7d491c6f874eb8fc3c12c7d5d983ec8eee6b4655a8", "adminx/modules/Documents/view/_product_panels.twig": "e07898ea6a7cc0004ac7545b689afdc6b474e6d8b24357f5c5190b4ca5589142", - "adminx/modules/Documents/view/api.twig": "60e004a449d5df2b39e24a073acdf3622b2d379e668bca5834d59bf9e3e07294", - "adminx/modules/Documents/view/edit.twig": "206ad52382062e3603601f038638f26c5602a5a01b0494eb80c0902d8f266f6c", - "adminx/modules/Documents/view/index.twig": "b6f005bbb66550e43f59d75b8311f290e6c728687473a1a7da2dd74af56b8a7e", - "adminx/modules/Documents/view/redirects.twig": "0d8e6c0431c3cb4571a40f021ad3f55a9c2df1092b500fa3735258bb80231d7a", - "adminx/modules/Documents/view/views.twig": "07ae0d509df51aeb551ae5aea2a19766c1cb00fd255ab3d9734ffdb7e55764c8", + "adminx/modules/Documents/view/api.twig": "a035a2d566298587ce8530c98915e80e4e7f9b0f41a60e0e55d4547b996a6c40", + "adminx/modules/Documents/view/bulk-editor.twig": "27d9baca8cf591dc2ee81b26fda3efc9017c6809c1d23168a749d01bddfebdfa", + "adminx/modules/Documents/view/edit.twig": "a7934bde27ca84a47b9a5865b8bedb3e28756b91337e479592e1fefba93ec6aa", + "adminx/modules/Documents/view/index.twig": "004594c77dba92188e58d73228af397ac825e723c3c1aba9ee7b94072f828ba1", + "adminx/modules/Documents/view/redirects.twig": "4a4903edc81e4770220852d9be9f02a983ddde6f829a4360ea01d3b68456582b", + "adminx/modules/Documents/view/views.twig": "821f19085ba3997b6ef8f005e838f6fd449f0b32bce0d2804271311856cce8f0", "adminx/modules/Events/Controller.php": "c57fc25e815c47c14626371d044079e6711f0a4bce2004d2b9256db62f0d0e85", "adminx/modules/Events/Model.php": "a3a09370aa47766c7c4e70ec002af98a13bfcad3151b9ecb96a6ffb6cddf7ae2", "adminx/modules/Events/assets/events.css": "91457d384056a6ea96bdc6bf012c033c19e0cb21401460ae9b0f9398493258fc", @@ -322,45 +341,64 @@ "adminx/modules/Events/view/index.twig": "7057cee5abe6ace177509c54c2d1736ab88b96f9f17f6db2bec0553b4eb1ac5f", "adminx/modules/GlobalSearch/Controller.php": "6a109bbe2ca833df90138af056cb6cc32c313677d3bda071b3638448ca7e33fa", "adminx/modules/GlobalSearch/Model.php": "b81c82cc43c6f90e411645b3f934b42d7457eaa7eea1f5afd7c8efa362f82d97", - "adminx/modules/GlobalSearch/module.php": "b7b5d9c9179500edbbb82a7ca0583374ac2a13bc8aa3f2eca5c6b9242d6ddbeb", - "adminx/modules/Groups/Controller.php": "37959bbc4cf73ea2968616c195763fbd1bbe14cea90c01d4dae5fad2970803d6", - "adminx/modules/Groups/Model.php": "889ba3e4c93ee59ddd6515f4fa4b5ce4c82335e91b3dc6eb315dd095ce6f8fa2", + "adminx/modules/GlobalSearch/language/en/interface.xml": "a025b7a82786864c5d74bea07880e3c7b3f0cd7e52d8142ea5be8db346ae1a3e", + "adminx/modules/GlobalSearch/language/ru/interface.xml": "0e04520cac25e685b624730af1016c66124e7b040fbc759d9f3b386e625cc851", + "adminx/modules/GlobalSearch/module.php": "ecb43024f7ec904897196c70741021e16d7dbafd92189b67870e6a57748fe31f", + "adminx/modules/Groups/Controller.php": "cfa1f34a638002b8e3aa9f5058a17578e464b51aecb2354579065999a841dfb5", + "adminx/modules/Groups/Model.php": "e782959ec804e4c32b3496dd9b49ae589c2a6149d8484e8ea35a1a68bdf65a5b", + "adminx/modules/Groups/PermissionSimulator.php": "c24f53a6b6538d0b403bb41d67d8c4e6740f872fd42617f1ad2e7c5d076dac0c", "adminx/modules/Groups/Setup.php": "b7140a87474e343455fa81809f8f8f2dfe7e1ee3640c5f024a1bb06ebd783865", - "adminx/modules/Groups/assets/groups.css": "6d16a57db9ab5f42f8722b52dcecefb02a51d68360b9855187dfece83b36191d", - "adminx/modules/Groups/assets/groups.js": "1e8e6c9b183cae19587e823f037e2dec5e69aedf10629c555edeffe68369a64b", + "adminx/modules/Groups/assets/groups.css": "403f4555599cf1637ec2b5a2e6248a1fdde7b1bf599646c1c103e816fc3fa85f", + "adminx/modules/Groups/assets/groups.js": "235a058028abd1d9559b915f8a36da9333bd7b381b0113f9c98b28305c593188", "adminx/modules/Groups/language/en/client.xml": "dbceb0c80a844d05f3c370dbc495a71142818c0c9e3ff94c33586bea89f07831", "adminx/modules/Groups/language/en/interface.xml": "ca7dfd681c7f1e8580914cafdfd259d5d3be84868e7ee97288fa36ab3f785de7", "adminx/modules/Groups/language/en/runtime.xml": "7ab07b963b2b29f046f6c363f04c1104de6d5914f5e70b6b8cd42f5770847168", + "adminx/modules/Groups/language/en/simulator.xml": "de91b32f039df2dd87ecd47e27725a6dffe018867a53a72df2d21e3394e63146", "adminx/modules/Groups/language/ru/client.xml": "320b9b55d6eaf6f4d3643f3fa4675544a779540ec34b3b94e03deac6f57579b0", "adminx/modules/Groups/language/ru/interface.xml": "6f9da63ae65ecc8890ea16dbe7e879bdf5520d5f74c0a6204285b19d580f15e3", "adminx/modules/Groups/language/ru/runtime.xml": "eea7d7d2b88befb79b50d00c89dc63d0bdfa95883483d9606ce0605c03c92402", + "adminx/modules/Groups/language/ru/simulator.xml": "14d8db8fa0e8a71206ad93817fbf2fac71cc023003977e03ffb2b7e581d16659", "adminx/modules/Groups/migrations/001_register_public_debug_permission.sql": "a81a4843b74ad72aca16c8016b767dca248b2ea950673d99c6945d1b8b867fe9", "adminx/modules/Groups/migrations/002_remove_observer_role.sql": "310c6d1a9bfa5693f4a3f680f63279ea9dd040e51284d6cf5de9ad519bd2152d", "adminx/modules/Groups/migrations/003_seed_default_roles.sql": "75cda12582ae86e538fdd455d064eacfcadf5f4dc50e51838718f154d73ed750", "adminx/modules/Groups/migrations/004_merge_legacy_roles.sql": "3ca98bc7f0348d04594e42beb8768a6fde6a732d39e7528068efc0b89d9c5ce0", "adminx/modules/Groups/migrations/005_register_development_site_permission.sql": "0a87db84297b7a8b5959217b82b6dbdc88507a822b342e145ddb7d800f50a73e", "adminx/modules/Groups/migrations/006_normalize_control_panel_permission.sql": "eb58f4d6a59efe656359d003d81f995e989393d7235a3fae7e38c0733fe174fc", - "adminx/modules/Groups/module.php": "4f1d59f9aa351b18b111396d74001597ffefa744cd55240c7339fee06f009542", - "adminx/modules/Groups/view/list.twig": "48949134b29f8c2f62bef3d90e1438131e5dbc53cba84214e11e5cbed10040ed", - "adminx/modules/Media/Controller.php": "a2aef03585bf4144d60821ef2a4ed075a1393bbdfd7d28da672cad3dd67584d5", + "adminx/modules/Groups/module.php": "fcc5b6212c5d6b77d9a17c9a4878c50233a5b48b373cb6a113825a82c82b248a", + "adminx/modules/Groups/view/list.twig": "15af0a1544c7933f57e6bb45ec2a3302d12a025b671c98e1009bae01f5bce605", + "adminx/modules/Groups/view/simulator.twig": "82f0c5aba5acc6e7a079c03e355edefe4061efca60d34b3195ee16ffdc951306", + "adminx/modules/Media/Controller.php": "cf853d384714160ef9fdb8d4ad108fefe331572fa2cd43e3d05faf67c3e0d368", + "adminx/modules/Media/GlobalSearchProvider.php": "799e9b9f1455ad1070bb388245ea6adc5c76b9bf5756bfe0d46b96f3378be92d", "adminx/modules/Media/ImagePresets.php": "8874cbf436a1a33f655c9a395b5f16e143c04c7df1d957c43e3d4ce3ae0645d5", - "adminx/modules/Media/Model.php": "657f2be87d6639d541d2494ccde70e8e5cf69e6ee7f798b1c92ea9f8364f9d30", - "adminx/modules/Media/assets/media.css": "df70c5a04119c46ea39537aea6b08abae381fbf56e367e82e0223d9d5efe9bb7", - "adminx/modules/Media/assets/media.js": "3364bd7ea9353072cd9067111f17bd11d7cb717469555bd821dfc7fcdde5f4bb", + "adminx/modules/Media/MediaAudit.php": "2deeacd899b4410cc620b40b6e448c388f54eb553b5a57a23475ac0b5cae02f3", + "adminx/modules/Media/MediaSearchIndex.php": "7625627ecc00b74ea75aa1818dec55c468515c966dc00333fe1f2706eef154cc", + "adminx/modules/Media/MediaTrash.php": "3251e6cccd092d0c04c0cac70838c070210443c87dd0967c84a4e323bb15c5fb", + "adminx/modules/Media/MediaUsageIndex.php": "d39d232bc56352c9cc2335a2e3bd2a7b7f415709c70ece4b38f1a462c2969415", + "adminx/modules/Media/Model.php": "1ac78ca828c6f492857e731a715d54939525dd29545bf81fb4065fcc02f0bc50", + "adminx/modules/Media/assets/media.css": "e7db9f602bc1a46644ccf172f977ebe0798c572c73d33f64584c9569d531611f", + "adminx/modules/Media/assets/media.js": "1ce96968f03008c9e0d536070579962e80070b946920ee03d9f4e965664044e3", + "adminx/modules/Media/language/en/analyzer.xml": "5c6e8bec29fe73d0403cf041c7580800ecc2677d94f2148ffaf44c04d337c6d4", "adminx/modules/Media/language/en/client.xml": "06cecdb6cacdd0871334a3da0a3c7be27d7f835e2a048d37ea31ae4f37469270", + "adminx/modules/Media/language/en/improvements.xml": "5873d8eb9c43db0a584161ae3f086dfd101a8df25bbd4d518a2fd283f4f8cb3d", "adminx/modules/Media/language/en/interface.xml": "2eab0a0d6acf094c43c690e4c09683378a26dfaec7e258c23f3f7bfb1fc9e9a7", "adminx/modules/Media/language/en/runtime.xml": "8768433d736f0e8b3508b1a68de1281b2f841c2a9decbd8195603a31bff3a92f", + "adminx/modules/Media/language/ru/analyzer.xml": "c0102f4d337a3279b412e56e508a1d8d4b1196bdcebecd46bc3e95e56ba520ae", "adminx/modules/Media/language/ru/client.xml": "272d1eddafc77c79533be4fa89d5daa2d560c1860bc2bab4a1fbf6d609849768", + "adminx/modules/Media/language/ru/improvements.xml": "d3b92183909c2159debe29d4594544129f3981d253dded9f20704e0394b158a5", "adminx/modules/Media/language/ru/interface.xml": "743b1b2e5f498ce05225aca69f866abe2f24765a260baa9d81c1947954029fa9", "adminx/modules/Media/language/ru/runtime.xml": "538db360abf2d61e32d6476846173ea4f5115703021362314a9100df3854d6f7", "adminx/modules/Media/migrations/001_image_presets.sql": "303dec5f331135ab3fda9d264b7a7b674b987cdefa0a7904c544a5d63554f15a", - "adminx/modules/Media/module.php": "97131d5f990b1eb43fa69b0f255ffaf993b03bb392092fa558dc498f156c75e9", - "adminx/modules/Media/view/file.twig": "0fedbf7fb3d7b36ff9dd717b73b5d5077e9d101a8a3b5ba5f44db64bfc0394f8", - "adminx/modules/Media/view/index.twig": "a834a9ce896c5b3d633f5b9a28de360f3a57efd14afcfd7957b0b02363ea65a1", + "adminx/modules/Media/migrations/002_media_search_index.sql": "e84168429acdd900d494c91e398a8974ae97023d8bee3385321c2947dd627378", + "adminx/modules/Media/module.php": "af83896b4cef13943f197b4c121a534a7ebea86a771ae12714a2c8c4d51e4495", + "adminx/modules/Media/view/audit.twig": "06936cd87c80b5c7f6ecec8729f227c2ab3fd492e61b11ae7040e6423939b518", + "adminx/modules/Media/view/file.twig": "b317dacbc61411831a9029e30fb1ff3457fa1d34164df3b891b6a9a2fdbe32ce", + "adminx/modules/Media/view/index.twig": "c3a4c42477df3a10fcc8ee558bb03fe25782a58f6f47734cb078686798a2d06a", + "adminx/modules/Media/view/trash.twig": "991f5db2deac791b9d378d2c06bc85d69d4afe3960202c57929e335ce4fd8a90", "adminx/modules/Modules/Controller.php": "de3c800b32a05ab41dac31d4964242fb0635aef6ef32f69a332509159b8e755d", "adminx/modules/Modules/ModuleArchiveInstaller.php": "22f015a6695de9f4fea12a580adfb76837e59ee4e9de92c153782c5a40a97560", - "adminx/modules/Modules/ModuleRepository.php": "651efb2eb73beefd4d486761d0a554d45725f58aab47bcb6e88d7d61a0b8b87c", - "adminx/modules/Modules/NativeModuleAdapter.php": "607ed915ed6a956250b8625117084d9b5c46f149235e99457a514016d7ba375d", + "adminx/modules/Modules/ModuleRepository.php": "29ced1e9d5c36dab4f0018c10fab1b80282b8d225805bf13f075e148016c45f9", + "adminx/modules/Modules/NativeModuleAdapter.php": "b753875997fcabd019e4dc7420f5b43f3b75b6c673c24ee98462e6809f00b665", + "adminx/modules/Modules/NotificationProvider.php": "55571216fe84feca3484b06937f988748d00ec9bb68b45d0e85d16a3c3836b25", "adminx/modules/Modules/assets/modules.css": "ba3f1f6721dd1e529903254abe8fd24e20873b6c00e0a2d2d2d7bcc65170d884", "adminx/modules/Modules/assets/modules.js": "7ed1e03f0114ff942b4db057ca57586c2cc94ed55a2d10f5ca5dafa908acabb5", "adminx/modules/Modules/language/en/client.xml": "f54084951cb19973f33c9a28576353a09050129f8305d47590465083c0130791", @@ -369,12 +407,13 @@ "adminx/modules/Modules/language/ru/client.xml": "176d4ebdae73e7533082676c2e56199d47a43336ace47fbeb31392adad26d212", "adminx/modules/Modules/language/ru/interface.xml": "ee33c8914bb44478a8801ae23522954c860a6ecb007b848bb956fc07d5be4ce7", "adminx/modules/Modules/language/ru/runtime.xml": "271e31a8ae1586ed045861933a6db150427c78be7560942185633f35cf90686a", - "adminx/modules/Modules/module.php": "d3954b60d4eef716ba12e4782999fde169210d1cbfd35f0e6b9887c22b3f77e3", + "adminx/modules/Modules/module.php": "aad8c5f3a11dec21eb14a5d9a83e30c88cb4ea1206e78bc758804d015a828456", "adminx/modules/Modules/view/index.twig": "2ca402c0886f611129e884129f6e0b0022e14a8c385d5d4ad55abe49ee39f5c2", - "adminx/modules/Navigation/Controller.php": "796d25525322eb63e0428ad5f9964ae52ff3d91fc9aeee275c9f854aeb99a5de", - "adminx/modules/Navigation/Model.php": "46909ee83e0aff609337ccd942c7a69765d734ffbb47bea66015f4a821f0c4b8", - "adminx/modules/Navigation/assets/navigation.css": "66d2ee424dcb1d2044c337498b570e1c2539c7148ad19975fc776e8f5ed94ffb", - "adminx/modules/Navigation/assets/navigation.js": "b9c23fdd643e00f6f28cb57f4d75f4687bbd6d7b32b76f04df4126e24440f2f7", + "adminx/modules/Navigation/Controller.php": "99dca17606ed02b4554db50bb99c0414efac51f871c7fab9113f0c6ce77df4fc", + "adminx/modules/Navigation/Model.php": "f9e59561d2c1b918af994e827d206ea7f0683dff7aa342a6de9193bd877348ba", + "adminx/modules/Navigation/Revisions.php": "165a2e2d8ad218c1437fffbed9297b7be25d0ae6567f0d72aa78da872cefa1b8", + "adminx/modules/Navigation/assets/navigation.css": "e7ad029b253b19546ed2c0eb2a3527dbb3b03a56db13c6f3089c3da9c12933ca", + "adminx/modules/Navigation/assets/navigation.js": "72d68867419fab1b3e7e822a42a7fbcd866ccdbef8fa3051a245e23f23f5ec30", "adminx/modules/Navigation/language/en/client.xml": "9a0ad0248941cd6377ada049666387581bea0dd76353d34779b4c1343b74ba50", "adminx/modules/Navigation/language/en/interface.xml": "5ec5a64240356c4b87f65baea911762e64e88dd652c1fd7c801079247d697b79", "adminx/modules/Navigation/language/en/runtime.xml": "341b984ede453a135b6e155fd29c08d9d5a2ab16e62046379f982d9a56dc4a81", @@ -382,32 +421,35 @@ "adminx/modules/Navigation/language/ru/interface.xml": "cd5ac333b85534be6a81377a1b40fbd449174cf2d1f019d25b3bb3f5ba0713a5", "adminx/modules/Navigation/language/ru/runtime.xml": "45d994c39fa7fa092525ed4a61d6775008f3993f4cdcdd615d61e5f15a749cff", "adminx/modules/Navigation/migrations/001_create_navigation_tables.sql": "43c937b8d7d7f5b70569c591db58ee8592386b988e8cf148fae723b1f68b3012", - "adminx/modules/Navigation/module.php": "e7783bfdceac8787da92d10be2ffc38ed4a8988db4112e83faec8448c8ee4656", - "adminx/modules/Navigation/view/index.twig": "870b617bc857aeb3838e76ce79f0a7fbb81ff299eaa0155767fef5b86fbdf65d", - "adminx/modules/PublicSite/Controller.php": "91233dfdcb4d893511f0c7f13db8673bfeace19fce2d303a82d7c4b849f4c00e", - "adminx/modules/PublicSite/Model.php": "48ae7a07d130aa6ac6df195ab12b2e2d8e00e31e09a4e8bfb914f40eda8c93ad", + "adminx/modules/Navigation/migrations/002_navigation_revisions.sql": "cf24c9e9cdb95c2ba4ccea63ec4375d62f29370d5ef909a82a0c990624b8ad02", + "adminx/modules/Navigation/module.php": "8420e2f45024b5a1b076cd86a6658d19fe04156d337f87bcbe597b30a56dfce0", + "adminx/modules/Navigation/view/index.twig": "39092089d109c382f1714e5d724d1e857a8e579ca44b929b40708c9a3f6697e8", + "adminx/modules/PublicSite/Controller.php": "5805f86bed300709232e0a45fedf6950857dc8f23cb499f9c17786b61a67c722", + "adminx/modules/PublicSite/Model.php": "912fcd71f2ba7215fc9a411882c1eb5e0953b6ebaedfbb92f01b7d3de477a015", "adminx/modules/PublicSite/PresentationModel.php": "3407e5e29165c264c65527433cb94d7d13a80f724dc4d1bf23a49cd2de88b125", "adminx/modules/PublicSite/PresentationsController.php": "e2c7cc517f1a38d6b5e6828b1d652caa6b63622532d2f1f3c5f176e164ba6533", + "adminx/modules/PublicSite/PublicViewTemplates.php": "d1d7f7bff22703500f17807d5fe6b02d42a2fcc4cb6d7e7224fe0a47f7dc1ffa", "adminx/modules/PublicSite/assets/presentations.css": "fddb0b4300129f7d7c33853b7e31bce131eb6f867e577443429dcafac8cc67d4", "adminx/modules/PublicSite/assets/presentations.js": "3a74e7c7ae3269b98eca52d2d61db1f4f21871c6f4a5425cc7bab58d24efd963", - "adminx/modules/PublicSite/assets/public-site.css": "e66be43cbe9df41e5fac5317a6a3ff405afcc6713c9777629cb05e76603f298e", + "adminx/modules/PublicSite/assets/public-site.css": "69a17727b3a18a88d103886c2d7f79f2c59539ac70d735379e591657e7f04489", "adminx/modules/PublicSite/language/en/client.xml": "5a93fd6babc20670e54795211cf44ed155417aa933e5d4a50be21aebfa7bd510", "adminx/modules/PublicSite/language/en/interface.xml": "e5e868af542e9fe6c981dff18e31a0050d2d7ae2b8b217e7b71bd98b496b5f5b", "adminx/modules/PublicSite/language/ru/client.xml": "f49060544db65a516b2a643420871571b4da5911ae800075c01ae35e55a5d639", "adminx/modules/PublicSite/language/ru/interface.xml": "67a7d1a651d8dd4ca3edc6c281d68c15146a8e84cb18833f9f4041fbbd57a619", "adminx/modules/PublicSite/migrations/001_create_presentations.sql": "023d97f077af57107549dd9e420f5ed55a278fb8318caeb8d86f2dc7750dbfbb", - "adminx/modules/PublicSite/module.php": "a69322a39a6b658c24755a068efb42df73d89e41a11f8354cebf07f93aecb9a8", + "adminx/modules/PublicSite/module.php": "10fc91443e4a98b3f529d07fe1cde7bbced8f4f84335fb125d4b2bf509edc2f0", "adminx/modules/PublicSite/view/_placement_pagination.twig": "b10413bdba07695500803625486b56ae5283c5a0a6dff83c668c8f93d1eab182", - "adminx/modules/PublicSite/view/_tabs.twig": "dc0a2f83f9f6a917357f4a327fffdc59bfeda83f69e2a0bf0b9805de93237b9d", + "adminx/modules/PublicSite/view/_tabs.twig": "16229bf10dcfc7fa2da84a7041912f52fe597ea4209088826236c454d98bd52c", "adminx/modules/PublicSite/view/diagnostics.twig": "242682325bd7c99c75f9694d0e7ff6217124bf093364c027d13f609db660f4b7", "adminx/modules/PublicSite/view/index.twig": "54ea9422b90235e2926924f11c43b56d90ca8e6c4f255501fcc9c52068cb6f92", "adminx/modules/PublicSite/view/map.twig": "11fba1f54578b4908c669a7dae128949f53a7f1f03746f45a5059c92f473385d", - "adminx/modules/PublicSite/view/placements.twig": "c4c2af900bfae3ceb8d35802a277429a69afe47ddad49b6a6fd2509207dff36e", + "adminx/modules/PublicSite/view/placements.twig": "9e1ef173a28ed883ea236da0611a4c2e74de09937c5ca4b5a28ff8caa98c9776", "adminx/modules/PublicSite/view/presentations.twig": "d0b245362001ef2a1508fc5c4fa462895b4bbff104025544656828c4513d4cc1", - "adminx/modules/Requests/Controller.php": "c6d2c56bf10f28cfdda15c0f6d75e134ab5029087954fc44857323462029f1bb", - "adminx/modules/Requests/Model.php": "211e9f5d7b40d1d174c75d92673c6072f10d6ebb1ddeafaa9a109483b042c222", - "adminx/modules/Requests/assets/requests.css": "c62d55179c0413c398c3bc2743a3f7e66e0cb653c85a24469ea8940ec5e9a438", - "adminx/modules/Requests/assets/requests.js": "980bc0c1c311a40e0c49facf0a2b0dc984a3b05c8291ffc6a66f7942762b74af", + "adminx/modules/Requests/Controller.php": "b0171c1bfcbf1674b9ba0f8e9461dfa7b3ba5d7bce368b8a0e0dcc2659d32a67", + "adminx/modules/Requests/Model.php": "3ab6c5b11cccb31512e61026ae650c414e4bc30debaac3c2b306e9116e3fc49d", + "adminx/modules/Requests/Revisions.php": "78483f6667f25c050cbb2d224baa8ccf8d977b30eb84538c3cfb18c7af764bdc", + "adminx/modules/Requests/assets/requests.css": "bf29e90ce2211bbd4ce3b61b29bd3e60d5e5d8f75c9e4ffd99402cb000f27685", + "adminx/modules/Requests/assets/requests.js": "da981f0941d3008ceb4042540ecbfde1385431395f2f56eb94836b4139feaa0e", "adminx/modules/Requests/language/en/client.xml": "544406edaf2b568062e697f7264b5c5fbe3fc45c27e019615601e831612db0bc", "adminx/modules/Requests/language/en/interface.xml": "766190d3f850f6fc414a4646d786f79f394ee8f03524b3a2ae820fbe792d4602", "adminx/modules/Requests/language/en/runtime.xml": "8f618793f78ea2941557d9eb9c253f8b12ac6b60b76e7158801461b30dbfea28", @@ -427,8 +469,9 @@ "adminx/modules/Requests/migrations/011_order_tiebreaker.php": "533f3bd0cd40af166feb13dd08ad1a57dbc0a14e044cc10827ffcfc091ff178f", "adminx/modules/Requests/migrations/012_condition_value_sources.php": "c874ea024343e4d698cda7a138e13b23776a58f51706c2a16102ded1234b2aa2", "adminx/modules/Requests/migrations/013_sort_rules.php": "4c58c56d9766c16517bc2ceee1d6313b1728e564a332fe9a5cdf69a04d7afe70", - "adminx/modules/Requests/module.php": "c8fae975cbeec874dda310e5653c6887e34a7ce14bd33b03b72a664d68145709", - "adminx/modules/Requests/view/edit.twig": "319e06df37e3d5d449d7a7c6b2078b2049ca230109f9b754de550b3c34aab282", + "adminx/modules/Requests/migrations/014_request_revisions.sql": "1149785b788aeca0e6e074dbb0251f92e3813b4083170b7731348a7f8edf6bf5", + "adminx/modules/Requests/module.php": "d18ef457c854b5d223c731aeac386a806c14bc6fa0941d9f79911f2b15334060", + "adminx/modules/Requests/view/edit.twig": "b4ffac2dcbdc0895e00befe395c29e3c9d71bfe47852998e89f6ba8fe4e0d0a6", "adminx/modules/Requests/view/index.twig": "164490c16e041b928e1ecf604915656399690055d1f7de4bc88caf5f5abd485d", "adminx/modules/Requests/view/native-audit.twig": "42120fd41aa4afa39560fea3f2625a4e78a61cb4f9ccc0c0388ca319d4f39c06", "adminx/modules/Rubrics/AdminView.php": "a866300dda84253046f88d19a4467ae1a545c8b1dcac7672d35be986bb38a180", @@ -440,7 +483,7 @@ "adminx/modules/Rubrics/FieldEditors/ChoiceFieldEditor.php": "bedce6236a9ca6d0cc6da23589313b9e6694a5b0f804ae85021691d3f8b4e57a", "adminx/modules/Rubrics/FieldEditors/FieldEditorInterface.php": "fbf290cf1d6304a07c5bb753782d4473682b0cce78c6301c743ef05e19b45fed", "adminx/modules/Rubrics/FieldEditors/ListFieldEditor.php": "bc1c7ead2ed6666f7861418ed2222166a44870edc5efb401226a5b9d80e549a2", - "adminx/modules/Rubrics/FieldEditors/MediaFieldEditor.php": "4497d64ee4b50126c6003fd5e31997f1ba5e391e08a97c4fb2f07a108f3373af", + "adminx/modules/Rubrics/FieldEditors/MediaFieldEditor.php": "e63ba6ea4c569ac5237f057ee722e5a936a2235d670eb29fdcf171f61a95e3ad", "adminx/modules/Rubrics/FieldEditors/RelationFieldEditor.php": "9504549a3b81893ec8a114a286a960450bbd302cb2f8f9af97660c8332f91925", "adminx/modules/Rubrics/FieldEditors/SimpleFieldEditor.php": "e961e7ab2bf3a16d6d9131513f11d2d4b9520ecee5572ae7e1b7ae8475d1cacb", "adminx/modules/Rubrics/FieldTypes.php": "c6cc64b0e1b1fb29b65275d337e5747b9f9f68ca46171e7e7f07129cd27b8409", @@ -454,9 +497,9 @@ "adminx/modules/Rubrics/assets/field-sets.js": "efbb011014f756dd59ac1357441d06c9b69b278a9d934f7cd9d2d1d7398cd313", "adminx/modules/Rubrics/assets/rubrics.css": "98ec76c4301d02bdfef75de9af85e3ae493bfb3c491827b2a9c6e2552fdcae10", "adminx/modules/Rubrics/assets/rubrics.js": "38ddc91374036bf9aa12cc55abd5ee412b829a729379ed7f9727d84bc12be84d", - "adminx/modules/Rubrics/language/en/interface.xml": "e9f7ec72f954e35c81838e982ae58580ee0922b0a34b1885119db931366945ba", + "adminx/modules/Rubrics/language/en/interface.xml": "b13fb75bc6770cc6970ee314b2f3d8c492d6227532533d46381c8919fce8bc78", "adminx/modules/Rubrics/language/en/runtime.xml": "4cad3d97c6a2695d038286cedc2c4509230c3bc9fa789dba2da9db89e8d9c93d", - "adminx/modules/Rubrics/language/ru/interface.xml": "24df4dc3e08f8ac35d261c269e618c4b613eac6585493c58fa180a945016223e", + "adminx/modules/Rubrics/language/ru/interface.xml": "3654fb74ac081dbb291f5ad13f0c83b2a83b9ce9bc2ebdfa06f9e0a49417b2b0", "adminx/modules/Rubrics/language/ru/runtime.xml": "1bf3f8341af4561a4b6b1da98a28b689105285758da0fed959a6222fd83757d1", "adminx/modules/Rubrics/migrations/001_create_rubrics_tables.sql": "a827a92e9d83361569b7e1e7c65b65cb019d1675a9078e05002de25b3e62c72c", "adminx/modules/Rubrics/migrations/002_rubric_field_settings.sql": "4406f7ce1a9e9b0a105c4a33588791d41f12a55449baeca2565d5573e79f798d", @@ -474,10 +517,10 @@ "adminx/modules/Rubrics/migrations/014_rubric_purpose.php": "2913a1a7bb6f4c5665ad05704dcbbde78c7ab6b75b0ad7643fd00e482af69411", "adminx/modules/Rubrics/migrations/015_merge_directory_permissions.php": "648b3e232e9426f84d69238295515342e42695e46288b901950cde2e8979b0e6", "adminx/modules/Rubrics/migrations/016_rubric_trash.sql": "09385e8d14d5d4d977db59c60035338404019783cc44c6c784cc2172faab08f0", - "adminx/modules/Rubrics/module.php": "3fbfbc6f5c3919d76b1055e505bac915798743d6dbbbf1fd178ef9d42d07ba77", + "adminx/modules/Rubrics/module.php": "4aec29585bc8cb20be30b4a741eeb124f5836bd714593ff717f0ce59a889ebb1", "adminx/modules/Rubrics/view/_content_tabs.twig": "e4c0a9b899b5085aeaec2e1793b8eeac4a7619959fffc7ce39151af1250e0d9f", "adminx/modules/Rubrics/view/field-sets.twig": "911dd7cc9546411e1b29724466328c7d2db8db630b2b52d33e8443a066b16810", - "adminx/modules/Rubrics/view/index.twig": "5b715531de7a7aaad85b92657a2b22a1f16bafbf8198a62fe2175fecac83bce5", + "adminx/modules/Rubrics/view/index.twig": "896fb1f4b8e56803f857857b288f78207efe60cd659277f257abf85b2e8e1cc8", "adminx/modules/Security/Controller.php": "c7f2263f460057191402dacf8948b822f9b33bbacbe94e6e9ad871613147fc86", "adminx/modules/Security/assets/security.css": "fd8c477d07c38450587153402380040b72fa9a92c60edd532c4b21046cfe375a", "adminx/modules/Security/assets/security.js": "f270dd0b910b9aa4815680b8d80d66ffb31bc7862f74e628cc60efba4205a99b", @@ -493,7 +536,7 @@ "adminx/modules/Settings/Constants.php": "03c0af9ffe9c17fe879974c49396567ac0584f2474101715baf6ada847ac3ff4", "adminx/modules/Settings/Controller.php": "90aff255d936753ab763ec534a097e9988f6c737bd5be2ba1194a7700a372e72", "adminx/modules/Settings/Model.php": "9e16004c78a03d39db1bdfd5e23b88b77e8afc7b8f57a2a21997602ace277440", - "adminx/modules/Settings/ProductionDiagnostics.php": "41430528b6387ad2df815a3834f3032c96e29a71d099ce68a846dbe942d4acc7", + "adminx/modules/Settings/ProductionDiagnostics.php": "b23d473a5f6e7c231c68abe4d48159b177699b5f0c23020e96252b240f86c87d", "adminx/modules/Settings/Schema.php": "7367374ace3e25fd86e5a829ebedb0358ba38bf5be0cfab9b859f1e5d3cf0525", "adminx/modules/Settings/assets/settings.css": "c8207dc25cdad75f5d2b811a4f8b4444dc48df88d9a0982e9d442f810497bacf", "adminx/modules/Settings/assets/settings.js": "4254d8985d73ff4646799eefdb54deae1f094c2373531cfc22d6692e48b9d1a6", @@ -509,15 +552,16 @@ "adminx/modules/Settings/migrations/004_seed_core_display_defaults.sql": "31aab8dd3cfd944bc406343f580ed753470191e7918516c9e7c2b9a8381ba7b9", "adminx/modules/Settings/migrations/005_order_system_navigation.php": "df82c4966f28ff3b7d9cf184013477c60a4ae635345f21809bd58b94310e8957", "adminx/modules/Settings/migrations/006_create_admin_saved_views.sql": "69cf1cc085a31b2310f82fe55ad6e0abbb6f14707715205e5a53f06334759a99", - "adminx/modules/Settings/module.php": "a754703ca23db15f500d3edaaa912abeb2097c32bf8615b31605f6018c952199", + "adminx/modules/Settings/migrations/007_public_site_url.sql": "7aab89b5e7ea5f68b519322341c90d42b424b7f994d706f949aa326e571f6cbe", + "adminx/modules/Settings/module.php": "b5de078a2dd6e0b766ac0d5456f7fa60ded80741c29053e6689ee3e48b539593", "adminx/modules/Settings/view/index.twig": "7d64c286f8e580ae51b1529253fd816cdcabf8122a564fcc428ced98bd7b14d6", "adminx/modules/Templates/Controller.php": "00b1758860293b56b052a8c41f7174d0d97ba1d78e012b80f8e9f29194eb77d4", "adminx/modules/Templates/Model.php": "4f7a9082f462170533c0c140ef085f6a33111d20061b464864eb86066d50289f", - "adminx/modules/Templates/Revisions.php": "9ed6496fb8e28474028944cccf0f34af98f816374bcb54075c8222fdc9a8864c", + "adminx/modules/Templates/Revisions.php": "54522d2b5a05564637902e48dcdff99a51d858b22b159c109a125f2f6c746430", "adminx/modules/Templates/Syntax.php": "a0172c03a4e091f5fcaafde39713189bc341c1cf7ac9ae1eed50c5cec657bd24", "adminx/modules/Templates/TagRegistry.php": "85344896cf21767c67b19092830e1ee9545b21067f1e43140efe2faf6eca2731", "adminx/modules/Templates/assets/templates.css": "fca6f4e7a5deffdc99d4fedff515a59b8e76c8289961971947e10ea384e9221d", - "adminx/modules/Templates/assets/templates.js": "5e5ba6534ee1752d7b23154d44626358a83ba209461b3c0623b8f07efc20fbd6", + "adminx/modules/Templates/assets/templates.js": "0d648c0c6a1dfff93e6c7d3a7d1303ed6b3229ad617ef7a20f684d3b8aefd408", "adminx/modules/Templates/language/en/client.xml": "d7280440a3c00674ae2db6a2c837a8114fbfd4fa27590b7e0ddd72395fb08887", "adminx/modules/Templates/language/en/interface.xml": "50d541fc8b9af282413030476e7677ff2fb07ae20168311aa130d37e2b9670f7", "adminx/modules/Templates/language/en/runtime.xml": "4d9c9214729aadcacb7e062417b8befb2965f3184ca39334e2aef7fd90fb47b5", @@ -530,9 +574,11 @@ "adminx/modules/Templates/view/index.twig": "7c53cd26dd6bd6fd4af757ed53e239c0ba21578c75aa6cbc575ffb5d0c6db454", "adminx/modules/Themes/Controller.php": "85af532e079d799c0bad87921b99587c946fe92ec073b4016eea650e5c4ef12d", "adminx/modules/Themes/Model.php": "e291c6d4bbd964e7086180e7329df2867e2748c8f007596a95c88a954159e320", - "adminx/modules/Themes/Revisions.php": "04ee9814b4f0f329ad9c1983333f5f923343bd0179d68897e21102dfea97c9df", + "adminx/modules/Themes/Revisions.php": "f3927eadf841873b7a64976e28f2752b6d1be839327922720a0cd33e30d0884d", "adminx/modules/Themes/assets/themes.css": "c54dd5451693f5117a22c9b7d7a742847e6c6d97a7700d38dd28111337392d38", - "adminx/modules/Themes/assets/themes.js": "6c1b2346f475f97299b8657ca3d867293680c40df929ca467c4c30dc81d76c0e", + "adminx/modules/Themes/assets/themes.js": "1ab50ed9c1ff67ad4e4b8b295c245ad5f040c7b11bab102d007953ba1f79042e", + "adminx/modules/Themes/assets/view-overrides.css": "62e6d1a90c8819f50318240e93941054aaeb1b15442d3cebb954a4e839075699", + "adminx/modules/Themes/assets/view-overrides.js": "e531167445281fe7beb93110d6e089ab258a4d110108c3fd44fd4ff981dc7c6d", "adminx/modules/Themes/language/en/client.xml": "5f9d0a1ca7320e8590468e626433fff92b55cb9ca38d8723d4a363feb77dc3b5", "adminx/modules/Themes/language/en/interface.xml": "30365f6c02c048fa2d0e94feb82bda45bdf8cc9fa665a7943593bb60636f368a", "adminx/modules/Themes/language/en/runtime.xml": "66f4efbea3acf5be49cda9931841873e7c8bd68e263ef7a247233b4d762e9887", @@ -541,9 +587,11 @@ "adminx/modules/Themes/language/ru/runtime.xml": "f8046256e75de65f6f20c8d5b5bd6a927653ca4dc8b6b8f205756b255b624c51", "adminx/modules/Themes/migrations/001_create_theme_asset_revisions.sql": "21a2ce27d0b97be6493cdb86bffbfb0cb8b16762cc7b0025d83bec71d56a760c", "adminx/modules/Themes/migrations/002_place_theme_navigation.php": "62a2f2953f3d5bb8df7e39c83a1de83a56008952540c8c28c6a12f33a8fd7f95", - "adminx/modules/Themes/module.php": "d5fce96f5841882714e8f540369568bc3c1676313d1a2ad659b3efa96c71189c", + "adminx/modules/Themes/module.php": "7f961194564ff6b2d7a6c450fad2f966118d71589a6e2d9f1f183d4726f35c7b", "adminx/modules/Themes/view/index.twig": "68a06ac9ee43b0052c877e89c411a1d149f212418def78e44a1f4a73d80344a9", + "adminx/modules/Themes/view/view-overrides.twig": "876f8f47ff598962614198fd89e5f112f04294215d1ffbe5f6ea3bb294af60ea", "adminx/modules/Updates/Controller.php": "1aef3e6cec043e4e66ab862b4f0c7081d452bc8a1295fc125860eb0b02fa89ce", + "adminx/modules/Updates/NotificationProvider.php": "19ae6ba3da35213683a2287c8523a3b966daf7ab432d9c94f9364a59ea9b379d", "adminx/modules/Updates/assets/updates.css": "53cc7af1afac5910ffd0c11be847215fa53ff95b9a1efb140e5a5f3f6c48d9ec", "adminx/modules/Updates/assets/updates.js": "c3788da4b31718e297fefcc47e740f20be10342f3c637aa0a7c4678ab1bcd920", "adminx/modules/Updates/language/en/client.xml": "d1f09120fed6c918cbbd4df166627bc44579d530f61c77c753cf147582ce3399", @@ -552,22 +600,25 @@ "adminx/modules/Updates/language/ru/client.xml": "7dc664bbf759fe501c96a145dd6a052f2dd5541890a8aeb1d533fee378cde0f3", "adminx/modules/Updates/language/ru/interface.xml": "1d8a827ac996bf7da2d15f75b52d2205f0f36105cd73b50cebbbf1e99ea98bc7", "adminx/modules/Updates/language/ru/runtime.xml": "45d622ea8c6f772d5f53690bd7c08beb55efcbd51c7308f0336c995885b68258", - "adminx/modules/Updates/module.php": "c26612bb4383e4433543a9e822bc18360b57815af67875b29434bfdc8e263dd2", + "adminx/modules/Updates/module.php": "13dcee85d0558141f4305c2b0dd37401706dc4cbf193564153a0eaecf5dbc681", "adminx/modules/Updates/view/index.twig": "8d975051ca96fd99902714bd60b6a85131d6304578ea65a3dbf732523b638ece", - "adminx/modules/Users/Controller.php": "ed5728b5794d250fcbec2e2bc83bf7812a4279e028949b2373ed83f7873d72d3", - "adminx/modules/Users/Model.php": "7e30cd6dacb7ef7b7f619f0e888d84cc8ffa470b6e76cac8cb6049aa13d89326", - "adminx/modules/Users/assets/users.css": "19b53f5a235b79b5ff34576692ace47cab69e85586958f763118f59a07b2920d", - "adminx/modules/Users/assets/users.js": "a9d7bd3b5b2e44988ac1c5d270d44f896b7cf1796608961caa0d8129508d11bb", - "adminx/modules/Users/language/en/client.xml": "d1d17febaf2fbc74243f0e799bae71b32c656ceed81da7fc62f13dbf73ca7b4e", - "adminx/modules/Users/language/en/interface.xml": "28db6106985d83555542a171a8a59b06cfc0797251ad9a533fd4796be5eb4c56", - "adminx/modules/Users/language/en/runtime.xml": "b68f556143021c97fd369813ee0fcdfe66af5febcc74ed757b53605b2b797b10", - "adminx/modules/Users/language/ru/client.xml": "69d389f961f82692717c4bfdb6057700c23f09e226410108053ec9f1574295f9", - "adminx/modules/Users/language/ru/interface.xml": "3129fdc122cf197452954b328192ef8a042b085b540f572de86fc5b239e7e462", - "adminx/modules/Users/language/ru/runtime.xml": "00c74dd13ee0fb0a578f0465ebd74a9c2b87d43399ee0e22ddbc911df2043b6c", + "adminx/modules/Users/Controller.php": "dd8e11ec359064b37437057f0e1295dd1f20239186d5726119ee0764bce2fd10", + "adminx/modules/Users/Model.php": "afd334340bbbeed9ac638421bc490262a21173007734740d2724d4ddc8cace63", + "adminx/modules/Users/NotificationProvider.php": "460ab91a010e473937318542903a30053e08303b512b94ffe43559a85da8c57d", + "adminx/modules/Users/assets/users.css": "1322f3db7bbfa13e548a8c019ac6580f947de5786c5f460435db2561dc7b1f28", + "adminx/modules/Users/assets/users.js": "d7d2cdeda4a0bf569ef14eae2bdbb6feb593ea65951382cc2208052518fafd10", + "adminx/modules/Users/language/en/client.xml": "a84c325dcf979712bf3eb5cfe1cae2f2d4a9bf698702d003bfeb66954eaee159", + "adminx/modules/Users/language/en/interface.xml": "4434abf4230c2a616004dbfcfd4d79f43ccc60f32030842d0eade5bc3bfc28eb", + "adminx/modules/Users/language/en/runtime.xml": "2f4b8e5ae2ed591f03bc5d10e4e305b2404dee89f75ee4480f0daead10cc58f5", + "adminx/modules/Users/language/ru/client.xml": "64054dfdd9c2bd70084b283e776597c86b1899a603fb5a07935832ff5960a3ba", + "adminx/modules/Users/language/ru/interface.xml": "9df72a9413ddbde9513a6ad9b5d04a0bd68a828fc328197bdd180a5a77d4b274", + "adminx/modules/Users/language/ru/runtime.xml": "5bb9db970f2e2da5849e20272d700ec4c70de65443ab4bf10b960aea79195842", "adminx/modules/Users/migrations/001_normalize_public_session_activity.sql": "e72f1cacea2cff5f74f7baebe3cb1f154b87fd7368541b1a3a5d2300a0deae86", "adminx/modules/Users/migrations/002_harden_public_remember_tokens.php": "85c92acd3ed962dfc4bf85b78e07de87569b1f0aacb2c616287c95bb39fbee2a", - "adminx/modules/Users/module.php": "81c13d81fd73878aaa372590b0ed5f2a8c19ec59d409c5199a304271b1011746", - "adminx/modules/Users/view/list.twig": "b64d5ee4c2e680a3d49dfbd2efe17c7da9c057746fd3b0884b4bb536cacc363e", + "adminx/modules/Users/migrations/003_system_user_security.php": "6e2cd00d63dd7c050c0a578fde2031764d52a697a70b711ddddad1e32dbad917", + "adminx/modules/Users/migrations/004_system_session_expiry.php": "00a1012683681fc5fbdbf3cc6258001ab2c6377f445f7b616fd1c513d2b30bcc", + "adminx/modules/Users/module.php": "c952fe3182dc176870aa155f5848089c21f4026146bab53efac0902d2de7730d", + "adminx/modules/Users/view/list.twig": "845e0b0c8a04891096a2352b1a683fbd4614333017beea413434793173749efa", "adminx/view/404.twig": "86f93ca1cb44b0c225a03a33704b903f8a96e1697a05d40b0b71dedb8f665769", "adminx/view/_pagination.twig": "9d5e761594ae97d7cb7b518592028adfd2041b5d99f736b24a318b2766e350dd", "adminx/view/_saved_views.twig": "51217212a3b0ef7437de4dd55a07fdf55aaa3138155b00f65fd85af686412da1", @@ -576,38 +627,41 @@ "adminx/view/main.twig": "4aa00f5a2c50e3cc4d339c0ad9377526db6e8a65575843be59dfd20df5210344", "configs/db.config.example.php": "e42bdb47aa7d1bc9cd85cb36e05acf940cafd951d6220664c485d1b971f18809", "configs/index.php": "48e6b249e3d83530187c095ede0198af498c59f384bd458db74f9e0c2306eba9", - "configs/public.config.php": "7047f7acfb9f64e56c7320c3cdc306522a187f5f9e12cef6e95937446ee07441", + "configs/public.config.php": "54ad564eeb68979b3040fc3cddda8e9b5607c016fd91a045bdb2ef3d0a1afa03", "help/README.md": "d12965e7851dec5241dd74db731c110a7e0f4acfd7139ae28ce21d53193b1123", - "help/administration/README.md": "b07c99046a52b6b30cf8e50ee00a33137087771ca486b8c4e365a8844096850b", + "help/administration/README.md": "af3efa4bcd54eb23236735d461484e13b6319751904ca8856412b931e2ae032d", "help/administration/console.md": "5efb2adbee857eaf0b8c2816ef1556addb2c9b21420bf972c6bdf62911fa9ad4", "help/administration/content-and-system-tools.md": "55747a150dcf00cb3682d30b1edcf054201eb051d0e25274513ad41716a9f392", + "help/administration/customer-center.md": "6d35e85b1cc8e44db1e2866fc0c908390745d32b77120b1ae6fe5cd710ef7eab", "help/administration/dashboard.md": "98a93834bb648b4b94fead19d31c371d988013028ece38caf9d4b8d455628e93", "help/administration/operations.md": "4c585d6c3236f11656000332ff99c066187eb36cd860852c7fc857020487c05a", - "help/administration/search-and-activity.md": "f0f470ab432ff344b176699393574acc2c9a9d153b2d71986db9201bea374482", - "help/administration/settings.md": "89ff256f830a827929847d0d30662e1af0a2cefcf7bff214d6875d7c14a164da", + "help/administration/permission-simulator.md": "f5f7d59c121833068e75fd202efc60def6e5837d86172ab5ab07233fbd0a5bd9", + "help/administration/search-and-activity.md": "24bd0d317f5e26e3f152e0a452f8ad37b0fca148c0b10a1357ba000a3373d38f", + "help/administration/settings.md": "e1ff48e80116a51b928ca4348a57efcbd939f15553e9ceb5c2ea833296e7601d", "help/administration/updates.md": "1fe89c7ab6a1cac896bcbf447e130acc3792c7c93b3d2b063160dda32e1b9ca8", - "help/administration/users.md": "4f035c98f136e7a3b794d3634c2d22809bfb07b3be3f8ddf3d403ae07d96c14e", + "help/administration/users.md": "1df60ef3d2dba8d53e48ec9d759e1d17236893ea36a5c86ef91063cbc6a4a026", "help/api/README.md": "13f2b6663760feb1660dd3ba5d2b0b2c3528d00540c45efa040a54494fb511a6", "help/api/authentication.md": "71f289056bbcd688c2ed19c1af43e4b233db92d76760b95ac9e63efddea99c08", "help/api/documents.md": "717101e0fedee8ddd987d499a9ceffb19b4d24e0a04481aef72cc6330e88fa43", "help/api/errors.md": "630270fb28d5d385e368f3b0cc1c5cc2acb3c56607bb8c9af62c2e62a6d09993", - "help/content/README.md": "3278c036ddadf187cf1fb0648682f6a1acefdf5d1d95c4960692a35fefe8cf99", + "help/content/README.md": "273451d12b6360df56cc58323bfb8fd7521b4e3daf8f7ad93c874fe6877af048", "help/content/blocks.md": "1d55be2a9f1a48df426e5856a54307d77b352d0037d68f08ed7e16a322d9ef8c", - "help/content/catalog.md": "5ccfe015ac45bed261bdae480a87704142b41c772c7bb8c425363a1dc06fc6d9", + "help/content/bulk-editor.md": "821107322b125c9344bd04346037fbd19f8ccd0ad6da822fe78af3299d01f746", + "help/content/catalog.md": "2e278644df3a40d05fad7b2a753a90398667b4bb6cbd88dcee8177e3a930b3b8", "help/content/content-model-tools.md": "baaf738aec454b1855ea110b8f5e1ef3ceaaad51cb920d4551cd088daeab7e76", "help/content/content-studio.md": "91ef485e614a9a71a07d4e6c143377f7052ddf3b00d3f2f0d0a32258bdb14dbe", "help/content/directories.md": "ff6c5ea9bd7fe1fb9d0aa1875d03fc3bc00b2bd2b0ec83d6496cd55168db294c", - "help/content/documents.md": "e5d7f51af5488cf3363326697bc5dec56f377b45963fcd6a029a354871661afd", + "help/content/documents.md": "273851452c0fb8fddd47019fea600da187025dcca271bdd91d59acae15d6b4c5", "help/content/navigation.md": "a02d0f88b5a3aa67fd14a2c26a48c9c3c4bb73f4824fa453c20f3e2ef4ef24b8", "help/content/presentations.md": "6e349bb4d1d1577617e6bf3d987b86ca6b255f64a47cd1ddb0e38c5242b788cb", - "help/content/public-site.md": "3abb9bc9c78059d22e8a75cc4d01220c0b9558da4a1b2c8b342bc26c8e353d04", + "help/content/public-site.md": "ea0edea4b3afda64987e5643397237fab227f5fdfa9d9551b73b93b32558f818", "help/content/requests.md": "c008b1026520c85400fb9240cb117fcaf384531c06e859c1e36ccbb3a79a8590", "help/content/rubrics.md": "2c9842ce5647fc85663b02cef14f141c673694f0a20856c9262b6ccbea436c3c", "help/content/templates.md": "c6fae2ef91c741479d1f34fe66d00e83f2d2ec1aba1a4099773a04a6e1b6552c", "help/content/themes.md": "f522107e2ee27590a40d726f26538a3101925d0d0173fa1e58422089b5418388", "help/content/twig-components.md": "15e40ce305f4416eff6ede5168be32c033f712776484fb6c72fead9a0d7d70e1", "help/core/README.md": "c8c42e4915e049e2821cbb548a496bd4caaa9c7921a205db5d7ddaa9445ace42", - "help/core/auth.md": "da714664fb7848676229a9695d49c811786165a79de7fda4b7ff0156a52eff6a", + "help/core/auth.md": "bc987c47877223d5b1d97a9fc35e28113ce8b3e9e61e2b0f5ca2c623f4f0371a", "help/core/bootstrap.md": "72b6759b1066e2906dc855dac13880e0227c1d70f751f81365c6afae2cf919f7", "help/core/controllers.md": "6767a22647bd06b7af5bfee2a9268bce95d67baaf3f4a828b88233def1e4c665", "help/core/permissions.md": "3147cfd90fd1139961822505e611c444e54bea4ab66b86f1c0da121301fcc57a", @@ -625,35 +679,38 @@ "help/debug/README.md": "41b7954c443330efe28cd3a14519c7c8076165a9d96c0dc3d3781a111e757085", "help/debug/custom-data.md": "445ed27dbda5b0ca035504e2f0cda023746def5edd3aa1f62189e133946e293b", "help/en/README.md": "cdf84634ac731f1f434ec496b171307c38fccd6e6df335dd4dbf074e3e2cc12e", - "help/en/administration/README.md": "5482951c74022a90b44f70bf8c47ed4499b95eeabd79bd99edf33f89cfa06805", + "help/en/administration/README.md": "478a59925324a3dfb3da193b8e83b9a45e38fe77cf41644efbc35cb0b65e0298", "help/en/administration/console.md": "0938f6598c01381d79235a6b524d34f0f9a35fa37a35e7042baf04cf4f4c195e", + "help/en/administration/customer-center.md": "a252969dde9cb67d26e21a998cf4bddb128da66f518742e856d9c02ef62efc81", "help/en/administration/dashboard.md": "1945cb5191944f41a88e5a59eb91f1baef550f9a1d751817b08be5112d23d122", "help/en/administration/operations.md": "cea8833c931040aa0d00ba0a181270cf95b6ca95f199b6c250c0c8b839b86b97", - "help/en/administration/search-and-activity.md": "65f3df81100e462eb16f17a4d07c8106156a99166cc4bfe18fea2e078318a669", - "help/en/administration/settings.md": "68586a8a8a397d9b5f76d52867f5a1ea0247ee47087faec22802b2f8c4c2c755", + "help/en/administration/permission-simulator.md": "2299db13d8242f69ac5a205acf4a859fabf3a4e96d46152f9f384c86070aa8e4", + "help/en/administration/search-and-activity.md": "91843360a78326531e329c140dd58405ca901428ae9ae618e44e9418db838a20", + "help/en/administration/settings.md": "157d88a8af755ddc0a27ffad3556eed7dfe4caa9af7f3f9d853d057c611aa1d4", "help/en/administration/updates.md": "a0ec3d0929602c59935836b5e03e35679b86058bc482dcb37475f892262401be", - "help/en/administration/users.md": "018541f3fae298feb40f0c162bf813b2ef0ee4e782c7067dc5c4efb207177db4", + "help/en/administration/users.md": "e9ef5d433e6c8dde5540826ffa14f36588bc703c30f8af14035b28bc89795eef", "help/en/api/README.md": "660c017b0480b988a5e9a31a3e2b8a412999364bd5d901a9684acf3cf1a1d2bb", "help/en/api/authentication.md": "23ef6699d3082373dfff77253a79ac5179c0088d3f69e1d1145c8cc7e05efa25", "help/en/api/documents.md": "266d2cf298bfffd62a02289b97bc01be508d6dcb71d0a8b0b3bf840afa5307c1", "help/en/api/errors.md": "4c8ca63333fa082aa56cff33c1ad3659bc8030524e490a2f43d122ac4ef68309", - "help/en/content/README.md": "b88e329102be722abbf1b8a9e99d90ad960e79ec73fe03b1854276c92d5fc094", + "help/en/content/README.md": "7c871512fea7f1bf635d5a816e322161dedbd8c158d520cd0385467e8dadd1ff", "help/en/content/blocks.md": "34657f0a7ab977bf78d5b7645bf80a0755f40830bab4d4620ca7f39ea5992210", - "help/en/content/catalog.md": "d4139c69df31e32b4c37308f96d539020d989f952f75b9f601565303cf2aee48", + "help/en/content/bulk-editor.md": "c99d5eb5058d6714f0281b84bfdf9020fc63ef27519c095af5f92fa2d5e96b44", + "help/en/content/catalog.md": "99a6e5c3e17ac0c7f2e04cbde4f790334f687b3aebed5815be5006ab361e530a", "help/en/content/content-model-tools.md": "fc50f8c334a129d3ead4a58503a7659abc2b1fdea7c9d277feff1e3ba4bb7b6b", "help/en/content/content-studio.md": "194fe41a1eefdf7da3c9a47b8539ed3ed9d785d2a75816a39da6d2784ec40925", "help/en/content/directories.md": "cfadf2361ead3bed9b63435041af8c91e24ec8b492906f0d9b976047edc123fb", - "help/en/content/documents.md": "369712175c276d241ab5545ff35c2df56e62d0445c4506ff6b7610d9def035b0", + "help/en/content/documents.md": "2831a54d7c9e02b89231c48c406475dff64721e48ce76d3bd92b768043422333", "help/en/content/navigation.md": "18f6e033c5c01eecbd7a3bc4b7c557f272f0039c811ea6f4ec2354206b0a5f21", "help/en/content/presentations.md": "f255470ae47453d2e458b1829316c0e1e8e7fae73c9a1c1190d89f522b78d871", - "help/en/content/public-site.md": "ab639977029d10f8a88679b524c771ad6437c49e4e061b8f95a4ade0188efcc4", + "help/en/content/public-site.md": "619cf5d38d49f6dc1c54836d4e27853da03a82f3404b69ce7e0c395a3222ec45", "help/en/content/requests.md": "71aea0d1fa6dc9b6f6dd41f1c7a4d80af93c0b5e18341dc26f59d1f0c156604b", "help/en/content/rubrics.md": "bd69370ac19ab0aaf6d4c1cba615ae21f57cc3e241ecb6706f23a69a794fb3c0", "help/en/content/templates.md": "f91c141dfee841a1af8321625788cecb9dabaef75901e188127815b6d5b5ce6b", "help/en/content/themes.md": "b502abddb9114cc6cfb990e75c34fbf970c4e9c95940f27bddbd5fec80747b0c", "help/en/content/twig-components.md": "5fee2a73c795f96168ae8bcccbc4dabcad3c82a0adce69a2e2add878dafa033a", "help/en/core/README.md": "ce0c4d0757cec5786cd79fdeb8a839250a947d87e2b5a5905f7a8010db78df6e", - "help/en/core/auth.md": "1fc1e444627669e1c2d0b5b15e363bd69dbfd18085783695229015213a9a0cc5", + "help/en/core/auth.md": "4317b5a6cea85bd347275609ff082f1a3fe6ade060b8c5d251db29c09c18ea8a", "help/en/core/bootstrap.md": "e2c7b022ccf6bf9dd2e888d3cde42bea82d36be382d8d0cbd508d3facee43f7b", "help/en/core/controllers.md": "968107531f9bd1a714a62d9f390704f29136fb531f1b72a36eaa72cb0cb76952", "help/en/core/permissions.md": "6e6f1d5d1c20c85f6e3d71aa3f4a7de8e822ca08358aae38981bc7f7082b13ec", @@ -673,7 +730,7 @@ "help/en/fields/README.md": "46c4fb2b44f1fef2f25ed2be3c730dd1d4e1aa0f48950e6069c3772acdcc5b17", "help/en/fields/development.md": "4a953779ec8f08a79e235bd658d58ebc69f5758e2d8c55d49d5bc074e5aa0a41", "help/en/fields/operations.md": "7f3e2e991df10e5bc8bee9bc7251b9bd96e5137aaffc5142401f80afadd752aa", - "help/en/fields/settings.md": "577d87c57c76fd959e8eeb6a9e25060ff82d43846eecc5a02f95a700872743d5", + "help/en/fields/settings.md": "c319d6fd35f50658a901a498ee13fba3ba83af774bb4e9ece0a8c1abda0ce233", "help/en/fields/storage.md": "2e8d0faf13e79f508e6f91e78c3b988811d3c39379c1fa79de441e06a44ff64d", "help/en/fields/templates.md": "634dfc6e4f14b3beb5c84cdfaf5d2371535c51dd464638474ae5b67a4dba0f8a", "help/en/fields/types.md": "db8451912c4910fe0bc416cdfcf23f16a8978dbfe83f481f887e2fd3f3832bb0", @@ -712,22 +769,25 @@ "help/en/hooks/lifecycle.md": "ec82cd9ccb2bc48e9054ec1876c40552859da4ead45129f920026f821f84e87f", "help/en/installation/README.md": "a122a89fc4a910064f02fc906a0fdf3a9098147f9ed2f98a75185ed66d190f29", "help/en/installation/admin-directory.md": "26a7b2198078f8ea11dba7fa6a0699cf35c66e78fc88279c82012e6a01f59a38", - "help/en/media/README.md": "cb9abeaed976e18cd7b294f7c078b7cfbda339e0022853d3656c7a29b7fed13e", - "help/en/modules/README.md": "52a6ba0fc3d163c4912b28021b272977f47a3f0120a906f8e8c614f143950a47", + "help/en/media/README.md": "1f346b26bd6cb0f7238204ed0cb1d56cea9f6bac7f96e06d062fc8d01226d702", + "help/en/media/analyzer.md": "d49175369396da1cc3d50ec4cbc7684f36971021532c648375999e37f90a4f20", + "help/en/modules/README.md": "fac8da620ec1f087bef3c7cc3a4178d95747ccece3524ee6c53ba6c3a65b4a21", "help/en/modules/antispam.md": "1808f910d530f7068e60e2a9306c15ae5b69cccc30ee3e492f35cd535004f65d", "help/en/modules/banners.md": "b6046d0094b0554c31b56dd15ea299e247f5f3dc13c8ca8fce5714bb6c3193cd", "help/en/modules/benchmark.md": "e7b9d233abd386db41413f45c79639a3bebfc37241bdbab461028e87a5615f87", - "help/en/modules/cache-warmup.md": "9404771e435f1130fa6812ba7c444e21933f1e47487ba7f1e96e8992db1d224a", - "help/en/modules/comments.md": "854896934b2d93d6527f9ede94e85b5e9aecbc282fdaeb9572f810c7dd773dba", - "help/en/modules/commerce.md": "38ae79ce7675efae05a55c9425a1f61ad966556cc8cbccf3355087d0a9d6e96b", + "help/en/modules/cache-warmup.md": "d92183948e03dd4f9a18308d36df7d5ba57e7e87f268b5f3f1de38ed99c518dc", + "help/en/modules/comments.md": "55f45f748f32fbd36233c43574e212f3fa3fdd1ce8eb819f9d1cafb53215ed97", + "help/en/modules/commerce.md": "d916b293c7bfb3567fee02f5d9f15de0d5329dde60d5c5d43f24bce660860156", "help/en/modules/commerceml.md": "610f6b2f9abbeeac604da0408192ae52dae7d6f3fb09e0d321e14d1cc190b808", "help/en/modules/contacts.md": "3d4d7b109a7a7bf20ceda0cb0da65634131a837c7d39c4a1433f317c3bad3512", "help/en/modules/content-packages.md": "4b89df6d05fa75e484e38e404d53fa7ecc6b61aa6930fbf74cf9b6611bfbf532", "help/en/modules/contributions.md": "ec283e61f09d6008a7208fced495a2748685c50bbfe22b384f458cf338e63125", + "help/en/modules/crossposting.md": "7b8fc0b89c90e27c8e90431221cfbf7eca4c3cd2b5495a0c8ed5d76906dc1daa", "help/en/modules/dashboard-widgets.md": "363ebd63b5d28c23f4be2e3cded9e690db78cb0b79c459217eced1151e2b6a8f", "help/en/modules/demo-site.md": "d8ccb9d14f664b905c89d81fd046116d1b6fb45f2c0105ff2876d5c19b16f57e", "help/en/modules/document-import.md": "cf0726b5cf95b6549ae6732ea37ce127ef3a1fe3b70109d83d74f5f48e4cf0f7", - "help/en/modules/experiments.md": "226ab03a0bc93dcb1bfbb0ca8df6df7caabf82c949fc653b72e9fff7cd812c5b", + "help/en/modules/environment-comparison.md": "6922835a63bbddbde19d12058082a11dd4a4d37fecb05532178935386ba3fa01", + "help/en/modules/experiments.md": "dc22f38fa9803460ba0db1ff930d85a18a4ac634c173fd99136970bf0363701e", "help/en/modules/faq.md": "647e30e267ac260742a20cb264ae9882c907fb056068a4339c88795792c17344", "help/en/modules/file-security.md": "48f6c59bb95a8065163aceaad99471128e9be5bcb5f2d96512ad64223e580067", "help/en/modules/files.md": "91a51a8b16b0b89ab97ce110eb1ab1882b4319b40412ec3941521942b1a7621c", @@ -745,10 +805,12 @@ "help/en/modules/oauth-providers.md": "9bde340e64c98b9100d322db465a1180b5aecc60ff7222bf1a0bfb4f1daec015", "help/en/modules/operations.md": "56c39f40464f75aefeb58d185082fe8d351ed77aae7b7eaf2800ac698095930b", "help/en/modules/packages.md": "4977df2fc7c614f79df9d9c4aaf23d48cc74dea0463fe3a66b123e80de7bb277", - "help/en/modules/personal-tools.md": "90967b72fc40e8e98b6088c098c1ab362d057d802daa3a1635f7587de5cc7971", + "help/en/modules/personal-tools.md": "ba5bac493e1d561c8b035c87f85b6799d44392527b9e37e9bb6001de7c4a6512", "help/en/modules/polls.md": "a8f86b04024cfcf490e9ee2f5dc2e4293ec088cdcc48dce11c074b02310c1180", - "help/en/modules/popups.md": "46a2d6171349d186cfd18af984c7a65dc1184f62406a700767233f3176e789ea", - "help/en/modules/ratings.md": "37fdf968a33dab9224ede4f71bf093a8d9121613574961341fc0e436a2bd321a", + "help/en/modules/popups.md": "c261b0cc6ad92dacca72e240676701e531a32a75f65f3399c3a7acc0a27a98f2", + "help/en/modules/public-widgets.md": "ff19a83e4914d067d163ed8f40ad38c40e935ed4b7c64a64aad51b3d66736ce5", + "help/en/modules/quiz.md": "7d869a92c4d9b05fc3cbec652ac95ed8c9aa7f84f307becb3b1a582293295145", + "help/en/modules/ratings.md": "bbe6a3ed5ec767ff6f1224e48fab1cae3e9a0ec1c3e55543a6fd56385d26da7b", "help/en/modules/related.md": "863982d9102efb874dbd362107bee773780b7c319a78b544a79008dd74fc64a3", "help/en/modules/reliable-events.md": "7069a39f950f0e5688b09633c840e3be73b0b4c244b89115016e357aeac393c8", "help/en/modules/repository.md": "1f835de827a64b04d6c00a18c060fbd6c414a7367daf8f10e9b6c6106dcaacb2", @@ -756,10 +818,10 @@ "help/en/modules/rss.md": "22958cb500ddfcd83201086b36e43365d51f9f125b8663ec38e2e30eded4d0ae", "help/en/modules/scheduler.md": "fc0c34af054e0081c6ad8cb1abddcff014c0a6eb77178c111fb0b15e0585c75f", "help/en/modules/search-analytics.md": "3e21c935d76171a5a74cba9fc2904092d2d30062918c2f7ff1988aae0e499264", - "help/en/modules/search.md": "973ff0a67f7ccece663d74c2a92c177869ac3fdd15484aa8c2d602c4fc5b039b", + "help/en/modules/search.md": "6bd582bc687436d99d292655ab4f9c3e3726c53a5dcc7d9dd94744545d22daf0", "help/en/modules/seo-audit.md": "e18adcb79847fb5a1cedc8cfff03b5938eac1c2e2d22816ecc5e5b02b48b95d3", "help/en/modules/site-readiness.md": "dbccccf0633f0e72d32cc8a2b0de319dea576f28b67f0d9c2907106c97fef1c1", - "help/en/modules/smsc-auth.md": "789c352d0d9efa5d1c4d88f88b508d925b08518704326613ce35cadd1bb87fb1", + "help/en/modules/smsc-auth.md": "d66bdd5cb748db83b565e7cbc8150bd38099de778e94c9c4cb0ff1d065703479", "help/en/modules/system-health.md": "f43c3745ee1437f392724d35299d208dd73ff344a3efff60273cc7b23c6fa731", "help/en/modules/traffic-analytics.md": "5ed8a48a4d05350d8dced3db8fadcedbf32a2bf80147da39872a0f591ca8ce84", "help/en/modules/tutorial.md": "2e6bcf3e7de1799fadddbe5b5dd56de613c91baf2a4d6c45232eb2144279780c", @@ -767,7 +829,7 @@ "help/fields/README.md": "01d359b4ebeac9401be857443c4c16aee73933cd7d6307e0a25c2e3db6f98d5a", "help/fields/development.md": "4b3de5adc867d34dab7d3a658eefd4deb0622b9c62f1a4fa5a6b0f24ecb264cc", "help/fields/operations.md": "658f5ba5ff96cc99192d5486275d2ce9f98555cd8c8d43aed9f8f08a14bf5ce1", - "help/fields/settings.md": "cba0acfc0fd93818e2e13c100026bb46643d4e579964ba9ab6d50fe7675599aa", + "help/fields/settings.md": "7e1bf3424f01208608285c48b10f563718e85b51786de26507113eecbf7a81c8", "help/fields/storage.md": "502a8b0ab9c22b5e023f71f94cd0960bf00506c9cc2368ad72f54c354595c116", "help/fields/templates.md": "7538e7df6ee2e7535a106c97c6a3d980678a0f148132d530bfcada9fcad4be7b", "help/fields/types.md": "3197b168942ad3bdad5bf645cdaf4d54765f52307b6947041a7a255f5225bb60", @@ -806,22 +868,25 @@ "help/hooks/lifecycle.md": "0d73b7eea62dbc1cc5f3a07449f465105ad8a477adf85b22d2adbc53529b81d2", "help/installation/README.md": "6a9c434bbe81f493ad3b07ad83fd8c8a8739423f67f3e657b5a9a3dba0d2738a", "help/installation/admin-directory.md": "fcb31d8bad1c18dd7e5c6843dc4f7b8c4139dbceaa670a7c4e0c060479f5ef9d", - "help/media/README.md": "2693af05de4dc54b280427607dfa5824e3098944b93ba9b92f76715060b9864d", - "help/modules/README.md": "a232a6e666b9ceea49a140b1a9d896cdf9e777581c6dc6ce287967e97c0bdbcb", + "help/media/README.md": "9a8027ffa29ad342e194868890174fbdf59b5dbbbd7ef39db9f1cf79888b2070", + "help/media/analyzer.md": "a057f3e95842d8d5168f9d456be656c91dc2382c26ce9f205bb620a902132ad4", + "help/modules/README.md": "01f2d804dbc841e849e41fc07e9d86d5118adc30a3da98a39672200c2ae17d65", "help/modules/antispam.md": "ddcb858c6c80ee140addbdcd0e27ae3f7900100f1745ee006edabf52d0955fff", "help/modules/banners.md": "e8faa2a923c5f5c39a0b7caf80a511d03fb680bd2604ddc59ec0362a244ee77f", "help/modules/benchmark.md": "6c31f16fc3f083f29c65398c38b78609a3d94da168d30a93825af8ee9650eb0d", - "help/modules/cache-warmup.md": "3f3ac8252b0f808ccba046daa69fde3bbb438b35252a16fa47255f5624e0f19b", - "help/modules/comments.md": "a9b9a4ff122af3a2842b2e85d007973af935a07ce721f933ddaabe1d07e0e1a9", - "help/modules/commerce.md": "de7b8fd3c27516044060ebfd96152f428a35851f2dfee62fb4edd10ee09f2ff0", + "help/modules/cache-warmup.md": "56c05ec252b44a1a0d1c4b09efa90cf9d450b0b138787903c898f56b377b8749", + "help/modules/comments.md": "04bcdea5aa7740e3749b51dcc9fc2954e98b22298d3e4fcdcc9865d94e1d0376", + "help/modules/commerce.md": "7fac85f0cea873bc57baa79c5bc57bd4d5bdb11af291d105e01785ab84f3cedc", "help/modules/commerceml.md": "60a0a5c541b560b5a6a4ed8213d9145caace68c15be534af13f5f817eb5251ba", "help/modules/contacts.md": "8df21307a300a335aaf243cb42a107829114ac031da128cfdfc6f4b94452dd3a", "help/modules/content-packages.md": "8dde0ebc897b7891a6afadb6491f4797bef26622918addab67f0f4ebfd719678", "help/modules/contributions.md": "3213f68af10e1b6a200aa61a3aaa9dad270da7ccd675b43bf7cc1ded90b55e60", + "help/modules/crossposting.md": "d6d0922c8d48d3f35418a00227a96d9343de024ecbc8affc1712c0a223f4547d", "help/modules/dashboard-widgets.md": "db967aa0dc13d9c72631e219b6b3769ffc350211eb0ed86a467e5a3baa1462d0", "help/modules/demo-site.md": "6dcb58f90c00d921ab1ab097424ea27845079d810f026a128f3ff9ea190982cd", "help/modules/document-import.md": "579d5270328a962c0d171cfebef07a9709153c2a992ca22b8f07637dfc2b3c91", - "help/modules/experiments.md": "923c3656188192e0cfde640d20c5e0a4aa6add4840e5dc0b747583b105e14256", + "help/modules/environment-comparison.md": "ad50d177174276ad57b12df588e54da45c7da946d352786a7d025f9f17f16666", + "help/modules/experiments.md": "54401edcc2a81e6cd1bc65e0365df3f76aca153b8062a94c0fe22113ea05d8c4", "help/modules/faq.md": "ac536c5fdb5269f54f55475319d351c27993c37609769c92025a2e00be5c8c0e", "help/modules/file-security.md": "5e8fc01b5c1f8b73f117f1ecb305e2ee642db92edbbe932cfb416b16c7ff705d", "help/modules/files.md": "86305ab6e33640990d36f03ea39ba319d2984aba0bd7f300cb035ef497e94aaf", @@ -839,10 +904,12 @@ "help/modules/oauth-providers.md": "834009cf17629433a998486f29331f09b285902b9076f1afd2eaaf9ea5492f6a", "help/modules/operations.md": "07cb9879b636d2c2d54fef0774498a608d855f0328c7d536b0fa71a123abfa48", "help/modules/packages.md": "9ec909565ec66d39489cf2a89d36d31c0e67408088fe864697d2089ac21b5131", - "help/modules/personal-tools.md": "3d31c43946003ab61818d8c580a5a44baa6bbd48037de33de7c88a0d51c5aa3a", + "help/modules/personal-tools.md": "ab458ebeba45a5c5c22d70a23dcc69cada4426afc224a442867352b0893f206b", "help/modules/polls.md": "4d13eabeb2a546ff036c8095cecd3e8a6781ecc0de7446b27c4b26b152397ee8", - "help/modules/popups.md": "f95ab2440beafdce8801aebae32070e3b4d4f04148bcc9e95a0aa3231ac96411", - "help/modules/ratings.md": "e4c8da2672ccec5ce085c507919fd016ef204427ce919f1a9c2b1c0146fe7c8c", + "help/modules/popups.md": "606fade21c6a62351f6c880e636b0fcb29a12f091ee165d7fbaa430df16deae5", + "help/modules/public-widgets.md": "148e424b5fdfed849ed9f9e53d0d3e6138af67373f6e8548c4fae37782f87536", + "help/modules/quiz.md": "003b69bccacf650b3aa761a6feb86b1dd778e8d5d2356131b11a10da1879f569", + "help/modules/ratings.md": "606686cb0584e49c7c74267c1be9bf37cdd35616d3f37787d314b1a0303f7c59", "help/modules/related.md": "5500f2aa6a7c95367e439565b14037d2b73ef4218ea0a824088b3f32979c8979", "help/modules/reliable-events.md": "983074f9490bc5897acd0373e8c1c748a127ba54ea1248a4e3675caddfe7e4a5", "help/modules/repository.md": "e371ac47aaf81408242408d795df6cfdf62cbaffa232192cc4cff7dfb5e6c5ff", @@ -850,10 +917,10 @@ "help/modules/rss.md": "0f8fe5fa5bd4991b432068a5a520d4a45d41dd6a2c6011f7f293f74c8d9c1588", "help/modules/scheduler.md": "a39cf392ce9cf5af63e92063b95aea63979a24b27218a4b090b94834ef90b536", "help/modules/search-analytics.md": "0444e0dacabe408358bcdfda4b6b2a475095e8b1c0ffc1361ff58c4db0576d35", - "help/modules/search.md": "c1a130e487de27719a8171590e69fef6bf9e85136ae4d3ab862a380826c62e55", + "help/modules/search.md": "3dd37c32598c12654dd4ab97ec0c50d42ffc5083485ed37be0f135296af23afe", "help/modules/seo-audit.md": "b0fb78eda0e83c0711b7717fcc730e7bc0cb86c6d692f21aa749cc4975f0ee27", "help/modules/site-readiness.md": "2c7f845ece693ef14f23ea2b3b226f38ebfed305fc67bf842fae971f0e12255a", - "help/modules/smsc-auth.md": "57a5c3686d7267c166edc858deb7144af4d3002eb6b9d01d8f2ceea5a9877e00", + "help/modules/smsc-auth.md": "13b844d102adb451d154ee9aff9b44d8b5faaac67c4491d73c832962f9a194d2", "help/modules/system-health.md": "671d44d89f0d3ada4fcaa4ef3d8a7662a6c77dea6c0cd567d8f3e923d29e12a5", "help/modules/traffic-analytics.md": "12f966acc6ffdf73e2d40682c6db203f8dc3918ba5461a78607c1d783ed54d02", "help/modules/tutorial.md": "4239eef8d793b7bb799c901c821b229e456c7602ac694c0e3ffc5028b4eb57c8", @@ -862,26 +929,26 @@ "license.txt": "b8093fa95967d0f6024a5334bc3119c694aba6f51be2fce66be6939f68c39e93", "recovery.php": "2ecded66156c70919f9e83451ede4bbc1a5d5e5636e9362b2317ef05f733004f", "setup/.htaccess": "a25c35a118024ed8b958455ccb123a5f899ad4f1103977e53d50f94b5499c7ea", - "setup/Installer.php": "44d515370518a276696b6fed8e342bf5488720d486c27304c41ae152e7967a5a", + "setup/Installer.php": "a5e6e39f1983198b27ae32777a6b2fe4b85326e72ddeae6b50091cceade84e18", "setup/Localization.php": "e6de0faeaa493fc317907099df2ab58c76f7b643ac70ea233deaa5bcb86d79db", "setup/PackageExtractor.php": "57cb5473905992333936d7984221e5188cd72756d6e48206a42d96e8ab59442d", "setup/assets/installer.css": "79e43dc7123f37206429e8535d92c5069c53231402c67b63ea9481bbea60ab42", "setup/assets/installer.js": "892a982b872390fea21455562353408da906114b973ccbfc3a3cce06465ab1e8", - "setup/core-migrations.php": "65d30c41652f5e6a61e70c05a198c784b1062560e607bb5f158b876a5b0c4e20", - "setup/index.php": "63bff35919aa2f4cc9ac817b92020d01d5e8a8d6ec387071e0d1ece1edaedc15", + "setup/core-migrations.php": "c747a8a9289f27385310cd8189e7b618b7b78b0ddcc22c1d283425a5ee4a962b", + "setup/index.php": "cdbb99ab89035afb665d4a288c87633dc0749a75cf64a0bbf00e5ec8470fef36", "setup/language/en.php": "963dd284ee244e6d006dede8522c1118dc0909974dcd24d477fd06b7e1965fa1", "setup/language/ru.php": "8522b13c4f147804538eaa857585296db811c736e1361e6b403daecea05f34f8", "setup/recovery.php": "359ae9b79508a382bdde175824b18a5bdb0fd69ddb7776c7d18ac9eb826d127a", - "setup/schema.sql": "5c496281fe216cefb416477ebd9e3fb1d11b5ec3236b857dcda0a2bbe57a1930", + "setup/schema.sql": "f3a291d2dacebf1b1141254bd899bb522f20ff8c5c9ef5b76e04c324e0de6d73", "storage/.htaccess": "ca761a92f2b6eb47feffcf200a8fd846e7fdd8abe065008d29beff0319ca3496", "system/App/Common/AdminAssets.php": "dcd58f19fda48263b2912a10dfdc24e3f12967683649c4f614a8d54c62d51b86", "system/App/Common/AdminLocation.php": "d2729b0d627ddbdeb7f0c4b110664ed949f373946f80929c51688391c64352de", - "system/App/Common/ApiTokenRepository.php": "2d750efef3e5f8624e3f307a6a8074886d983f324d74d6455eb3ed0061bb57a4", - "system/App/Common/AuditLog.php": "2961c0545d395e42d21119e1a8beafc0c3ffb3015ee67dd20edac4d1f302082e", - "system/App/Common/Auth.php": "886b33e077babf3b8d9fc4c14bb4535ee12aed8a34799e16e4f73ab657c6cbba", + "system/App/Common/ApiTokenRepository.php": "0795e1add3723708240949b7aeada364fbeadd74366884b57e9142efcfaf617c", + "system/App/Common/AuditLog.php": "4e2dfceba55fd653d071261766c9051322a03a994034677463782ca0d09f3f87", + "system/App/Common/Auth.php": "647421cae2ec8bbf273d687ac1fda134c7bcc28db4977e01f5896c79e7c2c83d", "system/App/Common/Auth/IdentityLinker.php": "7da4d902405bb4ead2278e5bc3578cd0318422fe82318bec94257ed44d5ba98c", "system/App/Common/Auth/PublicUserNames.php": "0345d1f452e0a9accaa9b98eea71f27a428692a5b4f2ad225a10ad8bcf777684", - "system/App/Common/Auth/PublicUserProvider.php": "5661296372762ad2b3a4297d4ce76f171c58ec4343ab585b71f58b856bdad96f", + "system/App/Common/Auth/PublicUserProvider.php": "163449ae80d412a605fc60380e97107b8710d6e14d06baf042d9e63d35d75c29", "system/App/Common/Cache.php": "bb322246cbfa12cbcdd67c16a24c55715ad67b31d356b4f18091c0aa1d00eb9a", "system/App/Common/CacheKey.php": "47400cd90d1880d061995bfea585b56210e18a870607d9b1cf7f0eeaccf15032", "system/App/Common/CompiledModuleRegistry.php": "9ab055fa759f4041944bb94bfc2e28361986a09b742210981928bd6ee15f758e", @@ -890,7 +957,7 @@ "system/App/Common/CoreUpdate/PatchPackage.php": "2e54a4c7e10c56f2cc77afecb97f75d69742696982d0aaf554c35f4d7469ed52", "system/App/Common/CoreUpdate/ReleaseState.php": "8917d093677b05b3dc0b4ee97e8b26d18f9a98af78a3f87123fd7779d024af41", "system/App/Common/CoreUpdate/UpdateJob.php": "60edfe53acfaaf55898488d889a0abf62d226fdbfe17740059eec5cb2e6b3173", - "system/App/Common/CoreUpdate/UpdateRepository.php": "adb226ce961f920ab97cf195e277c9e60a93ab846bde0e34492ba66b53caae16", + "system/App/Common/CoreUpdate/UpdateRepository.php": "b8fbc3e0852b0d3d8be7b65a4513d12487dc3a42fd67c6f14330c7d5d85edc09", "system/App/Common/CsvLogWriter.php": "9deaa3e2bd85eec300c65e89e22549f48aa9e2850ad50b2ee8826f8d4fa53aa1", "system/App/Common/DatabaseConfiguration.php": "3eec09e9a0d9302761ca6b9ebfcb47b01aaded1bb7ed5834b1d55cac806918d3", "system/App/Common/DatabaseSchema.php": "a71320db73eace5f630f2106d402565702c88bebae8056aced6daf922a78ab88", @@ -934,13 +1001,13 @@ "system/App/Common/ModuleTagRegistry.php": "9e68b64303825a25ef4f71f31a7adedee9d2752e4d1d06162605da1dc510a6a5", "system/App/Common/Navigation.php": "1a1b0a3552d941dc0fc59c334ab705e86a73ee96ee42871dc958012bec1d81f6", "system/App/Common/NotFoundLog.php": "3b1cdeefd05ae250e90d1a05dd9e1bc0ba2cb0c2cbb89d504fe009151f20452a", - "system/App/Common/OutboundHttpClient.php": "5d1182b75cbb507a962fab64c11ca3922826bd9810dfbbf3903589edc686e9ca", + "system/App/Common/OutboundHttpClient.php": "1be0216c2147b35a329e18d78911e7c5aa2cc225141b749ccba8c1bd9222a26d", "system/App/Common/PackageModuleRuntime.php": "5ad492b9053bcea4d3bd4398b11fb2d976272e2036d379bdc24c19dd5fe7486f", "system/App/Common/Permission.php": "8e2241bca829de0104741dc2518860cd15489866dc310437e07aeab2bf0f0355", "system/App/Common/Proxy.php": "707564208ec8c24ac2ffb5bf41cba34f1b39059ce6bda31a69aaf4b116d864e2", "system/App/Common/PublicAsset.php": "06f9ae414f7336206073e07597669991098114c396e80748c654c82b370a39c4", - "system/App/Common/PublicAuthSettings.php": "d73f2ee75141e055be0968d190ad11190a0b1275cdc0330670274b46b8206f07", - "system/App/Common/PublicBootstrap.php": "1208a8797bbe695d4607fa804f654402883bd47f926e0a39fa8766928c16e509", + "system/App/Common/PublicAuthSettings.php": "a3dd7d0bef9ab608c1e99b161d30962cceeacb4babf37bc24c26511d2d433b3d", + "system/App/Common/PublicBootstrap.php": "b22aa593a13d32ea6382b8ec342a5cfd79a9986b627730eb77e6fec4aaca85cd", "system/App/Common/PublicConfiguration.php": "1daf6c26de04277f8618be4e25453aa479de9f7dfc09ba770d405eb7f9454760", "system/App/Common/PublicModuleRuntime.php": "5aafede8a2c0cf35023419a024566ca453676ea2dd9274a4d5ca99faa9273d15", "system/App/Common/PublicRouteRegistry.php": "6898abe0018f2630807f9697306ecfa2d63482f38832ba556b15eea21fa14222", @@ -952,15 +1019,17 @@ "system/App/Common/Registry.php": "7b4ddbb3f96a7ad54a8d43e151cb595e598d3620b368d21721927ce9d54e1ea3", "system/App/Common/ResponseSecurityHeaders.php": "b159a7105d3c1fb3ffdefbbc5997741d7b0f83bfb26fabcac96f61b53969364f", "system/App/Common/Router.php": "4b0dd3fd9f732177070ce3326b691a8decfbe698aa0d8cf687a18f6c02b73d91", - "system/App/Common/RuntimeConstantSchema.php": "08daf898ed9bfa12940adf8ddcd3e0dac811d28de8b798770afc42cbd20f1125", + "system/App/Common/RuntimeConstantSchema.php": "1ea1a029a29e4b0284d047f6ee6ec97dd50f4b42556e909a1a03481a4a380105", "system/App/Common/RuntimeConstants.php": "decb1277dada75ee2b5c7d531f064db7ffecef5b3ad71d306111b2a45121d34f", - "system/App/Common/RuntimeDirectoryGuard.php": "adc9ae97a5e5ae494e3b58073f195426daf2915bdae02c4e3e07312df272fa6b", + "system/App/Common/RuntimeDirectoryGuard.php": "2263f100530f7b0a7790f698581dff14181d4e2a917c12ad1ea888e5df1f59b2", "system/App/Common/Session.php": "4a870a109c75ca941bebfd798d64b1396dde7d56ec060d9491f7d71b70a13deb", "system/App/Common/Session/SessionDB.php": "6340cd88d5ce61ca39601347275547293a0b857cdd3978a4293eaa71076f83db", "system/App/Common/Session/SessionFiles.php": "9be6009241d0cf6f2f909ddc6881a3dc3cf1de0aefd5ef6103de8032c997dbf0", "system/App/Common/Session/SessionNative.php": "3ca6de1038e0b26e03e0797e9b1dc321ec383d85ad058503509d6b369d88168f", "system/App/Common/Settings.php": "d739b499a2d3c893f387cf3f5033815b863edf08414b1565063fb9d5e7d53f0e", "system/App/Common/SettingsRegistry.php": "0a24cf4e55e48d05a8af09ac00bbcbcc1179385254af8fd914e754964a1362b8", + "system/App/Common/SignedEventProof.php": "732c3b27f360dcbadcef405cac3d2cec5827e02cc789dc203001627750efcec8", + "system/App/Common/SiteOrigin.php": "d0471ed37ff0c8e08b4ab9e12138e793996d6bfe0a467ab020e82e301c773d19", "system/App/Common/StoredPhpRuntime.php": "2bd4fa83c970cfa0b8002b11cf36bd0def7b37ea19c025f7c6b2f37f0de38286", "system/App/Common/SystemTables.php": "85e95425b64d35bd8a3a5d4ddb14c1367ecbaa64ff6aaf4378055a1019895806", "system/App/Common/Template.php": "5a0cacde5059fa9918321e6baa71a09a754a024055dedffae161486c51902f45", @@ -975,7 +1044,7 @@ "system/App/Content/CatalogMenuRepository.php": "d2b384255fb9e67264f071064dafd645913a73ee640e559d600b1671455443eb", "system/App/Content/CatalogTables.php": "5abd93b17142ea5c57a0726c8c6e3dcaf896790cf4ba31ea69be19a828ac9517", "system/App/Content/ContactsTables.php": "d0042c761b854903b483e1b3a30d6014c596eef1a03c40cdbedae4fecb3c25d8", - "system/App/Content/ContentPlacementMap.php": "b4328ef770c98ab8f93d395d2d13ca5ff54d94a2679977ed2615e4d33c50d55d", + "system/App/Content/ContentPlacementMap.php": "55950a599eb5385e2d81833815f0e2c08bdd06c61450565052ec1c19d38e00fe", "system/App/Content/ContentTables.php": "a63105d8f3859921a9c1d17a4b8c69d3950ff0294de144814bd2b4768d236fde", "system/App/Content/ContentTagDependencies.php": "e96882109203f4db60559cc324100ce5596c8f8a65571ab53b8b86cdd3d89fa9", "system/App/Content/Directories/DirectoryRepository.php": "47d4f7a91841ff437c4448aa71f6a2b38c621dcf5a1204bd2ff4948f3ad671c1", @@ -992,7 +1061,7 @@ "system/App/Content/Documents/DocumentMediaFinalization.php": "5ea218f79f835a4fc25da0199aab87137e620b60c31d8e2f587a1b911b3af6b1", "system/App/Content/Documents/DocumentMediaFinalizer.php": "785487028e221726c6bc15662f83ed985701a8e5d21e9fc9605e816ef4c835b7", "system/App/Content/Documents/DocumentMediaPath.php": "23b40c98d3cf9a73df3b7f7083b58b60dd80b8bda71e3e3a6354342cc72322a8", - "system/App/Content/Documents/DocumentMutationService.php": "52e8f539cb62a3d787bdbae9e2df5d8d21bf75a39fa46c1d25545884b2b022ae", + "system/App/Content/Documents/DocumentMutationService.php": "5ad32bacb4ad5c2b2760db613be291f154ea3882695c2aa05607b1c5bc80f25f", "system/App/Content/Documents/DocumentPickerRepository.php": "0e8efb5b42aadd74725213b8302b53b5147d7e0eb2e83d301a5f073bb0c13142", "system/App/Content/Documents/DocumentRevisionPayload.php": "c7f83350de98b6d98531508eafecc4a2b5a0551c7f2ba77a9e5b954fd1287ea0", "system/App/Content/Documents/DocumentRubricCodeRunner.php": "a9c04196b76ab91c7b1f8b53afbd45fc6727805c953fb5fe5ff36b69846d389f", @@ -1023,8 +1092,8 @@ "system/App/Content/Fields/FieldType.php": "47979aaa49b773e2a00288ffccb746218b58ece8fa6d178a90d0cc9c853055d9", "system/App/Content/Fields/FieldValidationException.php": "1a1d6d5278d6f8ff697e5afe75e0a6f59db2b12121c93b786fd87f8b81287717", "system/App/Content/Fields/FieldValidator.php": "5408849670818b379a66d5db9b7aa0d94ebbd8438d7788124f62cd221dae5561", - "system/App/Content/Fields/FieldValueCodec.php": "66b6981228683dd9b6c8bd850fe2fe84de5ac3101bf9abbbb91664a7edda5f2f", - "system/App/Content/Fields/HtmlSanitizer.php": "a716694280998f65bdfd6adef26c0d0b7e2f912413fa531a0b2070a09f6cfee0", + "system/App/Content/Fields/FieldValueCodec.php": "5868e9aaa11a8aa2c4dde33e8b358861b4a5a49f6ff235c1d0e22b836dc3df55", + "system/App/Content/Fields/HtmlSanitizer.php": "003ddd83bc6b3c89e1ccdbfe4abf94e013c4a6b1272cf29c1ed36790f3ac7919", "system/App/Content/Fields/MediaFieldValue.php": "5821060785f859b414a448d4998a9e7b40df69d536574f0fbe600f1d7611ea35", "system/App/Content/Fields/PublicFieldRuntime.php": "9abd8e5dd43c56b27dcbf87d258cd9764ae1d67e95a397a63a6c6516f7a417f8", "system/App/Content/Fields/PublicFieldTemplateContext.php": "785d6757710dee59d8d30756917235bb4f49eb5bd743c01d2365c05d74bf0a5a", @@ -1112,48 +1181,48 @@ "system/App/Content/Requests/RequestRuntimeValue.php": "9f0b9eeec1bf32cee8c13e1298e6b64c5a59f51c05d86b798523125a91ea8274", "system/App/Content/Requests/RequestTieBreakerAdvisor.php": "bd547d4dd74c45246e3a49bfef3320964d723c520fce3339ea5884b89aabd08f", "system/App/Content/Requests/RequestViewPreview.php": "ab80344f456d50aef43716572179b411a60af47523a71bbd45d317cc5967e899", - "system/App/Content/Revisions/JsonRevisionStore.php": "46b07200bd19bf0fe1d177e47ad84ed4de531b6271ebbccc80a9ff40c44b538f", + "system/App/Content/Revisions/JsonRevisionStore.php": "e38e56c1fe8dc1ed3e8e6918971a9cbe6dc98cf00d74bb0eb41c9298bb5f0063", "system/App/Content/Rubrics/FieldSetMerge.php": "15ae21c53a11a3c40f59abe3b117c640506def0355964216ad7350f8cf8e66d5", "system/App/Content/Rubrics/FieldSetRegistry.php": "a9df395474b9a28cdd1dd5dba40fe49e485835218afe472c414b9696094b9a70", "system/App/Content/Rubrics/RubricFieldMutationService.php": "3fec278925d14f7ec2efed63ba51cd91def333220ca4e18f3a42a202a8806674", "system/App/Content/Rubrics/RubricMutationService.php": "e3190ef1ddd94bdaf077148ce84c5758b2a32683369730a2b847dbbd0d72a74c", "system/App/Content/Templates/TemplateMutationService.php": "8c7e52202454e35eb0c8176325cc23ba760ac40ff0430cbba5e1446c83cf7a87", - "system/App/Frontend/Auth/AuthService.php": "58a551f0c372194ffc2cca3e12f1e9a8ab743d07682f6b2c086ec1f7bc77ad54", - "system/App/Frontend/Auth/Controller.php": "968a682faaad88e76f127b7381a183ee393ba367a51e40cd17395bd69d4fb4e2", - "system/App/Frontend/Auth/Feature.php": "60ff394cbf95ea3ca3f35476a6eb2bc4016fe472be85cd11ebac117635a54440", - "system/App/Frontend/Auth/FormTemplateRepository.php": "97882ee4b46e0c4e31493a44831564409b5a64c096363aaf812f181d13abae64", + "system/App/Frontend/Auth/AuthService.php": "1cef24cffd7773f3783ec96479b8e7daa1808b03a34dfc8da37b84e6e09144b8", + "system/App/Frontend/Auth/Controller.php": "04793123f0d4bef77141e3f8471d454ac5b4d2b17dce4630eca40da0e2f0b576", + "system/App/Frontend/Auth/Feature.php": "dbaeac564886c7949e0e47c85fc5496c25221d880c4f03cee40cc5e15d042a1c", + "system/App/Frontend/Auth/FormTemplateRepository.php": "bcd8797e63cb67803aef2267a2c62e3ad368b5fd947057a768adbb1288ee9281", "system/App/Frontend/Auth/OAuth/HttpClient.php": "b09acf92b312bd05ca78500fccaac3b77d36a1046d64d3615a00d60af4e3594f", "system/App/Frontend/Auth/OAuth/IdentityRepository.php": "35ab143e0d536185125c685f17f5f4bb0ba0cc72daba62552ef6f82370e38ec1", - "system/App/Frontend/Auth/OAuth/OAuthFlow.php": "842e9c9bea61578bdf124dc8bd1921e5c505349822a95e5812bf09e2710f2278", + "system/App/Frontend/Auth/OAuth/OAuthFlow.php": "f7a08104f75de4ee81f753c8e935c321dc5afb08b76062ec9de4cf6a4ba9fb50", "system/App/Frontend/Auth/OAuth/OAuthService.php": "c310a2a6756425a4b2875b12c048b0476dd5b424e89561ac1fa73aebfb3cf045", "system/App/Frontend/Auth/OAuth/ProviderInterface.php": "c99525fd51a1bebc24c189f52f1a08bcef087b98ffa960d1b378deb68022d9ba", "system/App/Frontend/Auth/OAuth/ProviderRegistry.php": "e0ff4bc12ef2349d4f914299c78715438d9fbd44ab98e00937534786baa07800", "system/App/Frontend/Auth/Phone/ChallengeRepository.php": "1e53d355aaf0adcc8e10a2b1b50e638916fd688c62a7ed6a4bd9c161c14a6390", "system/App/Frontend/Auth/Phone/Controller.php": "bb07bfb74a89742c2dbeeb7edf79f02ae43ef6a252d02a3bb7c3c78144cf280d", - "system/App/Frontend/Auth/Phone/PhoneAuthService.php": "816a29b7102b8dd58d3c46d4e563f83beda0397b037bc17377f1f8d4fc8545b0", + "system/App/Frontend/Auth/Phone/PhoneAuthService.php": "783b671cb6b6f80eb4ecbd7bec86d417dd7030c7768ba01f742600e4a6b55a89", "system/App/Frontend/Auth/Phone/ProviderInterface.php": "55107c8f6a89bea01b91314dfe523b684bccf5bf9f1a3c8f047fd3a509aa6d00", "system/App/Frontend/Auth/Phone/ProviderRegistry.php": "fbeb488c9b63843085b680500ecafd61389a29f55e0d76692550939e378d2f16", "system/App/Frontend/Auth/ProfileRepository.php": "9cd7be35b048faf407a925e6369a5895b8bfafe6cf756eb3c4321ea4979a9af6", "system/App/Frontend/Auth/Registration/CheckoutRegistrationGateInterface.php": "6ce038a47e21b594a47bee16075dbf102775b493efd5f2304fa8f80341d34bec", - "system/App/Frontend/Auth/Registration/EmailRegistrationGate.php": "52617ab06e53ac64c3168fac48260c3c8382d7eb2bb9dbc280942f7fb3bfaedc", + "system/App/Frontend/Auth/Registration/EmailRegistrationGate.php": "ce6dcad98a944aade0fd9a80444c183b4f29bc50e1f119aa3d1d72bdfc3fa732", "system/App/Frontend/Auth/Registration/RegistrationGateInterface.php": "99e73d0d6db972bb5b2c2a7a21dd50823a9bcef7d05937c525d0e7096775aedd", "system/App/Frontend/Auth/Registration/RegistrationGateRegistry.php": "bc7c8546780daacd23243d16778062384f043661674956d1a5a47a8b6440eb44", - "system/App/Frontend/Auth/Renderer.php": "2a14b5593495e1b587b79ebc7715a4bd37784a104030837e10b056bfed63ea3a", + "system/App/Frontend/Auth/Renderer.php": "94ed24082c9170c6f7ad09d1b59565c8b4251947979b6502f426d8adfff13efc", "system/App/Frontend/Auth/Tags.php": "92fc1c466e720be36a0adce5a735e47c94bf88d9a3e70ba9d62196ef63e639f5", "system/App/Frontend/Auth/TokenRepository.php": "5e09b154d4747bfb3d2795a9b449ae4aefffe47508edef92d1a7b392c0371207", - "system/App/Frontend/Auth/UserRepository.php": "21bfe56cb9f2f74ab9c58947cac9e8132159afddc0900d7d7136e919c3482bac", + "system/App/Frontend/Auth/UserRepository.php": "07a35cb3a82201465360b7f75e4be1534c88d53789ad2a5b1ef617be97a060e3", "system/App/Frontend/Auth/assets/auth.css": "a16949511a17462d9157945f2ceccb445254b34243e9e368796dd3137aac49c8", "system/App/Frontend/Auth/assets/auth.js": "840c7322fc21f9a8dd61af8cd16e0ceed36d6beaeac45d948bedbcb87b6012c4", - "system/App/Frontend/Auth/view/login.twig": "bbe2c12ce95f16cb2b2a07f945c8b8798e9386e9e3cbf16ddf5d0a3ca3944016", + "system/App/Frontend/Auth/view/login.twig": "1f33ca2d7b99d9ad60a82deb5e3904b7b1be1ef6a2c6c021ee565523ee0fe074", "system/App/Frontend/Auth/view/message.twig": "4e9a660af7d0deae9424a07550816c401902e75279414b11657e0eddf573deea", "system/App/Frontend/Auth/view/oauth.twig": "7ccb2c7ae36a378586334887c32fda96909cb6fa235601f1f697d4c8049af648", "system/App/Frontend/Auth/view/oauth_connections.twig": "e2d845ce9d906ca12d3f642e92ede1aa415203f7dc2ac472006f40e80eb8bf27", "system/App/Frontend/Auth/view/overview.twig": "97ad0cd5f72f63b6c92139f7cba54041840131975fdca417352dacf7f64ddb68", "system/App/Frontend/Auth/view/panel.twig": "3e7237263702462969b937254b6e2d4268599352f801ed4681db7b1aab07f508", "system/App/Frontend/Auth/view/password.twig": "099d7c1e48649faba57eb50df9bd9a091c79dad12e9c1fee9b38c3f2e025ed80", - "system/App/Frontend/Auth/view/phone.twig": "969628667cd4d518987c1b9bb1632e70f44de6c9373e7e92b5ef8d94584066fd", - "system/App/Frontend/Auth/view/profile.twig": "bd2e46b731ac0e3454c91c746a81c20690bea613803363aa58f4a6ae50b494eb", - "system/App/Frontend/Auth/view/register.twig": "1f97ce555e5aafbcf2b4f6942e3603e798a7ee2b5c3dcf197fb5afba44978810", + "system/App/Frontend/Auth/view/phone.twig": "8119ebc00ef866b80eadb559ce71b4d1c6511fc88523e41e7d3ac693db10dbdf", + "system/App/Frontend/Auth/view/profile.twig": "71032aeeebc80036301181932c90ee004d9440bbc64b1b3f79ba03c0e3037a87", + "system/App/Frontend/Auth/view/register.twig": "92f78ab66ba8c16e0b7c3727ac80ae239e01f696399b72049f19a1087d3f3cc9", "system/App/Frontend/Auth/view/remember.twig": "fb170509256e4ecf13ebe5f64cae8cbd6ad8ef143abe645f4bc1f269e0d8a0fe", "system/App/Frontend/Auth/view/reset.twig": "5dff5378aa0e2203fd67fe9f1dd61586ee51e3d3f6138011b9d2b5dec4da31aa", "system/App/Frontend/BlockRenderer.php": "1d23d7ff85ba1052f896e204d1885f99ec2c0f6382e01d5d1903d69fe661305b", @@ -1185,7 +1254,7 @@ "system/App/Frontend/Feeds/FeedPresets.php": "bff8edea9f095a63fa2c127ee6afb35c5a3ce4434ca309e9a8a7dd047ba16428", "system/App/Frontend/Feeds/Generator.php": "4234369a293bad237fe0d36dd3c78da616d03ce37070ac72988893f796773da9", "system/App/Frontend/Feeds/PathController.php": "faf72ea5ba08256c8566a5ee9a73adfcabd59ef0f510c108a6003b659659635b", - "system/App/Frontend/Feeds/Repository.php": "1f5188ade6ea32e13861598647319202c44c632393a50a5ec55fc96279b2b189", + "system/App/Frontend/Feeds/Repository.php": "3d36c493b3714780ed078537ecc8788c7f403a6a6eebb022128bd3e9b3e6dfa1", "system/App/Frontend/Feeds/Schema.php": "a4603347d631e6a748e0b245901f952736754e4e5e73c96289ed17a1998f5a31", "system/App/Frontend/Feeds/Service.php": "3d0f3badbc07a9d47e5902c106be270a24a71e2d64a636ca909b2393bdeb59f7", "system/App/Frontend/FieldEndpoint.php": "0bb0200a52169ecb77498df32d6ee3053324844cfa2e9200d6b3b819dd4c6aa8", @@ -1196,16 +1265,16 @@ "system/App/Frontend/Media/ThumbnailGateway.php": "556d46b0a6d8f1a289eb76f2ea2828f7b9c3b16b33934d3888201327f82dff61", "system/App/Frontend/Media/ThumbnailPresetPolicy.php": "59a6e2ffadc2f8fef3bae4ea11a32a230a6c8cfe22e008e0347505460281baf0", "system/App/Frontend/Media/ThumbnailPresetScanner.php": "d972f234a36e66679eff7cf6256a6adf35a51f9c6a9f3a2b23781d0118e4dba4", - "system/App/Frontend/Media/ThumbnailStorage.php": "594c1cd2fb24dbff8ff92d6b450b20080270c0fe7d04efc6f47022344a890b48", - "system/App/Frontend/Media/ThumbnailUrl.php": "a84292ca6f3cf2976f1098027fbb1792854e1d4a8dde5c068f73e025a5d1fa11", - "system/App/Frontend/Media/WatermarkService.php": "99d2a1c95d907aca7374b48dfe51413885923943909f3507ef030f87b156f215", + "system/App/Frontend/Media/ThumbnailStorage.php": "c7e15a7b91989b668e28a655df45d6ac6c6c6e3db9b67fbeca88b087b4c65c2c", + "system/App/Frontend/Media/ThumbnailUrl.php": "0fc3e35b535ffaa475e85cce6ae8b6a4cee1fd6fbcba67d55adc7648330bf43b", + "system/App/Frontend/Media/WatermarkService.php": "69136f8e840587a1929e267b5a074c7708fe11d66d0898e334e9f4cca1265afa", "system/App/Frontend/MetaGenerator.php": "7809c2e95bb893bac356e228867043f9b03c3dfa19e8fbc64db1b82241738473", "system/App/Frontend/ModuleTagRenderer.php": "0b41289126478a1aeda6cf7119f8f3d5f89b43bb7684c92ab5447d5dab408795", "system/App/Frontend/NavigationRenderer.php": "d176186b06d56832e640a8b3d53ccad4aff04e3834b261d83670d675271e4d00", "system/App/Frontend/NavigationRepository.php": "3714ca40102621191837fbed7511873327250ecb186c54bb0cce89781bdb0db1", "system/App/Frontend/PageComponentRenderer.php": "4176d2748b73b1c7cb72963cf67cfafd66246d93119728d9d688e6a48b882485", "system/App/Frontend/PageShellRenderer.php": "e87c2ca686fbf6fd721a35656e752aaf590633a196c93715f75d36ddd78245fb", - "system/App/Frontend/PageTemplateRepository.php": "07af7abf99d0faf9909767685fb392ebc2acbf03aa8172a7701e5001d66d863b", + "system/App/Frontend/PageTemplateRepository.php": "58f73d71e6bb394f725aee0201f436236214acca343dc75bacdcfa752f3e8caf", "system/App/Frontend/PageTemplateResolver.php": "1af594b212cd4e30d5de361415a8e238493995d8bf955df9291e40169afe621c", "system/App/Frontend/PaginationRenderer.php": "27dda732ec0547a389c4843a77b59090014c5cf0339dbebee51abf4fd0bc7ed6", "system/App/Frontend/PublicAvatar.php": "5a7afe665f25fbf4bc09a7861b9b84e98f8c294adbe9b047455e5deacda508f9", @@ -1226,10 +1295,10 @@ "system/App/Frontend/PublicRedirectResponder.php": "67b0915e9820fb5d0f4270d86ea39d99502acc49c86997aef7fe0d9ef3fe7b60", "system/App/Frontend/PublicRequestApi.php": "803113eaeaf272d423ba39331e0dfa25a540251aab4961d2b796a6734215ed37", "system/App/Frontend/PublicSettings.php": "e70b9a478cefe4eb9f55ba0cde37ed14e270be345d08773e8a6b736be3bef321", - "system/App/Frontend/QuickEdit/PublicQuickEdit.php": "97e6a1498886b74b0a21338732b238f185ea80bedd06a84cba0c70e9aad3fd8b", + "system/App/Frontend/QuickEdit/PublicQuickEdit.php": "11e2c721e676d3ddba73c6f8cbe11ac9a9ad15acfd4e97ddfd88ba7f8f43dfd7", "system/App/Frontend/RequestFieldRenderer.php": "01d6e96d630727f740874334e7ffbdfbdc076d896a6d8a390723c8aaaec41c97", "system/App/Frontend/RequestItemContext.php": "28f70292d7feeb77b88246d1d3299ff54447afc1aa6c525211835a4cfd51ebb4", - "system/App/Frontend/RequestItemRenderer.php": "a941b0ec8eb558792499fb0b9ea5b2ff049a59ddb0b61c3f364aa62fe959fd52", + "system/App/Frontend/RequestItemRenderer.php": "6426d63609073d85938dbaef0f77935d856a57eca225503d812e31f07ab98790", "system/App/Frontend/RequestListReadModel.php": "7eedb83e42382cada17e60484108702a52f8a890b59d93cd921e194025af6064", "system/App/Frontend/RequestListRenderer.php": "b042f3961c9609a24eaf2b012dd62840ad183d938bf6312b32731c236ea07ef1", "system/App/Frontend/RequestRenderer.php": "99145390cbfe803388786dcf6de60cb4f384224955681e7f855e4b7ef19355b1", @@ -1246,11 +1315,12 @@ "system/App/Frontend/ThemePageShellRenderer.php": "483e2cf8ef91099b5ff3b3c1d70aa3a4d2ddf354add2bff650ec523150384ea9", "system/App/Frontend/ThemeSettings.php": "77509ac00183373005f2456a58abf2c8608d6677751f7540d8cb8f5db36284a4", "system/App/Frontend/ThemeSettingsTags.php": "13fa6b59c8518dca33f6b3482e0f9a00c14a248d430756f62a9b0c5175346fab", - "system/App/Frontend/view/content/list.twig": "34bcf0e7afbdfc53f02c5629f98f5551c7a80d3121dad70e513fbd0c7466d75c", + "system/App/Frontend/view/content/list.twig": "60db68391fa334c46694d3d646a0458e661e147770feb86e7be174b62e1f1f65", "system/App/Functions/custom.php": "173e6aef242ae4899d8b0d39f41b8235f7b38d75a5017954cdd6287f43c36dc4", "system/App/Helpers/Arr.php": "b7e5c439521da8ee1f85d978e561e9466da94d70f5172620deb94677fc964aaf", "system/App/Helpers/Cookie.php": "f6648c6e0366528c5f04d77ad35cf59f71c4eac2e4c092da1d59ac332b648b5f", "system/App/Helpers/Crypt.php": "45d4f4b441bc0ad0e160a3d1461095cee28a7372d6ee15fbfd0a4b1ba1e5c6f5", + "system/App/Helpers/Csv.php": "860a13c22958a728caea746fbeca1355d0825e124d7f83000b0f5f45a7b82775", "system/App/Helpers/Date.php": "2ed2f352f52316484be9f915c73f90357d89aaf6945a398dcea1bed136c2c454", "system/App/Helpers/Debug.php": "d495a96e844ed9109ec8eb71227e5f02155f61b08b719793d8be16a543f3c106", "system/App/Helpers/Dir.php": "0791ed320ab894e2c768d16768c227e9c68bbc627723eae289f587551037fb66", @@ -1269,17 +1339,18 @@ "system/App/Helpers/SearchText.php": "1747521adb640161c6f4df648134c8349769e3cd7ddbc726118af81f1a4dcbf0", "system/App/Helpers/Secure.php": "99a5a170cd574e5b6a6c3b433c5b8bac92407fba62088c93e893e215f18162a3", "system/App/Helpers/SoftDelete.php": "9b85f227bbbc12e4bddd9bcf211b477044041b87bf94d1a422f80d46a10c3ccd", + "system/App/Helpers/SplitTest.php": "d0c875c264df66509b53693dccae8d9f482b069dfe98eb5820481077f3c4508c", "system/App/Helpers/Str.php": "7eddedb821b476f5284573171899a97ab653b23fc2777cbef780a802a1e9aac2", "system/App/Helpers/TagParser.php": "7cf90d3af459df482aba5594873bd6807f9944288061d06934668e4bb9c18540", - "system/App/Helpers/Url.php": "418ed3e2682f9097b62d495769864843acc2a9650a90a98d24542acdfdb0946f", + "system/App/Helpers/Url.php": "9a5936995f171b83ef6b254f64bac00296333ff2289e155ae2f6b589eadabe7c", "system/App/Helpers/Valid.php": "d5ebfabf53755ae3e66acade5dcaebd9ca0f5f151f988e1fa0a51d3dba9a0983", "system/App/Helpers/Zip.php": "cf34358f6908b026e745bb94e5355b4376d46d31d3dfe78cabbf118c04f745a8", - "system/bootstrap.php": "2c72cc44f36c9250612b30f8f73efd510b47a5497a71b8a18658627e82e9bb59", + "system/bootstrap.php": "01287f2ec1724095accdb87968e69f19cc438c732544e8160d39ab2a994bebad", "system/distribution.json": "c498272f17ce6f0fbd79401fb264089d6380799a0a102c7f6e5c2fa2149439f7", "system/language/ru/lang.xml": "7d46cc9822f064a1af4d6528ccfe2dd4a9cc318912e327fd807befd2ca3dfd11", "system/preload.php": "66bfb1aaf36ba85945b729b09a37e96ead0be1cc2749ebd9abd4b9da4f25ba0e", "system/public.php": "3f72b5bf44bc0ba5ba6fb6b3adc2e2f9d5e9902d9cdfa3d7b748f4aebd179b87", - "system/release.php": "e49c5a7ff2bd517746dc5f3a173014739abeb085b3fc0b4a413c568ff7543549", + "system/release.php": "e9aeb913c895912f8dae625b0311fb38cd871c20232eede2948e934ba4bff656", "system/vendor/Captcha/fonts/palatino_linotype_bold.png": "38b6a1d4a4bd1d0c056205119c5e24176913ac0d898371f53a0339952a319365", "system/vendor/Captcha/fonts/perpetua_bold.png": "55dbbe97dae128dea47b5e8d640414b3d813119c854c688037a20a581ec8fdc4", "system/vendor/Captcha/fonts/times_bold.png": "d3d32ce1f418e3c3c3b1afe3b6acabadb36207bc12368f5e86420cf1a529ff8f", @@ -1863,7 +1934,7 @@ "templates/public/index.php": "c57a4bb3eb5ae6c0394cc748a14dc7f703a9d819333f181f0edffffa680100bc", "templates/public/templates/.htaccess": "ef94d4c2ac2a038b6c2ae673d9573333a0a20f7fac9e2d75af11c6c387a157a3", "templates/public/theme.json": "4fb596d58dbda7f58c26c4f3640c56d6f6981f402ffbd368229adddc23683690", - "tmp/.htaccess": "6b817083ef9d947002e5ffe1e216de9b1ecd9bba813f0d8493b25dab83001699", + "tmp/.htaccess": "ca761a92f2b6eb47feffcf200a8fd846e7fdd8abe065008d29beff0319ca3496", "uploads/.htaccess": "90cd571b4c9f09a63732254fbcdca92cc7da8560a6e5b4d9ec7e58ac40e528bf", "uploads/index.php": "e91434fe4986c22a2bba7ceda8e59d1aa2c9f943353035c83939cfd4f423fdd8", "robots.txt": "d8119db904b34c2aab74cc2b6a09d13119f7c19add8b51ffbf4717eba65e8218" @@ -1893,6 +1964,8 @@ "adminx/Support/Twig/AdminLocaleExtension.php": "core", "adminx/Support/Twig/AdminLocaleNodeVisitor.php": "core", "adminx/Support/Twig/AdminTextNode.php": "core", + "adminx/Support/ViewOverrideEditor.php": "core", + "adminx/Support/WorkItemContext.php": "core", "adminx/assets/css/adminkit.css": "core", "adminx/assets/fonts/NunitoSans-Bold.woff2": "core", "adminx/assets/fonts/NunitoSans-ExtraBold.woff2": "core", @@ -2014,6 +2087,7 @@ "adminx/modules/Auth/language/ru/runtime.xml": "core", "adminx/modules/Auth/module.php": "core", "adminx/modules/Auth/view/login.twig": "core", + "adminx/modules/Auth/view/password.twig": "core", "adminx/modules/Blocks/Controller.php": "core", "adminx/modules/Blocks/Model.php": "core", "adminx/modules/Blocks/Revisions.php": "core", @@ -2068,12 +2142,16 @@ "adminx/modules/Console/module.php": "core", "adminx/modules/Console/view/index.twig": "core", "adminx/modules/Customers/Controller.php": "core", + "adminx/modules/Customers/CustomerCenter.php": "core", + "adminx/modules/Customers/GlobalSearchProvider.php": "core", "adminx/modules/Customers/Model.php": "core", "adminx/modules/Customers/assets/customers.css": "core", "adminx/modules/Customers/assets/customers.js": "core", + "adminx/modules/Customers/language/en/center.xml": "core", "adminx/modules/Customers/language/en/client.xml": "core", "adminx/modules/Customers/language/en/interface.xml": "core", "adminx/modules/Customers/language/en/runtime.xml": "core", + "adminx/modules/Customers/language/ru/center.xml": "core", "adminx/modules/Customers/language/ru/client.xml": "core", "adminx/modules/Customers/language/ru/interface.xml": "core", "adminx/modules/Customers/language/ru/runtime.xml": "core", @@ -2083,7 +2161,10 @@ "adminx/modules/Customers/migrations/004_materialize_public_auth_schema.sql": "core", "adminx/modules/Customers/migrations/005_checkout_registration.sql": "core", "adminx/modules/Customers/migrations/006_phone_identity.php": "core", + "adminx/modules/Customers/migrations/007_customer_center.php": "core", "adminx/modules/Customers/module.php": "core", + "adminx/modules/Customers/view/customer-center-detail.twig": "core", + "adminx/modules/Customers/view/customer-center.twig": "core", "adminx/modules/Customers/view/index.twig": "core", "adminx/modules/Dashboard/Controller.php": "core", "adminx/modules/Dashboard/Widgets.php": "core", @@ -2122,21 +2203,29 @@ "adminx/modules/Directories/Model.php": "core", "adminx/modules/Directories/assets/directories.css": "core", "adminx/modules/Directories/assets/directories.js": "core", + "adminx/modules/Directories/language/en/client.xml": "core", + "adminx/modules/Directories/language/en/interface.xml": "core", + "adminx/modules/Directories/language/ru/client.xml": "core", + "adminx/modules/Directories/language/ru/interface.xml": "core", "adminx/modules/Directories/migrations/001_create_directories.sql": "core", "adminx/modules/Directories/module.php": "core", "adminx/modules/Directories/view/index.twig": "core", + "adminx/modules/Documents/BulkEditor.php": "core", "adminx/modules/Documents/Controller.php": "core", "adminx/modules/Documents/DocumentHookRunner.php": "core", + "adminx/modules/Documents/DocumentMediaReplacement.php": "core", "adminx/modules/Documents/EditConflict.php": "core", "adminx/modules/Documents/Model.php": "core", "adminx/modules/Documents/Revisions.php": "core", "adminx/modules/Documents/assets/documents.css": "core", "adminx/modules/Documents/assets/documents.js": "core", "adminx/modules/Documents/assets/redirects.js": "core", + "adminx/modules/Documents/language/en/bulk-editor.xml": "core", "adminx/modules/Documents/language/en/client.xml": "core", "adminx/modules/Documents/language/en/interface.xml": "core", "adminx/modules/Documents/language/en/runtime.xml": "core", "adminx/modules/Documents/language/en/zz-interface.xml": "core", + "adminx/modules/Documents/language/ru/bulk-editor.xml": "core", "adminx/modules/Documents/language/ru/client.xml": "core", "adminx/modules/Documents/language/ru/interface.xml": "core", "adminx/modules/Documents/language/ru/runtime.xml": "core", @@ -2160,6 +2249,7 @@ "adminx/modules/Documents/module.php": "core", "adminx/modules/Documents/view/_product_panels.twig": "core", "adminx/modules/Documents/view/api.twig": "core", + "adminx/modules/Documents/view/bulk-editor.twig": "core", "adminx/modules/Documents/view/edit.twig": "core", "adminx/modules/Documents/view/index.twig": "core", "adminx/modules/Documents/view/redirects.twig": "core", @@ -2179,18 +2269,23 @@ "adminx/modules/Events/view/index.twig": "core", "adminx/modules/GlobalSearch/Controller.php": "core", "adminx/modules/GlobalSearch/Model.php": "core", + "adminx/modules/GlobalSearch/language/en/interface.xml": "core", + "adminx/modules/GlobalSearch/language/ru/interface.xml": "core", "adminx/modules/GlobalSearch/module.php": "core", "adminx/modules/Groups/Controller.php": "core", "adminx/modules/Groups/Model.php": "core", + "adminx/modules/Groups/PermissionSimulator.php": "core", "adminx/modules/Groups/Setup.php": "core", "adminx/modules/Groups/assets/groups.css": "core", "adminx/modules/Groups/assets/groups.js": "core", "adminx/modules/Groups/language/en/client.xml": "core", "adminx/modules/Groups/language/en/interface.xml": "core", "adminx/modules/Groups/language/en/runtime.xml": "core", + "adminx/modules/Groups/language/en/simulator.xml": "core", "adminx/modules/Groups/language/ru/client.xml": "core", "adminx/modules/Groups/language/ru/interface.xml": "core", "adminx/modules/Groups/language/ru/runtime.xml": "core", + "adminx/modules/Groups/language/ru/simulator.xml": "core", "adminx/modules/Groups/migrations/001_register_public_debug_permission.sql": "core", "adminx/modules/Groups/migrations/002_remove_observer_role.sql": "core", "adminx/modules/Groups/migrations/003_seed_default_roles.sql": "core", @@ -2199,25 +2294,39 @@ "adminx/modules/Groups/migrations/006_normalize_control_panel_permission.sql": "core", "adminx/modules/Groups/module.php": "core", "adminx/modules/Groups/view/list.twig": "core", + "adminx/modules/Groups/view/simulator.twig": "core", "adminx/modules/Media/Controller.php": "core", + "adminx/modules/Media/GlobalSearchProvider.php": "core", "adminx/modules/Media/ImagePresets.php": "core", + "adminx/modules/Media/MediaAudit.php": "core", + "adminx/modules/Media/MediaSearchIndex.php": "core", + "adminx/modules/Media/MediaTrash.php": "core", + "adminx/modules/Media/MediaUsageIndex.php": "core", "adminx/modules/Media/Model.php": "core", "adminx/modules/Media/assets/media.css": "core", "adminx/modules/Media/assets/media.js": "core", + "adminx/modules/Media/language/en/analyzer.xml": "core", "adminx/modules/Media/language/en/client.xml": "core", + "adminx/modules/Media/language/en/improvements.xml": "core", "adminx/modules/Media/language/en/interface.xml": "core", "adminx/modules/Media/language/en/runtime.xml": "core", + "adminx/modules/Media/language/ru/analyzer.xml": "core", "adminx/modules/Media/language/ru/client.xml": "core", + "adminx/modules/Media/language/ru/improvements.xml": "core", "adminx/modules/Media/language/ru/interface.xml": "core", "adminx/modules/Media/language/ru/runtime.xml": "core", "adminx/modules/Media/migrations/001_image_presets.sql": "core", + "adminx/modules/Media/migrations/002_media_search_index.sql": "core", "adminx/modules/Media/module.php": "core", + "adminx/modules/Media/view/audit.twig": "core", "adminx/modules/Media/view/file.twig": "core", "adminx/modules/Media/view/index.twig": "core", + "adminx/modules/Media/view/trash.twig": "core", "adminx/modules/Modules/Controller.php": "core", "adminx/modules/Modules/ModuleArchiveInstaller.php": "core", "adminx/modules/Modules/ModuleRepository.php": "core", "adminx/modules/Modules/NativeModuleAdapter.php": "core", + "adminx/modules/Modules/NotificationProvider.php": "core", "adminx/modules/Modules/assets/modules.css": "core", "adminx/modules/Modules/assets/modules.js": "core", "adminx/modules/Modules/language/en/client.xml": "core", @@ -2230,6 +2339,7 @@ "adminx/modules/Modules/view/index.twig": "core", "adminx/modules/Navigation/Controller.php": "core", "adminx/modules/Navigation/Model.php": "core", + "adminx/modules/Navigation/Revisions.php": "core", "adminx/modules/Navigation/assets/navigation.css": "core", "adminx/modules/Navigation/assets/navigation.js": "core", "adminx/modules/Navigation/language/en/client.xml": "core", @@ -2239,12 +2349,14 @@ "adminx/modules/Navigation/language/ru/interface.xml": "core", "adminx/modules/Navigation/language/ru/runtime.xml": "core", "adminx/modules/Navigation/migrations/001_create_navigation_tables.sql": "core", + "adminx/modules/Navigation/migrations/002_navigation_revisions.sql": "core", "adminx/modules/Navigation/module.php": "core", "adminx/modules/Navigation/view/index.twig": "core", "adminx/modules/PublicSite/Controller.php": "core", "adminx/modules/PublicSite/Model.php": "core", "adminx/modules/PublicSite/PresentationModel.php": "core", "adminx/modules/PublicSite/PresentationsController.php": "core", + "adminx/modules/PublicSite/PublicViewTemplates.php": "core", "adminx/modules/PublicSite/assets/presentations.css": "core", "adminx/modules/PublicSite/assets/presentations.js": "core", "adminx/modules/PublicSite/assets/public-site.css": "core", @@ -2263,6 +2375,7 @@ "adminx/modules/PublicSite/view/presentations.twig": "core", "adminx/modules/Requests/Controller.php": "core", "adminx/modules/Requests/Model.php": "core", + "adminx/modules/Requests/Revisions.php": "core", "adminx/modules/Requests/assets/requests.css": "core", "adminx/modules/Requests/assets/requests.js": "core", "adminx/modules/Requests/language/en/client.xml": "core", @@ -2284,6 +2397,7 @@ "adminx/modules/Requests/migrations/011_order_tiebreaker.php": "core", "adminx/modules/Requests/migrations/012_condition_value_sources.php": "core", "adminx/modules/Requests/migrations/013_sort_rules.php": "core", + "adminx/modules/Requests/migrations/014_request_revisions.sql": "core", "adminx/modules/Requests/module.php": "core", "adminx/modules/Requests/view/edit.twig": "core", "adminx/modules/Requests/view/index.twig": "core", @@ -2366,6 +2480,7 @@ "adminx/modules/Settings/migrations/004_seed_core_display_defaults.sql": "core", "adminx/modules/Settings/migrations/005_order_system_navigation.php": "core", "adminx/modules/Settings/migrations/006_create_admin_saved_views.sql": "core", + "adminx/modules/Settings/migrations/007_public_site_url.sql": "core", "adminx/modules/Settings/module.php": "core", "adminx/modules/Settings/view/index.twig": "core", "adminx/modules/Templates/Controller.php": "core", @@ -2390,6 +2505,8 @@ "adminx/modules/Themes/Revisions.php": "core", "adminx/modules/Themes/assets/themes.css": "core", "adminx/modules/Themes/assets/themes.js": "core", + "adminx/modules/Themes/assets/view-overrides.css": "core", + "adminx/modules/Themes/assets/view-overrides.js": "core", "adminx/modules/Themes/language/en/client.xml": "core", "adminx/modules/Themes/language/en/interface.xml": "core", "adminx/modules/Themes/language/en/runtime.xml": "core", @@ -2400,7 +2517,9 @@ "adminx/modules/Themes/migrations/002_place_theme_navigation.php": "core", "adminx/modules/Themes/module.php": "core", "adminx/modules/Themes/view/index.twig": "core", + "adminx/modules/Themes/view/view-overrides.twig": "core", "adminx/modules/Updates/Controller.php": "core", + "adminx/modules/Updates/NotificationProvider.php": "core", "adminx/modules/Updates/assets/updates.css": "core", "adminx/modules/Updates/assets/updates.js": "core", "adminx/modules/Updates/language/en/client.xml": "core", @@ -2413,6 +2532,7 @@ "adminx/modules/Updates/view/index.twig": "core", "adminx/modules/Users/Controller.php": "core", "adminx/modules/Users/Model.php": "core", + "adminx/modules/Users/NotificationProvider.php": "core", "adminx/modules/Users/assets/users.css": "core", "adminx/modules/Users/assets/users.js": "core", "adminx/modules/Users/language/en/client.xml": "core", @@ -2423,6 +2543,8 @@ "adminx/modules/Users/language/ru/runtime.xml": "core", "adminx/modules/Users/migrations/001_normalize_public_session_activity.sql": "core", "adminx/modules/Users/migrations/002_harden_public_remember_tokens.php": "core", + "adminx/modules/Users/migrations/003_system_user_security.php": "core", + "adminx/modules/Users/migrations/004_system_session_expiry.php": "core", "adminx/modules/Users/module.php": "core", "adminx/modules/Users/view/list.twig": "core", "adminx/view/404.twig": "core", @@ -2438,8 +2560,10 @@ "help/administration/README.md": "core", "help/administration/console.md": "core", "help/administration/content-and-system-tools.md": "core", + "help/administration/customer-center.md": "core", "help/administration/dashboard.md": "core", "help/administration/operations.md": "core", + "help/administration/permission-simulator.md": "core", "help/administration/search-and-activity.md": "core", "help/administration/settings.md": "core", "help/administration/updates.md": "core", @@ -2450,6 +2574,7 @@ "help/api/errors.md": "core", "help/content/README.md": "core", "help/content/blocks.md": "core", + "help/content/bulk-editor.md": "core", "help/content/catalog.md": "core", "help/content/content-model-tools.md": "core", "help/content/content-studio.md": "core", @@ -2484,8 +2609,10 @@ "help/en/README.md": "core", "help/en/administration/README.md": "core", "help/en/administration/console.md": "core", + "help/en/administration/customer-center.md": "core", "help/en/administration/dashboard.md": "core", "help/en/administration/operations.md": "core", + "help/en/administration/permission-simulator.md": "core", "help/en/administration/search-and-activity.md": "core", "help/en/administration/settings.md": "core", "help/en/administration/updates.md": "core", @@ -2496,6 +2623,7 @@ "help/en/api/errors.md": "core", "help/en/content/README.md": "core", "help/en/content/blocks.md": "core", + "help/en/content/bulk-editor.md": "core", "help/en/content/catalog.md": "core", "help/en/content/content-model-tools.md": "core", "help/en/content/content-studio.md": "core", @@ -2570,6 +2698,7 @@ "help/en/installation/README.md": "core", "help/en/installation/admin-directory.md": "core", "help/en/media/README.md": "core", + "help/en/media/analyzer.md": "core", "help/en/modules/README.md": "core", "help/en/modules/antispam.md": "core", "help/en/modules/banners.md": "core", @@ -2581,9 +2710,11 @@ "help/en/modules/contacts.md": "core", "help/en/modules/content-packages.md": "core", "help/en/modules/contributions.md": "core", + "help/en/modules/crossposting.md": "core", "help/en/modules/dashboard-widgets.md": "core", "help/en/modules/demo-site.md": "core", "help/en/modules/document-import.md": "core", + "help/en/modules/environment-comparison.md": "core", "help/en/modules/experiments.md": "core", "help/en/modules/faq.md": "core", "help/en/modules/file-security.md": "core", @@ -2605,6 +2736,8 @@ "help/en/modules/personal-tools.md": "core", "help/en/modules/polls.md": "core", "help/en/modules/popups.md": "core", + "help/en/modules/public-widgets.md": "core", + "help/en/modules/quiz.md": "core", "help/en/modules/ratings.md": "core", "help/en/modules/related.md": "core", "help/en/modules/reliable-events.md": "core", @@ -2664,6 +2797,7 @@ "help/installation/README.md": "core", "help/installation/admin-directory.md": "core", "help/media/README.md": "core", + "help/media/analyzer.md": "core", "help/modules/README.md": "core", "help/modules/antispam.md": "core", "help/modules/banners.md": "core", @@ -2675,9 +2809,11 @@ "help/modules/contacts.md": "core", "help/modules/content-packages.md": "core", "help/modules/contributions.md": "core", + "help/modules/crossposting.md": "core", "help/modules/dashboard-widgets.md": "core", "help/modules/demo-site.md": "core", "help/modules/document-import.md": "core", + "help/modules/environment-comparison.md": "core", "help/modules/experiments.md": "core", "help/modules/faq.md": "core", "help/modules/file-security.md": "core", @@ -2699,6 +2835,8 @@ "help/modules/personal-tools.md": "core", "help/modules/polls.md": "core", "help/modules/popups.md": "core", + "help/modules/public-widgets.md": "core", + "help/modules/quiz.md": "core", "help/modules/ratings.md": "core", "help/modules/related.md": "core", "help/modules/reliable-events.md": "core", @@ -2818,6 +2956,8 @@ "system/App/Common/Session/SessionNative.php": "core", "system/App/Common/Settings.php": "core", "system/App/Common/SettingsRegistry.php": "core", + "system/App/Common/SignedEventProof.php": "core", + "system/App/Common/SiteOrigin.php": "core", "system/App/Common/StoredPhpRuntime.php": "core", "system/App/Common/SystemTables.php": "core", "system/App/Common/Template.php": "core", @@ -3108,6 +3248,7 @@ "system/App/Helpers/Arr.php": "core", "system/App/Helpers/Cookie.php": "core", "system/App/Helpers/Crypt.php": "core", + "system/App/Helpers/Csv.php": "core", "system/App/Helpers/Date.php": "core", "system/App/Helpers/Debug.php": "core", "system/App/Helpers/Dir.php": "core", @@ -3126,6 +3267,7 @@ "system/App/Helpers/SearchText.php": "core", "system/App/Helpers/Secure.php": "core", "system/App/Helpers/SoftDelete.php": "core", + "system/App/Helpers/SplitTest.php": "core", "system/App/Helpers/Str.php": "core", "system/App/Helpers/TagParser.php": "core", "system/App/Helpers/Url.php": "core", diff --git a/README.md b/README.md index b50e09b..1f23829 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # AVE.cms -**AVE.cms 3.3 build 0.35** — модульная система управления сайтами на PHP. +**AVE.cms 3.3 build 0.37** — модульная система управления сайтами на PHP. Она подходит для проектов, в которых структуру содержимого нужно собирать из собственных рубрик, полей, документов, запросов, блоков и шаблонов, не привязываясь к заранее заданному типу сайта. diff --git a/adminx/Support/GlobalSearchRegistry.php b/adminx/Support/GlobalSearchRegistry.php index 3e1bd51..313ac7e 100644 --- a/adminx/Support/GlobalSearchRegistry.php +++ b/adminx/Support/GlobalSearchRegistry.php @@ -17,6 +17,7 @@ defined('BASEPATH') || die('Direct access to this location is not allowed.'); use App\Common\Permission; + use App\Common\Language; /** * Реестр провайдеров административного поиска. @@ -140,7 +141,7 @@ return array( 'type' => self::code(isset($row['type']) ? $row['type'] : $providerCode), - 'group' => self::text(isset($row['group']) ? $row['group'] : 'Результаты', 80), + 'group' => Language::translateSource(self::text(isset($row['group']) ? $row['group'] : 'Результаты', 80)), 'title' => $title, 'subtitle' => self::text(isset($row['subtitle']) ? $row['subtitle'] : '', 300), 'url' => $url, diff --git a/adminx/Support/SectionHelp.php b/adminx/Support/SectionHelp.php index 96dcf46..ff86803 100644 --- a/adminx/Support/SectionHelp.php +++ b/adminx/Support/SectionHelp.php @@ -83,6 +83,7 @@ self::item('#^/public-site/presentations$#', 'Представления', 'Создаёт повторно используемое оформление карточек, списков, слайдеров и других результатов.', array('Черновик проверяется на реальном материале и не меняет публичный сайт.', 'Публикация фиксирует версию, а назначение выбирает место и режим применения.', 'Представление меняет внешний вид готовых данных, но не заменяет рубрику, запрос, блок или модуль.'), 'Для совместимости каждое новое назначение начинает работу в режиме «Текущий вывод».', 'content/public-site'), self::item('#^/public-site/map$#', 'Визуальная карта сайта', 'Показывает деревья меню, связанные страницы и опубликованные документы вне навигации.', array('Уровни строятся из действующих пунктов навигации.', 'Ссылка документа открывает его штатный редактор.', 'Документ вне меню может использоваться запросом, каталогом или прямой ссылкой.'), 'Карта является обзором и ничего не публикует.', 'content/public-site'), self::item('#^/public-site/placements$#', 'Размещения компонентов', 'Показывает, в каких сохранённых шаблонах используются блоки, подборки, навигации и модульные теги.', array('Вкладка «Размещения» показывает каждое найденное место отдельно.', 'Вкладка «Где используется» объединяет одинаковые компоненты и показывает число связей.', 'Кнопки справа открывают конкретный исходный шаблон и конкретный подключённый компонент.'), 'Карта предназначена для поиска связей и ничего не меняет на публичном сайте.', 'content/public-site'), + self::item('#^/public-site/templates$#', 'Шаблоны публичного сайта', 'Редактирует общие файловые Twig-компоненты активной темы.', array('Список материалов имеет системный резерв.', 'Оболочка страницы и остальные компоненты темы редактируются без поиска файла вручную.', 'Сохранение проверяет Twig, создаёт ревизию и очищает публичный кеш.'), 'Шаблоны рубрик, запросов и блоков остаются в собственных разделах.', 'content/public-site'), self::item('#^/public-site(?:/.*)?$#', 'Публичный сайт', 'Показывает, из каких базовых компонентов собирается сайт и где используется каждый из них.', array('Раздел является картой и не дублирует редакторы шаблонов, рубрик, запросов, блоков и меню.', 'Представления — дополнительный способ оформить уже подготовленные данные.', 'Диагностика сообщает только о явных пробелах в обязательных настройках.'), 'Начните со вкладки «Структура», а содержимое меняйте в штатном редакторе нужного компонента.', 'content/public-site'), self::item('#^/catalog/products/shipping$#', 'Доставка и упаковка', 'Показывает готовность товарных данных к расчёту доставки и позволяет редактировать грузовые места без открытия документа.', array('Каждое грузовое место хранит количество, вес и три габарита.', 'Расчёт включается отдельно и использует только полностью заполненную упаковку.', 'Фильтр помогает быстро найти товары без размеров или с выключенным расчётом.'), 'Вес и размеры указываются для одного грузового места; количество умножает итоговые показатели.'), @@ -91,6 +92,7 @@ self::item('#^/catalog/variant-groups(?:/.*)?$#', 'Группы вариантов', 'Объединяет отдельные товары с собственными URL и артикулами в одну карточку публичного листинга.', array('Основной вариант представляет группу до выбора цвета или исполнения.', 'Порядок вариантов определяет порядок переключателей на публичной карточке.', 'Выключенный вариант остаётся документом, но не предлагается покупателю.'), 'Каждый вариант должен сохранять собственные цену, остаток, URL и артикул.'), self::item('#^/catalog/card-templates$#', 'Карточки товаров', 'Управляет Twig-разметкой и CSS товарной карточки отдельно для разных мест публичного сайта.', array('Черновик можно проверять на живых данных товара без публикации.', 'Контексты назначают представление главной странице, каталогу и другим листингам.', 'Ревизии сохраняют историю черновиков и опубликованных версий.'), 'Публичный сайт меняется только после публикации представления и включения нативного режима нужного контекста.'), self::item('#^/catalog/filter-templates$#', 'Шаблоны фильтров', 'Управляет Twig-разметкой, CSS и клиентским поведением фасетов товарного каталога.', array('Алгоритм выборки остаётся в каталоге, а шаблон отвечает за представление.', 'Черновик можно проверить до публикации.', 'Теги шаблона выводят название фильтра, варианты и рассчитанные количества товаров.'), 'После публикации проверьте выбор, сброс и повторное применение нескольких фильтров.'), + self::item('#^/catalog/public-templates$#', 'Публичные шаблоны товаров', 'Редактирует разметку страницы товара, вариантов, рекомендаций и комплектов в активной теме.', array('Если override отсутствует, редактор показывает резервный шаблон модуля.', 'Первое сохранение создаёт файл в активной теме и подключает пространство products_public.', 'Список данных под редактором объясняет доступные Twig-переменные и вставляет их в код.'), 'Сохранение меняет публичный сайт сразу; предыдущие версии остаются в ревизиях темы.', 'content/catalog'), self::item('#^/catalog/\d+/\d+$#', 'Конструктор каталога', 'Связывает поле типа catalog с деревом разделов, документами, полями и публичными фильтрами.', array('Разделы задают структуру каталога и набор доступных полей.', 'Условия фильтров определяют SQL-ограничения публичной выборки.', 'Порядок полей и фильтров меняется перетаскиванием и сохраняется сразу.'), 'Изменения структуры влияют на редактор документов и публичный каталог.'), self::item('#^/catalog#', 'Каталог', 'Управляет структурами каталогов, товарным индексом, фильтрами и связью с документами.', array('Каждый каталог создаётся для конкретного поля рубрики типа catalog.', 'Товары являются документами и не дублируются в отдельной основной таблице.', 'Для вариантов одного товара используются группы вариантов.'), 'Начинайте с выбора каталога, затем настраивайте разделы, поля и фильтры.', 'content/catalog'), @@ -103,16 +105,21 @@ self::item('#^/navigation#', 'Навигация', 'Создаёт меню сайта: шаблоны уровней, пункты, вложенность, условия показа и связи с документами.', array('Каждая навигация имеет собственный тег для вставки в шаблон.', 'Пункты можно сортировать и переносить между уровнями.', 'Связанный документ позволяет не вводить URL вручную.'), 'После изменения структуры очищается кеш соответствующей навигации.', 'content/navigation'), self::item('#^/requests#', 'Запросы', 'Формирует выборки документов по рубрикам и полям, а затем выводит их через шаблоны элементов и списка.', array('Условия определяют, какие документы попадут в результат.', 'Основной и постраничный шаблоны отвечают только за представление.', 'External/AJAX-флаги разрешают прямой вызов запроса и требуют осторожности.'), 'Проверяйте запрос после изменения условий: он может использоваться сразу в нескольких страницах.', 'content/requests'), self::item('#^/media#', 'Медиа и файлы', 'Управляет файлами в uploads: загрузкой, папками, изображениями, превью и конвертацией WebP.', array('Сетка и список показывают одни и те же физические файлы.', 'Редактор изображений создаёт производные файлы для crop и resize.', 'При удалении исходника удаляются его превью и связанная WebP-копия.'), 'Не размещайте служебные PHP-файлы в uploads; раздел предназначен только для пользовательских медиа.', 'media'), + self::item('#^/certificates(?:/.*)?$#', 'Сертификаты', 'Хранит документы соответствия и связывает их с товарами или другими материалами.', array('Срок действия помогает заранее находить документы, которые требуют проверки.', 'Файл выбирается из медиатеки и остаётся доступен независимо от карточки связи.', 'Связанные товары используют один сертификат без дублирования файла.'), 'Истёкший срок снимает отметку о действующем сертификате, но сам товар автоматически не удаляет.'), + self::item('#^/ipra(?:/.*)?$#', 'ИПРА', 'Управляет программами реабилитации и связанными назначениями для работы с клиентами.', array('Запись связывается с пользователем сайта и хранит рабочие сроки.', 'Статус отражает текущий этап обработки, а не публичность документа.', 'Перед изменением назначения проверьте связанные обращения и заказы.'), 'Медицинские и персональные данные должны быть доступны только ролям, которым они необходимы.'), + self::item('#^/registrations(?:/.*)?$#', 'Регистрационные удостоверения', 'Ведёт реестр РУ и связи с товарами, чтобы показывать актуальный признак регистрации.', array('Один номер можно связать с несколькими товарами.', 'Дата окончания используется для контроля актуальности отметки.', 'Файл удостоверения выбирается из общей медиатеки.'), 'Окончание срока убирает признак действующего РУ; доступность товара определяется отдельно.'), + self::item('#^/service(?:/.*)?$#', 'Сервисное обслуживание', 'Хранит сервисные обращения, сроки, ответственных и историю работ по оборудованию.', array('Карточка объединяет клиента, оборудование и текущее состояние обращения.', 'Смена статуса фиксирует этап работы и остаётся в истории.', 'Сроки помогают находить просроченные обращения.'), 'Не удаляйте завершённые обращения, если история нужна для гарантийного обслуживания.'), + self::item('#^/system/release-assistant(?:/.*)?$#', 'Помощник выпуска', 'Проверяет состав локального релиза, версии модулей, миграции и готовность архивов перед ручной публикацией.', array('Предварительная проверка ничего не публикует и не меняет рабочий сайт.', 'В отчёте отдельно показаны изменённые файлы, таблицы и версии модулей.', 'Сборку запускают только после устранения ошибок проверки.'), 'Раздел предназначен для владельца исходного проекта и не включается в обычную публичную сборку.'), self::item('#^/system/orders#', 'Заказы и корзина', 'Обрабатывает заказы, состояния оплаты и доставки, шаблоны корзины и брошенные сессии покупателей.', array('Карточка заказа фиксирует товары, цены и выбранные варианты на момент оформления.', 'Способы оплаты настраиваются во вкладке «Оплата», реквизиты провайдеров — во вкладке «Gateways».', 'Брошенные корзины очищаются по сроку хранения и доступны для диагностики.'), 'Отмена и возврат могут влиять на платёжный шлюз — проверяйте итоговый статус операции.'), - self::item('#^/system/customers#', 'Пользователи сайта', 'Управляет публичными аккаунтами, регистрацией, OAuth-гейтами и полями профиля.', array('Email-регистрация, восстановление доступа и создание аккаунта после заказа входят в ядро.', 'Дополнительные поля независимо подключаются к регистрации и личному кабинету.', 'Подключённые OAuth-гейты открывают ту же публичную сессию, что и обычная форма входа.', 'Отключение аккаунта запрещает вход, но сохраняет связанные данные.'), 'Email остаётся основным идентификатором; дополнительные способы входа поставляются отдельными модулями.'), + self::item('#^/system/customers#', 'Пользователи сайта', 'Управляет публичными аккаунтами, регистрацией, OAuth-гейтами и полями профиля.', array('Регистрацию можно разрешить по email, телефону или обоими способами.', 'Телефонный аккаунт не требует email; адрес можно добавить и подтвердить позже.', 'Дополнительные поля независимо подключаются к регистрации и личному кабинету.', 'Подключённые OAuth-гейты открывают ту же публичную сессию, что и обычная форма входа.', 'Отключение аккаунта запрещает вход, но сохраняет связанные данные.'), 'Для регистрации по телефону установите и настройте SMS-провайдер; без него телефонная форма не показывается.'), self::item('#^/content/contacts/forms/\d+$#', 'Конструктор формы', 'Настраивает поля, шаблон формы, получателей, письмо и поведение после отправки.', array('Теги полей доступны в шаблоне формы и письма.', 'Получатели задаются по одному на строку.', 'История обращений хранит отправленные значения и ответы менеджеров.'), 'После изменения шаблона отправьте тестовое обращение и проверьте письмо.'), self::item('#^/content/contacts#', 'Формы и обращения', 'Управляет публичными формами обратной связи и журналом сообщений посетителей.', array('Форма состоит из полей, шаблонов и почтовых настроек.', 'История позволяет менять статус обращения и отвечать посетителю.', 'Копирование формы создаёт независимый набор настроек.'), 'Проверяйте адреса получателей и настройки системной почты перед публикацией формы.'), self::item('#^/content/rss#', 'RSS-каналы', 'Публикует документы выбранной рубрики в XML-ленте по настраиваемому адресу /rss/{alias}.', array('Заголовок и описание можно брать из полей рубрики.', 'Количество элементов ограничивает размер выдаваемой ленты.', 'Префикс /rss/ зарезервирован и не может использоваться документами.'), 'После изменения алиаса обновите внешние подписки и проверьте XML из списка.'), self::item('#^/content/feeds#', 'Товарные фиды', 'Формирует управляемые XML/YML-выгрузки товаров для партнеров и торговых площадок.', array('Категории определяют товарную выборку, исключения применяются после дочерних разделов.', 'Поля XML и характеристики связываются с индексом каталога или полями товарной рубрики.', 'Предпросмотр не меняет кеш, а пересоздание атомарно публикует новый файл.'), 'Не меняйте закрепленные legacy URL у действующих партнеров; редактируйте связанную конфигурацию фида.'), self::item('#^/content/price-import#', 'Импорт цен', 'Обновляет цены товаров из XLSX после обязательного предварительного просмотра изменений.', array('Сначала загрузите файл или получите его по настроенной HTTPS-ссылке.', 'Выберите лист и сформируйте предварительный просмотр.', 'Применение изменяет только подтверждённые строки и записывает результат в историю.'), 'Не применяйте импорт, если предварительный просмотр показывает неожиданные товары или пустые цены.'), - self::item('#^/users#', 'Пользователи', 'Управляет сотрудниками, которые могут входить в панель управления, их ролями и активностью.', array('Роль определяет доступные разделы и действия.', 'Отключённый пользователь не может войти, но сохраняется в журнале действий.', 'Нельзя отключить или удалить собственную активную учётную запись.'), 'Публичные аккаунты находятся в отдельном разделе «Пользователи сайта».', 'administration/users'), + self::item('#^/users#', 'Пользователи', 'Управляет сотрудниками, которые могут входить в панель управления, их ролями, паролями и активными сессиями.', array('Роль определяет доступные разделы и действия.', 'Во вкладке «Сессии и входы» видны устройства, IP и неудачные попытки входа.', 'Временный пароль можно потребовать сменить при следующем входе.', 'Отключение учётной записи завершает все её активные сессии.', 'Нельзя отключить или удалить собственную активную учётную запись.'), 'Публичные аккаунты находятся в отдельном разделе «Пользователи сайта».', 'administration/users'), self::item('#^/roles#', 'Роли и права', 'Определяет набор разрешений для администраторов и разграничивает доступ к функциям системы.', array('Права сгруппированы по модулям и назначаются роли целиком.', 'Системная роль admin всегда имеет полный доступ.', 'Роль с назначенными пользователями нельзя удалить.'), 'После изменения роли права применяются при следующей проверке доступа пользователя.'), self::item('#^/settings/main$#', 'Основные настройки', 'Управляет публичным доступом, параметрами сайта, почты, документов и вывода.', array('Режим разработки возвращает посетителям временную страницу 503 и закрывает динамические маршруты от индексации.', 'Сотрудник с отдельным правом продолжает видеть документы, модули и API.', 'Поля сгруппированы по назначению, а тип значения проверяется перед сохранением.', 'Секреты устанавливаемых модулей не должны храниться в общих настройках.'), 'Перед открытием сайта переключите публичный доступ и проверьте страницу в отдельном браузере без авторизации.'), self::item('#^/settings/interface$#', 'Настройка интерфейса', 'Определяет порядок и видимость пунктов левого меню и виджетов дашборда.', array('Перетаскивание меняет порядок только для панели управления.', 'Скрытие пункта не отменяет право роли и не блокирует прямой URL.', 'Виджеты установленных модулей появляются в списке автоматически.'), 'Доступ к функциям всегда ограничивайте через роли и права, а не только через видимость меню.'), @@ -127,25 +134,25 @@ self::item('#^/events#', 'Системные события', 'Объединяет аудит действий, ошибки, SQL-журнал, 404 и статистику внешних переходов.', array('Цвет и состояние помогают отличать успешные операции, предупреждения и ошибки.', 'Переходы группируются по посетителю, странице входа и источнику; открытые IP и произвольные query-параметры не сохраняются.', 'Фильтры и CSV-экспорт работают отдельно для каждого источника.'), 'Очистка журнала необратима и не исправляет причину ошибки.'), self::item('#^/security/ip-blocks#', 'IP-блокировки', 'Управляет ручными блокировками адресов, а автоматические ограничения выполняет RateLimiter.', array('Блокировка может быть постоянной или иметь дату окончания.', 'Причина видна администраторам и записывается в аудит.', 'Разблокировка возвращает доступ сразу.'), 'Не блокируйте адрес reverse proxy без проверки реального клиентского IP.'), self::item('#^/database#', 'База данных', 'Показывает рабочие таблицы AVE.cms, выполняет обслуживание, применяет миграции ядра и управляет полными резервными копиями.', array('OPTIMIZE применяется только к выбранной таблице или ко всей рабочей схеме после подтверждения.', 'Применение миграций не восстанавливает данные: это отдельная операция развития структуры ядра.', 'Новые дампы используют {{prefix}}, поэтому могут восстанавливаться с другим dbpref.', 'Перед восстановлением система проверяет файл и автоматически создаёт страховочную копию.'), 'Восстановление полностью заменяет таблицы текущей установки; рабочую копию дополнительно храните вне сервера.', 'database'), - self::item('#^/kanban$#', 'Канбан', 'Организует личные рабочие карточки по колонкам и показывает доску на дашборде.', array('Карточки и порядок сохраняются без перезагрузки.', 'Колонки можно настроить под собственный процесс.', 'Каждый сотрудник работает со своей доской.'), 'Не используйте канбан как журнал системных ошибок: для них предназначены события и Todo.'), + self::item('#^/kanban$#', 'Канбан', 'Организует рабочие карточки по колонкам и показывает доску на дашборде.', array('Карточки и порядок сохраняются без перезагрузки.', 'Колонки можно настроить под собственный процесс.', 'Карточке можно назначить ответственного, срок и связанный документ.', 'Командная карточка видна коллегам, а назначенная отдельно появляется у ответственного.'), 'Колонки принадлежат владельцу доски: назначенный сотрудник видит карточку в общем списке, но не перестраивает чужую доску.'), self::item('#^/notes$#', 'Заметки', 'Хранит личные текстовые заметки сотрудника и закрепляет важные записи на дашборде.', array('Заметки видны только их владельцу.', 'Закрепление влияет на порядок выдачи.', 'Быстрое создание доступно из шапки, если вклад модуля включён.'), 'Не храните в заметках пароли, токены и другие секреты.'), self::item('#^/reminders$#', 'Напоминания', 'Хранит личные задачи с датой и выводит просроченные пункты в общем колокольчике.', array('Срок определяет состояние напоминания.', 'Выполнение сохраняется сразу.', 'Просроченный счётчик формируется отдельно для текущего сотрудника.'), 'Удаление напоминания необратимо; выполненный пункт можно вернуть в работу.'), self::item('#^/notfound$#', 'Ошибки 404', 'Показывает несуществующие публичные адреса и помогает создать документ или редирект.', array('Сначала проверьте источник переходов и частоту запроса.', 'Для старого адреса существующей страницы создайте постоянный редирект.', 'Случайные сканирующие URL можно удалить из журнала без создания страницы.'), 'Редирект должен вести на действительно соответствующий документ, а не всегда на главную.'), self::item('#^/seo-audit$#', 'SEO-аудит', 'Проверяет документы на технические и редакционные проблемы, влияющие на поисковое представление.', array('Фильтры помогают сначала разобрать критичные и массовые проблемы.', 'Оценка документа складывается из нескольких независимых проверок.', 'Исправления выполняются в редакторе документа и проявляются после повторного аудита.'), 'Автоматическая оценка является подсказкой и не заменяет проверку содержания страницы.', 'modules/seo-audit'), - self::item('#^/todo$#', 'Todo', 'Хранит личный список замечаний и следующих действий для текущего администратора.', array('Задачу можно создать из выпадающего списка в шапке на любой странице панели управления.', 'Чекбокс сразу сохраняет состояние без перезагрузки.', 'Каждый пользователь видит только собственные задачи.'), 'Удаление задачи необратимо; выполненные пункты можно вернуть в работу обычным чекбоксом.'), + self::item('#^/todo$#', 'Todo', 'Хранит рабочие задачи сотрудников со сроками и связями с документами.', array('Задачу можно создать из выпадающего списка в шапке на любой странице панели управления.', 'Ответственный видит назначенную задачу и может отметить её выполненной.', 'Режим «Команда» открывает задачу всем сотрудникам с правом просмотра Todo.', 'Связанный документ открывается прямо из строки задачи.'), 'Изменять и удалять задачу может её создатель; выполнение доступно создателю и ответственному.'), self::item('#^/modules/search#', 'Поиск по сайту', 'Индексирует опубликованные документы и формирует обычную страницу результатов и JSON для живого поиска.', array('Области позволяют отдельно искать по товарам, новостям, статьям и другим наборам рубрик.', 'Алгоритм определяет разбор строки и вес точного совпадения, заголовка и содержимого.', 'Штатный HTML можно заменить шаблонами модуля или существующим запросом документов.'), 'После изменения рубрик или индексируемых полей выполните полную переиндексацию.', 'modules/search'), self::item('#^/modules/commerceml#', 'Обмен с 1С', 'Принимает CommerceML по защищённому адресу и сопоставляет товары 1С с документами товарной рубрики.', array('Для каждой интеграции создайте отдельный профиль с логином, паролем и рубрикой.', 'Поля внешнего ID, артикула, цены и остатка выбираются из полей назначенной рубрики.', 'Пробный XML можно загрузить из списка профилей и проверить результат в журнале обменов.'), 'Создание неизвестных товаров выключено по умолчанию: сначала проверьте сопоставление на существующих документах.'), - self::item('#^/modules/experiments#', 'A/B-тесты', 'Распределяет посетителей между вариантами и собирает показы и конверсии без смешивания вариантов в общем кеше.', array('Код эксперимента используется в шаблонном теге, API и атрибутах отслеживания конверсии.', 'Вес вариантов задаёт распределение внутри выбранного охвата аудитории.', 'Черновик не участвует в публичном выводе; перед запуском проверьте каждый вариант.'), 'Изменение цены штатным вариантом влияет только на представление и не меняет сумму заказа.'), + self::item('#^/modules/experiments#', 'A/B-тесты', 'Распределяет посетителей между вариантами и проверяет, достаточно ли данных для вывода.', array('Код эксперимента используется в шаблонном теге, API и атрибутах отслеживания конверсии.', 'Вес вариантов задаёт распределение внутри выбранного охвата аудитории.', 'Минимум показов защищает от слишком раннего выбора победителя.', 'После набора выборки панель показывает погрешность, достоверность и устойчивый вариант.'), 'Не останавливайте тест по одному проценту конверсии: дождитесь состояния с достоверным результатом.'), self::item('#^/modules/popups#', 'Всплывающие окна', 'Управляет кампаниями поп-апов, условиями показа, A/B-вариантами и собранными заявками.', array('Сценарий определяет момент показа: задержку, прокрутку или попытку ухода.', 'Пути включения и исключения принимают по одному шаблону на строку; символ * заменяет произвольную часть.', 'HTML вариантов контролируется проектом, а заявки обрабатываются на отдельной вкладке.'), 'Проверьте кампанию на тестовом URL и мобильном экране до включения автоматической вставки.'), - self::item('#^/modules/reviews#', 'Отзывы', 'Собирает оценки и отзывы к документам, позволяет модерировать публикацию и отвечать от имени компании.', array('Состояние определяет, попадёт ли отзыв в публичный список.', 'Признак подтверждённой покупки устанавливается только после проверки заказа.', 'Ответ компании сохраняется вместе с отзывом и выводится его публичным шаблоном.'), 'Удаление отзыва необратимо; спорный материал безопаснее сначала отклонить.', 'modules/reviews'), + self::item('#^/modules/reviews#', 'Отзывы', 'Собирает оценки и отзывы к документам, позволяет модерировать публикацию и отвечать от имени компании.', array('Состояние определяет, попадёт ли отзыв в публичный список.', 'Признак подтверждённой покупки устанавливается только после проверки заказа.', 'Выберите несколько строк, чтобы опубликовать, отклонить, вернуть на модерацию или удалить их одним действием.', 'Ответ компании сохраняется вместе с отзывом и выводится его публичным шаблоном.'), 'Удаление отзыва необратимо; спорный материал безопаснее сначала отклонить.', 'modules/reviews'), self::item('#^/modules/scheduler#', 'Планировщик', 'Собирает фоновые задачи установленных модулей в единый реестр, запускает их по расписанию и хранит журнал.', array('Защищённый URL добавляется в планировщик хостинга с интервалом в одну минуту.', 'Cron-выражение и активность каждой задачи меняются прямо в таблице.', 'Ручной запуск использует тот же обработчик и попадает в общий журнал.'), 'После смены секретного URL обновите задание на хостинге: старый адрес сразу перестанет работать.'), self::item('#^/modules/search-analytics#', 'Поисковые запросы', 'Показывает реальные поисковые фразы посетителей, частоту запросов и случаи пустой выдачи.', array('Фильтр по области разделяет спрос по новостям, товарам, статьям и другим настроенным областям.', 'Состояние и заметка позволяют отмечать разобранные запросы и планировать новый контент.', 'Показатель «Результатов сейчас» помогает проверить, исправлена ли нулевая выдача.'), 'Очистка истории удаляет аналитические данные, но не затрагивает поисковый индекс.'), self::item('#^/modules/system-health#', 'Мониторинг здоровья', 'Проверяет доступность базы данных, диска, фоновых задач и заданных внешних HTTPS-сервисов.', array('Текущий снимок показывает независимый результат каждой инфраструктурной проверки.', 'История помогает отличить разовый сбой от повторяющейся проблемы.', 'Для внешнего сервиса задаются ожидаемый HTTP-статус и ограниченный таймаут.'), 'Мониторинг фиксирует рабочее состояние, но не заменяет приёмочный чек-лист перед запуском сайта.'), self::item('#^/modules/traffic-analytics#', 'Источники трафика', 'Группирует входящие переходы по UTM-меткам, источникам, кампаниям и посадочным страницам.', array('Период и фильтры одинаково применяются к сводке, таблицам и CSV-экспорту.', 'UTM-конструктор собирает ссылку в браузере и не сохраняет введённые значения.', 'Один вход посетителя на ту же страницу за день агрегируется без хранения IP.'), 'Перед очисткой выбранного периода выгрузите CSV, если данные нужны для отчётности.'), self::item('#^/modules/antispam#', 'Антиспам', 'Настраивает профили защиты публичных форм и показывает журнал отклонённых отправок.', array('Профиль объединяет лимиты, проверку скрытого поля и анализ содержимого для конкретной формы.', 'Журнал объясняет, какое правило остановило запрос, и помогает скорректировать чувствительность.', 'Доверенные исключения применяйте только к контролируемым интеграциям.'), 'Не ослабляйте общий профиль из-за одного ложного срабатывания: создайте отдельный профиль для нужной формы.'), self::item('#^/modules/interactions#', 'Взаимодействия', 'Хранит общий журнал оценок, реакций и голосов, который используют прикладные модули.', array('Канал определяет допустимые действия и типы объектов.', 'Агрегаты ускоряют публичный вывод счётчиков и рейтингов.', 'Пересчёт восстанавливает агрегаты из исходного журнала действий.'), 'Обычно этот раздел не вставляется в шаблон напрямую: публичный интерфейс предоставляют рейтинги, опросы и комментарии.'), - self::item('#^/modules/ratings#', 'Рейтинги', 'Добавляет на документы и другие разрешённые объекты настраиваемую шкалу оценок.', array('Документы включают страницы, товары, новости и статьи, построенные на рубриках.', 'Тег [mod_rating] использует текущий документ; явная цель задаётся типом и ID.', 'Кнопки тегов вставляют доступные значения в HTML-шаблон рейтинга.'), 'Голоса и итоговые значения хранятся в модуле «Взаимодействия».'), - self::item('#^/modules/comments#', 'Комментарии', 'Управляет древовидными обсуждениями, премодерацией, антиспамом и публичными шаблонами.', array('Разрешите только те типы объектов, на которых действительно нужны обсуждения.', 'Шаблоны списка, комментария и формы имеют собственные наборы тегов.', 'Публикация, скрытие и отметка спама выполняются из общего журнала.'), 'Оценка комментариев использует отдельный канал модуля «Взаимодействия».'), + self::item('#^/modules/ratings#', 'Рейтинги', 'Добавляет на документы и другие разрешённые объекты настраиваемую шкалу оценок.', array('Документы включают страницы, товары, новости и статьи, построенные на рубриках.', 'Тег [mod_rating] использует текущий документ; явная цель задаётся типом и ID.', 'Список голосов позволяет удалить ошибочную оценку или сбросить рейтинг объекта.', 'После модерации среднее значение пересчитывается автоматически.'), 'Удаление голосов необратимо и записывается в аудит; сам документ при этом не изменяется.'), + self::item('#^/modules/comments#', 'Комментарии', 'Управляет древовидными обсуждениями, премодерацией, антиспамом и публичными шаблонами.', array('Разрешите только те типы объектов, на которых действительно нужны обсуждения.', 'Шаблоны списка, комментария и формы имеют собственные наборы тегов.', 'Публикация, скрытие и отметка спама выполняются из общего журнала.', 'Выбор строк позволяет обработать до 500 комментариев одним массовым действием.'), 'Оценка комментариев использует отдельный канал модуля «Взаимодействия».'), self::item('#^/modules/polls#', 'Опросы', 'Создаёт одиночные и множественные голосования с расписанием, доступом и управляемыми результатами.', array('Для публикации вставьте [mod_poll:ID] в документ, блок или шаблон.', 'Архив опросов доступен по настраиваемому публичному URL.', 'Шаблоны опроса, варианта, результата и архива имеют разные наборы тегов.'), 'Удаление опроса также удаляет варианты ответа и собранные голоса.'), self::item('#^/modules/galleries#', 'Галереи', 'Собирает изображения медиатеки в упорядоченные сетки и слайдеры с собственными публичными URL.', array('Файлы остаются в медиатеке; галерея хранит только связи, подписи и порядок.', 'Тег [mod_gallery:ID] выводит коллекцию в документе, блоке или шаблоне.', 'Отдельные шаблоны управляют оболочкой галереи и разметкой одного изображения.'), 'Удаление галереи не удаляет исходные файлы и созданные для них превью.'), self::item('#^/modules/banners#', 'Баннеры', 'Управляет рекламными местами, ротацией изображений, расписанием и внутренней статистикой переходов.', array('Стабильный код места используется в теге [mod_banner:КОД].', 'Вес влияет только на стратегию «По весу»; равномерная ротация выбирает материал с меньшим числом показов.', 'Изображения выбираются из общей медиатеки и не удаляются вместе с баннером.'), 'Нулевой лимит означает отсутствие ограничения; сброс статистики необратим.'), diff --git a/adminx/Support/SensitiveRoutes.php b/adminx/Support/SensitiveRoutes.php index d4bdc56..96decf8 100644 --- a/adminx/Support/SensitiveRoutes.php +++ b/adminx/Support/SensitiveRoutes.php @@ -26,6 +26,7 @@ 'core.update' => 'Установка подписанного обновления ядра', 'stored_php.execute' => 'Выполнение сохранённого PHP-кода', 'stored_php.write' => 'Сохранение PHP-кода, исполняемого runtime', + 'stored_html.write' => 'Сохранение HTML форм и разметки, исполняемых в публичной странице', 'theme_assets.write' => 'Изменение исполняемых CSS/JavaScript и файлов публичной темы', 'legacy_migration.write' => 'Изменение реквизитов исходной системы', 'legacy_migration.execute' => 'Очистка стартовых данных и миграция старой AVE.cms', @@ -86,6 +87,12 @@ 'POST /modules/legacy-migration/runs/{id}/rollback' => array('legacy_migration.execute', 'run_legacy_migration'), 'POST /modules/legacy-migration/runs/{id}/files' => array('legacy_migration.execute', 'run_legacy_migration'), 'POST /database/backup/restore' => array('database.restore', 'manage_database'), + 'POST /modules/popups/{id}' => array('stored_html.write', 'manage_popup_code'), + 'POST /modules/popups/{id}/delete' => array('stored_html.write', 'manage_popup_code'), + 'POST /modules/popups/settings' => array('stored_html.write', 'manage_popup_code'), + 'POST /modules/experiments/{id}' => array('stored_html.write', 'manage_experiment_code'), + 'POST /modules/experiments/{id}/delete' => array('stored_html.write', 'manage_experiment_code'), + 'POST /modules/experiments/settings' => array('stored_html.write', 'manage_experiment_code'), ); public static function validate(array $route) diff --git a/adminx/Support/ViewOverrideEditor.php b/adminx/Support/ViewOverrideEditor.php new file mode 100644 index 0000000..0809695 --- /dev/null +++ b/adminx/Support/ViewOverrideEditor.php @@ -0,0 +1,196 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Support; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\Twig; + use App\Adminx\Themes\Model; + use App\Frontend\ThemeAssets; + use App\Helpers\Json; + + /** Safe editor for a fixed set of public Twig view overrides. */ + class ViewOverrideEditor + { + protected $namespace; + protected $fallbackRoot; + protected $fallbackPrefix; + protected $definitions; + + public function __construct($namespace, $fallbackRoot, $fallbackPrefix, array $definitions) + { + $namespace = trim((string) $namespace); + if (!preg_match('/^[A-Za-z][A-Za-z0-9_]{0,63}$/', $namespace)) { + throw new \InvalidArgumentException('Некорректное пространство публичных шаблонов'); + } + + $this->namespace = $namespace; + $this->fallbackRoot = rtrim(str_replace('\\', '/', (string) $fallbackRoot), '/'); + $this->fallbackPrefix = trim(str_replace('\\', '/', (string) $fallbackPrefix), '/'); + $this->definitions = $this->normalizeDefinitions($definitions); + } + + public function all() + { + $out = array(); + foreach ($this->definitions as $code => $definition) { + $out[] = $this->describe($code, $definition); + } + + return $out; + } + + public function one($code) + { + $code = trim((string) $code); + if ($code === '' || !isset($this->definitions[$code])) { + $code = (string) key($this->definitions); + } + + return $this->describe($code, $this->definitions[$code], true); + } + + public function save($code, $content, $authorId, $action = 'public-view') + { + $this->assertCode($code); + $item = $this->one($code); + $syntax = $this->lint($content, $code); + if (empty($syntax['ok'])) { + throw new \InvalidArgumentException($syntax['message']); + } + + Model::saveFile($item['theme'], $item['theme_path'], (string) $content, (int) $authorId, $action); + $this->ensureNamespace($item['theme'], (int) $authorId); + return $this->one($item['code']); + } + + public function deleteOverride($code, $authorId) + { + $this->assertCode($code); + $item = $this->one($code); + if (empty($item['has_fallback'])) { + throw new \RuntimeException('Для этого шаблона нет резервной версии'); + } + + if (empty($item['has_theme_file'])) { + throw new \RuntimeException('Переопределение темы не найдено'); + } + + Model::deletePath($item['theme'], $item['theme_path'], (int) $authorId); + return $this->one($item['code']); + } + + public function lint($content, $code = 'template') + { + $content = (string) $content; + if (trim($content) === '') { + return array('ok' => false, 'message' => 'Twig-шаблон не может быть пустым'); + } + + try { + Twig::twig()->createTemplate($content, 'public_view_' . preg_replace('/[^a-z0-9_]/i', '_', (string) $code)); + } catch (\Throwable $e) { + return array('ok' => false, 'message' => 'Ошибка Twig: ' . $e->getMessage()); + } + + return array('ok' => true, 'message' => 'Twig-синтаксис корректен'); + } + + protected function describe($code, array $definition, $includeContent = false) + { + $theme = ThemeAssets::currentTheme(); + $themePath = 'views/' . $this->namespace . '/' . $definition['file']; + $themeFile = BASEPATH . '/templates/' . $theme . '/' . $themePath; + $fallbackFile = $this->fallbackRoot !== '' ? $this->fallbackRoot . '/' . $definition['file'] : ''; + $manifest = ThemeAssets::manifest($theme); + $namespaceEnabled = in_array($this->namespace, isset($manifest['view_overrides']) ? $manifest['view_overrides'] : array(), true); + $hasThemeFile = is_file($themeFile) && is_readable($themeFile); + $hasFallback = $fallbackFile !== '' && is_file($fallbackFile) && is_readable($fallbackFile); + $activeOverride = $namespaceEnabled && $hasThemeFile; + $source = $activeOverride ? 'theme' : ($hasThemeFile ? 'theme_disabled' : ($hasFallback ? 'fallback' : 'missing')); + $item = array_merge($definition, array( + 'code' => $code, + 'namespace' => $this->namespace, + 'theme' => $theme, + 'theme_path' => $themePath, + 'fallback_path' => $this->fallbackPrefix !== '' ? $this->fallbackPrefix . '/' . $definition['file'] : '', + 'has_theme_file' => $hasThemeFile, + 'has_fallback' => $hasFallback, + 'can_delete_override' => $hasThemeFile && $hasFallback, + 'namespace_enabled' => $namespaceEnabled, + 'active_override' => $activeOverride, + 'source' => $source, + 'source_label' => $activeOverride ? 'Активная тема' : ($hasThemeFile ? 'Файл темы отключён' : ($hasFallback ? 'Резерв компонента' : 'Файл отсутствует')), + 'modified_label' => $hasThemeFile ? date('d.m.Y H:i', (int) filemtime($themeFile)) : ($hasFallback ? date('d.m.Y H:i', (int) filemtime($fallbackFile)) : ''), + )); + + if ($includeContent) { + $sourceFile = $hasThemeFile ? $themeFile : $fallbackFile; + $item['content'] = $sourceFile !== '' && is_file($sourceFile) ? (string) file_get_contents($sourceFile) : ''; + $item['fallback_content'] = $hasFallback ? (string) file_get_contents($fallbackFile) : ''; + } + + return $item; + } + + protected function ensureNamespace($theme, $authorId) + { + $manifest = ThemeAssets::manifest($theme); + $overrides = isset($manifest['view_overrides']) && is_array($manifest['view_overrides']) + ? $manifest['view_overrides'] + : array(); + if (in_array($this->namespace, $overrides, true)) { return; } + + $overrides[] = $this->namespace; + $manifest['view_overrides'] = array_values(array_unique($overrides)); + Model::saveFile( + $theme, + ThemeAssets::MANIFEST, + Json::encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n", + (int) $authorId, + 'public-view-namespace' + ); + } + + protected function assertCode($code) + { + if (!isset($this->definitions[trim((string) $code)])) { + throw new \InvalidArgumentException('Публичный шаблон не найден'); + } + } + + protected function normalizeDefinitions(array $definitions) + { + $out = array(); + foreach ($definitions as $code => $definition) { + $code = trim((string) $code); + $file = isset($definition['file']) ? ThemeAssets::normalizePath($definition['file']) : ''; + if (!preg_match('/^[a-z][a-z0-9_-]{0,63}$/', $code) || $file === '' || substr($file, -5) !== '.twig') { + throw new \InvalidArgumentException('Некорректное описание публичного шаблона'); + } + + $definition['file'] = $file; + $definition['title'] = isset($definition['title']) ? (string) $definition['title'] : $code; + $definition['description'] = isset($definition['description']) ? (string) $definition['description'] : ''; + $definition['icon'] = isset($definition['icon']) ? (string) $definition['icon'] : 'ti ti-template'; + $definition['group'] = isset($definition['group']) ? (string) $definition['group'] : ''; + $definition['variables'] = isset($definition['variables']) && is_array($definition['variables']) ? $definition['variables'] : array(); + $out[$code] = $definition; + } + + if (!$out) { throw new \InvalidArgumentException('Реестр публичных шаблонов пуст'); } + return $out; + } + } diff --git a/adminx/Support/WorkItemContext.php b/adminx/Support/WorkItemContext.php new file mode 100644 index 0000000..1c236aa --- /dev/null +++ b/adminx/Support/WorkItemContext.php @@ -0,0 +1,117 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Support; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\SystemTables; + use App\Common\Permission; + use App\Content\ContentTables; + use App\Content\Documents\DocumentPickerRepository; + use DB; + + /** Shared assignee and document context for panel work items. */ + class WorkItemContext + { + public static function users() + { + $rows = DB::query( + 'SELECT id,name,email FROM ' . SystemTables::table('users') + . ' WHERE is_active=1 ORDER BY name,email,id' + )->getAll(); + $result = array(); + foreach ($rows ?: array() as $row) { + $result[] = array( + 'id' => (int) $row['id'], + 'name' => trim((string) $row['name']) !== '' ? (string) $row['name'] : (string) $row['email'], + 'email' => (string) $row['email'], + ); + } + + return $result; + } + + public static function assignee($userId) + { + $userId = max(0, (int) $userId); + if ($userId < 1) { return 0; } + return (int) DB::query( + 'SELECT id FROM ' . SystemTables::table('users') . ' WHERE id=%i AND is_active=1 LIMIT 1', + $userId + )->getValue(); + } + + public static function documents($query, $limit = 20) + { + if (!Permission::check('view_documents')) { return array(); } + return (new DocumentPickerRepository())->search($query, array(), $limit); + } + + public static function target($type, $id) + { + if (!Permission::check('view_documents')) { return array('', 0); } + $type = strtolower(trim((string) $type)); + $id = max(0, (int) $id); + if ($type !== 'document' || $id < 1) { return array('', 0); } + $exists = (int) DB::query( + 'SELECT Id FROM ' . ContentTables::table('documents') . ' WHERE Id=%i AND document_deleted!=%s LIMIT 1', + $id, + '1' + )->getValue(); + return $exists > 0 ? array('document', $exists) : array('', 0); + } + + public static function decorateTargets(array &$items) + { + if (!Permission::check('view_documents')) { return; } + $ids = array(); + foreach ($items as $item) { + if (isset($item['target_type'], $item['target_id']) && $item['target_type'] === 'document' && (int) $item['target_id'] > 0) { + $ids[(int) $item['target_id']] = (int) $item['target_id']; + } + } + + $documents = array(); + if ($ids) { + $rows = DB::query( + 'SELECT Id,document_title,document_alias FROM ' . ContentTables::table('documents') + . ' WHERE Id IN (' . implode(',', $ids) . ') AND document_deleted!=%s', + '1' + )->getAll(); + foreach ($rows ?: array() as $row) { $documents[(int) $row['Id']] = $row; } + } + + foreach ($items as &$item) { + $item['target_title'] = ''; + $item['target_alias'] = ''; + $item['target_url'] = ''; + $id = isset($item['target_id']) ? (int) $item['target_id'] : 0; + if (!isset($documents[$id])) { continue; } + $item['target_title'] = htmlspecialchars_decode((string) $documents[$id]['document_title'], ENT_QUOTES); + $item['target_alias'] = (string) $documents[$id]['document_alias']; + $item['target_url'] = rtrim(ADMINX_BASE, '/') . '/documents/' . $id . '/edit'; + } + + unset($item); + } + + public static function dueAt($value) + { + $value = trim((string) $value); + if ($value === '') { return 0; } + $timestamp = strtotime($value); + return $timestamp === false ? 0 : max(0, (int) $timestamp); + } + } diff --git a/adminx/assets/js/adminx.js b/adminx/assets/js/adminx.js index f332fe0..1fe7019 100644 --- a/adminx/assets/js/adminx.js +++ b/adminx/assets/js/adminx.js @@ -575,6 +575,8 @@ // cancelLabel, onConfirm }); // ------------------------------------------------------------------ // Adminx.Confirm = { + active: null, + ICONS: { info: ['info', 'ti-info-circle'], success: ['success', 'ti-circle-check'], @@ -584,6 +586,11 @@ open: function (cfg) { cfg = cfg || {}; + if (this.active) { + this.active.focus(); + return false; + } + var kind = this.ICONS[cfg.kind] ? cfg.kind : 'warning'; var ic = this.ICONS[kind]; @@ -615,21 +622,46 @@ requestAnimationFrame(function () { overlay.classList.add('show'); }); var self = this; - var close = function () { + var previousFocus = document.activeElement; + var confirmButton = modal.querySelector('[data-ok]'); + var settled = false; + var finish = function (confirmed) { + if (settled) { return; } + settled = true; + document.removeEventListener('keydown', onKey, true); + self.active = null; overlay.classList.remove('show'); - setTimeout(function () { overlay.remove(); document.removeEventListener('keydown', onKey); }, 180); + setTimeout(function () { + overlay.remove(); + if (!confirmed && previousFocus && document.contains(previousFocus)) { + previousFocus.focus(); + } + }, 180); + if (confirmed && typeof cfg.onConfirm === 'function') { + cfg.onConfirm(); + } + }; + var onKey = function (e) { + if (e.key !== 'Escape' && e.key !== 'Enter') { return; } + e.preventDefault(); + e.stopPropagation(); + if (typeof e.stopImmediatePropagation === 'function') { e.stopImmediatePropagation(); } + finish(e.key === 'Enter'); }; - var onKey = function (e) { if (e.key === 'Escape') { close(); } }; overlay.addEventListener('click', function (e) { - if (e.target === overlay || e.target.closest('[data-cancel]')) { close(); return; } + if (e.target === overlay || e.target.closest('[data-cancel]')) { finish(false); return; } if (e.target.closest('[data-ok]')) { - close(); - if (typeof cfg.onConfirm === 'function') { cfg.onConfirm(); } + finish(true); } }); - document.addEventListener('keydown', onKey); - modal.querySelector('[data-ok]').focus(); + document.addEventListener('keydown', onKey, true); + this.active = { + focus: function () { confirmButton.focus(); }, + close: function () { finish(false); } + }; + confirmButton.focus(); + return true; } }; diff --git a/adminx/index.php b/adminx/index.php index 8d77577..ac24ebc 100644 --- a/adminx/index.php +++ b/adminx/index.php @@ -172,6 +172,18 @@ $authUser && Auth::ensureBrowserToken(); $canAdmin = $authUser !== null && Permission::checkAcp('admin_panel'); + $forcePasswordPath = in_array($requestPath, array('/account/password', '/logout', '/locale'), true); + if ($canAdmin && !empty($authUser['must_change_password']) && !$forcePasswordPath) { + if (Request::isAjax()) { + Response::json(array( + 'success' => false, + 'message' => 'Сначала смените временный пароль.', + 'redirect' => ADMINX_BASE . '/account/password', + ), 428); + } + + Request::redirect(ADMINX_BASE . '/account/password'); + } //-- Пункты меню для layout: фильтр по правам + активный пункт + абсолютный href. $navFlat = []; diff --git a/adminx/modules/Auth/Controller.php b/adminx/modules/Auth/Controller.php index b8abdb7..e9a4af0 100644 --- a/adminx/modules/Auth/Controller.php +++ b/adminx/modules/Auth/Controller.php @@ -57,6 +57,16 @@ $login = Request::postStr('login'); $password = (string) Request::post('password', ''); $remember = Request::postBool('remember', false); + $identityKey = 'admin-login-user:' . hash('sha256', mb_strtolower(trim($login), 'UTF-8')); + $ipKey = 'admin-login-ip:' . Request::ip(); + if (RateLimiter::tooManyAttempts($identityKey, 5) || RateLimiter::tooManyAttempts($ipKey, 20)) { + $this->auditLoginFailure($login, 'rate_limit'); + $wait = max(RateLimiter::availableIn($identityKey), RateLimiter::availableIn($ipKey)); + return $this->render('@auth/login.twig', array( + 'error' => 'Слишком много попыток входа. Повторите через ' . max(1, $wait) . ' сек.', + 'login_prev' => $login, + )); + } //-- Современный Auth API (SystemTables users: email/password_hash/role). //-- Вход по email или логину; запоминание — по флажку. @@ -68,19 +78,20 @@ $result = Auth::attempt($login, $password, $options); if (is_array($result)) { - Session::regenerateCsrf(); - $this->redirect($this->base() . '/'); - return null; + return $this->loginCompleted($result, $identityKey, $ipKey); } //-- Legacy-пароль (перенесённые пользователи, md5(md5(pass+salt))): //-- проверяем и молча перехешируем в bcrypt при первом входе. - if ($this->legacyLogin($login, $password, $options)) { - Session::regenerateCsrf(); - $this->redirect($this->base() . '/'); - return null; + $legacyUser = $this->legacyLogin($login, $password, $options); + if (is_array($legacyUser)) { + return $this->loginCompleted($legacyUser, $identityKey, $ipKey); } + RateLimiter::hit($identityKey, 600); + RateLimiter::hit($ipKey, 600); + $this->auditLoginFailure($login, isset($result) ? (string) $result : 'failed'); + $messages = [ 1 => AdminLocale::text('auth_err_required', 'Введите логин или email и пароль.'), 2 => AdminLocale::text('auth_err_invalid', 'Неверный логин или пароль.'), @@ -96,6 +107,61 @@ ]); } + /** GET /account/password — mandatory temporary-password replacement. */ + public function passwordForm(array $params = array()) + { + $user = Auth::user(); + if (!$user) { $this->redirect($this->base() . '/login'); return null; } + + return $this->render('@auth/password.twig', array( + 'error' => '', + 'user' => $user, + )); + } + + /** POST /account/password — finish a forced password change. */ + public function changePassword(array $params = array()) + { + $user = Auth::user(); + if (!$user) { $this->redirect($this->base() . '/login'); return null; } + try { + $this->verifyCsrf(); + } catch (\RuntimeException $e) { + return $this->render('@auth/password.twig', array( + 'error' => 'Сессия устарела, обновите страницу и попробуйте снова.', 'user' => $user, + )); + } + + $current = (string) Request::post('current_password', ''); + $password = (string) Request::post('password', ''); + $confirm = (string) Request::post('password_confirm', ''); + $error = ''; + if (!password_verify($current, (string) $user['password_hash'])) { + $error = 'Текущий пароль указан неверно.'; + } elseif (mb_strlen($password, 'UTF-8') < 8) { + $error = 'Новый пароль должен содержать не меньше 8 символов.'; + } elseif (!hash_equals($password, $confirm)) { + $error = 'Новые пароли не совпадают.'; + } elseif (password_verify($password, (string) $user['password_hash'])) { + $error = 'Новый пароль должен отличаться от временного.'; + } + + if ($error !== '') { + return $this->render('@auth/password.twig', array('error' => $error, 'user' => $user)); + } + + \App\Adminx\Users\Model::completePasswordChange((int) $user['id'], $password); + AuditLog::record('auth.password_changed', array( + 'actor_id' => (int) $user['id'], + 'actor_name' => isset($user['name']) ? (string) $user['name'] : '', + 'target_type' => 'user', + 'target_id' => (int) $user['id'], + )); + Session::regenerateCsrf(); + $this->redirect($this->base() . '/'); + return null; + } + /** POST /locale — смена только языка интерфейса Adminx. */ public function language(array $params = array()) { @@ -181,7 +247,36 @@ $arr['password_hash'] = $hash; Auth::login($arr, $options); - return true; + return $arr; + } + + protected function loginCompleted(array $user, $identityKey, $ipKey) + { + RateLimiter::clear($identityKey); + RateLimiter::clear($ipKey); + \App\Adminx\Users\Model::recordSuccessfulLogin((int) $user['id']); + AuditLog::record('auth.login_succeeded', array( + 'actor_id' => (int) $user['id'], + 'actor_name' => isset($user['name']) ? (string) $user['name'] : '', + 'target_type' => 'user', + 'target_id' => (int) $user['id'], + )); + Session::regenerateCsrf(); + $this->redirect($this->base() . (!empty($user['must_change_password']) ? '/account/password' : '/')); + return null; + } + + protected function auditLoginFailure($identifier, $reason) + { + $user = \App\Adminx\Users\Model::findByIdentifier((string) $identifier); + AuditLog::record('auth.login_failed', array( + 'target_type' => 'user', + 'target_id' => $user ? (int) $user->id : null, + 'meta' => array( + 'identifier' => mb_substr(trim((string) $identifier), 0, 190, 'UTF-8'), + 'reason' => (string) $reason, + ), + )); } /** POST /logout — выход (ajax + CSRF). */ diff --git a/adminx/modules/Auth/language/en/interface.xml b/adminx/modules/Auth/language/en/interface.xml index 603190d..4bb807e 100644 --- a/adminx/modules/Auth/language/en/interface.xml +++ b/adminx/modules/Auth/language/en/interface.xml @@ -13,4 +13,13 @@ The session is out of date, please refresh the page and try again. Too many tries. Repeat after The account has been disabled. + Temporary password change + Change your temporary password + The control panel will open after the password is changed. + For + Temporary password + New password + At least 6 characters; the password must differ from the temporary one. + Repeat the new password + Save new password diff --git a/adminx/modules/Auth/language/en/runtime.xml b/adminx/modules/Auth/language/en/runtime.xml index a30478a..786ad09 100644 --- a/adminx/modules/Auth/language/en/runtime.xml +++ b/adminx/modules/Auth/language/en/runtime.xml @@ -13,4 +13,9 @@ Too many tries. Repeat after The account has been disabled. sec. + Too many login attempts. Try again in + The current password is incorrect. + The new password must contain at least 6 characters. + The new passwords do not match. + The new password must differ from the temporary one. diff --git a/adminx/modules/Auth/language/ru/interface.xml b/adminx/modules/Auth/language/ru/interface.xml index 1fe9bf9..220891f 100644 --- a/adminx/modules/Auth/language/ru/interface.xml +++ b/adminx/modules/Auth/language/ru/interface.xml @@ -13,4 +13,13 @@ Сессия устарела, обновите страницу и попробуйте снова. Слишком много попыток. Повторите через Учётная запись отключена. + Смена временного пароля + Смените временный пароль + После смены откроется панель управления. + Для + Временный пароль + Новый пароль + Минимум 6 символов, пароль должен отличаться от временного. + Повторите новый пароль + Сохранить новый пароль diff --git a/adminx/modules/Auth/language/ru/runtime.xml b/adminx/modules/Auth/language/ru/runtime.xml index d3ce48c..3e0ffea 100644 --- a/adminx/modules/Auth/language/ru/runtime.xml +++ b/adminx/modules/Auth/language/ru/runtime.xml @@ -13,4 +13,9 @@ Слишком много попыток. Повторите через Учётная запись отключена. сек. + Слишком много попыток входа. Повторите через + Текущий пароль указан неверно. + Новый пароль должен содержать не меньше 6 символов. + Новые пароли не совпадают. + Новый пароль должен отличаться от временного. diff --git a/adminx/modules/Auth/module.php b/adminx/modules/Auth/module.php index 455dd8b..a5e0dd9 100644 --- a/adminx/modules/Auth/module.php +++ b/adminx/modules/Auth/module.php @@ -23,7 +23,7 @@ return [ 'code' => 'auth', 'name' => 'Доступ', - 'version' => '0.1.0', + 'version' => '0.2.1', 'public_routes' => [ '/login', @@ -34,6 +34,8 @@ array('GET', '/login', array(\App\Adminx\Auth\Controller::class, 'form')), array('POST', '/login', array(\App\Adminx\Auth\Controller::class, 'login')), array('POST', '/locale', array(\App\Adminx\Auth\Controller::class, 'language')), + array('GET', '/account/password', array(\App\Adminx\Auth\Controller::class, 'passwordForm'), array('permission' => 'admin_panel')), + array('POST', '/account/password', array(\App\Adminx\Auth\Controller::class, 'changePassword'), array('permission' => 'admin_panel')), array('POST', '/reauth', array(\App\Adminx\Auth\Controller::class, 'reauth'), array('permission' => 'admin_panel')), array('POST', '/logout', array(\App\Adminx\Auth\Controller::class, 'logout')), ), diff --git a/adminx/modules/Auth/view/password.twig b/adminx/modules/Auth/view/password.twig new file mode 100644 index 0000000..009a080 --- /dev/null +++ b/adminx/modules/Auth/view/password.twig @@ -0,0 +1,22 @@ +{% extends '@adminx/auth.twig' %} +{% block title %}{{ lang.auth_temp_password_title|default('Смена временного пароля') }}{% endblock %} +{% block content %} +
+
+
+

+

{{ lang.auth_temp_password_heading|default('Смените временный пароль') }}

+

{{ lang.auth_temp_password_for|default('Для') }} {{ user.name|default(user.email) }}. {{ lang.auth_temp_password_intro|default('После смены откроется панель управления.') }}

+
+ {% if error %}{% endif %} +
+ +
+
{{ lang.auth_temp_password_hint|default('Минимум 8 символов, пароль должен отличаться от временного.') }}
+
+ +
+
+
AVE.cms · {{ lang.app_admin_panel|default('Панель управления') }}
+
+{% endblock %} diff --git a/adminx/modules/Blocks/Revisions.php b/adminx/modules/Blocks/Revisions.php index 0138208..7cacf21 100644 --- a/adminx/modules/Blocks/Revisions.php +++ b/adminx/modules/Blocks/Revisions.php @@ -133,6 +133,10 @@ $row = self::store()->formatRow($row, $withSnapshot); $snapshot = $row['snapshot']; $text = $withSnapshot && is_array($snapshot) && isset($snapshot['sysblock_text']) ? (string) $snapshot['sysblock_text'] : ''; + $current = $withSnapshot ? Model::raw((int) $row['block_id']) : array(); + $comparison = $withSnapshot && is_array($snapshot) + ? JsonRevisionStore::compareSnapshots($current ? Model::snapshot($current) : array(), $snapshot) + : array(); return array( 'id' => (int) $row['id'], @@ -152,6 +156,7 @@ 'text_size_label' => $withSnapshot ? JsonRevisionStore::formatBytes(strlen($text)) : '', 'snapshot' => $snapshot, 'code' => $text, + 'comparison' => $comparison, ); } diff --git a/adminx/modules/Blocks/assets/blocks.js b/adminx/modules/Blocks/assets/blocks.js index d62e998..f3f1ceb 100644 --- a/adminx/modules/Blocks/assets/blocks.js +++ b/adminx/modules/Blocks/assets/blocks.js @@ -720,6 +720,8 @@ var fields = document.querySelector('[data-revision-fields]'); var restore = document.querySelector('[data-revision-restore]'); var remove = document.querySelector('[data-revision-delete]'); + var comparison = item.comparison || {}; + var changed = comparison.summary ? parseInt(comparison.summary.total_changed || 0, 10) : 0; var clear = document.querySelector('[data-revisions-clear]'); if (title) { title.textContent = 'Ревизии: ' + (row.dataset.name || ('#' + row.dataset.id)); } @@ -831,7 +833,7 @@ if (title) { title.textContent = '#' + item.id + ' · ' + (item.action_label || item.action || 'Ревизия'); } if (meta) { - meta.textContent = (item.created_label || '-') + (item.author_name ? ' · ' + item.author_name : '') + (item.text_size_label ? ' · ' + item.text_size_label : ''); + meta.textContent = (item.created_label || '-') + (item.author_name ? ' · ' + item.author_name : '') + (item.text_size_label ? ' · ' + item.text_size_label : '') + ' · ' + (changed ? ('изменений: ' + changed) : 'совпадает с текущим'); } if (fields) { fields.innerHTML = @@ -845,7 +847,7 @@ (String(snapshot.sysblock_visual) === '1' ? ', visual' : '') + ''; } - if (restore) { restore.disabled = !this.currentRevisionId; } + if (restore) { restore.disabled = !this.currentRevisionId || !changed; } if (remove) { remove.disabled = !this.currentRevisionId; if (this.currentRevisionId) { remove.setAttribute('data-revision-delete', String(this.currentRevisionId)); } diff --git a/adminx/modules/Blocks/module.php b/adminx/modules/Blocks/module.php index f320921..b167ac3 100644 --- a/adminx/modules/Blocks/module.php +++ b/adminx/modules/Blocks/module.php @@ -17,7 +17,7 @@ return array( 'code' => 'blocks', 'name' => 'Блоки', - 'version' => '0.1.3', + 'version' => '0.1.4', 'permissions' => array( 'key' => 'blocks', diff --git a/adminx/modules/Catalog/Controller.php b/adminx/modules/Catalog/Controller.php index ed72e6b..85c7819 100644 --- a/adminx/modules/Catalog/Controller.php +++ b/adminx/modules/Catalog/Controller.php @@ -32,6 +32,7 @@ use App\Adminx\Support\CodeEditor; use App\Adminx\Support\SavedViews; use App\Content\PaymentPrograms; + use App\Content\Documents\DocumentMediaDraft; use App\Content\ProductShipping; use App\Frontend\ThemeAssets; use App\Adminx\Packages\Products\AttributeNormalization; @@ -48,7 +49,9 @@ use App\Adminx\Packages\Products\ProductRelations; use App\Adminx\Packages\Products\ProductCollections; use App\Adminx\Packages\Products\ProductDemands; + use App\Adminx\Packages\Products\ProductDuplicator; use App\Adminx\Packages\Products\ProductReadiness; + use App\Adminx\Packages\Products\PublicViewTemplates; use App\Adminx\Packages\Products\ShippingPackageTemplates; use App\Adminx\Packages\Products\VariantGroups; use App\Adminx\Packages\Commerce\Promotions; @@ -96,6 +99,8 @@ 'filters' => $filters, 'saved_views' => SavedViews::all('catalog_products', Auth::id(), $this->savedViewFields('products')), 'can_manage' => Permission::check('manage_products'), + 'can_copy' => Permission::check('manage_products') && Permission::check('manage_documents'), + 'can_toggle' => Permission::check('manage_products') && Permission::check('manage_documents'), )); } @@ -118,6 +123,7 @@ 'state' => $state, 'page' => Request::getInt('page', 1), 'per_page' => Request::getInt('per_page', 25), + 'include_quality' => true, ); return $this->render('@products/product-quality.twig', array( 'result' => Model::products($filters), @@ -475,6 +481,55 @@ )); } + public function publicTemplates(array $params = array()) + { + if (!Permission::check('view_products')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } + $this->publicTemplateAssets(); + return $this->render('@themes/view-overrides.twig', array( + 'page_title' => 'Публичные шаблоны товаров', + 'page_description' => 'Каталог, карточки, страница товара, сравнение и обращения в активной теме.', + 'back_url' => '/catalog/products', + 'back_label' => 'Товары', + 'route_base' => '/catalog/public-templates', + 'list_title' => 'Публичный вывод', + 'tabs_template' => '@products/_tabs.twig', + 'product_tab' => 'public_templates', + 'templates' => PublicViewTemplates::all(), + 'selected' => PublicViewTemplates::one(Request::getStr('template', '')), + 'can_manage' => Permission::check('manage_products') && Permission::check('manage_themes'), + 'can_view_themes' => Permission::check('view_themes'), + )); + } + + public function lintPublicTemplate(array $params = array()) + { + if (($error = $this->publicTemplateGuard(false)) !== null) { return $error; } + $result = PublicViewTemplates::lint(Request::postStr('content', '')); + return !empty($result['ok']) + ? $this->success($result['message'], array('data' => $result)) + : $this->error($result['message'], array('content' => $result['message']), 422); + } + + public function savePublicTemplate(array $params = array()) + { + if (($error = $this->publicTemplateGuard()) !== null) { return $error; } + $code = isset($params['code']) ? (string) $params['code'] : ''; + try { $item = PublicViewTemplates::save($code, Request::postStr('content', ''), Auth::id()); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.public_template_saved', null, array('theme' => $item['theme'], 'path' => $item['theme_path'])); + return $this->success('Шаблон активной темы сохранён', array('data' => $item)); + } + + public function deletePublicTemplate(array $params = array()) + { + if (($error = $this->publicTemplateGuard()) !== null) { return $error; } + $code = isset($params['code']) ? (string) $params['code'] : ''; + try { $item = PublicViewTemplates::deleteOverride($code, Auth::id()); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.public_template_override_deleted', null, array('theme' => $item['theme'], 'path' => $item['theme_path'])); + return $this->success('Переопределение удалено, используется шаблон модуля', array('data' => $item)); + } + public function cardTemplate(array $params = array()) { if (!Permission::check('view_products')) { return $this->error('Недостаточно прав', array(), 403); } @@ -536,7 +591,7 @@ if (!$syntax['ok']) { return $this->error($syntax['message'], array('draft_markup' => $syntax['message']), 422); } try { $this->bootPublicCatalogPreview(); - $catalog = \App\Modules\Products\Repository::fromDocumentId(Request::postInt('preview_catalog_id', 0)); + $catalog = \App\Modules\Products\Repository::forFilterPreviewFromDocumentId(Request::postInt('preview_catalog_id', 0)); if (!$catalog) { throw new \RuntimeException('Выберите раздел каталога с настроенными фильтрами'); } $result = \App\Modules\Products\FilterTemplateRenderer::preview($catalog, $markup, $css); $html = '' @@ -729,13 +784,14 @@ if (!Permission::check('view_products')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } $this->attributeAssets(); $view = Request::getStr('view', 'overview'); - if (!in_array($view, array('overview', 'normalization', 'attributes', 'sets', 'migration', 'sections'), true)) { $view = 'overview'; } + if (!in_array($view, array('overview', 'normalization', 'attributes', 'sets', 'filters', 'migration', 'sections'), true)) { $view = 'overview'; } return $this->render('@products/attributes.twig', array( 'view' => $view, 'stats' => Attributes::stats(), 'value_types' => Attributes::valueTypes(), 'attributes' => Attributes::all(Request::getStr('q', ''), Request::getStr('status', '')), 'product_attributes' => $view === 'migration' ? Attributes::all('', 'active', 'product') : array(), 'sets' => Attributes::sets(), 'legacy_fields' => $view === 'migration' ? Attributes::legacyFields() : array(), 'sections' => $view === 'sections' ? Attributes::sections() : array(), + 'section_filters' => $view === 'filters' ? Attributes::sectionFilterEditor(Request::getInt('section_id', 0)) : array(), 'normalization' => $view === 'normalization' ? AttributeNormalization::audit() : array(), 'overview' => $view === 'overview' ? Attributes::overview() : array(), 'filters' => array('q'=>Request::getStr('q',''),'status'=>Request::getStr('status','')), @@ -866,6 +922,19 @@ if(($error=$this->productGuard())!==null){return $error;}try{$data=Attributes::saveSection((int)$params['id'],Request::postAll(),Auth::id());}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}return $this->success('Режим раздела сохранён',array('data'=>$data)); } + public function saveAttributeSectionFilters(array $params = array()) + { + if (($error = $this->productGuard()) !== null) { return $error; } + $id = isset($params['id']) ? (int) $params['id'] : 0; + try { $data = Attributes::saveSectionFilters($id, Request::postAll(), Auth::id()); } + catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } + $this->audit('catalog.attribute_section_filters_saved', $id, $data); + return $this->success('Фильтры раздела сохранены', array( + 'redirect' => $this->base() . '/catalog/attributes?view=filters§ion_id=' . $id, + 'data' => $data, + )); + } + public function rebuildAttributeShadow(array $params = array()) { if(($error=$this->productGuard())!==null){return $error;}try{$data=Attributes::rebuildShadowIndex((int)$params['id']);}catch(\Throwable $e){return $this->error($e->getMessage(),array(),422);}$this->audit('catalog.attribute_shadow_rebuilt',(int)$params['id'],$data);return $this->success('Теневой индекс раздела перестроен',array('data'=>$data)); @@ -1103,8 +1172,8 @@ $sourceId = (int) $params['product']; $newId = 0; try { - $newId = DocumentsModel::copy($sourceId, Auth::id()); - Model::reindexDocument($newId); + $copy = ProductDuplicator::copy($sourceId, Auth::id()); + $newId = (int) $copy['id']; VariantGroups::add((int) $params['id'], $newId); } catch (\Throwable $e) { if ($newId > 0) { try { DocumentsModel::delete($newId); DocumentsModel::purge($newId); } catch (\Throwable $cleanupError) {} } @@ -1115,6 +1184,45 @@ return $this->success('Вариант создан копированием', array('redirect' => $this->base() . '/catalog/products/' . $newId . '/edit')); } + public function copyProduct(array $params = array()) + { + if (($error = $this->guardPermission('manage_products', 'manage_documents')) !== null) { return $error; } + $sourceId = isset($params['id']) ? (int) $params['id'] : 0; + try { + $copy = ProductDuplicator::copy($sourceId, Auth::id()); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + $this->audit('catalog.product_copied', (int) $copy['id'], array( + 'source_id' => $sourceId, + 'attributes' => (int) $copy['attributes'], + 'shipping' => !empty($copy['shipping']), + )); + return $this->success('Копия товара создана', array( + 'id' => (int) $copy['id'], + 'redirect' => $this->base() . '/catalog/products/' . (int) $copy['id'] . '/edit', + )); + } + + public function toggleProduct(array $params = array()) + { + if (($error = $this->guardPermission('manage_products', 'manage_documents')) !== null) { return $error; } + $productId = isset($params['id']) ? (int) $params['id'] : 0; + if (!Model::product($productId)) { return $this->error('Товар не найден', array(), 404); } + try { + $status = DocumentsModel::toggleStatus($productId); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + if ($status === false) { return $this->error('Товар не найден', array(), 404); } + $this->audit('catalog.product_status_changed', $productId, array('status' => (int) $status)); + return $this->success($status ? 'Товар опубликован' : 'Товар снят с публикации', array( + 'data' => array('status' => (int) $status), + )); + } + public function deleteVariantGroup(array $params = array()) { if (($error = $this->productGuard()) !== null) { return $error; } @@ -1181,14 +1289,16 @@ $variantGroup = VariantGroups::membership($id); $shippingProfile = ProductShipping::profile($id); $nativeAttributes = Attributes::productEditor($id); + $mediaDraftToken = DocumentMediaDraft::issue(Auth::id(), $id); return $this->render('@documents/edit.twig', array( 'document' => $document, - 'field_groups' => DocumentsModel::fieldsForDocument($id), + 'field_groups' => DocumentsModel::fieldsForDocument($id, $mediaDraftToken), + 'media_draft_token' => $mediaDraftToken, 'rubrics' => DocumentsModel::rubrics(), 'templates' => DocumentsModel::rubricTemplates((int) $document['rubric_id']), 'navigation_items' => DocumentsModel::navigationItems(), 'authors' => DocumentsModel::authors(), - 'is_new' => false, 'can_manage' => true, + 'is_new' => false, 'can_manage' => true, 'quick_edit' => Request::getBool('quick_edit', false), 'actor_id' => Auth::id(), 'catalog_mode' => true, 'product' => $product, 'variant_group' => $variantGroup, 'shipping_profile' => $shippingProfile, @@ -1199,6 +1309,7 @@ 'product_promotions' => Promotions::forProduct($id), 'can_manage_product_promotions' => Permission::check('manage_orders'), 'return_url' => $this->base() . '/catalog/products', + 'product_copy_url' => $this->base() . '/catalog/products/' . $id . '/copy', 'submit_url' => $this->base() . '/documents/' . $id, )); } @@ -1754,6 +1865,24 @@ AdminAssets::addScript('/modules/products/admin/assets/card-templates.js', 56); } + protected function publicTemplateAssets() + { + $this->assets(); + CodeEditor::useCodeMirror('htmlmixed'); + AdminAssets::addStyle(ADMINX_BASE . '/modules/Themes/assets/view-overrides.css', 56); + AdminAssets::addScript(ADMINX_BASE . '/modules/Themes/assets/view-overrides.js', 56); + } + + protected function publicTemplateGuard($requireThemePermission = true) + { + if (($error = $this->productGuard()) !== null) { return $error; } + if ($requireThemePermission && !Permission::check('manage_themes')) { + return $this->error('Для изменения публичной разметки требуется право управления темами', array(), 403); + } + + return null; + } + protected function audit($action, $targetId = null, array $meta = array()) { $user = Auth::user(); diff --git a/adminx/modules/Catalog/module.php b/adminx/modules/Catalog/module.php index 5937874..9280b17 100644 --- a/adminx/modules/Catalog/module.php +++ b/adminx/modules/Catalog/module.php @@ -14,7 +14,7 @@ defined('BASEPATH') || die('Direct access to this location is not allowed.'); return array( - 'code' => 'catalog', 'name' => 'Каталог', 'version' => '0.2.0', + 'code' => 'catalog', 'name' => 'Каталог', 'version' => '0.3.2', 'permissions' => array('key' => 'catalog', 'items' => array( array( 'code' => 'view_catalog', diff --git a/adminx/modules/Console/Runner.php b/adminx/modules/Console/Runner.php index 0a29553..070be80 100644 --- a/adminx/modules/Console/Runner.php +++ b/adminx/modules/Console/Runner.php @@ -31,7 +31,7 @@ return filter_var($value, FILTER_VALIDATE_BOOLEAN); } - return !defined('ENV_CMS') || strtolower((string) ENV_CMS) !== 'production'; + return false; } public static function execute($code) diff --git a/adminx/modules/Console/module.php b/adminx/modules/Console/module.php index cfee887..e35a895 100644 --- a/adminx/modules/Console/module.php +++ b/adminx/modules/Console/module.php @@ -20,7 +20,7 @@ return array( 'code' => 'console', 'name' => 'PHP-консоль', - 'version' => '0.1.0', + 'version' => '0.1.1', 'registry' => array('dynamic' => true), ); } @@ -28,7 +28,7 @@ return array( 'code' => 'console', 'name' => 'PHP-консоль', - 'version' => '0.1.0', + 'version' => '0.1.1', 'registry' => array('dynamic' => true), 'permissions' => array( 'key' => 'console', diff --git a/adminx/modules/Customers/Controller.php b/adminx/modules/Customers/Controller.php index 98fcfd3..1255ba2 100644 --- a/adminx/modules/Customers/Controller.php +++ b/adminx/modules/Customers/Controller.php @@ -19,19 +19,84 @@ use App\Common\AdminAssets; use App\Adminx\Support\Roles; use App\Adminx\Support\CodeEditor; + use App\Adminx\Support\SavedViews; use App\Common\Auth; use App\Common\Controller as BaseController; use App\Common\Permission; use App\Helpers\Request; use App\Helpers\Response; + use App\Helpers\Csv; + use App\Common\AuditLog; class Controller extends BaseController { + public function exportCustomers(array $params = array()) + { + if (!Permission::check('view_customers')) { Response::forbidden(); return ''; } + $q = Request::getStr('q', ''); + AuditLog::record('customers.exported', array('actor_id' => Auth::id(), 'target_type' => 'customers', 'meta' => array('q' => $q))); + Csv::download('customers_' . date('Ymd_His') . '.csv', array('ID', 'Имя', 'Фамилия', 'Логин', 'Email', 'Телефон', 'Компания', 'Статус', 'Регистрация', 'Последний вход'), function ($write) use ($q) { + $before = 0; + do { + $rows = Model::exportChunk($q, $before, 500); + foreach ($rows as $row) { $before = (int) $row['id']; $write(array($row['id'], $row['firstname'], $row['lastname'], $row['user_name'], $row['email'], $row['phone'], $row['company'], (string) $row['status'] === '1' ? 'Активен' : 'Отключён', (int) $row['reg_time'] > 0 ? date('d.m.Y H:i', (int) $row['reg_time']) : '', (int) $row['last_visit'] > 0 ? date('d.m.Y H:i', (int) $row['last_visit']) : '')); + } + } while (count($rows) === 500); + }); + return ''; + } + public function index(array $params=array()) { if(!Permission::check('view_customers')){Response::forbidden();return '';}AdminAssets::addStyle($this->base().'/modules/Customers/assets/customers.css',50);AdminAssets::addScript($this->base().'/modules/Customers/assets/customers.js',50);CodeEditor::useCodeMirror('htmlmixed'); - $oauthModules=Model::oauthModules();foreach($oauthModules as &$oauthModule){$oauthModule['can_open']=Permission::check($oauthModule['permission']);}unset($oauthModule); - $tab=Request::getStr('tab','customers');if(!in_array($tab,array('customers','fields','auth','pages'),true)){$tab='customers';}return $this->render('@customers/index.twig',array('tab'=>$tab,'customers'=>Model::customers(Request::getStr('q','')),'fields'=>Model::fields(),'stats'=>Model::stats(),'auth_settings'=>Model::authSettings(),'checkout_access_template_default'=>\App\Common\PublicAuthSettings::defaultCheckoutAccessTemplate(),'oauth_modules'=>$oauthModules,'customer_groups'=>Model::groups(),'admin_roles'=>Roles::map(),'page_templates'=>Model::pageTemplates(),'auth_forms'=>Model::authFormDefinitions(),'q'=>Request::getStr('q',''),'can_manage'=>Permission::check('manage_customers'),'can_manage_admin_access'=>Permission::check('manage_users'),'current_public_user_id'=>Model::publicIdForSystem(Auth::id()))); + $authMethods=Model::authMethods();$authMethodStats=array('installed'=>0,'active'=>0);foreach($authMethods as &$authMethod){$authMethod['can_open']=$authMethod['installed']?Permission::check($authMethod['permission']):Permission::check('view_modules');$authMethod['action_url']=$authMethod['installed']?$authMethod['url']:'/modules';$authMethod['action_label']=$authMethod['installed']?'Настроить':'Открыть модули';if($authMethod['installed']){$authMethodStats['installed']++;}if($authMethod['active']){$authMethodStats['active']++;}}unset($authMethod); + $tab=Request::getStr('tab','center');if(!in_array($tab,array('center','customers','fields','auth','pages'),true)){$tab='center';}$q=Request::getStr('q','');$segment=Request::getStr('segment','all');return $this->render('@customers/index.twig',array('tab'=>$tab,'customers'=>Model::customers($q),'fields'=>Model::fields(),'stats'=>Model::stats(),'center_customers'=>$tab==='center'?CustomerCenter::listing($q,$segment):array(),'center_stats'=>$tab==='center'?CustomerCenter::stats():array(),'center_segments'=>CustomerCenter::segments(),'duplicate_groups'=>$tab==='center'?CustomerCenter::duplicateGroups():array(),'segment'=>$segment,'saved_views'=>SavedViews::all('customers_center',Auth::id(),array('q','segment')),'auth_settings'=>Model::authSettings(),'checkout_access_template_default'=>\App\Common\PublicAuthSettings::defaultCheckoutAccessTemplate(),'auth_methods'=>$authMethods,'auth_method_stats'=>$authMethodStats,'customer_groups'=>Model::groups(),'admin_roles'=>Roles::map(),'page_templates'=>Model::pageTemplates(),'auth_forms'=>Model::authFormDefinitions(),'q'=>$q,'can_manage'=>Permission::check('manage_customers'),'can_manage_admin_access'=>Permission::check('manage_users'),'current_public_user_id'=>Model::publicIdForSystem(Auth::id()))); + } + + public function saveSavedView(array $params = array()) + { + if (($error = $this->savedViewGuard()) !== null) { return $error; } + $filters = json_decode(Request::postStr('filters', '{}'), true); + if (!is_array($filters)) { return $this->error('Некорректный набор фильтров', array(), 422); } + try { $views = SavedViews::save('customers_center', Auth::id(), Request::postStr('title', ''), $filters, array('q', 'segment')); } + catch (\InvalidArgumentException $e) { return $this->error($e->getMessage(), array(), 422); } + return $this->success('Представление сохранено', array('data' => array('views' => $views))); + } + + public function deleteSavedView(array $params = array()) + { + if (($error = $this->savedViewGuard()) !== null) { return $error; } + try { $views = SavedViews::delete('customers_center', Auth::id(), isset($params['id']) ? $params['id'] : '', array('q', 'segment')); } + catch (\InvalidArgumentException $e) { return $this->error($e->getMessage(), array(), 404); } + return $this->success('Представление удалено', array('data' => array('views' => $views))); + } + + protected function savedViewGuard() { if (($error = $this->csrfGuard()) !== null) { return $error; } return Permission::check('view_customers') ? null : $this->error('Недостаточно прав', array(), 403); } + + public function centerCustomer(array $params = array()) + { + if (!Permission::check('view_customers')) { return $this->error('Недостаточно прав', array(), 403); } + $customer = CustomerCenter::detail(isset($params['id']) ? (int) $params['id'] : 0, Auth::id()); + if (!$customer) { return $this->error('Пользователь не найден', array(), 404); } + return $this->success('', array('html' => array('detail' => $this->render('@customers/customer-center-detail.twig', array('customer' => $customer, 'can_manage' => Permission::check('manage_customers')))))); + } + + public function addCustomerNote(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + try { CustomerCenter::addNote(isset($params['id']) ? (int) $params['id'] : 0, Auth::id(), Request::postStr('note', '')); } + catch (\InvalidArgumentException $e) { return $this->error($e->getMessage(), array(), 422); } + catch (\Throwable $e) { error_log('Customer note: ' . $e->getMessage()); return $this->error('Не удалось добавить заметку', array(), 500); } + return $this->success('Заметка добавлена'); + } + + public function mergeCustomers(array $params = array()) + { + if (($error = $this->guard()) !== null) { return $error; } + try { $customer = CustomerCenter::merge(Request::postInt('target_id', 0), Request::postInt('source_id', 0), Auth::id()); } + catch (\InvalidArgumentException $e) { return $this->error($e->getMessage(), array(), 422); } + catch (\Throwable $e) { error_log('Customer merge: ' . $e->getMessage()); return $this->error('Не удалось объединить аккаунты', array(), 500); } + return $this->success('Аккаунты объединены', array('data' => array('id' => (int) $customer['user']['id']), 'reload' => true)); } public function toggle(array $params=array()) diff --git a/adminx/modules/Customers/CustomerCenter.php b/adminx/modules/Customers/CustomerCenter.php new file mode 100644 index 0000000..1321e3b --- /dev/null +++ b/adminx/modules/Customers/CustomerCenter.php @@ -0,0 +1,371 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Customers; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\DatabaseSchema; + use App\Common\SystemTables; + use App\Adminx\Support\AdminLocale; + use App\Content\BasketTables; + use App\Content\ContactsTables; + use App\Content\PublicUserTables; + use DB; + + /** Read model and safe merge operations for the customer workspace. */ + class CustomerCenter + { + public static function listing($query = '', $segment = 'all', $limit = 300) + { + $query = mb_substr(trim((string) $query), 0, 190, 'UTF-8'); + $segment = isset(self::segments()[$segment]) ? $segment : 'all'; + $limit = max(1, min(500, (int) $limit)); + $users = PublicUserTables::table('users'); + $orders = BasketTables::table('module_basket_history'); + $hasOrders = DatabaseSchema::tableExists($orders); + $orderJoin = $hasOrders + ? ' LEFT JOIN (SELECT order_user_id,COUNT(*) orders_count,SUM(order_total) orders_total,MAX(order_published) last_order_at' + . ' FROM ' . $orders . ' WHERE order_user_id>0 GROUP BY order_user_id) orders ON orders.order_user_id=u.Id' + : ''; + $sql = 'SELECT u.Id AS id,u.email,u.firstname,u.lastname,u.user_name,u.phone,u.company,u.status,u.reg_time,u.last_visit,' + . ($orderJoin !== '' ? 'COALESCE(orders.orders_count,0)' : '0') . ' orders_count,' + . ($orderJoin !== '' ? 'COALESCE(orders.orders_total,0)' : '0') . ' orders_total,' + . ($orderJoin !== '' ? 'COALESCE(orders.last_order_at,0)' : '0') . ' last_order_at' + . ' FROM ' . $users . ' u' . $orderJoin . ' WHERE u.deleted!=%s'; + $args = array('1'); + if ($query !== '') { + $sql .= ' AND (u.email LIKE %ss OR u.firstname LIKE %ss OR u.lastname LIKE %ss OR u.user_name LIKE %ss OR u.phone LIKE %ss OR u.company LIKE %ss'; + $args = array_merge($args, array($query, $query, $query, $query, $query, $query)); + if (ctype_digit($query)) { $sql .= ' OR u.Id=%i'; $args[] = (int) $query; } + $sql .= ')'; + } + + $now = time(); + if ($segment === 'new') { $sql .= ' AND u.reg_time>=%i'; $args[] = $now - 30 * 86400; } + elseif ($segment === 'buyers') { $sql .= $hasOrders ? ' AND COALESCE(orders.orders_count,0)>0' : ' AND 1=0'; } + elseif ($segment === 'repeat') { $sql .= $hasOrders ? ' AND COALESCE(orders.orders_count,0)>=2' : ' AND 1=0'; } + elseif ($segment === 'vip') { $sql .= $hasOrders ? ' AND COALESCE(orders.orders_total,0)>=100000' : ' AND 1=0'; } + elseif ($segment === 'inactive') { $sql .= ' AND (u.last_visit=0 OR u.last_visit<%i)'; $args[] = $now - 180 * 86400; } + elseif ($segment === 'without_orders' && $hasOrders) { $sql .= ' AND COALESCE(orders.orders_count,0)=0'; } + $sql .= ' ORDER BY orders_total DESC,last_order_at DESC,u.Id DESC LIMIT ' . $limit; + $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll() ?: array(); + foreach ($rows as &$row) { + $row['id'] = (int) $row['id']; + $row['orders_count'] = (int) $row['orders_count']; + $row['orders_total'] = (float) $row['orders_total']; + $row['last_order_at'] = (int) $row['last_order_at']; + $row['segment_codes'] = self::segmentCodes($row); + } + + unset($row); + return $rows; + } + + public static function stats() + { + $users = PublicUserTables::table('users'); + $orders = BasketTables::table('module_basket_history'); + $result = array( + 'total' => (int) DB::query('SELECT COUNT(*) FROM ' . $users . ' WHERE deleted!=%s', '1')->getValue(), + 'buyers' => 0, 'repeat' => 0, 'duplicates' => count(self::duplicateGroups()), + ); + if (DatabaseSchema::tableExists($orders)) { + $result['buyers'] = (int) DB::query('SELECT COUNT(DISTINCT order_user_id) FROM ' . $orders . ' WHERE order_user_id>0')->getValue(); + $result['repeat'] = (int) DB::query('SELECT COUNT(*) FROM (SELECT order_user_id FROM ' . $orders . ' WHERE order_user_id>0 GROUP BY order_user_id HAVING COUNT(*)>=2) repeated')->getValue(); + } + + return $result; + } + + public static function segments() + { + $segments = array('all' => 'Все', 'new' => 'Новые', 'buyers' => 'С заказами', 'repeat' => 'Повторные', 'vip' => 'От 100 000 ₽', 'inactive' => 'Неактивные', 'without_orders' => 'Без заказов'); + foreach ($segments as &$label) { $label = AdminLocale::translateMarkup($label); } + unset($label); + return $segments; + } + + public static function detail($id, $currentSystemId = 0) + { + $id = (int) $id; + $profile = Model::customer($id, $currentSystemId); + if (!$profile) { return null; } + $orderData = self::orderData( + $id, + isset($profile['user']['email']) ? $profile['user']['email'] : '', + isset($profile['user']['phone']) ? $profile['user']['phone'] : '' + ); + $profile['summary'] = $orderData['summary']; + $profile['orders'] = $orderData['orders']; + $profile['contacts'] = self::contacts(isset($profile['user']['email']) ? $profile['user']['email'] : ''); + $profile['popup_leads'] = self::popupLeads( + isset($profile['user']['email']) ? $profile['user']['email'] : '', + isset($profile['user']['phone']) ? $profile['user']['phone'] : '' + ); + $profile['quiz_leads'] = self::quizLeads( + isset($profile['user']['email']) ? $profile['user']['email'] : '', + isset($profile['user']['phone']) ? $profile['user']['phone'] : '' + ); + $profile['identities'] = self::identities($id); + $profile['engagement'] = self::engagement($id); + $profile['notes'] = self::notes($id); + $profile['segments'] = self::segmentCodes(array_merge($profile['user'], $orderData['summary'])); + return $profile; + } + + public static function addNote($userId, $authorId, $text) + { + $userId = (int) $userId; $authorId = (int) $authorId; + $text = trim((string) $text); + if (!Model::customer($userId)) { throw new \InvalidArgumentException('Пользователь не найден'); } + if ($text === '') { throw new \InvalidArgumentException('Введите текст заметки'); } + if (mb_strlen($text, 'UTF-8') > 4000) { throw new \InvalidArgumentException('Заметка не должна превышать 4000 символов'); } + if (!DatabaseSchema::tableExists(self::notesTable())) { throw new \RuntimeException('Примените миграцию центра покупателей'); } + DB::Insert(self::notesTable(), array('user_id' => $userId, 'author_id' => $authorId, 'note' => $text, 'created_at' => time())); + return (int) DB::insertId(); + } + + public static function merge($targetId, $sourceId, $actorSystemId = 0) + { + $targetId = (int) $targetId; $sourceId = (int) $sourceId; + if ($targetId <= 0 || $sourceId <= 0 || $targetId === $sourceId) { throw new \InvalidArgumentException('Выберите два разных аккаунта'); } + $target = Model::customer($targetId, $actorSystemId); $source = Model::customer($sourceId, $actorSystemId); + if (!$target || !$source) { throw new \InvalidArgumentException('Один из аккаунтов не найден'); } + if (!empty($source['is_current']) || !empty($source['system'])) { throw new \InvalidArgumentException('Нельзя объединить аккаунт, связанный с доступом в панель'); } + self::assertIdentityMerge($targetId, $sourceId); + DB::startTransaction(); + try { + $sourceUnique = array('email' => null); + if (DatabaseSchema::columnExists(PublicUserTables::table('users'), 'phone_normalized')) { $sourceUnique['phone_normalized'] = null; } + DB::Update(PublicUserTables::table('users'), $sourceUnique, 'Id=%i', $sourceId); + self::mergeCoreProfile($target['user'], $source['user']); + self::mergeProfileValues($targetId, $sourceId); + self::moveOptionalData($targetId, $sourceId); + $identities = PublicUserTables::table('user_identities'); + if (DatabaseSchema::tableExists($identities)) { DB::Update($identities, array('user_id' => $targetId, 'updated_at' => time()), 'user_id=%i', $sourceId); } + if (DatabaseSchema::tableExists(self::notesTable())) { DB::Update(self::notesTable(), array('user_id' => $targetId), 'user_id=%i', $sourceId); } + self::addNote($targetId, $actorSystemId, 'Объединён аккаунт #' . $sourceId . '. Контакты источника: ' . trim((string) $source['user']['email'] . ' ' . (string) $source['user']['phone'])); + DB::Update(PublicUserTables::table('users'), array('status' => '0', 'deleted' => '1', 'del_time' => time()), 'Id=%i', $sourceId); + DB::Delete(PublicUserTables::table('users_session'), 'user_id=%i', $sourceId); + DB::Delete(PublicUserTables::table('auth_tokens'), 'user_id=%i', $sourceId); + DB::commit(); + } catch (\Throwable $e) { DB::rollback(); throw $e; } + return self::detail($targetId, $actorSystemId); + } + + public static function duplicateGroups() + { + $table = PublicUserTables::table('users'); $groups = array(); + $queries = array( + 'email' => "SELECT LOWER(TRIM(email)) duplicate_value,GROUP_CONCAT(Id ORDER BY Id) ids,COUNT(*) amount FROM $table WHERE deleted!='1' AND email!='' GROUP BY LOWER(TRIM(email)) HAVING COUNT(*)>1 LIMIT 30", + ); + if (DatabaseSchema::columnExists($table, 'phone_normalized')) { + $queries['phone'] = "SELECT phone_normalized duplicate_value,GROUP_CONCAT(Id ORDER BY Id) ids,COUNT(*) amount FROM $table WHERE deleted!='1' AND phone_normalized IS NOT NULL GROUP BY phone_normalized HAVING COUNT(*)>1 LIMIT 30"; + } + + foreach ($queries as $kind => $sql) { + foreach (DB::query($sql)->getAll() ?: array() as $row) { + $ids = array_values(array_unique(array_filter(array_map('intval', explode(',', (string) $row['ids']))))); + if (count($ids) > 1) { $groups[] = array('kind' => $kind, 'value' => (string) $row['duplicate_value'], 'ids' => $ids, 'target_id' => min($ids)); } + } + } + + return $groups; + } + + /** + * Заказы человека: свои по аккаунту плюс гостевые по контактам. + * + * Оформить заказ можно без входа (`order_user_id = 0`), и такие покупки + * раньше не попадали на карточку вовсе. Ищем их так же, как обращения и + * заявки — по email и телефону, — но помечаем `matched_by`, чтобы + * догадка не выдавалась за подтверждённую принадлежность аккаунту. + */ + protected static function orderData($userId, $email = '', $phone = '') + { + $table = BasketTables::table('module_basket_history'); + $empty = array('summary' => array('orders_count' => 0, 'orders_total' => 0, 'last_order_at' => 0, 'guest_count' => 0), 'orders' => array()); + if (!DatabaseSchema::tableExists($table)) { return $empty; } + + $where = array('order_user_id=%i'); + $args = array((int) $userId); + $email = trim((string) $email); + if ($email !== '') { $where[] = '(order_user_id=0 AND LOWER(order_email)=LOWER(%s))'; $args[] = $email; } + $digits = preg_replace('/\D+/', '', (string) $phone); + if (strlen($digits) >= 10) { + $where[] = "(order_user_id=0 AND RIGHT(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(order_phone,' ',''),'(',''),')',''),'-',''),'+',''),10)=%s)"; + $args[] = substr($digits, -10); + } + + $condition = '(' . implode(' OR ', $where) . ')'; + $summaryArgs = array_merge(array( + 'SELECT COUNT(*) orders_count,COALESCE(SUM(order_total),0) orders_total,COALESCE(MAX(order_published),0) last_order_at,' + . ' COALESCE(SUM(order_user_id=0),0) guest_count FROM ' . $table . ' WHERE ' . $condition, + ), $args); + $summary = call_user_func_array(array(DB::class, 'query'), $summaryArgs)->getAssoc() ?: array(); + $listArgs = array_merge(array( + 'SELECT id,order_id,order_total,order_status,order_pay,order_published,order_user_id FROM ' . $table + . ' WHERE ' . $condition . ' ORDER BY order_published DESC,id DESC LIMIT 20', + ), $args); + $rows = call_user_func_array(array(DB::class, 'query'), $listArgs)->getAll() ?: array(); + foreach ($rows as &$row) { + $row['id'] = (int) $row['id']; + $row['order_total'] = (float) $row['order_total']; + $row['order_published'] = (int) $row['order_published']; + $row['matched_by'] = (int) $row['order_user_id'] > 0 ? 'account' : 'contact'; + } + + unset($row); + return array('summary' => array( + 'orders_count' => isset($summary['orders_count']) ? (int) $summary['orders_count'] : 0, + 'orders_total' => isset($summary['orders_total']) ? (float) $summary['orders_total'] : 0, + 'last_order_at' => isset($summary['last_order_at']) ? (int) $summary['last_order_at'] : 0, + 'guest_count' => isset($summary['guest_count']) ? (int) $summary['guest_count'] : 0, + ), 'orders' => $rows); + } + + protected static function contacts($email) + { + $table = ContactsTables::table('module_contacts_history'); $email = trim((string) $email); + if ($email === '' || !DatabaseSchema::tableExists($table)) { return array(); } + return DB::query('SELECT id,form_id,subject,status,date FROM ' . $table . ' WHERE LOWER(email)=LOWER(%s) ORDER BY date DESC,id DESC LIMIT 20', $email)->getAll() ?: array(); + } + + /** + * Заявки из поп-апов сопоставляются по email или телефону. + * Модуль устанавливается отдельно, поэтому отсутствие таблицы — + * обычное состояние, а не ошибка. + */ + protected static function popupLeads($email, $phone) + { + if (!class_exists('App\\Modules\\Popups\\Repository')) { return array(); } + try { + if (!DatabaseSchema::tableExists(\App\Modules\Popups\Tables::table('leads'))) { return array(); } + return \App\Modules\Popups\Repository::leadsForContact($email, $phone, 20); + } catch (\Throwable $e) { + return array(); + } + } + + /** Заявки из пошагового подбора — тот же принцип, что и у поп-апов. */ + protected static function quizLeads($email, $phone) + { + if (!class_exists('App\\Modules\\Quiz\\Repository')) { return array(); } + try { return \App\Modules\Quiz\Repository::leadsForContact($email, $phone, 20); } + catch (\Throwable $e) { return array(); } + } + + protected static function identities($userId) + { + $table = PublicUserTables::table('user_identities'); + return DatabaseSchema::tableExists($table) ? (DB::query('SELECT provider,email,created_at,last_login_at FROM ' . $table . ' WHERE user_id=%i ORDER BY provider', (int) $userId)->getAll() ?: array()) : array(); + } + + protected static function engagement($userId) + { + $result = array('favorites' => array(), 'viewed' => array()); + $favorites = BasketTables::table('module_basket_favorites'); + if (DatabaseSchema::tableExists($favorites)) { $result['favorites'] = array_map('intval', array_column(DB::query('SELECT fav_document_id FROM ' . $favorites . ' WHERE fav_user_id=%i ORDER BY fav_created_at DESC LIMIT 30', (int) $userId)->getAll() ?: array(), 'fav_document_id')); } + $viewed = BasketTables::table('module_basket_viewed'); + if (DatabaseSchema::tableExists($viewed)) { $result['viewed'] = array_map('intval', array_column(DB::query('SELECT viewed_document_id FROM ' . $viewed . ' WHERE viewed_user_id=%i ORDER BY viewed_at DESC LIMIT 30', (int) $userId)->getAll() ?: array(), 'viewed_document_id')); } + return $result; + } + + protected static function notes($userId) + { + if (!DatabaseSchema::tableExists(self::notesTable())) { return array(); } + return DB::query('SELECT n.*,u.name author_name FROM ' . self::notesTable() . ' n LEFT JOIN ' . SystemTables::table('users') . ' u ON u.id=n.author_id WHERE n.user_id=%i ORDER BY n.created_at DESC,n.id DESC LIMIT 100', (int) $userId)->getAll() ?: array(); + } + + protected static function segmentCodes(array $row) + { + $codes = array(); $now = time(); + if (!empty($row['reg_time']) && (int) $row['reg_time'] >= $now - 30 * 86400) { $codes[] = 'new'; } + if (!empty($row['orders_count'])) { $codes[] = 'buyers'; } + if (isset($row['orders_count']) && (int) $row['orders_count'] >= 2) { $codes[] = 'repeat'; } + if (isset($row['orders_total']) && (float) $row['orders_total'] >= 100000) { $codes[] = 'vip'; } + if (empty($row['last_visit']) || (int) $row['last_visit'] < $now - 180 * 86400) { $codes[] = 'inactive'; } + if (empty($row['orders_count'])) { $codes[] = 'without_orders'; } + return $codes; + } + + protected static function assertIdentityMerge($targetId, $sourceId) + { + $table = PublicUserTables::table('user_identities'); + if (!DatabaseSchema::tableExists($table)) { return; } + $conflict = DB::query('SELECT source.provider FROM ' . $table . ' source INNER JOIN ' . $table . ' target ON target.provider=source.provider AND target.user_id=%i WHERE source.user_id=%i LIMIT 1', (int) $targetId, (int) $sourceId)->getValue(); + if ($conflict) { throw new \InvalidArgumentException('У обоих аккаунтов подключён один способ входа: ' . $conflict); } + } + + protected static function mergeProfileValues($targetId, $sourceId) + { + $table = PublicUserTables::table('user_profile_values'); + if (!DatabaseSchema::tableExists($table)) { return; } + foreach (DB::query('SELECT field_id,value FROM ' . $table . ' WHERE user_id=%i', (int) $sourceId)->getAll() ?: array() as $row) { + $current = DB::query('SELECT value FROM ' . $table . ' WHERE user_id=%i AND field_id=%i', (int) $targetId, (int) $row['field_id'])->getValue(); + if ($current === null) { DB::Insert($table, array('user_id' => $targetId, 'field_id' => (int) $row['field_id'], 'value' => (string) $row['value'], 'updated_at' => time())); } + elseif (trim((string) $current) === '' && trim((string) $row['value']) !== '') { DB::Update($table, array('value' => (string) $row['value'], 'updated_at' => time()), 'user_id=%i AND field_id=%i', $targetId, (int) $row['field_id']); } + } + + DB::Delete($table, 'user_id=%i', $sourceId); + } + + protected static function mergeCoreProfile(array $target, array $source) + { + $fields = array('email', 'phone', 'phone_normalized', 'firstname', 'lastname', 'street', 'street_nr', 'zipcode', 'city', 'telefax', 'description', 'company', 'birthday', 'country'); + $values = array(); + foreach ($fields as $field) { + if (trim(isset($target[$field]) ? (string) $target[$field] : '') === '' && trim(isset($source[$field]) ? (string) $source[$field] : '') !== '') { + $values[$field] = $source[$field]; + } + } + + $targetEmail = strtolower(trim(isset($target['email']) ? (string) $target['email'] : '')); + $sourceEmail = strtolower(trim(isset($source['email']) ? (string) $source['email'] : '')); + if ($targetEmail === '' || ($targetEmail !== '' && $targetEmail === $sourceEmail)) { + $values['email_verified_at'] = max(isset($target['email_verified_at']) ? (int) $target['email_verified_at'] : 0, isset($source['email_verified_at']) ? (int) $source['email_verified_at'] : 0); + } + + $targetPhone = trim(isset($target['phone_normalized']) ? (string) $target['phone_normalized'] : ''); + $sourcePhone = trim(isset($source['phone_normalized']) ? (string) $source['phone_normalized'] : ''); + if ($targetPhone === '' || ($targetPhone !== '' && $targetPhone === $sourcePhone)) { + $values['phone_verified_at'] = max(isset($target['phone_verified_at']) ? (int) $target['phone_verified_at'] : 0, isset($source['phone_verified_at']) ? (int) $source['phone_verified_at'] : 0); + } + + if ($values) { DB::Update(PublicUserTables::table('users'), $values, 'Id=%i', (int) $target['id']); } + } + + protected static function moveOptionalData($targetId, $sourceId) + { + $orders = BasketTables::table('module_basket_history'); + if (DatabaseSchema::tableExists($orders)) { DB::Update($orders, array('order_user_id' => $targetId), 'order_user_id=%i', $sourceId); } + $favorites = BasketTables::table('module_basket_favorites'); + if (DatabaseSchema::tableExists($favorites)) { + DB::query('INSERT IGNORE INTO ' . $favorites . ' (fav_user_id,fav_document_id,fav_created_at) SELECT %i,fav_document_id,fav_created_at FROM ' . $favorites . ' WHERE fav_user_id=%i', $targetId, $sourceId); + DB::Delete($favorites, 'fav_user_id=%i', $sourceId); + } + + $viewed = BasketTables::table('module_basket_viewed'); + if (DatabaseSchema::tableExists($viewed)) { + DB::query('INSERT INTO ' . $viewed . ' (viewed_user_id,viewed_document_id,viewed_at) SELECT %i,source.viewed_document_id,source.viewed_at FROM ' . $viewed . ' source WHERE source.viewed_user_id=%i ON DUPLICATE KEY UPDATE viewed_at=GREATEST(' . $viewed . '.viewed_at,VALUES(viewed_at))', $targetId, $sourceId); + DB::Delete($viewed, 'viewed_user_id=%i', $sourceId); + } + } + + protected static function notesTable() + { + return SystemTables::prefix() . '_customer_notes'; + } + } diff --git a/adminx/modules/Customers/GlobalSearchProvider.php b/adminx/modules/Customers/GlobalSearchProvider.php new file mode 100644 index 0000000..9cc9e0d --- /dev/null +++ b/adminx/modules/Customers/GlobalSearchProvider.php @@ -0,0 +1,42 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Customers; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Content\PublicUserTables; + use DB; + + class GlobalSearchProvider + { + public static function search($query, $limit = 8) + { + $query = trim((string) $query); if ($query === '') { return array(); } + $sql = 'SELECT Id,email,firstname,lastname,user_name,phone,company,status FROM ' . PublicUserTables::table('users') + . ' WHERE deleted!=%s AND (email LIKE %ss OR firstname LIKE %ss OR lastname LIKE %ss OR user_name LIKE %ss OR phone LIKE %ss OR company LIKE %ss'; + $args = array('1', $query, $query, $query, $query, $query, $query); + if (ctype_digit($query)) { $sql .= ' OR Id=%i'; $args[] = (int) $query; } + $sql .= ') ORDER BY status DESC,Id DESC LIMIT ' . max(1, min(12, (int) $limit)); + $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll() ?: array(); + $out = array(); + foreach ($rows as $row) { + $title = trim($row['firstname'] . ' ' . $row['lastname']); if ($title === '') { $title = $row['user_name'] ?: ($row['email'] ?: $row['phone']); } + $subtitle = '#' . (int) $row['Id']; if ($row['email']) { $subtitle .= ' · ' . $row['email']; } if ((string) $row['status'] !== '1') { $subtitle .= ' · отключён'; } + $out[] = array('type' => 'customer', 'group' => 'Пользователи сайта', 'title' => $title, 'subtitle' => $subtitle, 'url' => '/system/customers?tab=customers&q=' . rawurlencode((string) ($row['email'] ?: $row['user_name'])), 'icon' => 'ti ti-user-heart', 'score' => 50); + } + + return $out; + } + } diff --git a/adminx/modules/Customers/Model.php b/adminx/modules/Customers/Model.php index 81e40dd..b7e8d93 100644 --- a/adminx/modules/Customers/Model.php +++ b/adminx/modules/Customers/Model.php @@ -39,6 +39,16 @@ if($q!==''){$sql.=' AND (email LIKE %ss OR firstname LIKE %ss OR lastname LIKE %ss OR phone LIKE %ss OR company LIKE %ss)';for($i=0;$i<5;$i++){$args[]=$q;}}$sql.=' ORDER BY Id DESC LIMIT 500';return call_user_func_array(array('DB','query'),array_merge(array($sql),$args))->getAll()?:array(); } + public static function exportChunk($q, $beforeId, $limit = 500) + { + $sql = 'SELECT Id AS id,email,firstname,lastname,user_name,phone,company,status,reg_time,last_visit FROM ' . self::table('users') . ' WHERE deleted!=%s'; + $args = array('1'); $q = trim((string) $q); + if ($q !== '') { $sql .= ' AND (email LIKE %ss OR firstname LIKE %ss OR lastname LIKE %ss OR phone LIKE %ss OR company LIKE %ss)'; for ($i = 0; $i < 5; $i++) { $args[] = $q; } } + if ((int) $beforeId > 0) { $sql .= ' AND Id<%i'; $args[] = (int) $beforeId; } + $sql .= ' ORDER BY Id DESC LIMIT ' . max(1, min(1000, (int) $limit)); + return call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll() ?: array(); + } + public static function stats() { $table=self::table('users');return array('total'=>(int)DB::query('SELECT COUNT(*) FROM '.$table.' WHERE deleted!=%s','1')->getValue(),'active'=>(int)DB::query('SELECT COUNT(*) FROM '.$table.' WHERE deleted!=%s AND status=%s','1','1')->getValue(),'verified'=>(int)DB::query('SELECT COUNT(*) FROM '.$table.' WHERE deleted!=%s AND (email_verified_at>0 OR phone_verified_at>0)','1')->getValue(),'fields'=>count(self::fields())); @@ -264,7 +274,7 @@ return $settings; } - public static function oauthModules() + public static function authMethods() { $definitions = array( array( @@ -290,24 +300,33 @@ $items = array(); foreach ($definitions as $definition) { $module = ModuleManager::get($definition['module']); - if (!$module || empty($module['installed'])) { continue; } + $available = $module !== null; + $installed = $available && !empty($module['installed']); $isPhone = isset($definition['kind']) && $definition['kind'] === 'phone'; - if ($isPhone) { + if ($installed && $isPhone) { $phoneProvider = PhoneProviderRegistry::get($definition['provider']); $isRegistered = $phoneProvider !== null; $config = class_exists('\\App\\Modules\\SmscAuth\\SecretStore') ? \App\Modules\SmscAuth\SecretStore::config() : array(); $configured = $isRegistered && $phoneProvider->configured(); - } else { + } elseif ($installed) { $isRegistered = in_array($definition['provider'], $registered, true); $config = $isRegistered ? ProviderRegistry::config($definition['provider']) : array(); $configured = $isRegistered && ProviderRegistry::configured($definition['provider'], $config); + } else { + $config = array(); + $configured = false; } - $active = !empty($module['enabled']) && $configured && !empty($config['enabled']); - if (empty($module['enabled'])) { + $moduleEnabled = $installed && !empty($module['enabled']); + $active = $moduleEnabled && $configured && !empty($config['enabled']); + if (!$available) { + $state = array('label' => 'Пакет отсутствует', 'badge' => 'badge-red'); + } elseif (!$installed) { + $state = array('label' => 'Не установлен', 'badge' => 'badge-gray'); + } elseif (!$moduleEnabled) { $state = array('label' => 'Модуль выключен', 'badge' => 'badge-gray'); } elseif (!$configured) { $state = array('label' => 'Нужны ключи', 'badge' => 'badge-amber'); @@ -318,7 +337,9 @@ } $items[] = array_merge($definition, array( - 'module_enabled' => !empty($module['enabled']), + 'available' => $available, + 'installed' => $installed, + 'module_enabled' => $moduleEnabled, 'configured' => $configured, 'active' => $active, 'allow_registration' => !empty($config['allow_registration']), @@ -390,6 +411,9 @@ 'profile' => array('label' => 'Профиль', 'description' => 'Контактные и дополнительные поля пользователя.', 'icon' => 'ti-user-circle', 'tile' => 'violet'), 'password' => array('label' => 'Смена пароля', 'description' => 'Форма для авторизованного пользователя.', 'icon' => 'ti-lock-cog', 'tile' => 'cyan'), 'message' => array('label' => 'Системное сообщение', 'description' => 'Результат регистрации, подтверждения и восстановления.', 'icon' => 'ti-message-circle-check', 'tile' => 'green'), + 'phone' => array('label' => 'Вход по телефону', 'description' => 'Запрос и проверка одноразового SMS-кода.', 'icon' => 'ti-device-mobile-message', 'tile' => 'cyan'), + 'oauth' => array('label' => 'Вход через сервисы', 'description' => 'Кнопки входа через подключённые OAuth-модули.', 'icon' => 'ti-brand-openid', 'tile' => 'blue'), + 'oauth_connections' => array('label' => 'Привязанные сервисы', 'description' => 'Управление внешними способами входа в профиле.', 'icon' => 'ti-plug-connected', 'tile' => 'violet'), ); } diff --git a/adminx/modules/Customers/assets/customers.css b/adminx/modules/Customers/assets/customers.css index f76cb31..705121e 100644 --- a/adminx/modules/Customers/assets/customers.css +++ b/adminx/modules/Customers/assets/customers.css @@ -33,6 +33,310 @@ .customers-table td:last-child { text-align: right; } +.customers-center-kpis { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} +.customers-segments { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 14px 0; +} +.customers-segments a { + display: inline-flex; + align-items: center; + min-height: 40px; + padding: 7px 11px; + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + background: var(--background-surface); + color: var(--text-secondary); + font-size: 12px; + text-decoration: none; +} +.customers-segments a:hover { + border-color: var(--border-strong); + color: var(--text-primary); +} +.customers-segments a.is-active { + border-color: var(--color-primary); + background: var(--blue-50); + color: var(--color-primary); + font-weight: 700; +} +.customers-duplicates { + display: block; + margin: 0 0 14px; + padding: 0; + overflow: hidden; +} +.customers-duplicates > summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 13px 15px; + cursor: pointer; + list-style: none; +} +.customers-duplicates > summary::-webkit-details-marker { + display: none; +} +.customers-duplicates > summary > span:first-child { + display: flex; + align-items: center; + gap: 8px; +} +.customers-duplicates > div { + display: grid; + gap: 8px; + padding: 0 14px 14px; +} +.customers-duplicates article { + display: flex; + align-items: center; + gap: 10px; + padding: 10px; + border-radius: var(--radius-sm); + background: var(--background-surface); +} +.customers-duplicates article > span:first-child { + flex: 1 1 auto; + min-width: 0; +} +.customers-duplicates article b, +.customers-duplicates article small { + display: block; +} +.customers-duplicates article small { + color: var(--text-secondary); +} +.customers-center-list { + padding: 0; +} +.customers-center-table th:nth-child(1) { + width: auto; +} +.customers-center-table th:nth-child(2) { + width: 150px; +} +.customers-center-table th:nth-child(3) { + width: 180px; +} +.customers-center-table th:nth-child(4) { + width: 260px; +} +.customers-center-table td > small, +.customers-center-table td > b { + display: block; +} +.customers-center-table td > small { + margin-top: 3px; + color: var(--text-secondary); + font-size: 11px; +} +.customers-center-table tbody tr[data-customer-center-open] { + cursor: pointer; +} +.customers-center-person { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} +.customers-center-person > span, +.customers-center-avatar { + display: grid; + place-items: center; + flex: 0 0 auto; + border-radius: var(--radius-sm); + background: var(--blue-100); + color: var(--blue-700); + font-weight: 800; +} +.customers-center-person > span { + width: 38px; + height: 38px; + font-size: 12px; +} +.customers-center-person > div { + min-width: 0; +} +.customers-center-person b, +.customers-center-person small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.customers-center-person small { + margin-top: 3px; + color: var(--text-secondary); + font-size: 11px; +} +.customers-center-segment-list { + display: flex; + flex-wrap: wrap; + gap: 5px; +} +.customer-center-drawer { + width: min(66.666vw, 1280px); + max-width: none; +} +.customer-center-drawer .drawer-footer { + justify-content: flex-end; +} +.customers-center-detail { + display: grid; + align-content: start; + gap: 14px; + background: var(--background-inset); +} +.customers-center-profile { + display: flex; + align-items: center; + gap: 14px; + padding: 18px; + border-radius: var(--radius-sm); + background: var(--background-surface); +} +.customers-center-avatar { + width: 58px; + height: 58px; + font-size: 18px; +} +.customers-center-profile > div:nth-child(2) { + flex: 1 1 auto; + min-width: 0; +} +.customers-center-profile h2, +.customers-center-profile p { + margin: 0; +} +.customers-center-profile h2 { + margin: 3px 0; + font-size: 20px; +} +.customers-center-profile p { + color: var(--text-secondary); +} +.customers-center-profile .customers-center-segment-list { + margin-top: 8px; +} +.customers-center-detail-kpis { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} +.customers-center-detail-kpis > span { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + align-items: center; + padding: 11px 12px; + border-radius: var(--radius-sm); + background: var(--background-surface); +} +.customers-center-detail-kpis i { + display: grid; + grid-row: 1/3; + place-items: center; + width: 30px; + height: 30px; + border-radius: var(--radius-sm); + background: var(--blue-100); + color: var(--blue-600); + font-size: 17px; +} +.customers-center-detail-kpis b, +.customers-center-detail-kpis small { + display: block; +} +.customers-center-detail-kpis small { + color: var(--text-secondary); + font-size: 10px; +} +.customers-center-detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + align-items: start; +} +.customers-center-detail-grid > .card { + min-width: 0; + padding: 0; +} +.customers-center-order-list, +.customers-center-contact-list, +.customers-center-identities, +.customers-center-notes { + display: grid; + gap: 7px; + padding: 12px; +} +.customers-center-order-list a, +.customers-center-contact-list article, +.customers-center-identities article { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 9px 10px; + border-radius: var(--radius-sm); + background: var(--background-inset); + color: var(--text-primary); + text-decoration: none; +} +.customers-center-order-list a:hover { + background: var(--background-hover); +} +.customers-center-order-list b, +.customers-center-order-list small, +.customers-center-identities b, +.customers-center-identities small, +.customers-center-contact-list b, +.customers-center-contact-list small { + display: block; +} +.customers-center-order-list small, +.customers-center-identities small, +.customers-center-contact-list small { + margin-top: 2px; + color: var(--text-secondary); + font-size: 10.5px; +} +.customers-center-identities article { + justify-content: flex-start; +} +.customers-center-identities article > i { + color: var(--color-primary); + font-size: 19px; +} +.customers-center-notes article { + padding: 10px; + border-radius: var(--radius-sm); + background: var(--background-inset); +} +.customers-center-notes p { + margin: 0; + white-space: pre-wrap; +} +.customers-center-notes small { + display: block; + margin-top: 6px; + color: var(--text-secondary); + font-size: 10.5px; +} +.customers-center-note-form { + display: grid; + gap: 9px; + padding: 12px; + border-top: 1px solid var(--border-default); +} +.customers-center-note-form .btn { + justify-self: end; +} .customer-editor-drawer { width: min(66.666vw, 1240px); max-width: none; @@ -807,7 +1111,8 @@ @media (max-width: 980px) { .customers-field-builder, .customers-pages-grid, - .customers-social-grid { + .customers-social-grid, + .customers-center-detail-grid { grid-template-columns: 1fr; } .customers-profile-preview { @@ -816,6 +1121,10 @@ .customers-auth-settings-grid { grid-template-columns: 1fr 1fr; } + .customers-center-kpis, + .customers-center-detail-kpis { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } @media (max-width: 980px) { .customers-auth-template-layout { @@ -837,11 +1146,14 @@ } .customer-editor-drawer, .customer-field-drawer, - .customer-auth-form-drawer { + .customer-auth-form-drawer, + .customer-center-drawer { width: 100%; max-width: 100%; } - .customers-editor-meta { + .customers-editor-meta, + .customers-center-kpis, + .customers-center-detail-kpis { grid-template-columns: 1fr; } .customers-switch-grid, @@ -858,11 +1170,14 @@ .customers-social-callback { grid-column: auto; } - .customers-social-status { + .customers-social-status, + .customers-center-profile, + .customers-duplicates article { align-items: stretch; flex-direction: column; } - .customers-social-status > .btn { + .customers-social-status > .btn, + .customers-center-profile > .btn { align-self: flex-end; } .customers-page-fields .field:nth-child(3), diff --git a/adminx/modules/Customers/assets/customers.js b/adminx/modules/Customers/assets/customers.js index c5d957e..cab1b13 100644 --- a/adminx/modules/Customers/assets/customers.js +++ b/adminx/modules/Customers/assets/customers.js @@ -21,6 +21,7 @@ if (event.target.matches('[data-customer-field] [name="type"]')) { self.updateOptionsVisibility(); } if (event.target.matches('[data-auth-show]')) { self.syncAuthFieldRows(); } if (event.target.matches('[data-registration-mode]')) { self.syncAuthMode(); } + if (event.target.matches('[data-registration-gate]')) { self.syncRegistrationGate(); } if (event.target.matches('[data-customer-admin-access]')) { self.syncAdminAccess(); } }); @@ -29,6 +30,10 @@ }); document.addEventListener('click', function (event) { + var centerOpen = event.target.closest('[data-customer-center-open]'); + if (centerOpen) { event.preventDefault(); self.openCustomerCenter(centerOpen.getAttribute('data-url')); return; } + var merge = event.target.closest('[data-customer-merge]'); + if (merge) { event.preventDefault(); self.mergeCustomers(merge); return; } var customerEdit = event.target.closest('[data-customer-edit]'); if (customerEdit) { event.preventDefault(); self.openCustomer(customerEdit); return; } var customerDelete = event.target.closest('[data-customer-delete]'); @@ -51,6 +56,13 @@ if (checkoutDefault) { self.setCheckoutTemplate(checkoutDefault.getAttribute('data-template') || ''); } }); + document.addEventListener('submit', function (event) { + var note = event.target.closest('[data-customer-note]'); + if (!note) { return; } + event.preventDefault(); + self.request(note.action, new FormData(note)).then(function () { self.openCustomerCenter(note.getAttribute('data-refresh-url'), true); }); + }); + var form = document.querySelector('[data-customer-field]'); if (form) { form.addEventListener('submit', function (event) { @@ -95,6 +107,7 @@ }); } this.syncAuthFieldRows(); + this.syncRegistrationGate(); this.syncAuthMode(); this.initSortable(); }, @@ -113,6 +126,34 @@ }); }, + openCustomerCenter: function (url, keepOpen) { + var detail = document.querySelector('[data-customer-center-detail]'); + if (!detail || !url) { return; } + detail.innerHTML = '
'; + if (!keepOpen && Adminx.Drawer) { Adminx.Drawer.open('customerCenterDrawer'); } + Adminx.Loader.show(); + Adminx.Ajax.request(url, { method: 'GET' }).then(function (payload) { + Adminx.Loader.hide(); + if (!payload.ok || !payload.data.success) { throw new Error(payload.data.message || Adminx.tr('Не удалось загрузить карточку')); } + detail.innerHTML = payload.data.html && payload.data.html.detail ? payload.data.html.detail : '
' + Adminx.tr('Нет данных') + '
'; + }).catch(function (error) { Adminx.Loader.hide(); detail.innerHTML = '
' + error.message + '
'; Adminx.Toast.show(error.message, 'error'); }); + }, + + mergeCustomers: function (button) { + var self = this; + var target = Number(button.getAttribute('data-target-id')) || 0; + var source = Number(button.getAttribute('data-source-id')) || 0; + Adminx.Confirm.open({ + kind: 'warning', title: Adminx.tr('Объединить аккаунты?'), + message: Adminx.tr('Данные исходного аккаунта будут перенесены в основной. Исходный аккаунт будет отключён и удалён.') + ' #' + source + ' → #' + target, + confirmLabel: Adminx.tr('Объединить'), + onConfirm: function () { + var body = new FormData(); body.set('target_id', target); body.set('source_id', source); + return self.request(Adminx.base() + '/system/customers/center/merge', body).then(function () { window.location.reload(); }); + } + }); + }, + openCustomer: function (button) { var form = document.querySelector('[data-customer-editor]'); var self = this; @@ -354,6 +395,26 @@ emailOnly.hidden = mode.value !== 'email'; }, + syncRegistrationGate: function () { + var gate = document.querySelector('[data-registration-gate]'); + var hasEmail = gate && gate.value !== 'phone'; + var emailAndPhone = gate && gate.value === 'email_phone'; + var description = document.querySelector('[data-auth-email-field-description]'); + var visibility = document.querySelector('[data-auth-email-visibility]'); + var required = document.querySelector('[data-auth-email-required]'); + if (!gate) { return; } + document.querySelectorAll('[data-auth-email-registration], [data-auth-email-form-fields], [data-auth-email-settings]').forEach(function (section) { + section.hidden = !hasEmail; + }); + if (description) { description.textContent = emailAndPhone ? 'Запрашивается только при выборе регистрации по email' : 'Логин и канал подтверждения регистрации по email'; } + if (visibility) { visibility.textContent = emailAndPhone ? 'В email-форме' : 'Показывается'; } + if (required) { + required.textContent = emailAndPhone ? 'По выбору способа' : 'Обязательно'; + required.classList.toggle('badge-blue', !emailAndPhone); + required.classList.toggle('badge-gray', emailAndPhone); + } + }, + setCheckoutTemplate: function (value) { var form = document.querySelector('[data-customer-auth]'); var textarea = form ? form.elements.checkout_access_template : null; diff --git a/adminx/modules/Customers/language/en/center.xml b/adminx/modules/Customers/language/en/center.xml new file mode 100644 index 0000000..ce57b01 --- /dev/null +++ b/adminx/modules/Customers/language/en/center.xml @@ -0,0 +1,31 @@ + + + Customer center + Customer operations + Account, purchases, inquiries, and customer interests are collected in one card. + Accounts + Customers + Repeat customers + Duplicate groups + Possible duplicates found + Same email + Same phone + Open customer card + Customer card + Profile and interaction history + Orders + Login methods + Inquiries + Manager notes + Internal information that is not visible to the customer + New note + Note added + Accounts merged + All + New + With orders + Repeat + Over ₽100,000 + Inactive + Without orders + diff --git a/adminx/modules/Customers/language/en/client.xml b/adminx/modules/Customers/language/en/client.xml index 1693af2..2b286b5 100644 --- a/adminx/modules/Customers/language/en/client.xml +++ b/adminx/modules/Customers/language/en/client.xml @@ -2,30 +2,12 @@ Failed to load profile New field + The change requires the Manage System Users right. User + Can log into the site and personal account. Site user - : '&#039;' }[character]; - }); - }, - - deleteField: function (button) { - var self = this; - Adminx.Confirm.open({ - kind: 'error', title: 'Delete field?', - message: 'Values of this field for site users will also be deleted.', - confirmLabel: 'Delete', confirmClass: 'btn-danger', - onConfirm: function () { self.request(button.getAttribute('data-url'), new FormData()).then(function () { window.location.reload(); }); } - }); - }, - - toggleField: function (input) { - var self = this; - var item = input.closest('[data-field-id]'); - this.request(input.getAttribute('data-url'), new FormData()).then(function (response) { - var active = !!Number(response.data && response.data.is_active); - input.checked = active; - item.classList.toggle('is-disabled', !active); - var preview = document.querySelector('[data-preview-field= + Role rights are configured in the “Roles and Rights” section. Action failed Saved + The same account will be able to work in the administrative interface. diff --git a/adminx/modules/Customers/language/ru/center.xml b/adminx/modules/Customers/language/ru/center.xml new file mode 100644 index 0000000..e6fbbf4 --- /dev/null +++ b/adminx/modules/Customers/language/ru/center.xml @@ -0,0 +1,31 @@ + + + Центр покупателей + Работа с покупателями + Аккаунт, покупки, обращения и интересы человека собраны в одной карточке. + Аккаунтов + Покупателей + Повторных + Групп дублей + Найдены возможные дубли + Одинаковый email + Одинаковый телефон + Открыть карточку + Карточка покупателя + Профиль и история взаимодействия + Заказы + Способы входа + Обращения + Заметки менеджеров + Внутренняя информация, покупатель её не видит + Новая заметка + Заметка добавлена + Аккаунты объединены + Все + Новые + С заказами + Повторные + От 100 000 ₽ + Неактивные + Без заказов + diff --git a/adminx/modules/Customers/language/ru/client.xml b/adminx/modules/Customers/language/ru/client.xml index 507d2d0..66d0ae2 100644 --- a/adminx/modules/Customers/language/ru/client.xml +++ b/adminx/modules/Customers/language/ru/client.xml @@ -2,30 +2,12 @@ Не удалось загрузить профиль Новое поле + Изменение требует права управления системными пользователями. Пользователь + Может входить на сайт и в личный кабинет. Пользователь сайта - : '&#039;' }[character]; - }); - }, - - deleteField: function (button) { - var self = this; - Adminx.Confirm.open({ - kind: 'error', title: 'Удалить поле?', - message: 'Значения этого поля у пользователей сайта также будут удалены.', - confirmLabel: 'Удалить', confirmClass: 'btn-danger', - onConfirm: function () { self.request(button.getAttribute('data-url'), new FormData()).then(function () { window.location.reload(); }); } - }); - }, - - toggleField: function (input) { - var self = this; - var item = input.closest('[data-field-id]'); - this.request(input.getAttribute('data-url'), new FormData()).then(function (response) { - var active = !!Number(response.data && response.data.is_active); - input.checked = active; - item.classList.toggle('is-disabled', !active); - var preview = document.querySelector('[data-preview-field= + Права роли настраиваются в разделе «Роли и права». Не удалось выполнить действие Сохранено + Та же учётная запись сможет работать в административном интерфейсе. diff --git a/adminx/modules/Customers/migrations/007_customer_center.php b/adminx/modules/Customers/migrations/007_customer_center.php new file mode 100644 index 0000000..e9db006 --- /dev/null +++ b/adminx/modules/Customers/migrations/007_customer_center.php @@ -0,0 +1,36 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\DatabaseSchema; + use App\Common\SystemTables; + use App\Content\BasketTables; + + return function (array $context) { + $notes = SystemTables::prefix() . '_customer_notes'; + if (!DatabaseSchema::tableExists($notes)) { + DB::query('CREATE TABLE `' . $notes . '` (' + . '`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,`user_id` INT UNSIGNED NOT NULL,`author_id` INT UNSIGNED NOT NULL DEFAULT 0,' + . '`note` TEXT NOT NULL,`created_at` INT UNSIGNED NOT NULL,PRIMARY KEY (`id`),KEY `idx_customer_notes` (`user_id`,`created_at`)' + . ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'); + } + + $orders = BasketTables::table('module_basket_history'); + if (DatabaseSchema::tableExists($orders) && !DatabaseSchema::indexExists($orders, 'idx_order_user_date')) { + DB::query('ALTER TABLE `' . $orders . '` ADD KEY `idx_order_user_date` (`order_user_id`,`order_published`)'); + } + + return 1; + }; diff --git a/adminx/modules/Customers/module.php b/adminx/modules/Customers/module.php index e90cdcd..41e9651 100644 --- a/adminx/modules/Customers/module.php +++ b/adminx/modules/Customers/module.php @@ -14,8 +14,10 @@ defined('BASEPATH') || die('Direct access to this location is not allowed.'); + use App\Adminx\Customers\GlobalSearchProvider; + return array( - 'code' => 'customers', 'name' => 'Пользователи сайта', 'version' => '0.3.1', + 'code' => 'customers', 'name' => 'Пользователи сайта', 'version' => '0.10.2', 'permissions' => array('key' => 'customers', 'items' => array( array( 'code' => 'view_customers', @@ -45,7 +47,10 @@ ), ), 'routes' => array( + array('GET', '/system/customers/export', array(\App\Adminx\Customers\Controller::class, 'exportCustomers')), array('GET', '/system/customers', array(\App\Adminx\Customers\Controller::class, 'index')), + array('POST', '/system/customers/saved-views', array(\App\Adminx\Customers\Controller::class, 'saveSavedView')), + array('POST', '/system/customers/saved-views/{id}/delete', array(\App\Adminx\Customers\Controller::class, 'deleteSavedView')), array('GET', '/system/customers/users/{id}', array(\App\Adminx\Customers\Controller::class, 'customer')), array('POST', '/system/customers/users/{id}', array(\App\Adminx\Customers\Controller::class, 'updateCustomer')), array('POST', '/system/customers/users/{id}/delete', array(\App\Adminx\Customers\Controller::class, 'deleteCustomer')), @@ -59,6 +64,9 @@ array('GET', '/system/customers/forms/{key}', array(\App\Adminx\Customers\Controller::class, 'authForm')), array('POST', '/system/customers/forms/{key}', array(\App\Adminx\Customers\Controller::class, 'saveAuthForm')), array('POST', '/system/customers/forms/{key}/reset', array(\App\Adminx\Customers\Controller::class, 'resetAuthForm')), + array('GET', '/system/customers/center/{id}', array(\App\Adminx\Customers\Controller::class, 'centerCustomer')), + array('POST', '/system/customers/center/{id}/notes', array(\App\Adminx\Customers\Controller::class, 'addCustomerNote')), + array('POST', '/system/customers/center/merge', array(\App\Adminx\Customers\Controller::class, 'mergeCustomers')), ), 'migrations' => array( array('id' => '001_normalize_public_groups', 'file' => 'migrations/001_normalize_public_groups.sql'), @@ -67,6 +75,11 @@ array('id' => '004_materialize_public_auth_schema', 'file' => 'migrations/004_materialize_public_auth_schema.sql'), array('id' => '005_checkout_registration', 'file' => 'migrations/005_checkout_registration.sql'), array('id' => '006_phone_identity', 'file' => 'migrations/006_phone_identity.php'), + array('id' => '007_customer_center', 'file' => 'migrations/007_customer_center.php'), ), 'view_globals' => array('module_code' => 'customers'), + 'admin_extension' => array( + 'url' => '/system/customers', 'feature' => 'Публичные аккаунты и профили', 'icon' => 'ti ti-user-heart', + 'search' => array('code' => 'customers', 'provider' => array(GlobalSearchProvider::class, 'search'), 'permission' => 'view_customers', 'priority' => 26, 'limit' => 8), + ), ); diff --git a/adminx/modules/Customers/view/customer-center-detail.twig b/adminx/modules/Customers/view/customer-center-detail.twig new file mode 100644 index 0000000..ba62f96 --- /dev/null +++ b/adminx/modules/Customers/view/customer-center-detail.twig @@ -0,0 +1,12 @@ +
{{ (customer.user.firstname|first ~ customer.user.lastname|first)|upper ?: '#' }}
Профиль #{{ customer.user.id }}

{{ (customer.user.firstname ~ ' ' ~ customer.user.lastname)|trim ?: customer.user.user_name }}

{{ customer.user.email ?: 'Email не указан' }}{% if customer.user.phone %} · {{ customer.user.phone }}{% endif %}

{% set labels={'new':'Новый','buyers':'С заказами','repeat':'Повторный','vip':'От 100 000 ₽','inactive':'Неактивный','without_orders':'Без заказов'} %}{% for code in customer.segments %}{{ labels[code] }}{% endfor %}
Редактировать аккаунт
+ +
{{ customer.summary.orders_count }}заказов{{ customer.summary.orders_total|number_format(0,'.',' ') }} ₽общая сумма{{ customer.engagement.favorites|length }}в избранном{{ customer.engagement.viewed|length }}просмотрено
+ +
+

Заказы

Последние покупки пользователя

{% if customer.summary.guest_count %}Гостевых: {{ customer.summary.guest_count }}{% endif %}
+

Способы входа

Связанные аккаунты и подтверждения

Email{{ customer.user.email_verified_at?'подтверждён':'не подтверждён' }}
{% if customer.user.phone %}
Телефон{{ customer.user.phone_verified_at?'подтверждён':'не подтверждён' }}
{% endif %}{% for identity in customer.identities %}
{{ identity.provider|upper }}последний вход {{ identity.last_login_at?identity.last_login_at|date('d.m.Y'):'—' }}
{% endfor %}
+

Обращения

Сообщения контактных форм по email

{% for item in customer.contacts %}
{{ item.subject ?: 'Обращение #' ~ item.id }}{{ item.date|date('d.m.Y H:i') }}{{ item.status=='new'?'Новое':(item.status=='replied'?'Отвечено':'Просмотрено') }}
{% else %}
Обращений не найдено
{% endfor %}
+ {% if customer.popup_leads %}

Заявки из поп-апов

Сопоставлены по email или телефону

{% for item in customer.popup_leads %}
{{ item.campaign_title ?: 'Заявка #' ~ item.id }}{{ item.created_at|date('d.m.Y H:i') }}{% if item.phone %} · {{ item.phone }}{% endif %}{{ item.status=='new'?'Новая':(item.status=='spam'?'Спам':'Обработана') }}
{% endfor %}
{% endif %} + {% if customer.quiz_leads %}

Заявки из подбора

Сопоставлены по email или телефону

{% for item in customer.quiz_leads %}
{{ item.quiz_title }}{{ item.created_label }} · ответов {{ item.answers_count }} · подошло {{ item.matches_count }}{{ item.lead_status=='new'?'Новая':(item.lead_status=='spam'?'Спам':'Обработана') }}
{% endfor %}
{% endif %} +

Заметки менеджеров

Внутренняя информация, покупатель её не видит

{% for note in customer.notes %}

{{ note.note }}

{{ note.author_name ?: 'Система' }} · {{ note.created_at|date('d.m.Y H:i') }}
{% else %}
Заметок пока нет
{% endfor %}
{% if can_manage %}
{% endif %}
+
diff --git a/adminx/modules/Customers/view/customer-center.twig b/adminx/modules/Customers/view/customer-center.twig new file mode 100644 index 0000000..e9c69cd --- /dev/null +++ b/adminx/modules/Customers/view/customer-center.twig @@ -0,0 +1,19 @@ +
Работа с покупателями

Центр покупателей

Аккаунт, покупки, обращения и интересы человека собраны в одной карточке.

{% set saved_views_form = '[data-customers-center-filter]' %}{% set saved_views_save_url = ADMINX_BASE ~ '/system/customers/saved-views' %}{% set saved_views_delete_url = ADMINX_BASE ~ '/system/customers/saved-views/__ID__/delete' %}{% set saved_views_fields = ['q','segment'] %}{% include '@adminx/_saved_views.twig' %}
+ +
+
{{ center_stats.total|default(0) }}
Аккаунтов
+
{{ center_stats.buyers|default(0) }}
Покупателей
+
{{ center_stats.repeat|default(0) }}
Повторных
+
{{ center_stats.duplicates|default(0) }}
Групп дублей
+
+ + + +{% if duplicate_groups %}
Найдены возможные дубли{{ duplicate_groups|length }}
{% for group in duplicate_groups %}
{{ group.kind=='email'?'Одинаковый email':'Одинаковый телефон' }}{{ group.value }}{% for id in group.ids %}#{{ id }}{% if not loop.last %}, {% endif %}{% endfor %}{% if can_manage %}{% for id in group.ids %}{% if id!=group.target_id %}{% endif %}{% endfor %}{% endif %}
{% endfor %}
{% endif %} + +
+

Покупатели

Откройте карточку, чтобы увидеть всю историю взаимодействия.

{{ center_customers|length }}
+
{% for customer in center_customers %}{% else %}{% endfor %}
ПокупательЗаказыАктивностьСегментыДействия
{{ (customer.firstname|first ~ customer.lastname|first)|upper ?: '#' }}
{{ (customer.firstname ~ ' ' ~ customer.lastname)|trim ?: customer.user_name }}{{ customer.email ?: customer.phone }} · #{{ customer.id }}
{{ customer.orders_count }} заказов{{ customer.orders_total|number_format(0,'.',' ') }} ₽{{ customer.last_order_at ? customer.last_order_at|date('d.m.Y') : 'Покупок нет' }}Вход: {{ customer.last_visit ? customer.last_visit|date('d.m.Y') : 'не входил' }}
{% for code in customer.segment_codes %}{{ center_segments[code] }}{% endfor %}
Покупатели не найденыИзмените поисковый запрос или сегмент.
+
+ + diff --git a/adminx/modules/Customers/view/index.twig b/adminx/modules/Customers/view/index.twig index f9ade17..12b0d43 100644 --- a/adminx/modules/Customers/view/index.twig +++ b/adminx/modules/Customers/view/index.twig @@ -5,22 +5,25 @@ -
+{% if tab!='center' %}
{{ stats.total }}
Всего
{{ stats.active }}
Активные
{{ stats.verified }}
Email подтверждён
{{ stats.fields }}
Доп. полей
-
+
{% endif %} -{% if tab=='customers' %} -
Аккаунты сайта

Пользователи

+{% if tab=='center' %} + {% include '@customers/customer-center.twig' %} +{% elseif tab=='customers' %} +
Аккаунты сайта

Пользователи

CSV

Публичные аккаунты

Посетители, зарегистрированные на сайте, их контакты и доступ к личному кабинету.

{{ customers|length }}
{% if can_manage %}{% endif %} @@ -70,17 +73,17 @@

Создание аккаунта

Кто может зарегистрироваться и когда станет доступен вход.

- + + -
Канал регистрации
EmailТелефон и SMS подключаются отдельным гейтом.
-
-

Поля формы регистрации

Базовые данные аккаунта. Дополнительные поля подключаются в соседнем табе.

+
+

Поля регистрации по email

Для регистрации по телефону достаточно номера и SMS-кода. Остальные данные пользователь заполнит в профиле.

ПолеПоказыватьОбязательное
-
EmailЛогин и канал подтвержденияВсегдаВсегда
+
Email{{ auth_settings.registration_gate == 'email_phone' ? 'Запрашивается только при выборе регистрации по email' : 'Логин и канал подтверждения регистрации по email' }}{{ auth_settings.registration_gate == 'email_phone' ? 'В email-форме' : 'Показывается' }}{{ auth_settings.registration_gate == 'email_phone' ? 'По выбору способа' : 'Обязательно' }}
ИмяОсновное имя пользователяВсегда
ФамилияДополняет отображаемое имя
ТелефонКонтакт для заказов и доставки
@@ -88,7 +91,7 @@
-
+

Пароль и подтверждение

Срок действия одноразовых ссылок и требования к паролю.

@@ -98,7 +101,7 @@
-
+

Аккаунт после заказа

Покупатель может создать тот же личный кабинет прямо при оформлении.

Пароль по почте не отправляется. После заказа пользователь сразу входит в аккаунт и получает одноразовую ссылку, чтобы задать пароль самостоятельно.

@@ -115,7 +118,7 @@
-
+

Ограничения регистрации

Один email или домен на строку. Проверка не зависит от регистра букв.

@@ -123,14 +126,13 @@ {% if can_manage %}
{% endif %} -{% if oauth_modules %}
-
Авторизация

Подключённые способы входа

OAuth и вход по одноразовому коду используют одну учётную запись сайта.

-
{{ oauth_modules|length }} подключено
+
Авторизация

Способы входа

Яндекс, VK ID и SMS-код подключаются к одной учётной записи сайта.

+
{{ auth_method_stats.active }} активно{{ auth_method_stats.installed }} установлено
- {% for provider in oauth_modules %} + {% for provider in auth_methods %}
{{ provider.mark }}

{{ provider.label }}

{{ provider.description }}

@@ -138,19 +140,17 @@
- Модуль включён - Ключи настроены + Модуль {{ provider.installed ? 'установлен' : 'не установлен' }} + Модуль {{ provider.module_enabled ? 'включён' : 'выключен' }} + Подключение {{ provider.configured ? 'настроено' : 'не настроено' }} Регистрация {{ provider.allow_registration ? 'разрешена' : 'выключена' }}
- {% if provider.can_open %}Настроить{% endif %} + {% if provider.can_open %}{{ provider.action_label }}{% endif %}
{% endfor %}
{% else %} -
Дополнительные способы входа устанавливаются модулями

Яндекс, VK ID и SMSC настраиваются отдельно в разделе «Модули». Удаление пакета физически убирает его код и форму настроек.

Открыть модули
-{% endif %} -{% else %}
Публичный сайт

Страницы личного кабинета

Каждая страница имеет редактируемый Twig-шаблон и выводится через `[tag:maincontent]` внутри выбранного шаблона сайта.

{% if can_manage %}
{% endif %}
Старые адреса продолжат работать

После смены URL системные `/login`, `/register`, `/remember`, `/password/reset` и `/personal` останутся совместимыми алиасами. Новые ссылки в интерфейсе и письмах сразу начнут использовать заданные адреса.

@@ -165,7 +165,7 @@
{% endfor %} -

Служебные маршруты

Подтверждение email, выход и общий шаблон системных результатов.

{% if can_manage %}
{% endif %}
+

Служебные маршруты и фрагменты

Подтверждение, выход, панель пользователя и подключаемые способы входа.

{% if can_manage %}
{% for key in ['panel','message','phone','oauth','oauth_connections'] %}{% set form_meta = auth_forms[key] %}{% endfor %}
{% endif %}
{% if can_manage %}
{% endif %} {% endif %} diff --git a/adminx/modules/Database/Backup.php b/adminx/modules/Database/Backup.php index e6ef436..e2a8b94 100644 --- a/adminx/modules/Database/Backup.php +++ b/adminx/modules/Database/Backup.php @@ -16,6 +16,7 @@ defined('BASEPATH') || die('Direct access to this location is not allowed.'); + use App\Common\Settings; use DB; /** @@ -25,11 +26,67 @@ */ class Backup { + /** Ключ настройки «сколько копий хранить»; 0 — хранить все. */ + const KEEP_SETTING = 'database_backup_keep'; + const KEEP_DEFAULT = 10; + const KEEP_MAX = 200; + public static function dir() { return BASEPATH . DS . 'tmp' . DS . 'backup'; } + public static function keep() + { + $value = Settings::get(self::KEEP_SETTING); + if ($value === null || $value === '') { return self::KEEP_DEFAULT; } + return max(0, min(self::KEEP_MAX, (int) $value)); + } + + public static function setKeep($value) + { + $value = max(0, min(self::KEEP_MAX, (int) $value)); + Settings::set(self::KEEP_SETTING, $value, 'integer'); + return $value; + } + + /** + * Оставляет только последние $keep копий, созданных системой. + * + * Загруженные вручную и чужие файлы не трогаются: их имя не проходит + * isManagedName(), и админ мог положить их сюда намеренно. Страховочные + * копии перед восстановлением тоже участвуют в ротации — иначе каждое + * восстановление оставляло бы вечный файл. + */ + public static function prune($keep = null) + { + $keep = $keep === null ? self::keep() : $keep; + $names = array(); + foreach (self::prunable(self::all(), $keep) as $name) { + $path = self::path($name); + if ($path !== null && @unlink($path)) { $names[] = $name; } + } + + return array('deleted' => count($names), 'names' => $names); + } + + /** + * Отбирает имена лишних копий: чистая функция, файлы не трогает. + * Список приходит из all() и уже отсортирован «новые сверху». + */ + public static function prunable(array $items, $keep) + { + $keep = max(0, min(self::KEEP_MAX, (int) $keep)); + if ($keep < 1) { return array(); } + + $managed = array(); + foreach ($items as $item) { + if (!empty($item['own'])) { $managed[] = (string) $item['name']; } + } + + return count($managed) <= $keep ? array() : array_slice($managed, $keep); + } + /** Список файлов резервных копий (name/size/mtime), новые сверху. */ public static function all() { @@ -202,7 +259,17 @@ flock($lock, LOCK_UN); fclose($lock); - return array('name' => $name, 'size' => filesize($path), 'tables' => count($tables), 'prefixes' => $prefixes); + //-- Ротация только после успешного переименования: сначала новая + //-- копия на диске, и лишь потом удаляются лишние старые. + $pruned = self::prune(); + + return array( + 'name' => $name, + 'size' => filesize($path), + 'tables' => count($tables), + 'prefixes' => $prefixes, + 'pruned' => (int) $pruned['deleted'], + ); } /** Новые имена AVE.cms и старые adminx-дампы поддерживаются одинаково. */ diff --git a/adminx/modules/Database/Controller.php b/adminx/modules/Database/Controller.php index 6573d1d..3c97871 100644 --- a/adminx/modules/Database/Controller.php +++ b/adminx/modules/Database/Controller.php @@ -42,6 +42,8 @@ 'tables' => Model::tables(), 'stats' => Model::stats(), 'backups' => Backup::all(), + 'backup_keep' => Backup::keep(), + 'backup_keep_max' => Backup::KEEP_MAX, 'schema' => ModuleManager::coreSchemaStatus(), 'can_manage' => Permission::check('manage_database'), ]); @@ -94,11 +96,29 @@ return $this->error('Не удалось создать бэкап: ' . $e->getMessage(), [], 500); } - return $this->success('Бэкап создан: ' . $info['name'] . ' (' . $info['tables'] . ' таблиц)', [ + $message = 'Бэкап создан: ' . $info['name'] . ' (' . $info['tables'] . ' таблиц)'; + if (!empty($info['pruned'])) { + $message .= '. Удалено старых копий: ' . (int) $info['pruned']; + } + + return $this->success($message, [ 'redirect' => $this->base() . '/database?tab=backups', ]); } + /** POST /database/backup/keep — сколько копий хранить. */ + public function backupKeep(array $params = array()) + { + if (($resp = $this->guard()) !== null) { return $resp; } + $keep = Backup::setKeep(Request::postInt('keep', Backup::KEEP_DEFAULT)); + $pruned = Backup::prune(); + $message = $keep > 0 + ? 'Хранить последних копий: ' . $keep + : 'Ротация выключена, копии не удаляются'; + if ($pruned['deleted'] > 0) { $message .= '. Удалено сейчас: ' . (int) $pruned['deleted']; } + return $this->success($message, array('redirect' => $this->base() . '/database?tab=backups')); + } + /** POST /database/backup/upload — загрузить и проверить внешний файл дампа. */ public function backupUpload(array $params = array()) { diff --git a/adminx/modules/Database/assets/database.css b/adminx/modules/Database/assets/database.css index 4ac0f57..69d1b18 100644 --- a/adminx/modules/Database/assets/database.css +++ b/adminx/modules/Database/assets/database.css @@ -314,3 +314,31 @@ font-size: 13px; font-weight: 600; } +/* --- Ротация резервных копий ------------------------------------------ */ +.database-backup-keep { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + padding: 14px 16px; +} +.database-backup-keep-text { + flex: 1 1 260px; + min-width: 0; +} +.database-backup-keep-text p { + margin: 4px 0 0; +} +.database-backup-keep-field { + flex: 0 0 auto; + width: 150px; + margin: 0; +} +@media (max-width: 700px) { + .database-backup-keep { + align-items: stretch; + } + .database-backup-keep-field { + width: 100%; + } +} diff --git a/adminx/modules/Database/assets/database.js b/adminx/modules/Database/assets/database.js index c34b731..f53e751 100644 --- a/adminx/modules/Database/assets/database.js +++ b/adminx/modules/Database/assets/database.js @@ -42,6 +42,14 @@ if (e.target.closest('[data-db-restore-close]')) { self.closeRestoreProgress(); return; } }); + var keepForm = document.querySelector('[data-db-backup-keep-form]'); + if (keepForm) { + keepForm.addEventListener('submit', function (e) { + e.preventDefault(); + self.saveBackupKeep(keepForm); + }); + } + document.addEventListener('input', function (e) { var filter = e.target.closest('[data-db-table-filter]'); if (filter) { self.filterTables(filter.value); } @@ -340,6 +348,15 @@ }); }, + saveBackupKeep: function (form) { + if (!form.reportValidity()) { return; } + this.post(this.base() + '/database/backup/keep', new FormData(form)).then(function (d) { + if (d.success === false) { Adminx.Toast.show(d.message || 'Ошибка', 'error'); return; } + Adminx.Toast.show(d.message || 'Сохранено', 'success'); + if (d.redirect) { window.location.href = d.redirect; } else { window.location.reload(); } + }); + }, + backup: function () { var self = this; Adminx.Confirm.open({ diff --git a/adminx/modules/Database/module.php b/adminx/modules/Database/module.php index 604baf5..256f011 100644 --- a/adminx/modules/Database/module.php +++ b/adminx/modules/Database/module.php @@ -21,7 +21,7 @@ return [ 'code' => 'database', 'name' => 'База данных', - 'version' => '0.1.5', + 'version' => '0.2.0', 'permissions' => [ 'key' => 'database', @@ -79,6 +79,7 @@ array('GET', '/database', array(\App\Adminx\Database\Controller::class, 'index')), array('POST', '/database/maintenance', array(\App\Adminx\Database\Controller::class, 'maintenance')), array('POST', '/database/backup', array(\App\Adminx\Database\Controller::class, 'backupCreate')), + array('POST', '/database/backup/keep', array(\App\Adminx\Database\Controller::class, 'backupKeep'), array('permission' => 'manage_database')), array('POST', '/database/backup/upload', array(\App\Adminx\Database\Controller::class, 'backupUpload'), array('permission' => 'manage_database')), array('GET', '/database/backup/download', array(\App\Adminx\Database\Controller::class, 'backupDownload')), array('POST', '/database/backup/delete', array(\App\Adminx\Database\Controller::class, 'backupDelete')), diff --git a/adminx/modules/Database/view/index.twig b/adminx/modules/Database/view/index.twig index 8aa8717..97c5ab6 100644 --- a/adminx/modules/Database/view/index.twig +++ b/adminx/modules/Database/view/index.twig @@ -123,6 +123,22 @@
Восстановление полностью заменяет данные текущей схемы

Перед запуском AVE.cms автоматически создаст страховочную копию. Не закрывайте страницу до завершения операции.

+ {% if can_manage %} +
+ + +
+ Сколько копий хранить +

После создания новой копии лишние старые удаляются. Загруженные вручную файлы не трогаются. Ноль отключает ротацию.

+
+ + + + {% endif %} +

Файлы резервных копий

Собственные и загруженные SQL-дампы базы данных.

diff --git a/adminx/modules/Directories/language/en/client.xml b/adminx/modules/Directories/language/en/client.xml new file mode 100644 index 0000000..d565e60 --- /dev/null +++ b/adminx/modules/Directories/language/en/client.xml @@ -0,0 +1,12 @@ + + + Delete directory? + Delete value? + Directory settings + Edit value + Directory is in use + New directory + New value + Request failed + Failed to save data + diff --git a/adminx/modules/Directories/language/en/interface.xml b/adminx/modules/Directories/language/en/interface.xml new file mode 100644 index 0000000..16e592f --- /dev/null +++ b/adminx/modules/Directories/language/en/interface.xml @@ -0,0 +1,32 @@ + + + Directories + Shared value lists that can be connected to multiple fields. + Create directory + Shared directories + Connect one list to multiple fields and maintain it centrally. + Directory list + The key is stored in the document; the label can change without rewriting documents. + No directories yet + Create a shared value list for fields. + No values yet + Add the first key and label pair. + New directory + New value + Shared value source for fields. + Stable technical code. It is generated from the name when left empty. + Stable key + Stored in the document. Avoid changing the key after it is in use. + Editors and site visitors will see this label. + Available for field selection + Show in fields + Open values + Add value + Directory settings + Directory is in use + Delete directory? + Edit value + Delete value? + Failed to save data + Request failed + diff --git a/adminx/modules/Directories/language/ru/client.xml b/adminx/modules/Directories/language/ru/client.xml new file mode 100644 index 0000000..1c5e55c --- /dev/null +++ b/adminx/modules/Directories/language/ru/client.xml @@ -0,0 +1,12 @@ + + + Удалить справочник? + Удалить значение? + Настройки справочника + Изменить значение + Справочник используется + Новый справочник + Новое значение + Ошибка запроса + Не удалось сохранить данные + diff --git a/adminx/modules/Directories/language/ru/interface.xml b/adminx/modules/Directories/language/ru/interface.xml new file mode 100644 index 0000000..6d4e27f --- /dev/null +++ b/adminx/modules/Directories/language/ru/interface.xml @@ -0,0 +1,32 @@ + + + Справочники + Общие списки значений, которые можно подключать к нескольким полям. + Создать справочник + Общие справочники + Один список можно подключить к нескольким полям и менять централизованно. + Список справочников + Ключ хранится в документе, подпись можно менять без перезаписи документов. + Справочников пока нет + Создайте общий список значений для полей. + Значений пока нет + Добавьте первую пару «ключ → подпись». + Новый справочник + Новое значение + Общий источник значений для полей. + Стабильный технический код. Если оставить пустым, сформируется из названия. + Стабильный ключ + Хранится в документе. После начала использования ключ лучше не менять. + Эту подпись увидят редактор и посетитель сайта. + Доступен для выбора в полях + Показывать в полях + Открыть значения + Добавить значение + Настройки справочника + Справочник используется + Удалить справочник? + Изменить значение + Удалить значение? + Не удалось сохранить данные + Ошибка запроса + diff --git a/adminx/modules/Directories/module.php b/adminx/modules/Directories/module.php index 1dd8f1f..5be306f 100644 --- a/adminx/modules/Directories/module.php +++ b/adminx/modules/Directories/module.php @@ -17,7 +17,7 @@ return array( 'code' => 'directories', 'name' => 'Справочники', - 'version' => '0.2.0', + 'version' => '0.2.1', 'requires' => array('rubrics'), 'routes' => array( diff --git a/adminx/modules/Documents/BulkEditor.php b/adminx/modules/Documents/BulkEditor.php new file mode 100644 index 0000000..09356e9 --- /dev/null +++ b/adminx/modules/Documents/BulkEditor.php @@ -0,0 +1,613 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Documents; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\AuditLog; + use App\Common\DatabaseSchema; + use App\Common\Lock; + use App\Content\CatalogTables; + use App\Content\ContentTables; + use App\Content\Documents\DocumentMutationService; + use App\Content\Documents\DocumentSearch; + use App\Helpers\File; + use DB; + + /** + * Server-side plans for safe, previewable document batch mutations. + * + * A plan freezes matching IDs before execution. The browser only sends the + * opaque token back and cannot replace the document list after preview. + */ + class BulkEditor + { + const VERSION = 1; + const MAX_DOCUMENTS = 5000; + const CHUNK_SIZE = 20; + const EXPIRES_AFTER = 7200; + + protected static $documentFields = array( + 'document_title' => array( + 'label' => 'Заголовок документа', + 'payload' => 'title', + 'help' => 'Основной заголовок документа в AVE.cms. У товара название на сайте может браться из отдельного поля рубрики.', + ), + 'document_excerpt' => array('label' => 'Тизер (анонс)', 'payload' => 'excerpt'), + 'document_tags' => array('label' => 'Теги', 'payload' => 'tags'), + 'document_meta_keywords' => array('label' => 'Meta keywords', 'payload' => 'meta_keywords'), + 'document_meta_description' => array('label' => 'Meta description', 'payload' => 'meta_description'), + 'document_property' => array('label' => 'Свойство / артикул', 'payload' => 'property'), + ); + + public static function options() + { + return array( + 'rubrics' => Model::rubrics(), + 'document_fields' => self::$documentFields, + 'products_available' => self::productsAvailable(), + ); + } + + public static function preview(array $input, $actorId) + { + $filters = self::filters($input); + $operation = self::operation($input, $filters); + $matches = self::matches($filters); + if (!$matches['ids']) { + throw new \InvalidArgumentException('По выбранным условиям документы не найдены'); + } + + if ($matches['total'] > self::MAX_DOCUMENTS) { + throw new \InvalidArgumentException( + 'Найдено ' . $matches['total'] . ' документов. Уточните фильтр: за один запуск можно обработать до ' . self::MAX_DOCUMENTS + ); + } + + $matchedTotal = (int) $matches['total']; + $changingRows = self::changingRows($matches['rows'], $operation); + if (!$changingRows) { + throw new \InvalidArgumentException('В найденных документах нет значений, которые изменит выбранное действие'); + } + + $sample = array(); + foreach (array_slice($changingRows, 0, 20) as $row) { + $change = self::previewChange($row, $operation); + $sample[] = array( + 'id' => (int) $row['Id'], + 'title' => html_entity_decode((string) $row['document_title'], ENT_QUOTES, 'UTF-8'), + 'rubric' => html_entity_decode((string) $row['rubric_title'], ENT_QUOTES, 'UTF-8'), + 'state' => (int) $row['document_status'] === 1 ? 'Опубликован' : 'Черновик', + 'before' => self::shortValue($change['before']), + 'after' => self::shortValue($change['after']), + 'changed' => $change['changed'], + 'note' => $change['note'], + ); + } + + $token = bin2hex(random_bytes(20)); + $plan = array( + 'version' => self::VERSION, + 'token' => $token, + 'actor_id' => (int) $actorId, + 'status' => 'prepared', + 'created_at' => time(), + 'expires_at' => time() + self::EXPIRES_AFTER, + 'filters' => $filters, + 'operation' => $operation, + 'ids' => array_values(array_map(function ($row) { return (int) $row['Id']; }, $changingRows)), + 'total' => count($changingRows), + 'matched_total' => $matchedTotal, + 'unchanged_total' => max(0, $matchedTotal - count($changingRows)), + 'cursor' => 0, + 'done' => 0, + 'skipped' => 0, + 'errors' => array(), + 'sample' => $sample, + ); + self::save($plan); + return self::publicPlan($plan); + } + + public static function runChunk($token, $actorId) + { + return Lock::run('document-bulk:' . (string) $token, function () use ($token, $actorId) { + $plan = self::load($token, $actorId); + if (in_array($plan['status'], array('completed', 'cancelled'), true)) { + return self::publicPlan($plan); + } + + $plan['status'] = 'running'; + $service = new DocumentMutationService(); + $end = min((int) $plan['total'], (int) $plan['cursor'] + self::CHUNK_SIZE); + while ((int) $plan['cursor'] < $end) { + $id = (int) $plan['ids'][$plan['cursor']]; + try { + $result = self::apply($service, $id, $plan['operation'], (int) $actorId); + if ($result) { $plan['done']++; } + else { $plan['skipped']++; } + } catch (\Throwable $e) { + $plan['errors'][] = '#' . $id . ': ' . $e->getMessage(); + if (count($plan['errors']) > 100) { + $plan['errors'] = array_slice($plan['errors'], -100); + } + } + + $plan['cursor']++; + } + + if ((int) $plan['cursor'] >= (int) $plan['total']) { + $plan['status'] = 'completed'; + $plan['completed_at'] = time(); + AuditLog::record('document.bulk_editor_completed', array( + 'actor_id' => (int) $actorId, + 'target_type' => 'document_batch', + 'meta' => array( + 'operation' => $plan['operation'], + 'filters' => $plan['filters'], + 'total' => $plan['total'], + 'done' => $plan['done'], + 'skipped' => $plan['skipped'], + 'errors' => count($plan['errors']), + ), + )); + } + + self::save($plan); + return self::publicPlan($plan); + }, 5.0, true); + } + + public static function cancel($token, $actorId) + { + return Lock::run('document-bulk:' . (string) $token, function () use ($token, $actorId) { + $plan = self::load($token, $actorId); + if ($plan['status'] !== 'completed') { + $plan['status'] = 'cancelled'; + $plan['cancelled_at'] = time(); + self::save($plan); + } + + return self::publicPlan($plan); + }, 5.0, true); + } + + public static function status($token, $actorId) + { + return self::publicPlan(self::load($token, $actorId)); + } + + protected static function apply(DocumentMutationService $service, $documentId, array $operation, $actorId) + { + $row = DB::query( + "SELECT * FROM " . ContentTables::table('documents') . " WHERE Id=%i AND document_deleted='0' LIMIT 1", + (int) $documentId + )->getAssoc(); + if (!$row) { return false; } + + $type = $operation['type']; + if ($type === 'publish' || $type === 'unpublish') { + if (Model::isProtectedDocument($documentId)) { return false; } + $status = $type === 'publish' ? 1 : 0; + if ((int) $row['document_status'] === $status) { return false; } + $service->save($documentId, array('status' => $status), $actorId, 'bulk_editor'); + return true; + } + + if ($type === 'recalculate') { + $service->save($documentId, array('fields' => array()), $actorId, 'bulk_editor'); + return true; + } + + if ($type === 'move') { + if (Model::isProtectedDocument($documentId)) { return false; } + $result = $service->move($documentId, $operation['target_rubric_id'], $actorId, 'bulk_editor'); + return !empty($result['moved']); + } + + $current = self::currentValue($row, $operation); + $next = self::changedValue($current, $operation); + if (!$next['changed']) { return false; } + if ($operation['target_type'] === 'document') { + $payloadKey = self::$documentFields[$operation['target']]['payload']; + $service->save($documentId, array($payloadKey => $next['value']), $actorId, 'bulk_editor'); + } else { + $service->save($documentId, array('fields' => array($operation['field_id'] => $next['value'])), $actorId, 'bulk_editor'); + } + + return true; + } + + protected static function filters(array $input) + { + $scope = isset($input['scope']) ? (string) $input['scope'] : 'all'; + if (!in_array($scope, array('all', 'documents', 'products'), true) || ($scope !== 'all' && !self::productsAvailable())) { + $scope = 'all'; + } + + $state = isset($input['state']) ? (string) $input['state'] : ''; + if (!in_array($state, array('', 'active', 'draft'), true)) { $state = ''; } + return array( + 'q' => trim(isset($input['q']) ? (string) $input['q'] : ''), + 'rubric_id' => max(0, isset($input['rubric_id']) ? (int) $input['rubric_id'] : 0), + 'state' => $state, + 'scope' => $scope, + ); + } + + protected static function operation(array $input, array $filters) + { + $type = isset($input['operation']) ? (string) $input['operation'] : ''; + $allowed = array('fill', 'set', 'clear', 'replace', 'move', 'publish', 'unpublish', 'recalculate'); + if (!in_array($type, $allowed, true)) { + throw new \InvalidArgumentException('Выберите действие'); + } + + $operation = array('type' => $type, 'label' => self::operationLabel($type)); + if (in_array($type, array('move', 'publish', 'unpublish', 'recalculate'), true)) { + if ($type === 'move') { + $targetRubricId = isset($input['target_rubric_id']) ? (int) $input['target_rubric_id'] : 0; + if ($targetRubricId <= 0 || !Model::rubric($targetRubricId)) { + throw new \InvalidArgumentException('Выберите целевую рубрику'); + } + + if ($filters['rubric_id'] > 0 && $targetRubricId === $filters['rubric_id']) { + throw new \InvalidArgumentException('Исходная и целевая рубрики совпадают'); + } + + $operation['target_rubric_id'] = $targetRubricId; + $operation['target_rubric_title'] = Model::rubric($targetRubricId)['rubric_title']; + } + + return $operation; + } + + $target = isset($input['target']) ? trim((string) $input['target']) : ''; + if (isset(self::$documentFields[$target])) { + $operation['target_type'] = 'document'; + $operation['target'] = $target; + $operation['target_label'] = self::$documentFields[$target]['label']; + } elseif (strpos($target, 'field:') === 0) { + $fieldId = (int) substr($target, 6); + if ($filters['rubric_id'] <= 0) { + throw new \InvalidArgumentException('Для изменения поля сначала выберите одну рубрику'); + } + + $field = self::field($fieldId, $filters['rubric_id']); + if (!$field) { throw new \InvalidArgumentException('Поле не принадлежит выбранной рубрике'); } + $operation['target_type'] = 'field'; + $operation['target'] = $target; + $operation['field_id'] = $fieldId; + $operation['target_label'] = html_entity_decode((string) $field['rubric_field_title'], ENT_QUOTES, 'UTF-8'); + } else { + throw new \InvalidArgumentException('Выберите поле для изменения'); + } + + $operation['value'] = isset($input['value']) ? (string) $input['value'] : ''; + $operation['search'] = isset($input['search']) ? (string) $input['search'] : ''; + if ($type === 'replace' && $operation['search'] === '') { + throw new \InvalidArgumentException('Укажите текст, который нужно заменить'); + } + + return $operation; + } + + protected static function matches(array $filters) + { + $where = " WHERE d.document_deleted='0'"; + $args = array(); + if ($filters['rubric_id'] > 0) { + $where .= ' AND d.rubric_id=%i'; + $args[] = $filters['rubric_id']; + } + + if ($filters['state'] === 'active') { $where .= " AND d.document_status='1'"; } + elseif ($filters['state'] === 'draft') { $where .= " AND d.document_status='0'"; } + + $productTable = CatalogTables::table('catalog_product_index'); + if (self::productsAvailable() && $filters['scope'] === 'products') { + $where .= ' AND EXISTS (SELECT 1 FROM ' . $productTable . ' bp WHERE bp.product_id=d.Id)'; + } elseif (self::productsAvailable() && $filters['scope'] === 'documents') { + $where .= ' AND NOT EXISTS (SELECT 1 FROM ' . $productTable . ' bp WHERE bp.product_id=d.Id)'; + } + + $search = DocumentSearch::criteria($filters['q'], 'd'); + if ($search['where'] !== '') { + $where .= ' AND ' . $search['where']; + $args = array_merge($args, $search['where_args']); + } + + $sql = 'SELECT d.Id,d.document_title,d.document_status,d.rubric_id,r.rubric_title' + . ' FROM ' . ContentTables::table('documents') . ' d' + . ' LEFT JOIN ' . ContentTables::table('rubrics') . ' r ON r.Id=d.rubric_id' + . $where . ' ORDER BY d.Id ASC LIMIT ' . (self::MAX_DOCUMENTS + 1); + $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll() ?: array(); + $ids = array(); + foreach ($rows as $row) { $ids[] = (int) $row['Id']; } + return array('ids' => $ids, 'rows' => $rows, 'total' => count($rows)); + } + + protected static function changingRows(array $rows, array $operation) + { + if (!$rows || $operation['type'] === 'recalculate') { return $rows; } + + $currentValues = array(); + $ids = array(); + foreach ($rows as $row) { $ids[] = (int) $row['Id']; } + if (isset($operation['target_type']) && $operation['target_type'] === 'document') { + $column = (string) $operation['target']; + if (!isset(self::$documentFields[$column])) { + throw new \InvalidArgumentException('Поле документа недоступно для массового изменения'); + } + + $valueRows = DB::query( + 'SELECT Id,`' . $column . '` current_value FROM ' . ContentTables::table('documents') . ' WHERE Id IN %li', + $ids + )->getAll(); + foreach ($valueRows ?: array() as $valueRow) { + $currentValues[(int) $valueRow['Id']] = html_entity_decode((string) $valueRow['current_value'], ENT_QUOTES, 'UTF-8'); + } + } elseif (isset($operation['target_type']) && $operation['target_type'] === 'field') { + $valueRows = DB::query( + 'SELECT df.document_id,CONCAT(COALESCE(df.field_value,\'\'),COALESCE(dft.field_value,\'\')) current_value' + . ' FROM ' . ContentTables::table('document_fields') . ' df' + . ' LEFT JOIN ' . ContentTables::table('document_fields_text') . ' dft' + . ' ON dft.document_id=df.document_id AND dft.rubric_field_id=df.rubric_field_id' + . ' WHERE df.document_id IN %li AND df.rubric_field_id=%i', + $ids, + (int) $operation['field_id'] + )->getAll(); + foreach ($valueRows ?: array() as $valueRow) { + $currentValues[(int) $valueRow['document_id']] = (string) $valueRow['current_value']; + } + } + + $changing = array(); + foreach ($rows as $row) { + if (isset($operation['target_type'])) { + $row['__bulk_current'] = isset($currentValues[(int) $row['Id']]) + ? $currentValues[(int) $row['Id']] + : ''; + } + + if (self::previewChange($row, $operation)['changed']) { $changing[] = $row; } + } + + return $changing; + } + + protected static function previewChange(array $row, array $operation) + { + if ($operation['type'] === 'publish') { + return array('before' => (int) $row['document_status'] ? 'Опубликован' : 'Черновик', 'after' => 'Опубликован', 'changed' => !(int) $row['document_status'], 'note' => ''); + } + + if ($operation['type'] === 'unpublish') { + return array('before' => (int) $row['document_status'] ? 'Опубликован' : 'Черновик', 'after' => 'Черновик', 'changed' => (bool) $row['document_status'], 'note' => ''); + } + + if ($operation['type'] === 'recalculate') { + return array('before' => 'Текущие значения', 'after' => 'Пересчитать поля и индексы', 'changed' => true, 'note' => ''); + } + + if ($operation['type'] === 'move') { + return array('before' => (string) $row['rubric_title'], 'after' => (string) $operation['target_rubric_title'], 'changed' => (int) $row['rubric_id'] !== (int) $operation['target_rubric_id'], 'note' => 'Совпадающие поля переносятся по системному имени'); + } + + $current = self::currentValue($row, $operation); + $next = self::changedValue($current, $operation); + return array('before' => $current, 'after' => $next['value'], 'changed' => $next['changed'], 'note' => $next['changed'] ? '' : 'Значение уже соответствует действию'); + } + + protected static function currentValue(array $row, array $operation) + { + if (array_key_exists('__bulk_current', $row)) { + return (string) $row['__bulk_current']; + } + + if ($operation['target_type'] === 'document') { + if (!array_key_exists($operation['target'], $row)) { + $value = DB::query( + 'SELECT `' . $operation['target'] . '` FROM ' . ContentTables::table('documents') . ' WHERE Id=%i', + (int) $row['Id'] + )->getValue(); + return html_entity_decode((string) $value, ENT_QUOTES, 'UTF-8'); + } + + return html_entity_decode((string) $row[$operation['target']], ENT_QUOTES, 'UTF-8'); + } + + return (string) DB::query( + 'SELECT CONCAT(COALESCE(df.field_value,\'\'),COALESCE(dft.field_value,\'\'))' + . ' FROM ' . ContentTables::table('document_fields') . ' df' + . ' LEFT JOIN ' . ContentTables::table('document_fields_text') . ' dft' + . ' ON dft.document_id=df.document_id AND dft.rubric_field_id=df.rubric_field_id' + . ' WHERE df.document_id=%i AND df.rubric_field_id=%i LIMIT 1', + (int) $row['Id'], + (int) $operation['field_id'] + )->getValue(); + } + + protected static function changedValue($current, array $operation) + { + $current = (string) $current; + $type = $operation['type']; + if ($type === 'fill') { + $value = trim($current) === '' ? (string) $operation['value'] : $current; + } elseif ($type === 'clear') { + $value = ''; + } elseif ($type === 'replace') { + $value = str_replace((string) $operation['search'], (string) $operation['value'], $current); + } else { + $value = (string) $operation['value']; + } + + return array('value' => $value, 'changed' => $value !== $current); + } + + protected static function field($fieldId, $rubricId) + { + return DB::query( + 'SELECT Id,rubric_id,rubric_field_title,rubric_field_alias,rubric_field_type' + . ' FROM ' . ContentTables::table('rubric_fields') . ' WHERE Id=%i AND rubric_id=%i LIMIT 1', + (int) $fieldId, + (int) $rubricId + )->getAssoc() ?: null; + } + + public static function fields($rubricId) + { + $commerce = self::commerceFieldUsage((int) $rubricId); + $rows = DB::query( + 'SELECT Id,rubric_field_title,rubric_field_alias,rubric_field_type' + . ' FROM ' . ContentTables::table('rubric_fields') + . ' WHERE rubric_id=%i ORDER BY rubric_field_position ASC,Id ASC', + (int) $rubricId + )->getAll(); + $out = array(); + foreach ($rows ?: array() as $row) { + $fieldId = (int) $row['Id']; + $usage = isset($commerce[$fieldId]) ? $commerce[$fieldId] : ''; + $out[] = array( + 'id' => $fieldId, + 'title' => html_entity_decode((string) $row['rubric_field_title'], ENT_QUOTES, 'UTF-8'), + 'alias' => (string) $row['rubric_field_alias'], + 'type' => (string) $row['rubric_field_type'], + 'usage' => $usage, + 'help' => $usage !== '' ? 'Это поле используется товарным представлением как ' . $usage . '. Изменение обновит витрину и товарный индекс.' : '', + ); + } + + return $out; + } + + protected static function commerceFieldUsage($rubricId) + { + if ($rubricId <= 0 || !self::productsAvailable()) { return array(); } + + $row = DB::query( + 'SELECT product_title_field_id,product_article_field_id,product_price_field_id,' + . 'product_old_price_field_id,product_stock_field_id,product_images_field_id' + . ' FROM ' . CatalogTables::table('module_catalog_settings') + . " WHERE rubric_id=%i AND purpose='commerce' ORDER BY id ASC LIMIT 1", + (int) $rubricId + )->getAssoc(); + if (!$row) { return array(); } + + $labels = array( + 'product_title_field_id' => 'название товара на сайте', + 'product_article_field_id' => 'артикул товара', + 'product_price_field_id' => 'текущая цена', + 'product_old_price_field_id' => 'старая цена', + 'product_stock_field_id' => 'остаток / наличие', + 'product_images_field_id' => 'изображения товара', + ); + $usage = array(); + foreach ($labels as $column => $label) { + $fieldId = isset($row[$column]) ? (int) $row[$column] : 0; + if ($fieldId > 0) { $usage[$fieldId] = $label; } + } + + return $usage; + } + + protected static function productsAvailable() + { + try { + return DatabaseSchema::tableExists(CatalogTables::table('catalog_product_index')); + } catch (\Throwable $e) { + return false; + } + } + + protected static function operationLabel($type) + { + $labels = array( + 'fill' => 'Заполнить пустые', 'set' => 'Установить значение', 'clear' => 'Очистить', + 'replace' => 'Найти и заменить', 'move' => 'Перенести в рубрику', + 'publish' => 'Опубликовать', 'unpublish' => 'Снять с публикации', + 'recalculate' => 'Пересчитать поля и индексы', + ); + return isset($labels[$type]) ? $labels[$type] : $type; + } + + protected static function shortValue($value) + { + $value = trim(preg_replace('/\s+/u', ' ', strip_tags((string) $value))); + if ($value === '') { return 'Пусто'; } + return mb_strlen($value, 'UTF-8') > 180 ? mb_substr($value, 0, 177, 'UTF-8') . '...' : $value; + } + + protected static function publicPlan(array $plan) + { + $total = max(1, (int) $plan['total']); + return array( + 'token' => $plan['token'], + 'status' => $plan['status'], + 'total' => (int) $plan['total'], + 'matched_total' => isset($plan['matched_total']) ? (int) $plan['matched_total'] : (int) $plan['total'], + 'unchanged_total' => isset($plan['unchanged_total']) ? (int) $plan['unchanged_total'] : 0, + 'processed' => (int) $plan['cursor'], + 'done' => (int) $plan['done'], + 'skipped' => (int) $plan['skipped'], + 'errors' => $plan['errors'], + 'progress' => (int) floor(((int) $plan['cursor'] / $total) * 100), + 'operation' => $plan['operation'], + 'filters' => $plan['filters'], + 'sample' => $plan['sample'], + ); + } + + protected static function load($token, $actorId) + { + if (!preg_match('/^[a-f0-9]{40}$/', (string) $token)) { + throw new \InvalidArgumentException('Некорректный идентификатор плана'); + } + + $path = self::path($token); + $data = is_file($path) ? json_decode((string) File::getContent($path), true) : null; + if (!is_array($data) || (int) $data['version'] !== self::VERSION || !hash_equals((string) $data['token'], (string) $token)) { + throw new \RuntimeException('План массового изменения не найден'); + } + + if ((int) $data['actor_id'] !== (int) $actorId) { + throw new \RuntimeException('План создан другим пользователем'); + } + + if ((int) $data['expires_at'] < time() && !in_array($data['status'], array('completed', 'cancelled'), true)) { + throw new \RuntimeException('Срок действия предпросмотра истёк. Выполните проверку ещё раз'); + } + + return $data; + } + + protected static function save(array $plan) + { + $directory = dirname(self::path($plan['token'])); + if (!is_dir($directory) && !@mkdir($directory, 0750, true) && !is_dir($directory)) { + throw new \RuntimeException('Не удалось создать каталог заданий'); + } + + $raw = json_encode($plan, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; + if (!File::putAtomic(self::path($plan['token']), $raw, 0640)) { + throw new \RuntimeException('Не удалось сохранить план массового изменения'); + } + } + + protected static function path($token) + { + return rtrim(BASEPATH, '/\\') . '/storage/jobs/document-bulk/' . (string) $token . '.json'; + } + } diff --git a/adminx/modules/Documents/Controller.php b/adminx/modules/Documents/Controller.php index e100298..15a8d62 100644 --- a/adminx/modules/Documents/Controller.php +++ b/adminx/modules/Documents/Controller.php @@ -25,6 +25,7 @@ use App\Common\ErrorReport; use App\Common\Permission; use App\Adminx\Support\CodeEditor; + use App\Adminx\Support\SavedViews; use App\Adminx\Rubrics\AdminView; use App\Content\Documents\DocumentHookException; use App\Content\Documents\ContentCacheInvalidator; @@ -44,6 +45,9 @@ class Controller extends BaseController { + protected function savedViewFields() { return array('q', 'field', 'rubric_id', 'state', 'per_page'); } + protected function savedViewGuard() { if (($error = $this->csrfGuard()) !== null) { return $error; } return Permission::check('view_documents') ? null : $this->error('Недостаточно прав', array(), 403); } + public function index(array $params = array()) { AdminAssets::addStyle($this->base() . '/modules/Documents/assets/documents.css', 50); @@ -84,9 +88,28 @@ 'table_name' => Model::documentsTable(), 'can_manage' => Permission::check('manage_documents'), 'open_create' => Request::getBool('create', false), + 'saved_views' => SavedViews::all('documents', Auth::id(), $this->savedViewFields()), )); } + public function saveSavedView(array $params = array()) + { + if (($error = $this->savedViewGuard()) !== null) { return $error; } + $filters = json_decode(Request::postStr('filters', '{}'), true); + if (!is_array($filters)) { return $this->error('Некорректный набор фильтров', array(), 422); } + try { $views = SavedViews::save('documents', Auth::id(), Request::postStr('title', ''), $filters, $this->savedViewFields()); } + catch (\InvalidArgumentException $e) { return $this->error($e->getMessage(), array(), 422); } + return $this->success('Представление сохранено', array('data' => array('views' => $views))); + } + + public function deleteSavedView(array $params = array()) + { + if (($error = $this->savedViewGuard()) !== null) { return $error; } + try { $views = SavedViews::delete('documents', Auth::id(), isset($params['id']) ? $params['id'] : '', $this->savedViewFields()); } + catch (\InvalidArgumentException $e) { return $this->error($e->getMessage(), array(), 404); } + return $this->success('Представление удалено', array('data' => array('views' => $views))); + } + public function create(array $params = array()) { if (!Permission::check('manage_documents')) { @@ -190,7 +213,13 @@ try { $scopes = Request::post('scopes', array()); $scopes = is_array($scopes) ? $scopes : array(); - $result = ApiTokenRepository::issue(Auth::id(), Request::postStr('name', ''), $scopes, Request::postStr('expires_at', '')); + $result = ApiTokenRepository::issueRestricted( + Auth::id(), + Request::postStr('name', ''), + $scopes, + array('documents:read', 'documents:write', 'documents:*'), + Request::postStr('expires_at', '') + ); AuditLog::record('document.api_token_created', array('actor_id'=>Auth::id(),'target_type'=>'api_token','target_id'=>$result['id'],'meta'=>array('scopes'=>$result['scopes']))); return $this->success('API-токен создан', array('data' => $result)); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } @@ -201,7 +230,10 @@ if (($err = $this->csrfGuard()) !== null) { return $err; } if (!Permission::check('manage_document_api')) { return $this->error('Недостаточно прав', array(), 403); } $id = isset($params['id']) ? (int) $params['id'] : 0; - if (!ApiTokenRepository::revoke($id)) { return $this->error('Токен не найден или уже отозван', array(), 404); } + if (!ApiTokenRepository::revokeRestricted($id, array('documents:read', 'documents:write'))) { + return $this->error('Токен документов не найден или уже отозван', array(), 404); + } + AuditLog::record('document.api_token_revoked', array('actor_id'=>Auth::id(),'target_type'=>'api_token','target_id'=>$id)); return $this->success('API-токен отозван'); } @@ -391,6 +423,70 @@ return $this->success('Пакетное действие выполнено', array('data' => $result)); } + public function bulkEditor(array $params = array()) + { + if (!Permission::check('manage_documents')) { + return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); + } + + AdminAssets::addStyle($this->base() . '/modules/Documents/assets/documents.css', 50); + AdminAssets::addScript($this->base() . '/modules/Documents/assets/documents.js', 50); + return $this->render('@documents/bulk-editor.twig', array( + 'bulk_options' => BulkEditor::options(), + )); + } + + public function bulkEditorFields(array $params = array()) + { + if (!Permission::check('manage_documents')) { + return $this->error('Недостаточно прав', array(), 403); + } + + return $this->success('', array('data' => array( + 'items' => BulkEditor::fields(Request::getInt('rubric_id', 0)), + ))); + } + + public function bulkEditorPreview(array $params = array()) + { + if (($err = $this->guard()) !== null) { return $err; } + try { + $plan = BulkEditor::preview(Request::postAll(), Auth::id()); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage(), array(), 422); + } catch (\Throwable $e) { + return $this->error(ErrorReport::publicMessage('Не удалось подготовить массовое изменение', $e, 'DOCBULKPREVIEW'), array(), 500); + } + + return $this->success('Предпросмотр подготовлен', array('data' => array('plan' => $plan))); + } + + public function bulkEditorRun(array $params = array()) + { + if (($err = $this->guard()) !== null) { return $err; } + try { + $plan = BulkEditor::runChunk(Request::postStr('token', ''), Auth::id()); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage(), array(), 422); + } catch (\Throwable $e) { + return $this->error(ErrorReport::publicMessage('Не удалось выполнить пакет документов', $e, 'DOCBULKRUN'), array(), 500); + } + + return $this->success($plan['status'] === 'completed' ? 'Массовое изменение завершено' : 'Пакет обработан', array('data' => array('plan' => $plan))); + } + + public function bulkEditorCancel(array $params = array()) + { + if (($err = $this->guard()) !== null) { return $err; } + try { + $plan = BulkEditor::cancel(Request::postStr('token', ''), Auth::id()); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Выполнение остановлено', array('data' => array('plan' => $plan))); + } + public function store(array $params = array()) { if (($err = $this->guard()) !== null) { @@ -559,6 +655,12 @@ return $this->error('Проверьте поля документа', $errors, 422); } + $mediaReplacement = DocumentMediaReplacement::capture( + $fieldGroups, + $fieldValues, + DocumentMediaReplacement::requestedFieldIds(Request::post('media_replace_fields', array())) + ); + $mediaFinalization = null; $previousDatabaseExceptionMode = DB::$throw_exception_on_error; DB::$throw_exception_on_error = true; @@ -622,10 +724,12 @@ $snapshotError = ContentCacheInvalidator::consumeError($id); $snapshot = (new DocumentSnapshotRepository())->find($id); DocumentSaveEvents::after('update', 'adminx', $id, (int) $doc['rubric_id'], Auth::id(), $input, $fieldValues, $doc, is_array($snapshot) ? $snapshot : array()); + $mediaCleanup = DocumentMediaReplacement::cleanup($mediaReplacement, $fieldValues); return $this->success('Документ сохранён', array('data' => array( 'id' => $id, 'document_version' => Model::documentVersion($id), 'snapshot_warning' => $snapshotError, + 'media_cleanup' => $mediaCleanup, 'media_draft_token' => DocumentMediaDraft::issue(Auth::id(), $id), ), 'redirect' => $this->base() . '/documents/' . $id . '/edit')); } diff --git a/adminx/modules/Documents/DocumentMediaReplacement.php b/adminx/modules/Documents/DocumentMediaReplacement.php new file mode 100644 index 0000000..53e3d83 --- /dev/null +++ b/adminx/modules/Documents/DocumentMediaReplacement.php @@ -0,0 +1,126 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Documents; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Adminx\Media\MediaAudit; + use App\Adminx\Media\Model as MediaModel; + use App\Content\Fields\DocumentMediaFieldType; + use App\Content\Fields\FieldRegistry; + + /** Removes superseded document media only after the document transaction succeeds. */ + class DocumentMediaReplacement + { + public static function requestedFieldIds($value) + { + $value = is_array($value) ? $value : array($value); + $ids = array(); + foreach ($value as $id) { + $id = (int) $id; + if ($id > 0) { $ids[$id] = $id; } + } + + return array_values($ids); + } + + /** Capture trusted old paths before field values are saved. */ + public static function capture(array $fieldGroups, array $newValues, array $requestedFieldIds) + { + $requested = array_fill_keys(self::requestedFieldIds($requestedFieldIds), true); + if (!$requested) { return array(); } + + $captured = array(); + foreach ($fieldGroups as $group) { + foreach (isset($group['items']) && is_array($group['items']) ? $group['items'] : array() as $field) { + $fieldId = isset($field['Id']) ? (int) $field['Id'] : 0; + if (!isset($requested[$fieldId]) || !array_key_exists($fieldId, $newValues)) { continue; } + $typeCode = isset($field['rubric_field_type']) ? (string) $field['rubric_field_type'] : ''; + $type = FieldRegistry::get($typeCode); + if (!$type instanceof DocumentMediaFieldType) { continue; } + + $oldValue = isset($field['parsed']) ? $field['parsed'] : array(); + $captured[$fieldId] = array( + 'type' => $typeCode, + 'paths' => self::uploadPaths($type->mediaPaths($oldValue)), + ); + } + } + + return $captured; + } + + public static function removedPaths(array $captured, array $newValues) + { + $removed = array(); + foreach ($captured as $fieldId => $field) { + $type = FieldRegistry::get(isset($field['type']) ? (string) $field['type'] : ''); + if (!$type instanceof DocumentMediaFieldType) { continue; } + $newValue = array_key_exists($fieldId, $newValues) ? $newValues[$fieldId] : array(); + $newPaths = array_fill_keys(self::uploadPaths($type->mediaPaths($newValue)), true); + foreach (isset($field['paths']) && is_array($field['paths']) ? $field['paths'] : array() as $path) { + if (!isset($newPaths[$path])) { $removed[$path] = $path; } + } + } + + return array_values($removed); + } + + /** Move currently unused files to the recoverable media trash. */ + public static function cleanup(array $captured, array $newValues) + { + $paths = self::removedPaths($captured, $newValues); + $result = array('checked' => count($paths), 'trashed' => 0, 'kept' => 0, 'errors' => array()); + if (!$paths) { return $result; } + + try { + $usage = MediaAudit::inspectUsageMany($paths, 1, false); + } catch (\Throwable $e) { + $result['errors'][] = $e->getMessage(); + return $result; + } + + foreach ($paths as $path) { + if (!isset($usage[$path])) { continue; } + if (!empty($usage[$path]['use_count'])) { + $result['kept']++; + continue; + } + + try { + MediaModel::trash($path); + $result['trashed']++; + } catch (\Throwable $e) { + $result['errors'][] = basename($path) . ': ' . $e->getMessage(); + } + } + + return $result; + } + + protected static function uploadPaths(array $paths) + { + $result = array(); + foreach ($paths as $path) { + $path = html_entity_decode(trim((string) $path), ENT_QUOTES, 'UTF-8'); + $parsed = parse_url($path, PHP_URL_PATH); + $path = is_string($parsed) ? rawurldecode($parsed) : $path; + $path = '/' . ltrim(preg_replace('#/+#', '/', str_replace('\\', '/', $path)), '/'); + if (strpos($path, '/uploads/') === 0 && strpos($path, '/../') === false) { $result[$path] = $path; } + } + + return array_values($result); + } + } diff --git a/adminx/modules/Documents/Model.php b/adminx/modules/Documents/Model.php index fa74246..ce029ba 100644 --- a/adminx/modules/Documents/Model.php +++ b/adminx/modules/Documents/Model.php @@ -22,6 +22,7 @@ use App\Content\Fields\FieldConditionEvaluator; use App\Content\Fields\FieldValueCodec; use App\Content\Fields\FieldContext; + use App\Content\Fields\HtmlSanitizer; use App\Content\Fields\FieldRegistry; use App\Content\Fields\FieldLifecycle; use App\Content\Fields\FieldSettings; @@ -1680,6 +1681,7 @@ $editor = FieldAdminEditors::describe($type); $kind = self::documentEditorKind($editor); $settings = FieldSettings::effective($row); + $description = self::decode($row['rubric_field_description']); if ($type === 'choice') { $kind = isset($settings['mode']) && (string) $settings['mode'] === 'multiple' ? 'choice_multi' : 'choice'; } @@ -1697,7 +1699,8 @@ 'rubric_field_type' => $type, 'rubric_field_numeric' => (int) $row['rubric_field_numeric'], 'rubric_field_search' => (int) $row['rubric_field_search'], - 'rubric_field_description' => self::decode($row['rubric_field_description']), + 'rubric_field_description' => $description, + 'rubric_field_description_html' => HtmlSanitizer::clean($description), 'rubric_field_default' => (string) $row['rubric_field_default'], 'rubric_field_settings' => isset($row['rubric_field_settings']) ? (string) $row['rubric_field_settings'] : '', 'group_settings' => isset($row['group_settings']) ? (string) $row['group_settings'] : '', @@ -2125,7 +2128,7 @@ $data = array( 'field_value' => $first, 'field_number_value' => ((int) $field['rubric_field_numeric'] === 1) - ? ($type === 'period' ? \App\Content\Fields\Types\PeriodValue::indexValue($value) : self::numericValue($value)) + ? ($type === 'period' ? \App\Content\Fields\Types\PeriodValue::indexValue($value) : FieldValueCodec::numericIndexValue($value)) : 0, 'document_in_search' => (int) $inSearch ? '1' : '0', ); @@ -2579,12 +2582,6 @@ return in_array($value, array('index,follow', 'index,nofollow', 'noindex,nofollow'), true) ? $value : 'index,follow'; } - protected static function numericValue($value) - { - $value = preg_replace('/[^\d.]/', '', (string) $value); - return $value === '' ? 0 : $value; - } - protected static function dateInput($time) { return (int) $time > 0 ? date('Y-m-d\\TH:i', (int) $time) : ''; diff --git a/adminx/modules/Documents/Revisions.php b/adminx/modules/Documents/Revisions.php index ef14051..a38f2d7 100644 --- a/adminx/modules/Documents/Revisions.php +++ b/adminx/modules/Documents/Revisions.php @@ -19,6 +19,7 @@ use DB; use App\Common\SystemTables; use App\Content\Documents\DocumentRevisionPayload; + use App\Content\Revisions\JsonRevisionStore; class Revisions { @@ -168,6 +169,27 @@ $payload = self::decodePayload((string) $row['doc_data']); $values = $withData ? $payload['fields'] : null; $document = $withData ? $payload['document'] : null; + $documentPreview = $withData ? self::documentPreview($payload['document']) : array(); + $fieldPreview = $withData && is_array($values) ? self::preview($values) : array(); + $comparison = array('document' => array(), 'fields' => array()); + if ($withData) { + $currentDocument = Model::one((int) $row['doc_id']); + $currentFields = Model::currentFieldValues((int) $row['doc_id']); + $comparison['document'] = JsonRevisionStore::compareSnapshots($currentDocument ?: array(), $payload['document']); + $comparison['fields'] = JsonRevisionStore::compareSnapshots($currentFields, $payload['fields']); + foreach ($documentPreview as &$item) { + $item['changed'] = !empty($comparison['document']['items'][$item['key']]['changed']); + } + + unset($item); + foreach ($fieldPreview as &$item) { + $key = (string) $item['field_id']; + $item['changed'] = !empty($comparison['fields']['items'][$key]['changed']); + } + + unset($item); + } + return array( 'id' => (int) $row['Id'], 'doc_id' => (int) $row['doc_id'], @@ -181,8 +203,9 @@ 'size_label' => self::formatBytes(strlen((string) $row['doc_data'])), 'values' => $values, 'document' => $document, - 'document_preview' => $withData ? self::documentPreview($payload['document']) : array(), - 'preview' => $withData && is_array($values) ? self::preview($values) : array(), + 'document_preview' => $documentPreview, + 'preview' => $fieldPreview, + 'comparison' => $comparison, ); } diff --git a/adminx/modules/Documents/assets/documents.css b/adminx/modules/Documents/assets/documents.css index d468d13..c0a4abf 100644 --- a/adminx/modules/Documents/assets/documents.css +++ b/adminx/modules/Documents/assets/documents.css @@ -1168,6 +1168,244 @@ width: 100%; } } +.documents-bulk-editor { + display: grid; + gap: 14px; +} +.documents-bulk-notice { + display: flex; + align-items: flex-start; + gap: 10px; + margin: 0; +} +.documents-bulk-notice > i { + margin-top: 2px; + font-size: 20px; +} +.documents-bulk-notice b, +.documents-bulk-notice span { + display: block; +} +.documents-bulk-notice span { + margin-top: 2px; + font-size: 12px; +} +.documents-bulk-form { + display: grid; + gap: 14px; +} +.documents-bulk-step, +.documents-bulk-preview, +.documents-bulk-progress { + padding: 0; + overflow: hidden; +} +.documents-bulk-controls { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + padding: 16px; +} +.documents-bulk-controls .field { + min-width: 0; +} +.documents-bulk-search, +.documents-bulk-value { + grid-column: span 2; +} +.documents-bulk-value .textarea { + min-height: 86px; + resize: vertical; +} +.documents-bulk-target-help { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 0 16px 16px; + padding: 10px 12px; + border-radius: 6px; + background: var(--blue-50); + color: var(--blue-700); + font-size: 12px; + line-height: 1.45; +} +.documents-bulk-target-help[hidden] { + display: none; +} +.documents-bulk-target-help > i { + margin-top: 1px; + font-size: 16px; +} +.documents-bulk-step-footer, +.documents-bulk-run { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 62px; + padding: 10px 16px; + border-top: 1px solid var(--border-default); + background: var(--background-muted); +} +.documents-bulk-step-footer > span { + display: flex; + align-items: center; + gap: 7px; + font-size: 12px; +} +.documents-bulk-table { + table-layout: fixed; +} +.documents-bulk-table th:nth-child(1) { + width: 24%; +} +.documents-bulk-table th:nth-child(2) { + width: 16%; +} +.documents-bulk-table th:nth-child(3), +.documents-bulk-table th:nth-child(4) { + width: 23%; +} +.documents-bulk-table th:nth-child(5) { + width: 14%; +} +.documents-bulk-table td { + vertical-align: top; + overflow-wrap: anywhere; +} +.documents-bulk-table td b, +.documents-bulk-table td small { + display: block; +} +.documents-bulk-table td small { + margin-top: 3px; + color: var(--text-muted); + font-size: 11px; +} +.documents-bulk-value-cell { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.45; +} +.documents-bulk-result { + color: var(--text-muted); + font-size: 12px; + font-weight: 600; +} +.documents-bulk-result.is-changed { + color: var(--color-success); +} +.documents-bulk-run > div b, +.documents-bulk-run > div span { + display: block; +} +.documents-bulk-run > div span { + margin-top: 2px; + color: var(--text-muted); + font-size: 11px; +} +.documents-bulk-progress { + padding: 18px; +} +.documents-bulk-progress-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} +.documents-bulk-progress-head b, +.documents-bulk-progress-head span { + display: block; +} +.documents-bulk-progress-head span { + margin-top: 3px; + color: var(--text-muted); + font-size: 12px; +} +.documents-bulk-progress-head strong { + font-size: 22px; + line-height: 1; +} +.documents-bulk-progress .progress { + height: 8px; + overflow: hidden; + border-radius: 4px; + background: var(--background-muted); +} +.documents-bulk-progress .progress > span { + display: block; + height: 100%; + border-radius: inherit; + background: var(--blue-600); + transition: width 180ms ease; +} +.documents-bulk-progress-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + margin-top: 14px; +} +.documents-bulk-progress-stats span { + padding: 9px 10px; + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 11px; +} +.documents-bulk-progress-stats b { + display: block; + margin-bottom: 2px; + color: var(--text-primary); + font-size: 17px; +} +.documents-bulk-progress-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 14px; +} +.documents-bulk-errors { + display: grid; + gap: 4px; + max-height: 180px; + margin-top: 12px; + padding: 10px; + overflow: auto; + border-radius: var(--radius-sm); + background: var(--color-danger-soft); + color: var(--color-danger); + font-size: 11px; +} +@media (max-width: 900px) { + .documents-bulk-controls { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .documents-bulk-progress-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 640px) { + .documents-bulk-controls { + grid-template-columns: 1fr; + } + .documents-bulk-search, + .documents-bulk-value { + grid-column: auto; + } + .documents-bulk-step-footer, + .documents-bulk-run { + align-items: stretch; + flex-direction: column; + } + .documents-bulk-step-footer .btn, + .documents-bulk-run .btn { + width: 100%; + } + .documents-bulk-progress-stats { + grid-template-columns: 1fr 1fr; + } +} .documents-views-panel { display: grid; gap: 16px; @@ -1814,6 +2052,27 @@ font-size: 12px; line-height: 1.45; } +.documents-field-description > :first-child { + margin-top: 0; +} +.documents-field-description > :last-child { + margin-bottom: 0; +} +.documents-field-description p { + margin: 0 0 6px; +} +.documents-field-description ul, +.documents-field-description ol { + margin: 4px 0 6px; + padding-left: 20px; +} +.documents-field-description strong, +.documents-field-description b { + color: var(--text-secondary); +} +.documents-field-description a { + color: var(--color-primary); +} .documents-catalog-field { display: grid; gap: 10px; @@ -2258,6 +2517,22 @@ gap: 8px; flex-wrap: wrap; } +.documents-media-replacement-note { + display: flex; + align-items: center; + gap: 7px; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.4; +} +.documents-media-replacement-note[hidden] { + display: none; +} +.documents-media-replacement-note i { + flex: 0 0 auto; + color: var(--amber-600); + font-size: 16px; +} .documents-media-list-actions input[type="file"], .visually-hidden { position: absolute; @@ -2804,6 +3079,12 @@ border: 1px solid var(--border-default); border-radius: var(--radius-md); } +.documents-revision-field.is-unchanged { + opacity: 0.64; +} +.documents-revision-field.is-changed { + border-color: var(--amber-300); +} .documents-revision-field div { display: flex; align-items: center; diff --git a/adminx/modules/Documents/assets/documents.js b/adminx/modules/Documents/assets/documents.js index 84d069d..b2c6d6a 100644 --- a/adminx/modules/Documents/assets/documents.js +++ b/adminx/modules/Documents/assets/documents.js @@ -96,6 +96,12 @@ var mediaClear = e.target.closest('[data-document-media-clear]'); if (mediaClear) { self.clearMediaRows(mediaClear.closest('[data-document-media-list]')); } + var mediaClearSingle = e.target.closest('[data-document-media-clear-single]'); + if (mediaClearSingle) { self.clearSingleMedia(mediaClearSingle.closest('[data-document-media-single]')); } + + var mediaReplace = e.target.closest('[data-document-media-replace]'); + if (mediaReplace) { self.openMediaReplacement(mediaReplace.closest('[data-document-media-list], [data-document-media-single]')); } + var mediaUpload = e.target.closest('[data-document-media-upload]'); if (mediaUpload) { self.openMediaUpload(mediaUpload.closest('[data-document-media-list], [data-document-media-single]')); } @@ -352,7 +358,9 @@ document.addEventListener('change', function (e) { if (!e.target.matches('[data-document-media-files]')) { return; } - self.uploadMediaFiles(e.target.closest('[data-document-media-list], [data-document-media-single]'), e.target.files); + var replace = e.target.getAttribute('data-replace-upload') === '1'; + e.target.removeAttribute('data-replace-upload'); + self.uploadMediaFiles(e.target.closest('[data-document-media-list], [data-document-media-single]'), e.target.files, replace); e.target.value = ''; }); @@ -1378,6 +1386,9 @@ if (payload.data && payload.data.snapshot_warning) { Adminx.Toast.show('Документ сохранён, но JSON-снимок не записан: ' + payload.data.snapshot_warning, 'warning'); } + if (payload.data && payload.data.media_cleanup && payload.data.media_cleanup.errors && payload.data.media_cleanup.errors.length) { + Adminx.Toast.show('Документ сохранён, но часть старых файлов не перемещена в корзину.', 'warning'); + } if (payload.data && payload.data.id) { self.field('id').value = payload.data.id; self.form.setAttribute('data-id', payload.data.id); @@ -1392,6 +1403,7 @@ if (payload.data && payload.data.media_draft_token) { self.refreshMediaDraft(payload.data.media_draft_token, payload.data.id || id); } + self.resetMediaReplacements(); if (self.form.getAttribute('data-quick-edit') === '1') { self.refreshQuickEditOpener(!stay); return; @@ -2480,10 +2492,20 @@ openMediaUpload: function (list) { var input = list ? list.querySelector('[data-document-media-files]') : null; - if (input) { input.click(); } + if (input) { + input.removeAttribute('data-replace-upload'); + input.click(); + } }, - uploadMediaFiles: function (container, files) { + openMediaReplacement: function (container) { + var input = container ? container.querySelector('[data-document-media-files]') : null; + if (!input) { return; } + input.setAttribute('data-replace-upload', '1'); + input.click(); + }, + + uploadMediaFiles: function (container, files, replace) { if (!container || !files || !files.length) { return; } var data = new FormData(); data.append('_csrf', this.csrf()); @@ -2495,6 +2517,9 @@ var self = this; this.ajax(container.getAttribute('data-upload-url') || (this.base() + '/media/upload'), data, function (payload) { var uploaded = ((payload.data || {}).files) || []; + if (replace && uploaded.length) { + self.prepareMediaReplacement(container); + } if (container.matches('[data-document-media-single]')) { self.applyUploadedSingle(container, uploaded[0] || null); } else { @@ -2625,17 +2650,14 @@ if (!list) { return; } var self = this; var clear = function () { - var box = list.querySelector('[data-document-media-items]'); - if (box) { box.innerHTML = ''; } - self.updateMediaListEmpty(list); - self.setDirty(true); + self.prepareMediaReplacement(list); }; if (Adminx.Confirm) { Adminx.Confirm.open({ kind: 'danger', - title: 'Очистить поле?', - message: 'Все элементы этого поля будут удалены из документа после сохранения.', - confirmLabel: 'Очистить', + title: 'Удалить все элементы?', + message: 'После сохранения элементы исчезнут из документа. Неиспользуемые файлы можно будет восстановить из корзины медиа.', + confirmLabel: 'Удалить все', onConfirm: clear }); return; @@ -2643,6 +2665,68 @@ if (confirm('Очистить все элементы поля?')) { clear(); } }, + clearSingleMedia: function (container) { + if (!container) { return; } + var self = this; + var clear = function () { self.prepareMediaReplacement(container); }; + if (Adminx.Confirm) { + Adminx.Confirm.open({ + kind: 'danger', + title: 'Удалить изображение?', + message: 'После сохранения изображение исчезнет из документа. Неиспользуемый файл можно будет восстановить из корзины медиа.', + confirmLabel: 'Удалить', + onConfirm: clear + }); + return; + } + if (confirm('Удалить изображение из документа?')) { clear(); } + }, + + prepareMediaReplacement: function (container) { + if (!container) { return; } + if (container.matches('[data-document-media-list]')) { + var box = container.querySelector('[data-document-media-items]'); + if (box) { box.innerHTML = ''; } + this.updateMediaListEmpty(container); + } else { + var input = container.querySelector('[data-document-media-url]'); + if (input) { + input.value = ''; + input.dispatchEvent(new Event('input', { bubbles: true })); + } + } + + this.markMediaReplacement(container); + this.setDirty(true); + }, + + markMediaReplacement: function (container) { + if (!this.form || !container) { return; } + var fieldId = parseInt(container.getAttribute('data-field-id'), 10) || 0; + if (!fieldId) { return; } + var existing = this.form.querySelector('input[name="media_replace_fields[]"][value="' + fieldId + '"]'); + if (!existing) { + existing = document.createElement('input'); + existing.type = 'hidden'; + existing.name = 'media_replace_fields[]'; + existing.value = String(fieldId); + this.form.appendChild(existing); + } + container.classList.add('is-replacing'); + var note = container.querySelector('[data-document-media-replacement-note]'); + if (note) { note.hidden = false; } + }, + + resetMediaReplacements: function () { + if (!this.form) { return; } + this.form.querySelectorAll('input[name="media_replace_fields[]"]').forEach(function (input) { input.remove(); }); + this.form.querySelectorAll('.is-replacing').forEach(function (container) { + container.classList.remove('is-replacing'); + var note = container.querySelector('[data-document-media-replacement-note]'); + if (note) { note.hidden = true; } + }); + }, + updateMediaPreview: function (input) { var key = input.getAttribute('data-media-key'); if (key && key !== 'url') { return; } @@ -3326,16 +3410,20 @@ var remove = document.querySelector('[data-document-revision-delete]'); var publicPreview = document.querySelector('[data-document-revision-preview]'); if (title) { title.textContent = 'Ревизия #' + (item.id || ''); } - if (meta) { meta.textContent = (item.created_label || '-') + (item.author_name ? ' · ' + item.author_name : '') + (item.size_label ? ' · ' + item.size_label : ''); } + if (meta) { + var changedCount = (item.document_preview || []).filter(function (field) { return !!field.changed; }).length + + (item.preview || []).filter(function (field) { return !!field.changed; }).length; + meta.textContent = (item.created_label || '-') + (item.author_name ? ' · ' + item.author_name : '') + (item.size_label ? ' · ' + item.size_label : '') + ' · изменений: ' + changedCount; + } if (fields) { var preview = item.preview || []; var documentPreview = item.document_preview || []; var systemHtml = documentPreview.length ? '
Основные настройки' + self.esc(documentPreview.length) + '
' + documentPreview.map(function (field) { - return '
' + self.esc(field.title || field.key) + '' + self.esc(field.key || '') + '
' + self.esc(field.value_preview || '') + '
'; + return '
' + self.esc(field.title || field.key) + '' + self.esc(field.key || '') + '' + (field.changed ? 'изменится' : 'совпадает') + '
' + self.esc(field.value_preview || '') + '
'; }).join('') + '
' : ''; var fieldsHtml = preview.length ? '
Поля рубрики' + self.esc(preview.length) + '
' + preview.map(function (field) { - return '
' - + '
' + self.esc(field.title || ('Поле #' + field.field_id)) + '#' + self.esc(field.field_id) + (field.type ? ' · ' + self.esc(field.type) : '') + '' + self.esc(field.size_label || '') + '
' + return '
' + + '
' + self.esc(field.title || ('Поле #' + field.field_id)) + '#' + self.esc(field.field_id) + (field.type ? ' · ' + self.esc(field.type) : '') + '' + (field.changed ? 'изменится' : 'совпадает') + '' + self.esc(field.size_label || '') + '
' + '
' + self.esc(field.value_preview || '') + '
' + '
'; }).join('') + '
' : '
В снимке нет значений полей.
'; @@ -3466,3 +3554,242 @@ document.addEventListener('DOMContentLoaded', function () { Adminx.Documents.init(); }); })(window, document); + +/** + * Universal document batch editor. The server owns the frozen ID list; this + * client only renders the preview and advances the prepared plan by chunks. + */ +(function (window, document) { + 'use strict'; + + var Adminx = window.Adminx || (window.Adminx = {}); + var root = document.querySelector('[data-document-bulk-editor]'); + if (!root) { return; } + + var form = root.querySelector('[data-document-bulk-form]'); + var operation = root.querySelector('[data-bulk-operation]'); + var scope = root.querySelector('[name="scope"]'); + var rubric = root.querySelector('[data-bulk-rubric]'); + var target = root.querySelector('[data-bulk-target]'); + var fieldGroup = root.querySelector('[data-bulk-field-options]'); + var previewPanel = root.querySelector('[data-bulk-preview-panel]'); + var progressPanel = root.querySelector('[data-bulk-progress-panel]'); + var token = ''; + var stopped = false; + + function updateTargetHelp() { + var help = root.querySelector('[data-bulk-target-help]'); + if (!help || !target) { return; } + var selected = target.options[target.selectedIndex]; + var message = selected ? String(selected.getAttribute('data-help') || '') : ''; + if (selected && selected.value === 'document_title' && scope && scope.value === 'products') { + message = 'Это заголовок документа в панели. Название товара на сайте обычно берётся из поля рубрики, помеченного «витрина: название товара на сайте».'; + } + var text = help.querySelector('span'); + if (text) { text.textContent = message; } + help.hidden = message === ''; + } + + function endpoint(path) { + return Adminx.base() + '/documents/bulk-editor' + path; + } + + function body(payload) { + var response = payload && payload.data ? payload.data : {}; + if (!payload || !payload.ok || response.success === false) { + throw new Error(response.message || 'Операция не выполнена'); + } + return response.data || {}; + } + + function post(path, data) { + return Adminx.Ajax.post(endpoint(path), data).then(body); + } + + function setVisibility(selector, visible) { + var node = root.querySelector(selector); + if (node) { node.hidden = !visible; } + } + + function updateOperation() { + var value = operation ? operation.value : ''; + var editsField = ['fill', 'set', 'clear', 'replace'].indexOf(value) !== -1; + setVisibility('[data-bulk-target-wrap]', editsField); + setVisibility('[data-bulk-search-wrap]', value === 'replace'); + setVisibility('[data-bulk-value-wrap]', value === 'fill' || value === 'set' || value === 'replace'); + setVisibility('[data-bulk-rubric-target-wrap]', value === 'move'); + updateTargetHelp(); + } + + function loadFields() { + if (!fieldGroup) { return; } + fieldGroup.replaceChildren(); + var id = rubric ? Number(rubric.value || 0) : 0; + if (!id) { + var empty = document.createElement('option'); + empty.disabled = true; + empty.textContent = 'Сначала выберите рубрику'; + fieldGroup.appendChild(empty); + return; + } + Adminx.Ajax.request(endpoint('/fields?rubric_id=' + encodeURIComponent(id))).then(function (payload) { + var data = body(payload); + (data.items || []).forEach(function (item) { + var option = document.createElement('option'); + option.value = 'field:' + item.id; + option.textContent = item.title + (item.alias ? ' · ' + item.alias : '') + ' [' + item.type + ']' + + (item.usage ? ' · витрина: ' + item.usage : ''); + option.setAttribute('data-help', item.help || ''); + fieldGroup.appendChild(option); + }); + if (!fieldGroup.children.length) { + var empty = document.createElement('option'); + empty.disabled = true; + empty.textContent = 'В рубрике нет полей'; + fieldGroup.appendChild(empty); + } + updateTargetHelp(); + }).catch(function (error) { + Adminx.Toast.show(error.message || 'Не удалось загрузить поля рубрики', 'error'); + }); + } + + function cell(text, className) { + var td = document.createElement('td'); + if (className) { td.className = className; } + td.textContent = text == null ? '' : String(text); + return td; + } + + function renderPreview(plan) { + token = plan.token || ''; + var rows = root.querySelector('[data-bulk-preview-rows]'); + rows.replaceChildren(); + (plan.sample || []).forEach(function (item) { + var tr = document.createElement('tr'); + var identity = document.createElement('td'); + var title = document.createElement('b'); + var meta = document.createElement('small'); + title.textContent = item.title; + meta.textContent = '#' + item.id + ' · ' + item.state; + identity.appendChild(title); + identity.appendChild(meta); + tr.appendChild(identity); + tr.appendChild(cell(item.rubric)); + tr.appendChild(cell(item.before, 'documents-bulk-value-cell')); + tr.appendChild(cell(item.after, 'documents-bulk-value-cell')); + var result = cell(item.changed ? 'Изменится' : (item.note || 'Без изменений')); + result.className = item.changed ? 'documents-bulk-result is-changed' : 'documents-bulk-result is-skipped'; + tr.appendChild(result); + rows.appendChild(tr); + }); + root.querySelector('[data-bulk-preview-count]').textContent = plan.total || 0; + root.querySelector('[data-bulk-preview-summary]').textContent = + (plan.operation && plan.operation.label ? plan.operation.label : 'Действие') + + (plan.operation && plan.operation.target_label ? ' · поле: ' + plan.operation.target_label : '') + + ' · найдено ' + (plan.matched_total || plan.total || 0) + + ', изменится ' + (plan.total || 0) + '.'; + previewPanel.hidden = false; + progressPanel.hidden = true; + previewPanel.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + + function renderProgress(plan) { + var finished = plan.status === 'completed' || plan.status === 'cancelled'; + var percent = Number(plan.progress || 0); + progressPanel.hidden = false; + root.querySelector('[data-bulk-progress-percent]').textContent = percent + '%'; + root.querySelector('[data-bulk-progress-bar]').style.width = percent + '%'; + root.querySelector('[data-bulk-progress-processed]').textContent = plan.processed || 0; + root.querySelector('[data-bulk-progress-done]').textContent = plan.done || 0; + root.querySelector('[data-bulk-progress-skipped]').textContent = plan.skipped || 0; + root.querySelector('[data-bulk-progress-errors]').textContent = (plan.errors || []).length; + root.querySelector('[data-bulk-progress-title]').textContent = + plan.status === 'completed' ? 'Массовое изменение завершено' : + (plan.status === 'cancelled' ? 'Выполнение остановлено' : 'Обрабатываем документы'); + root.querySelector('[data-bulk-progress-message]').textContent = + 'Обработано ' + (plan.processed || 0) + ' из ' + (plan.total || 0); + root.querySelector('[data-bulk-cancel]').hidden = finished; + root.querySelector('[data-bulk-finish]').hidden = !finished; + var errors = root.querySelector('[data-bulk-errors]'); + errors.hidden = !(plan.errors || []).length; + errors.replaceChildren(); + (plan.errors || []).forEach(function (message) { + var line = document.createElement('div'); + line.textContent = message; + errors.appendChild(line); + }); + } + + function runNext() { + if (stopped || !token) { return; } + var data = new FormData(); + data.append('token', token); + post('/run', data).then(function (result) { + var plan = result.plan || {}; + renderProgress(plan); + if (plan.status === 'running') { + window.setTimeout(runNext, 120); + } else { + Adminx.Toast.show(plan.errors && plan.errors.length ? 'Готово с ошибками' : 'Массовое изменение завершено', plan.errors && plan.errors.length ? 'warning' : 'success'); + } + }).catch(function (error) { + renderProgress({ status: 'cancelled', total: 0, processed: 0, errors: [error.message] }); + Adminx.Toast.show(error.message || 'Выполнение остановлено из-за ошибки', 'error'); + }); + } + + if (operation) { operation.addEventListener('change', updateOperation); } + if (rubric) { rubric.addEventListener('change', loadFields); } + if (scope) { scope.addEventListener('change', updateTargetHelp); } + if (target) { target.addEventListener('change', updateTargetHelp); } + updateOperation(); + loadFields(); + + form.addEventListener('submit', function (event) { + event.preventDefault(); + var button = form.querySelector('[data-bulk-preview]'); + button.disabled = true; + Adminx.Loader.show(); + post('/preview', new FormData(form)).then(function (result) { + renderPreview(result.plan || {}); + Adminx.Toast.show('Предпросмотр подготовлен', 'success'); + }).catch(function (error) { + Adminx.Toast.show(error.message || 'Не удалось подготовить предпросмотр', 'error'); + }).then(function () { + button.disabled = false; + Adminx.Loader.hide(); + }); + }); + + root.addEventListener('click', function (event) { + var run = event.target.closest('[data-bulk-run]'); + if (run) { + Adminx.Confirm.open({ + kind: 'warning', + title: 'Применить изменения ко всему набору?', + message: 'Будут обработаны все документы из зафиксированного предпросмотра. Для изменённых записей сохранятся ревизии.', + confirmLabel: 'Запустить', + onConfirm: function () { + stopped = false; + previewPanel.hidden = true; + renderProgress({ status: 'running', total: Number(root.querySelector('[data-bulk-preview-count]').textContent || 0), processed: 0, done: 0, skipped: 0, errors: [], progress: 0 }); + progressPanel.scrollIntoView({ behavior: 'smooth', block: 'start' }); + runNext(); + } + }); + return; + } + if (event.target.closest('[data-bulk-cancel]')) { + stopped = true; + var data = new FormData(); + data.append('token', token); + post('/cancel', data).then(function (result) { + renderProgress(result.plan || {}); + Adminx.Toast.show('Выполнение остановлено', 'warning'); + }).catch(function (error) { + Adminx.Toast.show(error.message || 'Не удалось остановить выполнение', 'error'); + }); + } + }); +})(window, document); diff --git a/adminx/modules/Documents/language/en/bulk-editor.xml b/adminx/modules/Documents/language/en/bulk-editor.xml new file mode 100644 index 0000000..eb94b81 --- /dev/null +++ b/adminx/modules/Documents/language/en/bulk-editor.xml @@ -0,0 +1,71 @@ + + + Bulk editor + Checks changes before starting and processes documents in small batches. + Review first, then apply + The preview freezes the exact document list. Changed documents get normal revisions, hooks run, and indexes are refreshed. + 1. Select documents + Narrow the set first. Filters do not change data. + Scope + Documents and products + Documents only + Products only + All rubrics + Published and drafts + Published only + Drafts only + 2. Select changes + Select a single rubric above before changing rubric fields. + Action + Select an action + Fill empty values only + Set value + Clear value + Find and replace + Move to another rubric + Publish + Unpublish + Recalculate fields and indexes + Document data + Selected rubric fields + Find + Exact substring + New value + Enter a value + New rubric + Select a rubric + Nothing is saved until the preview is confirmed. + Preview changes + 3. Preview + ID and document + Before + After + Result + Review examples before starting + The table shows the first 20 documents. The operation applies to the entire frozen set. + Apply to the entire set + Processing documents + Preparing... + processed + changed + unchanged + errors + Stop + Back to documents + Select a rubric first + The rubric has no fields + Could not load rubric fields + Will change + Action + · found + documents. + Processed + Completed with errors + Execution stopped because of an error + Preview prepared + Could not prepare preview + Apply changes to the entire set? + All documents in the frozen preview will be processed. Revisions are saved for changed records. + Start + Could not stop execution + diff --git a/adminx/modules/Documents/language/en/client.xml b/adminx/modules/Documents/language/en/client.xml index 1dd420f..75f9d75 100644 --- a/adminx/modules/Documents/language/en/client.xml +++ b/adminx/modules/Documents/language/en/client.xml @@ -228,6 +228,12 @@ The path will be written in the document field. A newer version is already saved on the server. Refresh the page, check the changes, and save the document again. sch + Action + Action + documents. + errors + processed + unchanged n Title t @@ -287,4 +293,10 @@ after saving New Template + Remove all items? + After saving, the items will be removed from the document. Unused files can be restored from the media trash. + Remove the image? + After saving, the image will be removed from the document. The unused file can be restored from the media trash. + Remove the image from the document? + The document was saved, but some old files were not moved to the trash. diff --git a/adminx/modules/Documents/language/ru/bulk-editor.xml b/adminx/modules/Documents/language/ru/bulk-editor.xml new file mode 100644 index 0000000..710cabc --- /dev/null +++ b/adminx/modules/Documents/language/ru/bulk-editor.xml @@ -0,0 +1,71 @@ + + + Массовый редактор + Проверяет изменения до запуска и обрабатывает документы небольшими пакетами. + Сначала проверка, затем применение + Предпросмотр фиксирует точный список документов. Для изменённых документов создаются обычные ревизии, выполняются хуки и обновляются индексы. + 1. Какие документы + Сначала сузьте набор. Фильтр не меняет данные. + Контур + Документы и товары + Только обычные документы + Только товары + Все рубрики + Опубликованные и черновики + Только опубликованные + Только черновики + 2. Что изменить + Для полей рубрики сначала выберите одну рубрику выше. + Действие + Выберите действие + Заполнить только пустые + Установить значение + Очистить значение + Найти и заменить + Перенести в другую рубрику + Опубликовать + Снять с публикации + Пересчитать поля и индексы + Основные данные документа + Поля выбранной рубрики + Что найти + Точное вхождение + Новое значение + Введите значение + Новая рубрика + Выберите рубрику + Ничего не сохранится до подтверждения результата проверки. + Проверить изменения + 3. Предпросмотр + ID и документ + Было + Станет + Результат + Проверьте примеры перед запуском + В таблице показаны первые 20 документов. Выполнение затронет весь зафиксированный набор. + Применить ко всему набору + Обрабатываем документы + Подготовка... + обработано + изменено + без изменений + ошибок + Остановить + Вернуться к документам + Сначала выберите рубрику + В рубрике нет полей + Не удалось загрузить поля рубрики + Изменится + Действие + · найдено + документов. + Обработано + Готово с ошибками + Выполнение остановлено из-за ошибки + Предпросмотр подготовлен + Не удалось подготовить предпросмотр + Применить изменения ко всему набору? + Будут обработаны все документы из зафиксированного предпросмотра. Для изменённых записей сохранятся ревизии. + Запустить + Не удалось остановить выполнение + diff --git a/adminx/modules/Documents/language/ru/client.xml b/adminx/modules/Documents/language/ru/client.xml index 0fe7fc6..14447ba 100644 --- a/adminx/modules/Documents/language/ru/client.xml +++ b/adminx/modules/Documents/language/ru/client.xml @@ -228,6 +228,12 @@ Путь будет записан в поле документа. На сервере уже сохранена более новая версия. Обновите страницу, проверьте изменения и сохраните документ повторно. щ + Действие + Действие + документов. + ошибок + обработано + без изменений н Название т @@ -287,4 +293,10 @@ после сохранения Новый Шаблон + Удалить все элементы? + После сохранения элементы исчезнут из документа. Неиспользуемые файлы можно будет восстановить из корзины медиа. + Удалить изображение? + После сохранения изображение исчезнет из документа. Неиспользуемый файл можно будет восстановить из корзины медиа. + Удалить изображение из документа? + Документ сохранён, но часть старых файлов не перемещена в корзину. diff --git a/adminx/modules/Documents/module.php b/adminx/modules/Documents/module.php index 845a9a4..45ba32d 100644 --- a/adminx/modules/Documents/module.php +++ b/adminx/modules/Documents/module.php @@ -17,7 +17,7 @@ return array( 'code' => 'documents', 'name' => 'Документы', - 'version' => '0.1.7', + 'version' => '0.3.3', 'permissions' => array( 'key' => 'documents', @@ -65,12 +65,19 @@ 'routes' => array( array('GET', '/documents', array(\App\Adminx\Documents\Controller::class, 'index')), + array('POST', '/documents/saved-views', array(\App\Adminx\Documents\Controller::class, 'saveSavedView')), + array('POST', '/documents/saved-views/{id}/delete', array(\App\Adminx\Documents\Controller::class, 'deleteSavedView')), array('GET', '/documents/create', array(\App\Adminx\Documents\Controller::class, 'create')), array('GET', '/documents/alias-check', array(\App\Adminx\Documents\Controller::class, 'aliasCheck')), array('POST', '/documents/slug', array(\App\Adminx\Documents\Controller::class, 'slug')), array('POST', '/documents/short-alias', array(\App\Adminx\Documents\Controller::class, 'shortAlias')), array('POST', '/documents/preview', array(\App\Adminx\Documents\Controller::class, 'previewPayload')), array('POST', '/documents/bulk', array(\App\Adminx\Documents\Controller::class, 'bulk')), + array('GET', '/documents/bulk-editor', array(\App\Adminx\Documents\Controller::class, 'bulkEditor')), + array('GET', '/documents/bulk-editor/fields', array(\App\Adminx\Documents\Controller::class, 'bulkEditorFields')), + array('POST', '/documents/bulk-editor/preview', array(\App\Adminx\Documents\Controller::class, 'bulkEditorPreview')), + array('POST', '/documents/bulk-editor/run', array(\App\Adminx\Documents\Controller::class, 'bulkEditorRun')), + array('POST', '/documents/bulk-editor/cancel', array(\App\Adminx\Documents\Controller::class, 'bulkEditorCancel')), array('POST', '/documents/snapshots/rebuild', array(\App\Adminx\Documents\Controller::class, 'rebuildSnapshots')), array('GET', '/documents/picker', array(\App\Adminx\Documents\Controller::class, 'documentPicker')), array('GET', '/documents/terms', array(\App\Adminx\Documents\Controller::class, 'termSuggestions')), diff --git a/adminx/modules/Documents/view/api.twig b/adminx/modules/Documents/view/api.twig index 800431e..1e09516 100644 --- a/adminx/modules/Documents/view/api.twig +++ b/adminx/modules/Documents/view/api.twig @@ -23,6 +23,7 @@
Документы + Массовый редактор Просмотры Редиректы JSON API diff --git a/adminx/modules/Documents/view/bulk-editor.twig b/adminx/modules/Documents/view/bulk-editor.twig new file mode 100644 index 0000000..4700213 --- /dev/null +++ b/adminx/modules/Documents/view/bulk-editor.twig @@ -0,0 +1,159 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Массовый редактор{% endblock %} +{% block content %} + + + + + + + +
+
+ +
Сначала проверка, затем применениеПредпросмотр фиксирует точный список документов. Для изменённых документов создаются обычные ревизии, выполняются хуки и обновляются индексы.
+
+ +
+ + +
+
+
+ +

1. Какие документы

Сначала сузьте набор. Фильтр не меняет данные.

+
+
+
+ {% if bulk_options.products_available %} + + {% else %} + + {% endif %} + + + +
+
+ +
+
+
+ +

2. Что изменить

Для полей рубрики сначала выберите одну рубрику выше.

+
+
+
+ + + + + + + + +
+ + +
+ + +
IDПользовательКонтактыКомпанияРегистрацияСтатусДействия
+ + +
ID и документРубрикаБылоСтанетРезультат
+
+
+
+ Проверьте примеры перед запуском + В таблице показаны первые 20 изменений. В очередь попадут только документы, где значение действительно изменится. +
+ +
+ + + +
+ +{% endblock %} diff --git a/adminx/modules/Documents/view/edit.twig b/adminx/modules/Documents/view/edit.twig index e187653..329fc4b 100644 --- a/adminx/modules/Documents/view/edit.twig +++ b/adminx/modules/Documents/view/edit.twig @@ -15,6 +15,7 @@

{{ is_new ? (catalog_mode ? 'Заполните основные данные. Характеристики, варианты и доставка откроются после первого сохранения.' : 'Создание базовой карточки документа.') : 'ID ' ~ document.Id ~ ', ' ~ (document.state_label|admin_trans) ~ ', изменён: ' ~ document.changed_label }}

+ {% if catalog_mode and not is_new and can_manage and product_copy_url %}{% endif %} К списку
@@ -363,7 +364,7 @@ {% else %}
{{ field.html|raw }}
{% endif %} - {% if field.rubric_field_description %}

{{ field.rubric_field_description }}

{% endif %} + {% if field.rubric_field_description_html %}
{{ field.rubric_field_description_html|raw }}
{% endif %} {% endfor %} diff --git a/adminx/modules/Documents/view/index.twig b/adminx/modules/Documents/view/index.twig index 9192cfc..6ce189d 100644 --- a/adminx/modules/Documents/view/index.twig +++ b/adminx/modules/Documents/view/index.twig @@ -42,6 +42,7 @@
Документы + Массовый редактор Просмотры Редиректы JSON API @@ -78,7 +79,7 @@

{{ pagination.from }}-{{ pagination.to }} из {{ pagination.total }} в текущем наборе.

- {{ filter_state_label }} +
{% set saved_views_form = '.documents-filter' %}{% set saved_views_save_url = ADMINX_BASE ~ '/documents/saved-views' %}{% set saved_views_delete_url = ADMINX_BASE ~ '/documents/saved-views/__ID__/delete' %}{% set saved_views_fields = ['q','field','rubric_id','state','per_page'] %}{% include '@adminx/_saved_views.twig' %}{{ filter_state_label }}
diff --git a/adminx/modules/Documents/view/redirects.twig b/adminx/modules/Documents/view/redirects.twig index fb70b44..b084d32 100644 --- a/adminx/modules/Documents/view/redirects.twig +++ b/adminx/modules/Documents/view/redirects.twig @@ -7,6 +7,7 @@
Документы + Массовый редактор Просмотры Редиректы JSON API diff --git a/adminx/modules/Documents/view/views.twig b/adminx/modules/Documents/view/views.twig index 01562cb..e3b1d49 100644 --- a/adminx/modules/Documents/view/views.twig +++ b/adminx/modules/Documents/view/views.twig @@ -7,6 +7,7 @@
Документы + Массовый редактор Просмотры Редиректы JSON API diff --git a/adminx/modules/GlobalSearch/language/en/interface.xml b/adminx/modules/GlobalSearch/language/en/interface.xml new file mode 100644 index 0000000..106e423 --- /dev/null +++ b/adminx/modules/GlobalSearch/language/en/interface.xml @@ -0,0 +1,13 @@ + + + Global search + Results + Documents + Rubrics + Blocks + Requests + Navigation + Users + Site users + Media + diff --git a/adminx/modules/GlobalSearch/language/ru/interface.xml b/adminx/modules/GlobalSearch/language/ru/interface.xml new file mode 100644 index 0000000..af51a68 --- /dev/null +++ b/adminx/modules/GlobalSearch/language/ru/interface.xml @@ -0,0 +1,13 @@ + + + Глобальный поиск + Результаты + Документы + Рубрики + Блоки + Запросы + Навигация + Пользователи + Пользователи сайта + Медиа + diff --git a/adminx/modules/GlobalSearch/module.php b/adminx/modules/GlobalSearch/module.php index b30fb65..38fced1 100644 --- a/adminx/modules/GlobalSearch/module.php +++ b/adminx/modules/GlobalSearch/module.php @@ -17,7 +17,7 @@ return array( 'code' => 'global_search', 'name' => 'Глобальный поиск', - 'version' => '0.2.0', + 'version' => '0.2.1', 'routes' => array( array('GET', '/search', array(\App\Adminx\GlobalSearch\Controller::class, 'index')), ), diff --git a/adminx/modules/Groups/Controller.php b/adminx/modules/Groups/Controller.php index 196c92f..f134b82 100644 --- a/adminx/modules/Groups/Controller.php +++ b/adminx/modules/Groups/Controller.php @@ -17,6 +17,8 @@ defined('BASEPATH') || die('Direct access to this location is not allowed.'); use App\Common\AdminAssets; + use App\Common\AuditLog; + use App\Common\Auth; use App\Common\Controller as BaseController; use App\Common\Permission; use App\Helpers\Request; @@ -43,6 +45,28 @@ ]); } + /** GET /roles/simulator — read-only explanation of effective access. */ + public function simulator(array $params = []) + { + AdminAssets::addStyle($this->base() . '/modules/Groups/assets/groups.css', 50); + AdminAssets::addScript($this->base() . '/modules/Groups/assets/groups.js', 50); + + $options = PermissionSimulator::options(); + $role = Request::getStr('role', ''); + $user = Request::getInt('user', 0); + try { + $result = PermissionSimulator::simulate($role, $user); + } catch (\Throwable $e) { + $result = PermissionSimulator::simulate('admin', 0); + } + + return $this->render('@groups/simulator.twig', [ + 'title' => 'Симулятор прав', + 'options' => $options, + 'result' => $result, + ]); + } + /** GET /roles/{id} — роль + её права (JSON, для формы). */ public function show(array $params = []) { @@ -91,6 +115,55 @@ return $this->success('Роль создана', ['redirect' => $this->base() . '/roles']); } + /** POST /roles/{id}/copy — новая роль с тем же набором прав. */ + public function copy(array $params = []) + { + if (($resp = $this->guard()) !== null) { + return $resp; + } + + $role = Model::find($params['id'] ?? 0); + if (!$role) { + return $this->error('Роль не найдена', [], 404); + } + + //-- Полный доступ admin держится фолбэком ядра, а не строками прав: + //-- копия получилась бы пустой, поэтому честно отказываем. + if ((string) $role->code === 'admin') { + return $this->error('Роль «admin» копировать нельзя: её полный доступ не хранится списком прав', [], 422); + } + + $code = strtolower(trim(Request::postStr('code'))); + if ($code === '') { $code = Model::freeCode((string) $role->code); } + $name = trim(Request::postStr('name')); + if ($name === '') { $name = mb_substr((string) $role->name . ' — копия', 0, 190, 'UTF-8'); } + + $errors = []; + if (!preg_match('/^[a-z][a-z0-9_]{1,49}$/', $code)) { + $errors['code'] = 'Код: латиница/цифры/подчёркивание, с буквы.'; + } elseif (Model::codeExists($code)) { + $errors['code'] = 'Роль с таким кодом уже есть.'; + } + + if ($errors) { + return $this->error('Проверьте поля формы', $errors); + } + + try { + $newId = Model::copy((int) $role->id, $code, $name); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), [], 422); + } + + AuditLog::record('role.copied', [ + 'actor_id' => Auth::id(), + 'target_type' => 'role', + 'target_id' => (int) $newId, + 'meta' => ['source_id' => (int) $role->id, 'source_code' => (string) $role->code, 'code' => $code], + ]); + return $this->success('Роль скопирована', ['redirect' => $this->base() . '/roles']); + } + /** POST /roles/{id} — обновить название и права. */ public function update(array $params = []) { diff --git a/adminx/modules/Groups/Model.php b/adminx/modules/Groups/Model.php index 101568b..2fd9390 100644 --- a/adminx/modules/Groups/Model.php +++ b/adminx/modules/Groups/Model.php @@ -221,6 +221,55 @@ } } + /** + * Копия роли вместе с набором прав. + * + * Новая роль всегда обычная: системный признак не наследуется, иначе + * копию нельзя было бы ни переименовать, ни удалить. Пользователи в + * копию не переносятся — их назначают отдельно. + */ + public static function copy($id, $code, $name) + { + $source = self::find($id); + if (!$source) { throw new \RuntimeException('Роль не найдена'); } + + //-- Без явного режима исключений ошибка уникальности кода оборвала бы + //-- выполнение мимо rollback и оставила роль без прав. + $previousMode = DB::$throw_exception_on_error; + DB::$throw_exception_on_error = true; + $newId = 0; + DB::startTransaction(); + try { + $newId = (int) self::create($code, $name); + if ($newId < 1) { throw new \RuntimeException('Не удалось создать копию роли'); } + self::setPermissions($newId, self::permissionCodes((int) $source->id)); + DB::commit(); + } catch (\Throwable $e) { + if (DB::$transaction_in_progress) { DB::rollback(); } + throw $e; + } finally { + DB::$throw_exception_on_error = $previousMode; + } + + return $newId; + } + + /** Свободный код вида role_copy, role_copy_2 — на случай повторных копий. */ + public static function freeCode($base) + { + $base = trim(preg_replace('/[^a-z0-9_]+/', '_', strtolower((string) $base)), '_'); + if ($base === '') { $base = 'role'; } + $base = substr($base, 0, 40) . '_copy'; + $candidate = $base; + $suffix = 2; + while (self::codeExists($candidate) && $suffix < 100) { + $candidate = $base . '_' . $suffix; + $suffix++; + } + + return $candidate; + } + public static function delete($id) { $id = (int) $id; diff --git a/adminx/modules/Groups/PermissionSimulator.php b/adminx/modules/Groups/PermissionSimulator.php new file mode 100644 index 0000000..0f6c2f6 --- /dev/null +++ b/adminx/modules/Groups/PermissionSimulator.php @@ -0,0 +1,310 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Groups; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\Language; + use App\Common\Navigation; + use App\Common\Permission; + use App\Common\PublicAuthSettings; + use App\Common\SystemTables; + use App\Content\ContentTables; + use App\Content\PublicUserTables; + use App\Frontend\RubricPermissionResolver; + use DB; + + /** Read-only explanation of effective panel and public-content permissions. */ + class PermissionSimulator + { + public static function options() + { + return array( + 'roles' => DB::query( + 'SELECT id, code, name, is_system FROM ' . SystemTables::table('roles') + . ' ORDER BY is_system DESC, name ASC' + )->getAll() ?: array(), + 'users' => DB::query( + 'SELECT id, name, email, role, is_active FROM ' . SystemTables::table('users') + . ' ORDER BY name ASC, id ASC' + )->getAll() ?: array(), + ); + } + + public static function simulate($roleCode, $userId) + { + $subject = self::subject($roleCode, $userId); + $permissionCodes = Permission::forRole($subject['role_code']); + $all = $subject['role_code'] === 'admin'; + $permissionMap = array_fill_keys($permissionCodes, true); + if ($all) { + $permissionMap['all_permissions'] = true; + } + + $can = function ($code) use ($all, $permissionMap) { + return $all || isset($permissionMap[(string) $code]); + }; + + $sections = array(); + foreach (Navigation::all() as $item) { + $visible = isset($item['visible']) ? $item['visible'] : true; + $stateVisible = is_callable($visible) ? (bool) call_user_func($visible) : (bool) $visible; + $permission = isset($item['permission']) ? (string) $item['permission'] : ''; + $allowed = $stateVisible && ($permission === '' || $can($permission)); + $sections[] = array( + 'code' => (string) $item['code'], + 'label' => Language::translateSource((string) $item['label']), + 'group' => Language::translateSource((string) $item['group']), + 'url' => (string) $item['url'], + 'icon' => (string) $item['icon'], + 'permission' => $permission, + 'allowed' => $allowed, + 'reason' => !$stateVisible + ? 'Раздел скрыт текущим состоянием системы' + : self::permissionReason($permission, $allowed, $all), + ); + } + + $actions = array(); + $allowedActions = 0; + $moduleLabels = Model::permissionModuleLabels(); + foreach (Model::allPermissionsGrouped() as $module => $permissions) { + $rows = array(); + $allowedInModule = 0; + foreach ($permissions as $permission) { + $allowed = $can($permission['code']); + if ($allowed) { + $allowedActions++; + $allowedInModule++; + } + + $permission['allowed'] = $allowed; + $permission['reason'] = self::permissionReason($permission['code'], $allowed, $all); + $rows[] = $permission; + } + + $actions[] = array( + 'module' => $module, + 'label' => isset($moduleLabels[$module]) + ? $moduleLabels[$module] + : $module, + 'allowed_count' => $allowedInModule, + 'items' => $rows, + ); + } + + $rubrics = self::rubrics($subject['public_group_id'], $can); + $documents = self::documents($rubrics, $can); + $allowedSections = count(array_filter($sections, function ($item) { + return !empty($item['allowed']); + })); + $readableRubrics = count(array_filter($rubrics, function ($item) { + return !empty($item['public_read']); + })); + + return array( + 'subject' => $subject, + 'permissions' => array_values($permissionCodes), + 'sections' => $sections, + 'actions' => $actions, + 'rubrics' => $rubrics, + 'documents' => $documents, + 'summary' => array( + 'sections_allowed' => $allowedSections, + 'sections_total' => count($sections), + 'actions_allowed' => $allowedActions, + 'actions_total' => array_sum(array_map(function ($group) { + return count($group['items']); + }, $actions)), + 'rubrics_readable' => $readableRubrics, + 'rubrics_total' => count($rubrics), + 'documents_visible' => count(array_filter($documents, function ($item) { + return !empty($item['public_read']); + })), + ), + ); + } + + protected static function subject($roleCode, $userId) + { + $userId = (int) $userId; + $user = null; + if ($userId > 0) { + $user = DB::query( + 'SELECT u.id, u.name, u.email, u.role, u.is_active, u.legacy_id,' + . ' pu.user_group, pug.user_group_name' + . ' FROM ' . SystemTables::table('users') . ' u' + . ' LEFT JOIN ' . PublicUserTables::table('users') . ' pu ON pu.Id = u.legacy_id' + . ' LEFT JOIN ' . PublicUserTables::table('user_groups') . ' pug ON pug.user_group = pu.user_group' + . ' WHERE u.id = %i LIMIT 1', + $userId + )->getAssoc(); + } + + if ($user) { + $roleCode = (string) $user['role']; + } else { + $roleCode = trim((string) $roleCode); + if ($roleCode === '') { + $roleCode = 'admin'; + } + } + + $role = DB::query( + 'SELECT id, code, name, is_system FROM ' . SystemTables::table('roles') + . ' WHERE code = %s LIMIT 1', + $roleCode + )->getAssoc(); + if (!$role) { + throw new \InvalidArgumentException('Выбранная роль не найдена'); + } + + $publicGroupId = $user && !empty($user['user_group']) + ? (int) $user['user_group'] + : self::defaultPublicGroup($roleCode); + $publicGroupName = $user && !empty($user['user_group_name']) + ? (string) $user['user_group_name'] + : (string) DB::query( + 'SELECT user_group_name FROM ' . PublicUserTables::table('user_groups') + . ' WHERE user_group = %i LIMIT 1', + $publicGroupId + )->getValue(); + + return array( + 'type' => $user ? 'user' : 'role', + 'user_id' => $user ? (int) $user['id'] : 0, + 'user_name' => $user ? (string) $user['name'] : '', + 'user_email' => $user ? (string) $user['email'] : '', + 'user_active' => $user ? (bool) $user['is_active'] : true, + 'role_id' => (int) $role['id'], + 'role_code' => (string) $role['code'], + 'role_name' => (string) $role['name'], + 'public_group_id' => $publicGroupId, + 'public_group_name' => $publicGroupName !== '' ? $publicGroupName : 'Группа #' . $publicGroupId, + ); + } + + protected static function rubrics($publicGroupId, callable $can) + { + $rows = DB::query( + 'SELECT r.Id, r.rubric_title, r.rubric_alias, r.rubric_purpose,' + . ' rp.rubric_permission, COUNT(d.Id) AS document_count' + . ' FROM ' . ContentTables::table('rubrics') . ' r' + . ' LEFT JOIN ' . ContentTables::table('rubric_permissions') . ' rp' + . ' ON rp.rubric_id = r.Id AND rp.user_group_id = %i' + . ' LEFT JOIN ' . ContentTables::table('documents') . ' d' + . ' ON d.rubric_id = r.Id AND d.document_deleted = %s' + . ' GROUP BY r.Id, r.rubric_title, r.rubric_alias, r.rubric_purpose, rp.rubric_permission' + . ' ORDER BY r.rubric_position ASC, r.rubric_title ASC', + (int) $publicGroupId, + '0' + )->getAll() ?: array(); + + $resolver = new RubricPermissionResolver(); + $result = array(); + foreach ($rows as $row) { + $row = (array) $row; + $permissions = self::parseRubricPermissions($row['rubric_permission']); + $row['permissions'] = $permissions; + $row['panel_view'] = $can('view_rubrics'); + $row['panel_manage'] = $can('manage_rubrics'); + $row['documents_panel_view'] = $can('view_documents'); + $row['documents_panel_manage'] = $can('manage_documents'); + $row['public_read'] = $resolver->allows($permissions, 'docread'); + $row['public_create'] = $resolver->allows($permissions, 'new') + || $resolver->allows($permissions, 'newnow'); + $row['public_edit'] = $resolver->allows($permissions, 'editall') + || $resolver->allows($permissions, 'editown'); + $row['reason'] = $row['public_read'] + ? 'Группа имеет право чтения или полный доступ' + : 'Для публичной группы не назначено docread'; + $result[] = $row; + } + + return $result; + } + + protected static function documents(array $rubrics, callable $can) + { + $rubricMap = array(); + foreach ($rubrics as $rubric) { + $rubricMap[(int) $rubric['Id']] = $rubric; + } + + $rows = DB::query( + 'SELECT d.Id, d.document_title, d.document_alias, d.document_status,' + . ' d.document_deleted, d.rubric_id, r.rubric_title' + . ' FROM ' . ContentTables::table('documents') . ' d' + . ' LEFT JOIN ' . ContentTables::table('rubrics') . ' r ON r.Id = d.rubric_id' + . ' ORDER BY d.document_changed DESC, d.Id DESC LIMIT 100' + )->getAll() ?: array(); + + $result = array(); + foreach ($rows as $row) { + $row = (array) $row; + $rubric = isset($rubricMap[(int) $row['rubric_id']]) ? $rubricMap[(int) $row['rubric_id']] : array(); + $row['panel_view'] = $can('view_documents'); + $row['panel_edit'] = $can('manage_documents'); + $row['public_read'] = !empty($rubric['public_read']) && (string) $row['document_deleted'] !== '1'; + if ((string) $row['document_deleted'] === '1') { + $row['reason'] = 'Документ удалён и публично отдаёт 404'; + } elseif (!empty($rubric['public_read'])) { + $row['reason'] = 'Разрешён docread для рубрики'; + } else { + $row['reason'] = 'Нет docread для рубрики'; + } + + $result[] = $row; + } + + return $result; + } + + protected static function permissionReason($code, $allowed, $all) + { + if ($code === '') { + return 'Отдельное право не требуется'; + } + + if ($all) { + return 'Роль администратора имеет полный доступ'; + } + + return $allowed + ? 'Право ' . $code . ' назначено роли' + : 'Право ' . $code . ' не назначено роли'; + } + + protected static function defaultPublicGroup($role) + { + $settings = PublicAuthSettings::all(); + $registered = isset($settings['default_group']) ? max(1, (int) $settings['default_group']) : 4; + $map = array('admin' => 1, 'guest' => 2, 'moderator' => 3, 'user' => $registered); + return isset($map[$role]) ? $map[$role] : $registered; + } + + protected static function parseRubricPermissions($serialized) + { + $result = array(); + foreach (explode('|', (string) $serialized) as $permission) { + $permission = trim($permission); + if ($permission !== '' && preg_match('/^[a-z0-9_-]+$/i', $permission)) { + $result[$permission] = $permission; + } + } + + return array_values($result); + } + } diff --git a/adminx/modules/Groups/assets/groups.css b/adminx/modules/Groups/assets/groups.css index 3fb932f..a15fccf 100644 --- a/adminx/modules/Groups/assets/groups.css +++ b/adminx/modules/Groups/assets/groups.css @@ -4,3 +4,283 @@ --section-accent: var(--violet-500); --section-accent-soft: var(--violet-100); } +.groups-tabs { + margin-bottom: 18px; +} +.groups-tabs .tab { + text-decoration: none; +} +.permission-simulator-picker { + display: grid; + grid-template-columns: minmax(260px, 1.5fr) minmax(190px, 0.75fr) minmax(240px, 1fr) auto; + align-items: end; + gap: 14px; + margin-bottom: 14px; + padding: 16px; +} +.permission-simulator-picker-copy { + display: flex; + align-items: flex-start; + gap: 12px; +} +.permission-simulator-picker-copy h2, +.permission-simulator-picker-copy p { + margin: 0; +} +.permission-simulator-picker-copy h2 { + margin-bottom: 3px; + font-size: 15px; +} +.permission-simulator-picker-copy p { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.45; +} +.permission-simulator-warning { + margin-bottom: 14px; +} +.permission-simulator-subject { + display: grid; + grid-template-columns: minmax(240px, 0.8fr) minmax(420px, 1.2fr); + align-items: center; + gap: 20px; + margin-bottom: 14px; + padding: 14px 16px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-surface); +} +.permission-simulator-subject > div { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} +.permission-simulator-subject > div > div { + min-width: 0; +} +.permission-simulator-subject b, +.permission-simulator-subject small { + display: block; +} +.permission-simulator-subject small { + overflow: hidden; + color: var(--text-muted); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} +.permission-simulator-subject dl { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin: 0; +} +.permission-simulator-subject dl div { + min-width: 0; +} +.permission-simulator-subject dt { + margin-bottom: 2px; + color: var(--text-muted); + font-size: 11px; +} +.permission-simulator-subject dd { + margin: 0; + font-size: 13px; +} +.permission-simulator-subject dd .mono { + margin-left: 5px; + color: var(--text-muted); + font-size: 11px; +} +.permission-simulator-summary, +.permission-simulator-header { + margin-bottom: 18px; +} +.permission-sections-table th:nth-child(1) { + width: 28%; +} +.permission-sections-table th:nth-child(2) { + width: 150px; +} +.permission-sections-table th:nth-child(3) { + width: 220px; +} +.permission-sections-table td:last-child, +.permission-rubrics-table td:last-child { + min-width: 260px; +} +.permission-sections-table td:last-child small, +.permission-rubrics-table td small, +.permission-documents-sample td small { + display: block; + margin-top: 3px; + color: var(--text-muted); + font-size: 11px; +} +.permission-section-name { + display: inline-flex; + align-items: center; + gap: 8px; +} +.permission-section-name i { + width: 26px; + height: 26px; + display: inline-grid; + place-items: center; + border-radius: var(--radius-sm); + background: var(--color-primary-soft); + color: var(--color-primary); +} +.permission-result { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; +} +.permission-result.is-allowed { + color: var(--color-success); +} +.permission-result.is-denied { + color: var(--color-danger); +} +.permission-sections-table tr.is-denied, +.permission-rubrics-table tr.is-denied, +.permission-documents-sample tr.is-denied { + opacity: 0.68; +} +.permission-action-groups { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 22px; +} +.permission-action-group { + padding: 0; + overflow: hidden; +} +.permission-action-group summary, +.permission-documents-sample summary { + display: grid; + grid-template-columns: minmax(0, 1fr) auto 20px; + align-items: center; + gap: 12px; + min-height: 54px; + padding: 10px 14px; + cursor: pointer; + list-style: none; +} +.permission-action-group summary::-webkit-details-marker, +.permission-documents-sample summary::-webkit-details-marker { + display: none; +} +.permission-action-group summary > span:first-child, +.permission-documents-sample summary > span:first-child { + display: flex; + align-items: center; + gap: 8px; +} +.permission-action-group summary > span:nth-child(2), +.permission-documents-sample summary > span:nth-child(2) { + color: var(--text-muted); + font-size: 12px; +} +.permission-action-group[open] summary, +.permission-documents-sample[open] summary { + border-bottom: 1px solid var(--border-default); +} +.permission-action-group[open] summary > i, +.permission-documents-sample[open] summary > i { + transform: rotate(180deg); +} +.permission-action-row { + display: grid; + grid-template-columns: 24px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + min-height: 48px; + padding: 8px 14px; + border-bottom: 1px solid var(--border-default); +} +.permission-action-row:last-child { + border-bottom: 0; +} +.permission-action-row.is-denied { + opacity: 0.62; +} +.permission-action-row b, +.permission-action-row small { + display: block; +} +.permission-action-row b { + font-size: 12px; +} +.permission-action-row small { + margin-top: 2px; + color: var(--text-muted); + font-size: 11px; +} +.permission-action-row > .mono { + color: var(--text-muted); + font-size: 10px; +} +.permission-rubrics-table th:nth-child(2), +.permission-rubrics-table th:nth-child(3), +.permission-rubrics-table th:nth-child(4), +.permission-rubrics-table th:nth-child(5) { + width: 105px; + text-align: center; +} +.permission-rubrics-table td:nth-child(2), +.permission-rubrics-table td:nth-child(3), +.permission-rubrics-table td:nth-child(4), +.permission-rubrics-table td:nth-child(5) { + text-align: center; +} +.permission-rubrics-table td:first-child a, +.permission-documents-sample td:first-child a { + color: var(--text-primary); + text-decoration: none; +} +.permission-documents-sample { + margin-top: 14px; + padding: 0; + overflow: hidden; +} +@media (max-width: 1040px) { + .permission-simulator-picker { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .permission-simulator-picker-copy { + grid-column: 1 / -1; + } + .permission-action-groups { + grid-template-columns: 1fr; + } +} +@media (max-width: 700px) { + .permission-simulator-picker, + .permission-simulator-subject, + .permission-simulator-subject dl { + grid-template-columns: 1fr; + } + .permission-simulator-picker .btn { + width: 100%; + } + .permission-action-group summary, + .permission-documents-sample summary { + grid-template-columns: minmax(0, 1fr) 20px; + } + .permission-action-group summary > span:nth-child(2), + .permission-documents-sample summary > span:nth-child(2) { + display: none; + } + .permission-action-row { + grid-template-columns: 24px minmax(0, 1fr); + } + .permission-action-row > .mono { + grid-column: 2; + } +} diff --git a/adminx/modules/Groups/assets/groups.js b/adminx/modules/Groups/assets/groups.js index 84696a4..b59282f 100644 --- a/adminx/modules/Groups/assets/groups.js +++ b/adminx/modules/Groups/assets/groups.js @@ -22,6 +22,8 @@ if (edit) { self.fillEdit(edit.closest('tr')); } var del = e.target.closest('[data-role-delete]'); if (del) { self.remove(del.closest('tr')); } + var copy = e.target.closest('[data-role-copy]'); + if (copy) { self.copy(copy.closest('tr')); } if (e.target.closest('[data-perms-all]')) { self.setAll(true); } if (e.target.closest('[data-perms-none]')) { self.setAll(false); } }); @@ -122,6 +124,30 @@ }); }, + copy: function (row) { + var id = row.dataset.id; + var base = this.base(); + Adminx.Confirm.open({ + kind: 'info', + title: 'Создать копию роли?', + message: 'Новая роль получит те же права, что и «' + row.dataset.name + '». Пользователи не переносятся, набор прав можно поправить сразу после создания.', + confirmLabel: 'Создать копию', + onConfirm: function () { + Adminx.Loader.show(); + Adminx.Ajax.post(base + '/roles/' + id + '/copy').then(function (payload) { + Adminx.Loader.hide(); + var d = payload.data || {}; + if (d.success === false) { Adminx.Toast.show(d.message || 'Не удалось скопировать', 'error'); return; } + Adminx.Toast.show(d.message || 'Роль скопирована', 'success'); + window.location.reload(); + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка сети', 'error'); + }); + } + }); + }, + remove: function (row) { var id = row.dataset.id; var base = this.base(); diff --git a/adminx/modules/Groups/language/en/simulator.xml b/adminx/modules/Groups/language/en/simulator.xml new file mode 100644 index 0000000..ce6f93f --- /dev/null +++ b/adminx/modules/Groups/language/en/simulator.xml @@ -0,0 +1,48 @@ + + + Permission simulator + Simulator + Shows effective access without signing in as the selected user or changing their session. + Roles and permissions sections + Subject + A selected user takes precedence over the role. A role uses its corresponding public group. + Or user + Not selected + disabled + Show access + Account disabled + The role permissions are shown below, but the user cannot sign in until the account is enabled. + Role check without a specific user + Panel role + Public group + Panel sections + Actions + Site rubrics + Of the latest 100 documents + Panel + Sections + What appears in the left menu and why each item is available or hidden. + Requirement + no separate permission + Allowed + Denied + Actions + All registered permissions grouped by section. + of + Public site + Rubrics and documents + Public group permissions are checked separately from panel permissions. + Documents + Read + Create + Edit + Reason + permissions not assigned + No rubrics found + Latest 100 documents + Show direct access check + View and edit + View only + No access + Unavailable + diff --git a/adminx/modules/Groups/language/ru/simulator.xml b/adminx/modules/Groups/language/ru/simulator.xml new file mode 100644 index 0000000..6a49b3f --- /dev/null +++ b/adminx/modules/Groups/language/ru/simulator.xml @@ -0,0 +1,48 @@ + + + Симулятор прав + Симулятор + Показывает эффективный доступ без входа под выбранным пользователем и без изменения его сессии. + Разделы ролей и прав + Кого проверяем + Пользователь имеет приоритет над выбранной ролью. Для роли применяется соответствующая публичная группа. + Или пользователь + Не выбран + отключён + Показать доступ + Учётная запись отключена + Набор прав роли показан ниже, но пользователь не сможет войти в панель до включения учётной записи. + Проверка роли без конкретного пользователя + Роль панели + Публичная группа + Разделов панели + Действий + Рубрик на сайте + Из последних 100 документов + Панель + Разделы + Что появится в левом меню и почему пункт доступен или скрыт. + Требование + без отдельного права + Доступен + Запрещён + Действия + Все зарегистрированные права сгруппированы по разделам. + из + Публичная часть + Рубрики и документы + Права публичной группы проверяются отдельно от прав панели. + Документов + Чтение + Создание + Редактирование + Причина + права не назначены + Рубрики не найдены + Последние 100 документов + Показать проверку прямого доступа + Просмотр и изменение + Только просмотр + Нет доступа + Недоступен + diff --git a/adminx/modules/Groups/module.php b/adminx/modules/Groups/module.php index a73ef69..50ba653 100644 --- a/adminx/modules/Groups/module.php +++ b/adminx/modules/Groups/module.php @@ -21,7 +21,7 @@ return [ 'code' => 'groups', 'name' => 'Роли и права', - 'version' => '0.1.1', + 'version' => '0.3.0', 'permissions' => [ 'key' => 'groups', @@ -62,9 +62,11 @@ 'routes' => array( array('GET', '/roles', array(\App\Adminx\Groups\Controller::class, 'index')), + array('GET', '/roles/simulator', array(\App\Adminx\Groups\Controller::class, 'simulator'), array('permission' => 'view_roles')), array('GET', '/roles/{id}', array(\App\Adminx\Groups\Controller::class, 'show')), array('POST', '/roles', array(\App\Adminx\Groups\Controller::class, 'store')), array('POST', '/roles/{id}', array(\App\Adminx\Groups\Controller::class, 'update')), + array('POST', '/roles/{id}/copy', array(\App\Adminx\Groups\Controller::class, 'copy'), array('permission' => 'manage_roles')), array('POST', '/roles/{id}/delete', array(\App\Adminx\Groups\Controller::class, 'destroy')), ), diff --git a/adminx/modules/Groups/view/list.twig b/adminx/modules/Groups/view/list.twig index 2a5e813..48f89b2 100644 --- a/adminx/modules/Groups/view/list.twig +++ b/adminx/modules/Groups/view/list.twig @@ -19,6 +19,11 @@
+ +
{{ stats.roles }}
Ролей
{{ stats.system }}
Системных
@@ -72,6 +77,7 @@
+ {% if not r.is_system %} {% endif %} diff --git a/adminx/modules/Groups/view/simulator.twig b/adminx/modules/Groups/view/simulator.twig new file mode 100644 index 0000000..c71f6ee --- /dev/null +++ b/adminx/modules/Groups/view/simulator.twig @@ -0,0 +1,153 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Симулятор прав{% endblock %} +{% block content %} + + + + + + + + +
+ +

Кого проверяем

Пользователь имеет приоритет над выбранной ролью. Для роли применяется соответствующая публичная группа.

+
+ + + + + +{% if result.subject.type == 'user' and not result.subject.user_active %} +
Учётная запись отключена

Набор прав роли показан ниже, но пользователь не сможет войти в панель до включения учётной записи.

+{% endif %} + +
+
+ +
{{ result.subject.type == 'user' ? result.subject.user_name : result.subject.role_name }}{{ result.subject.type == 'user' ? result.subject.user_email : 'Проверка роли без конкретного пользователя' }}
+
+
+
Роль панели
{{ result.subject.role_name }} {{ result.subject.role_code }}
+
Публичная группа
#{{ result.subject.public_group_id }} · {{ result.subject.public_group_name }}
+
+
+ +
+
{{ result.summary.sections_allowed }} / {{ result.summary.sections_total }}
Разделов панели
+
{{ result.summary.actions_allowed }} / {{ result.summary.actions_total }}
Действий
+
{{ result.summary.rubrics_readable }} / {{ result.summary.rubrics_total }}
Рубрик на сайте
+
{{ result.summary.documents_visible }}
Из последних 100 документов
+
+ +
+
+
Панель

Разделы

Что появится в левом меню и почему пункт доступен или скрыт.

+
+
+
+ + + + {% for item in result.sections %} + + + + + + + {% endfor %} + +
РазделГруппаТребованиеРезультат
{{ item.label }}{{ item.group }}{{ item.permission ?: 'без отдельного права' }}{{ item.allowed ? 'Доступен' : 'Запрещён' }}{{ item.reason }}
+
+
+ +
+
+
Панель

Действия

Все зарегистрированные права сгруппированы по разделам.

+
+
+ {% for group in result.actions %} +
+ {{ group.label }}{{ group.allowed_count }} из {{ group.items|length }} +
+ {% for item in group.items %} +
+ +
{{ item.name }}{{ item.description ?: item.reason }}
+ {{ item.code }} +
+ {% endfor %} +
+
+ {% endfor %} +
+ +
+
+
Публичная часть

Рубрики и документы

Права публичной группы проверяются отдельно от прав панели.

+
+
+
+ + + + {% for rubric in result.rubrics %} + + + + + + + + + {% else %}{% endfor %} + +
РубрикаДокументовЧтениеСозданиеРедактированиеПричина
{{ rubric.rubric_title }}#{{ rubric.Id }} · {{ rubric.rubric_alias }}{{ rubric.document_count }}{{ rubric.reason }}{{ rubric.permissions|join(' · ') ?: 'права не назначены' }}
Рубрики не найдены
+
+
+ +
+ Последние 100 документовПоказать проверку прямого доступа +
+ + + + {% for document in result.documents %} + + + + + + + + {% endfor %} + +
ДокументРубрикаПанельПубличная частьПричина
{{ document.document_title }}#{{ document.Id }}{{ document.rubric_title ?: ('#' ~ document.rubric_id) }}{{ document.panel_edit ? 'Просмотр и изменение' : (document.panel_view ? 'Только просмотр' : 'Нет доступа') }}{{ document.public_read ? 'Доступен' : 'Недоступен' }}{{ document.reason }}
+
+
+ +{% endblock %} diff --git a/adminx/modules/Media/Controller.php b/adminx/modules/Media/Controller.php index 687d20b..00064d5 100644 --- a/adminx/modules/Media/Controller.php +++ b/adminx/modules/Media/Controller.php @@ -20,11 +20,15 @@ use App\Common\Auth; use App\Common\Controller as BaseController; use App\Common\Permission; + use App\Adminx\Support\SavedViews; use App\Content\Documents\DocumentMediaDraft; use App\Helpers\Request; class Controller extends BaseController { + protected function savedViewFields() { return array('dir', 'q', 'type', 'view'); } + protected function savedViewGuard() { if (($error = $this->csrfGuard()) !== null) { return $error; } return Permission::check('view_media') ? null : $this->error('Недостаточно прав', array(), 403); } + public function index(array $params = array()) { AdminAssets::addStyle($this->base() . '/modules/Media/assets/media.css', 50); @@ -54,9 +58,88 @@ 'total' => $listing['total'], 'per_page' => $perPage, 'can_manage' => Permission::check('manage_media'), + 'saved_views' => SavedViews::all('media', Auth::id(), $this->savedViewFields()), )); } + public function saveSavedView(array $params = array()) + { + if (($error = $this->savedViewGuard()) !== null) { return $error; } + $filters = json_decode(Request::postStr('filters', '{}'), true); + if (!is_array($filters)) { return $this->error('Некорректный набор фильтров', array(), 422); } + try { $views = SavedViews::save('media', Auth::id(), Request::postStr('title', ''), $filters, $this->savedViewFields()); } + catch (\InvalidArgumentException $e) { return $this->error($e->getMessage(), array(), 422); } + return $this->success('Представление сохранено', array('data' => array('views' => $views))); + } + + public function deleteSavedView(array $params = array()) + { + if (($error = $this->savedViewGuard()) !== null) { return $error; } + try { $views = SavedViews::delete('media', Auth::id(), isset($params['id']) ? $params['id'] : '', $this->savedViewFields()); } + catch (\InvalidArgumentException $e) { return $this->error($e->getMessage(), array(), 404); } + return $this->success('Представление удалено', array('data' => array('views' => $views))); + } + + public function audit(array $params = array()) + { + AdminAssets::addStyle($this->base() . '/modules/Media/assets/media.css', 50); + AdminAssets::addScript($this->base() . '/modules/Media/assets/media.js', 50); + + $report = MediaAudit::report(); + $kind = Request::getStr('kind', 'unused'); + $allowedKinds = array('unused', 'duplicates', 'missing_originals', 'missing_previews', 'large', 'invalid', 'files'); + if (!in_array($kind, $allowedKinds, true)) { + $kind = 'unused'; + } + + $query = trim(Request::getStr('q', '')); + $page = max(1, Request::getInt('page', 1)); + $perPage = 100; + $rows = $report && isset($report[$kind]) && is_array($report[$kind]) ? $report[$kind] : array(); + if ($query !== '') { + $needle = function_exists('mb_strtolower') ? mb_strtolower($query) : strtolower($query); + $rows = array_values(array_filter($rows, function ($row) use ($needle) { + $haystack = isset($row['path']) ? (string) $row['path'] : json_encode($row); + $haystack = function_exists('mb_strtolower') ? mb_strtolower($haystack) : strtolower($haystack); + return strpos($haystack, $needle) !== false; + })); + } + + $total = count($rows); + $pages = max(1, (int) ceil($total / $perPage)); + $page = min($page, $pages); + $rows = array_slice($rows, ($page - 1) * $perPage, $perPage); + + return $this->render('@media/audit.twig', array( + 'title' => 'Анализатор медиа', + 'report' => $report, + 'kind' => $kind, + 'rows' => $rows, + 'filters' => array('q' => $query), + 'total' => $total, + 'page' => $page, + 'pages' => $pages, + 'can_manage' => Permission::check('manage_media'), + )); + } + + public function runAudit(array $params = array()) + { + if (($err = $this->guard()) !== null) { + return $err; + } + + try { + $report = MediaAudit::run(); + return $this->success('Анализ медиа завершён', array( + 'data' => array('summary' => $report['summary']), + 'redirect' => $this->base() . '/media/audit', + )); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + } + public function file(array $params = array()) { AdminAssets::addStyle($this->base() . '/modules/Media/assets/media.css', 50); @@ -74,6 +157,18 @@ 'can_manage' => Permission::check('manage_media'), 'supports_webp' => Model::supportsWebp(), 'presets' => ImagePresets::options(), + 'usage' => MediaUsageIndex::usage($file['path']), + )); + } + + public function trash(array $params = array()) + { + AdminAssets::addStyle($this->base() . '/modules/Media/assets/media.css', 50); + AdminAssets::addScript($this->base() . '/modules/Media/assets/media.js', 50); + return $this->render('@media/trash.twig', array( + 'title' => 'Корзина медиа', + 'items' => MediaTrash::all(), + 'can_manage' => Permission::check('manage_media'), )); } @@ -215,16 +310,73 @@ $path = Request::postStr('path', ''); $parent = Model::parentDir($path); try { - Model::delete($path); + $usage = MediaAudit::inspectUsage($path); + + if ((int) $usage['use_count'] > 0 && Request::postInt('confirm_usage', 0) !== 1) { + throw new \RuntimeException('Объект используется. Подтвердите перенос в корзину после просмотра связей'); + } + + Model::trash($path); } catch (\RuntimeException $e) { return $this->error($e->getMessage(), array(), 422); } - return $this->success('Удалено', array( + return $this->success('Перемещено в корзину', array( 'redirect' => $this->base() . '/media?dir=' . rawurlencode($parent ?: Model::ROOT), )); } + public function deleteCheck(array $params = array()) + { + if (($err = $this->guard()) !== null) { return $err; } + $path = Request::postStr('path', ''); + if (Model::isProtectedPath($path)) { return $this->error('Этот путь нельзя удалить', array(), 422); } + try { $usage = MediaAudit::inspectUsage($path); } + catch (\RuntimeException $e) { return $this->error($e->getMessage(), array(), 422); } + + return $this->success('', array('data' => array('usage' => $usage))); + } + + public function emptyFolder(array $params = array()) + { + if (($err = $this->guard()) !== null) { return $err; } + $path = Request::postStr('path', ''); + try { + $usage = MediaAudit::inspectUsage($path); + if ((int) $usage['use_count'] > 0 && Request::postInt('confirm_usage', 0) !== 1) { + throw new \RuntimeException('В папке есть используемые файлы. Подтвердите очистку после просмотра предупреждения'); + } + + $entry = Model::trashContents($path); + } catch (\RuntimeException $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Содержимое папки перемещено в корзину', array( + 'data' => array('count' => isset($entry['count']) ? (int) $entry['count'] : 0), + 'redirect' => $this->base() . '/media?dir=' . rawurlencode($path), + )); + } + + public function restoreTrash(array $params = array()) + { + if (($err = $this->guard()) !== null) { return $err; } + try { + $entry = MediaTrash::restore(isset($params['token']) ? $params['token'] : ''); + foreach ($entry['paths'] as $path) { MediaSearchIndex::indexPath($path); } + } + catch (\RuntimeException $e) { return $this->error($e->getMessage(), array(), 422); } + return $this->success('Объект восстановлен', array('redirect' => $this->base() . '/media/trash')); + } + + public function purgeTrash(array $params = array()) + { + if (($err = $this->guard()) !== null) { return $err; } + try { MediaTrash::purge(isset($params['token']) ? $params['token'] : ''); } + catch (\RuntimeException $e) { return $this->error($e->getMessage(), array(), 422); } + return $this->success('Объект удалён окончательно', array('redirect' => $this->base() . '/media/trash')); + } + public function clearThumbnails(array $params = array()) { if (($err = $this->guard()) !== null) { diff --git a/adminx/modules/Media/GlobalSearchProvider.php b/adminx/modules/Media/GlobalSearchProvider.php new file mode 100644 index 0000000..65417a8 --- /dev/null +++ b/adminx/modules/Media/GlobalSearchProvider.php @@ -0,0 +1,34 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Media; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + class GlobalSearchProvider + { + public static function search($query, $limit = 8) + { + $out = array(); + foreach (MediaSearchIndex::search($query, $limit) as $row) { + $out[] = array( + 'type' => 'media', 'group' => 'Медиа', 'title' => (string) $row['name'], + 'subtitle' => (string) $row['path'], 'url' => '/media/file?path=' . rawurlencode((string) $row['path']), + 'icon' => !empty($row['is_image']) ? 'ti ti-photo' : 'ti ti-file', 'score' => (float) $row['score'], + ); + } + + return $out; + } + } diff --git a/adminx/modules/Media/MediaAudit.php b/adminx/modules/Media/MediaAudit.php new file mode 100644 index 0000000..e8af1cc --- /dev/null +++ b/adminx/modules/Media/MediaAudit.php @@ -0,0 +1,686 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Media; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\DatabaseSchema; + use App\Common\SystemTables; + use App\Content\CatalogTables; + use App\Content\ContentTables; + use App\Helpers\Dir; + use App\Helpers\File; + use App\Helpers\Number; + use App\Frontend\Media\ThumbnailUrl; + use DB; + + /** + * Read-only inventory of uploads and references to them. + * + * An unused file is only a review candidate: a project may build its path + * dynamically. The analyzer never removes or rewrites files. + */ + class MediaAudit + { + const REPORT_VERSION = 1; + const LARGE_FILE_BYTES = 5242880; + const LARGE_IMAGE_SIDE = 4000; + const MAX_SOURCE_FILE_BYTES = 2097152; + + protected static $imageExtensions = array('jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'); + + public static function report() + { + $path = self::reportPath(); + if (!is_file($path)) { + return null; + } + + $data = json_decode((string) File::getContent($path), true); + return is_array($data) && isset($data['version']) && (int) $data['version'] === self::REPORT_VERSION + ? $data + : null; + } + + /** Read current references for one file or folder without trusting a saved report. */ + public static function inspectUsage($path, $limit = 50) + { + $rows = self::inspectUsageMany(array($path), $limit); + if (!$rows) { + throw new \RuntimeException('Файл или папка не найдены'); + } + + return reset($rows); + } + + /** Read current references for several files in one database/source pass. */ + public static function inspectUsageMany(array $paths, $limit = 50, $includeHistorical = true) + { + $targets = array(); + foreach ($paths as $path) { + $path = Model::normalize($path, ''); + $absolute = $path !== '' ? Model::abs($path) : ''; + if ($path === '' || (!is_file($absolute) && !is_dir($absolute))) { continue; } + $targets[$path] = array( + 'is_directory' => is_dir($absolute), + 'uses' => array(), + 'seen' => array(), + 'count' => 0, + ); + } + + if (!$targets) { return array(); } + + $references = self::databaseReferences((bool) $includeHistorical); + self::sourceReferences($references); + $limit = max(1, (int) $limit); + foreach ($references as $reference) { + $candidate = self::normalizeReference(isset($reference['path']) ? $reference['path'] : ''); + if ($candidate === '') { continue; } + $reference['path'] = $candidate; + $key = sha1(json_encode($reference)); + foreach ($targets as $path => &$target) { + if (!self::referenceMatches($candidate, $path, $target['is_directory']) || isset($target['seen'][$key])) { continue; } + $target['seen'][$key] = true; + $target['count']++; + if (count($target['uses']) < $limit) { $target['uses'][] = $reference; } + } + + unset($target); + } + + $checkedAt = time(); + $result = array(); + foreach ($targets as $path => $target) { + $result[$path] = array( + 'checked' => true, + 'live' => true, + 'path' => $path, + 'use_count' => (int) $target['count'], + 'uses' => $target['uses'], + 'report' => array( + 'available' => true, + 'generated_at' => $checkedAt, + 'generated_label' => date('d.m.Y H:i:s', $checkedAt), + ), + ); + } + + return $result; + } + + public static function run() + { + $started = microtime(true); + $inventory = self::inventory(); + $references = self::databaseReferences(); + self::sourceReferences($references); + + $usage = array(); + $invalid = array(); + foreach ($references as $reference) { + $normalized = self::normalizeReference($reference['path']); + if ($normalized === '') { + $reference['reason'] = 'Путь содержит недопустимые сегменты или не относится к uploads'; + $invalid[] = $reference; + continue; + } + + $reference['path'] = $normalized; + if (!isset($usage[$normalized])) { + $usage[$normalized] = array(); + } + + $usage[$normalized][] = $reference; + } + + self::propagateCompanionUsage($usage, $inventory['files']); + + $missingOriginals = array(); + foreach ($usage as $path => $items) { + if (!isset($inventory['files'][$path])) { + $missingOriginals[] = array( + 'path' => $path, + 'uses' => $items, + 'use_count' => count($items), + ); + } + } + + foreach ($inventory['orphan_previews'] as $preview) { + $missingOriginals[] = array( + 'path' => $preview['original'], + 'preview' => $preview['path'], + 'uses' => array(), + 'use_count' => 0, + ); + } + + $unused = array(); + $missingPreviews = array(); + $large = array(); + $files = array(); + foreach ($inventory['files'] as $path => $file) { + $file['uses'] = isset($usage[$path]) ? $usage[$path] : array(); + $file['use_count'] = count($file['uses']); + $files[] = $file; + + if ($file['use_count'] === 0 && !self::reservedFile($path)) { + $unused[] = $file; + } + + if ($file['is_image'] && empty($inventory['preview_counts'][$path])) { + $missingPreviews[] = $file; + } + + if ($file['size'] >= self::LARGE_FILE_BYTES + || ($file['is_image'] && max($file['width'], $file['height']) >= self::LARGE_IMAGE_SIDE)) { + $large[] = $file; + } + } + + usort($unused, array(__CLASS__, 'sortBySize')); + usort($large, array(__CLASS__, 'sortBySize')); + usort($missingOriginals, array(__CLASS__, 'sortByPath')); + usort($invalid, array(__CLASS__, 'sortByPath')); + usort($files, array(__CLASS__, 'sortByPath')); + + $duplicates = self::duplicates($inventory['files']); + $duplicateBytes = 0; + foreach ($duplicates as $group) { + $duplicateBytes += (int) $group['recoverable_bytes']; + } + + $report = array( + 'version' => self::REPORT_VERSION, + 'generated_at' => time(), + 'generated_label' => date('d.m.Y H:i:s'), + 'duration' => round(microtime(true) - $started, 2), + 'thresholds' => array( + 'large_file' => Number::formatSize(self::LARGE_FILE_BYTES), + 'large_side' => self::LARGE_IMAGE_SIDE . ' px', + ), + 'summary' => array( + 'files' => count($inventory['files']), + 'images' => $inventory['images'], + 'previews' => $inventory['previews'], + 'references' => count($references), + 'used_files' => count($inventory['files']) - count($unused), + 'unused' => count($unused), + 'duplicate_groups' => count($duplicates), + 'duplicate_bytes' => $duplicateBytes, + 'duplicate_size' => Number::formatSize($duplicateBytes), + 'missing_originals' => count($missingOriginals), + 'missing_previews' => count($missingPreviews), + 'large' => count($large), + 'invalid' => count($invalid), + ), + 'unused' => $unused, + 'duplicates' => $duplicates, + 'missing_originals' => $missingOriginals, + 'missing_previews' => $missingPreviews, + 'large' => $large, + 'invalid' => $invalid, + 'files' => $files, + ); + + MediaUsageIndex::rebuild($files, $report['generated_at']); + MediaSearchIndex::rebuild($files); + + $directory = dirname(self::reportPath()); + if (!Dir::create($directory) + || !File::putAtomic( + self::reportPath(), + json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n" + )) { + throw new \RuntimeException('Не удалось сохранить отчёт анализатора медиа'); + } + + return $report; + } + + protected static function inventory() + { + $result = array( + 'files' => array(), + 'preview_counts' => array(), + 'orphan_previews' => array(), + 'images' => 0, + 'previews' => 0, + ); + $root = rtrim(BASEPATH, '/\\') . '/uploads'; + if (!is_dir($root)) { + return $result; + } + + $thumbnailDirectory = ThumbnailUrl::directoryName(); + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + $previewRows = array(); + foreach ($iterator as $item) { + if (!$item->isFile() || $item->isLink()) { + continue; + } + + $absolute = str_replace('\\', '/', $item->getPathname()); + $relative = '/uploads/' . ltrim(substr($absolute, strlen(str_replace('\\', '/', $root))), '/'); + if (self::hiddenPath($relative)) { + continue; + } + + if (strpos('/' . trim($relative, '/') . '/', '/' . $thumbnailDirectory . '/') !== false) { + $result['previews']++; + $previewRows[] = array( + 'path' => $relative, + 'original' => self::previewOriginal($relative, $thumbnailDirectory), + ); + continue; + } + + $extension = strtolower(pathinfo($relative, PATHINFO_EXTENSION)); + $isImage = in_array($extension, self::$imageExtensions, true); + $width = 0; + $height = 0; + if ($isImage) { + $dimensions = @getimagesize($absolute); + if (is_array($dimensions)) { + $width = (int) $dimensions[0]; + $height = (int) $dimensions[1]; + } + + $result['images']++; + } + + $size = (int) $item->getSize(); + $result['files'][$relative] = array( + 'path' => $relative, + 'name' => basename($relative), + 'extension' => $extension, + 'is_image' => $isImage, + 'size' => $size, + 'size_label' => Number::formatSize($size), + 'width' => $width, + 'height' => $height, + 'dimensions' => $width > 0 && $height > 0 ? $width . ' × ' . $height : '', + 'modified_at' => (int) $item->getMTime(), + 'modified_label' => date('d.m.Y H:i', (int) $item->getMTime()), + ); + } + + foreach ($previewRows as $preview) { + if ($preview['original'] !== '' && isset($result['files'][$preview['original']])) { + if (!isset($result['preview_counts'][$preview['original']])) { + $result['preview_counts'][$preview['original']] = 0; + } + + $result['preview_counts'][$preview['original']]++; + continue; + } + + $result['orphan_previews'][] = $preview; + } + + return $result; + } + + protected static function databaseReferences($includeHistorical = true) + { + $sources = array( + array('table' => ContentTables::table('document_fields'), 'id' => 'Id', 'columns' => array('field_value'), 'label' => 'Поле документа', 'link' => '/documents/{document_id}/edit', 'select' => array('document_id')), + array('table' => ContentTables::table('document_fields_text'), 'id' => 'Id', 'columns' => array('field_value'), 'label' => 'Текстовое поле документа', 'link' => '/documents/{document_id}/edit', 'select' => array('document_id')), + array('table' => ContentTables::table('documents'), 'id' => 'Id', 'columns' => array('document_excerpt', 'document_meta_description', 'document_property', 'module_catalog'), 'label' => 'Документ', 'link' => '/documents/{Id}/edit'), + array('table' => ContentTables::table('rubric_fields'), 'id' => 'Id', 'columns' => array('rubric_field_default', 'rubric_field_settings', 'rubric_field_template', 'rubric_field_template_request', 'rubric_field_description'), 'label' => 'Поле рубрики', 'link' => '/rubrics/fields/{Id}'), + array('table' => ContentTables::table('rubrics'), 'id' => 'Id', 'columns' => array('rubric_template', 'rubric_start_code', 'rubric_code_start', 'rubric_code_end', 'rubric_teaser_template', 'rubric_header_template', 'rubric_og_template', 'rubric_footer_template', 'rubric_description'), 'label' => 'Рубрика', 'link' => '/rubrics/{Id}'), + array('table' => ContentTables::table('rubric_templates'), 'id' => 'id', 'columns' => array('template'), 'label' => 'Шаблон рубрики', 'link' => '/rubrics/templates/{id}'), + array('table' => ContentTables::table('templates'), 'id' => 'Id', 'columns' => array('template_text'), 'label' => 'Шаблон сайта', 'link' => '/templates/{Id}'), + array('table' => ContentTables::table('sysblocks'), 'id' => 'id', 'columns' => array('sysblock_text', 'sysblock_description'), 'label' => 'Блок', 'link' => '/blocks/{id}'), + array('table' => ContentTables::table('navigation'), 'id' => 'navigation_id', 'columns' => array('level1', 'level2', 'level3', 'level1_active', 'level2_active', 'level3_active', 'level1_begin', 'level1_end', 'level2_begin', 'level2_end', 'level3_begin', 'level3_end', 'begin', 'end'), 'label' => 'Шаблон навигации', 'link' => '/navigation/{navigation_id}'), + array('table' => ContentTables::table('navigation_items'), 'id' => 'navigation_item_id', 'columns' => array('alias', 'description', 'image', 'css_style'), 'label' => 'Пункт навигации', 'link' => '/navigation/items/{navigation_item_id}'), + array('table' => ContentTables::table('request'), 'id' => 'Id', 'columns' => array('request_template_item', 'request_template_main', 'request_description', 'request_result_contract'), 'label' => 'Запрос', 'link' => '/requests/{Id}'), + array('table' => SystemTables::table('settings'), 'id' => 'param', 'columns' => array('value'), 'label' => 'Настройка', 'link' => '/settings'), + array('table' => CatalogTables::table('catalog_product_index'), 'id' => 'product_id', 'columns' => array('image', 'images_json', 'card_fields_json'), 'label' => 'Товарный индекс', 'link' => '/catalog/products/{product_id}'), + ); + if ($includeHistorical) { + $sources[] = array('table' => ContentTables::table('document_rev'), 'id' => 'Id', 'columns' => array('doc_data'), 'label' => 'Ревизия документа', 'link' => '/documents/{doc_id}/edit', 'select' => array('doc_id')); + } + + $result = array(); + foreach ($sources as $source) { + if (!DatabaseSchema::tableExists($source['table']) + || !DatabaseSchema::columnExists($source['table'], $source['id'])) { + continue; + } + + $columns = array(); + foreach ($source['columns'] as $column) { + if (DatabaseSchema::columnExists($source['table'], $column)) { + $columns[] = $column; + } + } + + if (!$columns) { + continue; + } + + self::scanDatabaseSource($source, $columns, $result); + } + + self::scanCombinedDocumentFields($result); + + return $result; + } + + /** Long legacy field values are split between the regular and text tables. */ + protected static function scanCombinedDocumentFields(array &$result) + { + $fields = ContentTables::table('document_fields'); + $text = ContentTables::table('document_fields_text'); + if (!DatabaseSchema::tableExists($fields) || !DatabaseSchema::tableExists($text)) { + return; + } + + $cursor = 0; + do { + $rows = DB::query( + 'SELECT f.Id,f.document_id,f.rubric_field_id,' + . ' CONCAT(f.field_value,COALESCE(t.field_value,\'\')) field_value' + . ' FROM ' . $fields . ' f LEFT JOIN ' . $text . ' t' + . ' ON t.document_id=f.document_id AND t.rubric_field_id=f.rubric_field_id' + . ' WHERE f.Id>%i AND (LOCATE(\'uploads/\',f.field_value)>0 OR LOCATE(\'uploads/\',t.field_value)>0)' + . ' ORDER BY f.Id ASC LIMIT 500', + $cursor + )->getAll(); + foreach ($rows ?: array() as $row) { + $cursor = max($cursor, (int) $row['Id']); + foreach (self::extractPaths($row['field_value']) as $path) { + $result[] = array( + 'path' => $path, + 'source' => 'Полное значение поля документа', + 'source_id' => (string) $row['Id'], + 'column' => 'field_value', + 'link' => '/documents/' . (int) $row['document_id'] . '/edit', + ); + } + } + } while (count($rows ?: array()) === 500); + } + + /** Generated WebP/JPEG/PNG twins follow the usage of the referenced source. */ + protected static function propagateCompanionUsage(array &$usage, array $files) + { + $stems = array(); + foreach ($files as $path => $file) { + if (empty($file['is_image'])) { continue; } + $stem = strtolower(dirname($path) . '/' . pathinfo($path, PATHINFO_FILENAME)); + $stems[$stem][] = $path; + } + + foreach (array_keys($usage) as $path) { + $stem = strtolower(dirname($path) . '/' . pathinfo($path, PATHINFO_FILENAME)); + if (empty($stems[$stem])) { continue; } + foreach ($stems[$stem] as $companion) { + if (!isset($usage[$companion])) { + $usage[$companion] = $usage[$path]; + } + } + } + } + + protected static function scanDatabaseSource(array $source, array $columns, array &$result) + { + $cursor = 0; + $numericId = $source['id'] !== 'param'; + $select = array_unique(array_merge(array($source['id']), isset($source['select']) ? $source['select'] : array(), $columns)); + $quoted = array(); + foreach ($select as $column) { + if (DatabaseSchema::columnExists($source['table'], $column)) { + $quoted[] = '`' . $column . '`'; + } + } + + $conditions = array(); + foreach ($columns as $column) { + $conditions[] = "LOCATE('/uploads/', `" . $column . '`) > 0'; + $conditions[] = "LOCATE('uploads/', `" . $column . '`) > 0'; + } + + do { + $sql = 'SELECT ' . implode(',', $quoted) . ' FROM `' . $source['table'] . '` WHERE '; + if ($numericId) { + $rows = DB::query( + $sql . '`' . $source['id'] . '` > %i AND (' . implode(' OR ', $conditions) . ')' + . ' ORDER BY `' . $source['id'] . '` ASC LIMIT 500', + $cursor + )->getAll(); + } else { + $rows = DB::query( + $sql . '(' . implode(' OR ', $conditions) . ')' + . ' ORDER BY `' . $source['id'] . '` ASC LIMIT 5000' + )->getAll(); + } + + foreach ($rows ?: array() as $row) { + $row = (array) $row; + if ($numericId) { + $cursor = max($cursor, (int) $row[$source['id']]); + } + + $link = self::replaceTokens($source['link'], $row); + foreach ($columns as $column) { + foreach (self::extractPaths(isset($row[$column]) ? $row[$column] : '') as $path) { + $result[] = array( + 'path' => $path, + 'source' => $source['label'], + 'source_id' => (string) $row[$source['id']], + 'column' => $column, + 'link' => $link, + ); + } + } + } + } while ($numericId && count($rows ?: array()) === 500); + } + + protected static function sourceReferences(array &$result) + { + foreach (array('templates', 'modules') as $rootName) { + $root = rtrim(BASEPATH, '/\\') . '/' . $rootName; + if (!is_dir($root)) { + continue; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveCallbackFilterIterator( + new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), + function ($item) { + return !$item->isLink() && $item->getFilename() !== 'vendor'; + } + ), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + foreach ($iterator as $item) { + if (!$item->isFile() || $item->getSize() > self::MAX_SOURCE_FILE_BYTES + || !preg_match('/\.(?:php|inc|twig|html?|css|less|js|json)$/i', $item->getFilename())) { + continue; + } + + $content = File::getContent($item->getPathname()); + foreach (self::extractPaths($content) as $path) { + $relative = ltrim(str_replace('\\', '/', substr($item->getPathname(), strlen(BASEPATH))), '/'); + $result[] = array( + 'path' => $path, + 'source' => $rootName === 'templates' ? 'Файл темы' : 'Файл модуля', + 'source_id' => 0, + 'column' => $relative, + 'link' => '', + ); + } + } + } + } + + protected static function duplicates(array $files) + { + $bySize = array(); + foreach ($files as $file) { + if ($file['size'] <= 0) { + continue; + } + + $bySize[$file['size']][] = $file; + } + + $groups = array(); + foreach ($bySize as $size => $candidates) { + if (count($candidates) < 2) { + continue; + } + + $byHash = array(); + foreach ($candidates as $file) { + $absolute = rtrim(BASEPATH, '/\\') . $file['path']; + $hash = is_file($absolute) ? hash_file('sha256', $absolute) : ''; + if ($hash !== '') { + $byHash[$hash][] = $file; + } + } + + foreach ($byHash as $hash => $duplicates) { + if (count($duplicates) < 2) { + continue; + } + + $groups[] = array( + 'hash' => $hash, + 'hash_short' => substr($hash, 0, 12), + 'size' => (int) $size, + 'size_label' => Number::formatSize((int) $size), + 'count' => count($duplicates), + 'recoverable_bytes' => ((int) $size) * (count($duplicates) - 1), + 'recoverable_label' => Number::formatSize(((int) $size) * (count($duplicates) - 1)), + 'files' => $duplicates, + ); + } + } + + usort($groups, function ($left, $right) { + return $right['recoverable_bytes'] - $left['recoverable_bytes']; + }); + return $groups; + } + + protected static function extractPaths($value) + { + $value = html_entity_decode(str_replace('\/', '/', (string) $value), ENT_QUOTES, 'UTF-8'); + if ($value === '' || stripos($value, 'uploads/') === false) { + return array(); + } + + preg_match_all( + '~(?:https?://[^\s"\'<>]+)?(/?uploads/[^\s"\'<>?#\)\]\},;|\\\\]+)~iu', + $value, + $matches + ); + $result = array(); + foreach (isset($matches[1]) ? $matches[1] : array() as $path) { + $path = '/' . ltrim(rawurldecode((string) $path), '/'); + $result[$path] = $path; + } + + return array_values($result); + } + + protected static function normalizeReference($path) + { + $path = str_replace('\\', '/', trim((string) $path)); + $path = preg_replace('#/+#', '/', $path); + if ($path === '' || strpos($path, "\0") !== false || strpos($path, '/../') !== false + || strpos($path, '/uploads/') !== 0) { + return ''; + } + + return $path; + } + + protected static function referenceMatches($candidate, $path, $isDirectory) + { + if ($candidate === $path) { return true; } + if ($isDirectory) { return strpos($candidate, rtrim($path, '/') . '/') === 0; } + + $candidateExtension = strtolower(pathinfo($candidate, PATHINFO_EXTENSION)); + $pathExtension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + if (!in_array($candidateExtension, self::$imageExtensions, true) + || !in_array($pathExtension, self::$imageExtensions, true)) { + return false; + } + + $candidateStem = strtolower(dirname($candidate) . '/' . pathinfo($candidate, PATHINFO_FILENAME)); + $pathStem = strtolower(dirname($path) . '/' . pathinfo($path, PATHINFO_FILENAME)); + return $candidateStem === $pathStem; + } + + protected static function previewOriginal($path, $directory) + { + $path = str_replace('/' . trim($directory, '/') . '/', '/', (string) $path); + $extension = pathinfo($path, PATHINFO_EXTENSION); + $filename = pathinfo($path, PATHINFO_FILENAME); + $filename = preg_replace('/-[rcfts]\d+x\d+r?$/i', '', $filename); + return dirname($path) . '/' . $filename . ($extension !== '' ? '.' . $extension : ''); + } + + protected static function hiddenPath($path) + { + $basename = strtolower(basename((string) $path)); + return $basename === 'index.php' || $basename === '.htaccess' + || strpos((string) $path, '/.drafts/') !== false + || strpos((string) $path, '/.quarantine/') !== false + || strpos((string) $path, '/.temp/') !== false; + } + + protected static function reservedFile($path) + { + return preg_match('#^/uploads/(?:images/)?(?:noimage|placeholder)\.[a-z0-9]+$#i', (string) $path) === 1; + } + + protected static function replaceTokens($template, array $row) + { + foreach ($row as $key => $value) { + $template = str_replace('{' . $key . '}', rawurlencode((string) $value), $template); + } + + return $template; + } + + protected static function reportPath() + { + return rtrim(BASEPATH, '/\\') . '/storage/reports/media-audit.json'; + } + + protected static function sortBySize($left, $right) + { + if ((int) $left['size'] === (int) $right['size']) { + return strcmp((string) $left['path'], (string) $right['path']); + } + + return (int) $left['size'] < (int) $right['size'] ? 1 : -1; + } + + protected static function sortByPath($left, $right) + { + return strcmp((string) $left['path'], (string) $right['path']); + } + } diff --git a/adminx/modules/Media/MediaSearchIndex.php b/adminx/modules/Media/MediaSearchIndex.php new file mode 100644 index 0000000..546a93a --- /dev/null +++ b/adminx/modules/Media/MediaSearchIndex.php @@ -0,0 +1,83 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Media; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\DatabaseSchema; + use App\Content\ContentTables; + use DB; + + class MediaSearchIndex + { + public static function table() { return ContentTables::table('media_search_index'); } + + public static function rebuild(array $files) + { + if (!DatabaseSchema::tableExists(self::table())) { return 0; } + DB::query('DELETE FROM ' . self::table()); + $count = 0; + foreach ($files as $file) { self::put($file); $count++; } + return $count; + } + + public static function put(array $file) + { + if (!DatabaseSchema::tableExists(self::table()) || empty($file['path'])) { return false; } + $path = (string) $file['path']; + DB::query( + 'INSERT INTO ' . self::table() . ' (path_hash,path,name,extension,is_image,size,modified_at) VALUES (%s,%s,%s,%s,%i,%i,%i)' + . ' ON DUPLICATE KEY UPDATE path=VALUES(path),name=VALUES(name),extension=VALUES(extension),is_image=VALUES(is_image),size=VALUES(size),modified_at=VALUES(modified_at)', + sha1($path), $path, isset($file['name']) ? $file['name'] : basename($path), isset($file['extension']) ? $file['extension'] : pathinfo($path, PATHINFO_EXTENSION), + empty($file['is_image']) ? 0 : 1, isset($file['size']) ? (int) $file['size'] : 0, isset($file['modified_at']) ? (int) $file['modified_at'] : (isset($file['mtime']) ? (int) $file['mtime'] : time()) + ); + return true; + } + + public static function remove($path) + { + if (!DatabaseSchema::tableExists(self::table())) { return; } + $path = Model::normalize($path, ''); + if ($path === '') { return; } + $children = addcslashes(rtrim($path, '/'), '%_') . '/%'; + DB::query('DELETE FROM ' . self::table() . ' WHERE path_hash=%s OR path LIKE %s', sha1($path), $children); + } + + public static function indexPath($path) + { + $path = Model::normalize($path, ''); $abs = $path !== '' ? Model::abs($path) : ''; + if (is_file($abs)) { $file = Model::file($path); if ($file) { self::put($file); } return; } + if (!is_dir($abs)) { return; } + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($abs, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::LEAVES_ONLY); + $root = Model::abs(Model::ROOT); + foreach ($iterator as $item) { + if (!$item->isFile() || $item->isLink()) { continue; } + $relative = Model::ROOT . '/' . ltrim(str_replace('\\', '/', substr($item->getPathname(), strlen($root))), '/'); + $file = Model::file($relative); if ($file) { self::put($file); } + } + } + + public static function search($query, $limit) + { + if (!DatabaseSchema::tableExists(self::table())) { return array(); } + $query = trim((string) $query); $limit = max(1, min(20, (int) $limit)); + if ($query === '') { return array(); } + return DB::query( + 'SELECT path,name,extension,is_image,size,CASE WHEN LOWER(name)=LOWER(%s) THEN 100 WHEN LOWER(name) LIKE LOWER(%s) THEN 70 ELSE 30 END score' + . ' FROM ' . self::table() . ' WHERE name LIKE %ss OR path LIKE %ss ORDER BY score DESC,modified_at DESC LIMIT ' . $limit, + $query, $query . '%', $query, $query + )->getAll() ?: array(); + } + } diff --git a/adminx/modules/Media/MediaTrash.php b/adminx/modules/Media/MediaTrash.php new file mode 100644 index 0000000..d321a5a --- /dev/null +++ b/adminx/modules/Media/MediaTrash.php @@ -0,0 +1,174 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Media; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\AuditLog; + use App\Common\Auth; + use App\Common\Lock; + use App\Helpers\Dir; + use App\Helpers\File; + + class MediaTrash + { + public static function store($path, array $related = array()) + { + return self::storePaths($path, array_merge(array($path), $related), basename($path), null); + } + + public static function storeContents($path, array $paths) + { + return self::storePaths($path, $paths, basename($path) . ' — содержимое', 'folder'); + } + + protected static function storePaths($path, array $paths, $name, $type) + { + return Lock::run('media-trash', function () use ($path, $paths, $name, $type) { + $paths = array_values(array_unique($paths)); + $token = date('Ymd-His') . '-' . substr(sha1($path . uniqid('', true)), 0, 10); + $base = self::itemsRoot() . '/' . $token; + $moved = array(); + if (!Dir::create($base)) { throw new \RuntimeException('Не удалось подготовить корзину медиа'); } + + try { + foreach ($paths as $sourcePath) { + $sourcePath = Model::normalize($sourcePath, ''); + $source = $sourcePath !== '' ? Model::abs($sourcePath) : ''; + if ($source === '' || (!is_file($source) && !is_dir($source))) { continue; } + $target = $base . '/payload/' . ltrim(substr($sourcePath, strlen(Model::ROOT)), '/'); + if (!Dir::create(dirname($target)) || !@rename($source, $target)) { + throw new \RuntimeException('Не удалось переместить объект в корзину медиа'); + } + + $moved[] = array('path' => $sourcePath, 'stored' => $target); + } + } catch (\Throwable $e) { + self::rollbackMoves($moved); + self::removeTree($base); + throw $e; + } + + if (!$moved) { self::removeTree($base); throw new \RuntimeException('Файл или папка не найдены'); } + $entry = array( + 'token' => $token, + 'path' => $path, + 'name' => $name, + 'type' => $type !== null ? $type : (is_dir($moved[0]['stored']) ? 'folder' : 'file'), + 'deleted_at' => time(), + 'deleted_label' => date('d.m.Y H:i:s'), + 'actor_id' => (int) Auth::id(), + 'paths' => array_column($moved, 'path'), + ); + if (!File::putAtomic($base . '/entry.json', json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES))) { + self::rollbackMoves($moved); + self::removeTree($base); + throw new \RuntimeException('Не удалось записать данные корзины медиа'); + } + + AuditLog::record('media.trashed', array('actor_id' => Auth::id(), 'target_type' => 'media', 'meta' => array('path' => $path, 'token' => $token))); + return $entry; + }); + } + + public static function all() + { + $rows = array(); + foreach (glob(self::itemsRoot() . '/*/entry.json') ?: array() as $file) { + $row = json_decode((string) File::getContent($file), true); + if (is_array($row) && !empty($row['token'])) { $rows[] = $row; } + } + + usort($rows, function ($left, $right) { return (int) $right['deleted_at'] - (int) $left['deleted_at']; }); + return $rows; + } + + public static function restore($token) + { + return Lock::run('media-trash', function () use ($token) { + $entry = self::entry($token); + foreach ($entry['paths'] as $path) { + if (file_exists(Model::abs($path))) { throw new \RuntimeException('Исходный путь уже занят: ' . $path); } + } + + $moved = array(); + try { + foreach ($entry['paths'] as $path) { + $stored = self::storedPath($entry['token'], $path); + $target = Model::abs($path); + if ((!is_file($stored) && !is_dir($stored)) || !Dir::create(dirname($target)) || !@rename($stored, $target)) { + throw new \RuntimeException('Не удалось восстановить: ' . $path); + } + + $moved[] = array('path' => $path, 'stored' => $stored); + } + } catch (\Throwable $e) { + foreach (array_reverse($moved) as $row) { @rename(Model::abs($row['path']), $row['stored']); } + throw $e; + } + + self::removeTree(self::itemsRoot() . '/' . $entry['token']); + AuditLog::record('media.restored', array('actor_id' => Auth::id(), 'target_type' => 'media', 'meta' => array('path' => $entry['path'], 'token' => $entry['token']))); + return $entry; + }); + } + + public static function purge($token) + { + return Lock::run('media-trash', function () use ($token) { + $entry = self::entry($token); + self::removeTree(self::itemsRoot() . '/' . $entry['token']); + AuditLog::record('media.purged', array('actor_id' => Auth::id(), 'target_type' => 'media', 'meta' => array('path' => $entry['path'], 'token' => $entry['token']))); + return $entry; + }); + } + + protected static function entry($token) + { + $token = preg_replace('/[^a-zA-Z0-9_-]/', '', (string) $token); + $file = self::itemsRoot() . '/' . $token . '/entry.json'; + $row = is_file($file) ? json_decode((string) File::getContent($file), true) : null; + if (!is_array($row) || empty($row['paths'])) { throw new \RuntimeException('Объект корзины не найден'); } + return $row; + } + + protected static function storedPath($token, $path) + { + return self::itemsRoot() . '/' . $token . '/payload/' . ltrim(substr($path, strlen(Model::ROOT)), '/'); + } + + protected static function itemsRoot() + { + $root = rtrim(BASEPATH, '/\\') . '/storage/media-trash/items'; + Dir::create($root); + return $root; + } + + protected static function rollbackMoves(array $moved) + { + foreach (array_reverse($moved) as $row) { + Dir::create(dirname(Model::abs($row['path']))); + @rename($row['stored'], Model::abs($row['path'])); + } + } + + protected static function removeTree($path) + { + if (!is_dir($path)) { if (is_file($path)) { @unlink($path); } return; } + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($iterator as $item) { $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); } + @rmdir($path); + } + } diff --git a/adminx/modules/Media/MediaUsageIndex.php b/adminx/modules/Media/MediaUsageIndex.php new file mode 100644 index 0000000..aa6fb76 --- /dev/null +++ b/adminx/modules/Media/MediaUsageIndex.php @@ -0,0 +1,173 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Media; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\Lock; + use App\Helpers\Dir; + use App\Helpers\File; + + /** Compact, path-addressable companion index for the large media audit report. */ + class MediaUsageIndex + { + const VERSION = 1; + + public static function rebuild(array $files, $generatedAt) + { + return Lock::run('media-usage-index', function () use ($files, $generatedAt) { + $root = self::root(); + $building = $root . '.building-' . substr(sha1(uniqid('', true)), 0, 10); + self::removeTree($building); + if (!Dir::create($building)) { + throw new \RuntimeException('Не удалось подготовить индекс использования медиа'); + } + + $count = 0; + foreach ($files as $file) { + $path = isset($file['path']) ? self::normalize($file['path']) : ''; + if ($path === '' || empty($file['uses'])) { continue; } + $hash = sha1($path); + $dir = $building . '/' . substr($hash, 0, 2); + if (!Dir::create($dir) || !File::putAtomic($dir . '/' . $hash . '.json', json_encode(array( + 'path' => $path, + 'use_count' => count($file['uses']), + 'uses' => array_values($file['uses']), + ), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES))) { + self::removeTree($building); + throw new \RuntimeException('Не удалось записать индекс использования медиа'); + } + + $count++; + } + + File::putAtomic($building . '/meta.json', json_encode(array( + 'version' => self::VERSION, + 'generated_at' => (int) $generatedAt, + 'generated_label' => date('d.m.Y H:i:s', (int) $generatedAt), + 'used_files' => $count, + ), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + + $old = $root . '.old-' . substr(sha1(uniqid('', true)), 0, 8); + if (is_dir($root) && !@rename($root, $old)) { + self::removeTree($building); + throw new \RuntimeException('Не удалось заменить индекс использования медиа'); + } + + if (!@rename($building, $root)) { + if (is_dir($old)) { @rename($old, $root); } + self::removeTree($building); + throw new \RuntimeException('Не удалось активировать индекс использования медиа'); + } + + self::removeTree($old); + return self::meta(); + }); + } + + public static function usage($path) + { + $path = self::normalize($path); + $meta = self::meta(); + if ($path === '' || !$meta['available']) { + return array('checked' => false, 'path' => $path, 'use_count' => 0, 'uses' => array(), 'report' => $meta); + } + + $file = self::entryPath($path); + $data = is_file($file) ? json_decode((string) File::getContent($file), true) : array(); + if (!is_array($data) || !isset($data['path']) || $data['path'] !== $path) { + $data = array('path' => $path, 'use_count' => 0, 'uses' => array()); + } + + $data['checked'] = true; + $data['report'] = $meta; + return $data; + } + + public static function usageForPath($path, $limit = 50) + { + $path = self::normalize($path); + $abs = $path !== '' ? Model::abs($path) : ''; + if ($path === '' || !is_dir($abs)) { return self::usage($path); } + + $meta = self::meta(); + $result = array('checked' => $meta['available'], 'path' => $path, 'use_count' => 0, 'uses' => array(), 'report' => $meta); + if (!$meta['available']) { return $result; } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($abs, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + $seen = array(); + foreach ($iterator as $item) { + if (!$item->isFile() || $item->isLink()) { continue; } + $relative = '/uploads/' . ltrim(str_replace('\\', '/', substr($item->getPathname(), strlen(Model::abs(Model::ROOT)))), '/'); + $usage = self::usage($relative); + $result['use_count'] += (int) $usage['use_count']; + foreach ($usage['uses'] as $use) { + $key = md5(json_encode($use)); + if (isset($seen[$key])) { continue; } + $seen[$key] = true; + if (count($result['uses']) < max(1, (int) $limit)) { $result['uses'][] = $use; } + } + } + + return $result; + } + + public static function meta() + { + $file = self::root() . '/meta.json'; + $data = is_file($file) ? json_decode((string) File::getContent($file), true) : array(); + $available = is_array($data) && isset($data['version']) && (int) $data['version'] === self::VERSION; + return array( + 'available' => $available, + 'generated_at' => $available && isset($data['generated_at']) ? (int) $data['generated_at'] : 0, + 'generated_label' => $available && isset($data['generated_label']) ? (string) $data['generated_label'] : '', + 'used_files' => $available && isset($data['used_files']) ? (int) $data['used_files'] : 0, + ); + } + + protected static function entryPath($path) + { + $hash = sha1($path); + return self::root() . '/' . substr($hash, 0, 2) . '/' . $hash . '.json'; + } + + protected static function normalize($path) + { + $path = Model::normalize($path, ''); + return $path !== '' && Model::isAllowedPath($path) ? $path : ''; + } + + protected static function root() + { + return rtrim(BASEPATH, '/\\') . '/storage/reports/media-usage'; + } + + protected static function removeTree($path) + { + if (!is_dir($path)) { return; } + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($iterator as $item) { + $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); + } + + @rmdir($path); + } + } diff --git a/adminx/modules/Media/Model.php b/adminx/modules/Media/Model.php index 0cdd240..6ddf314 100644 --- a/adminx/modules/Media/Model.php +++ b/adminx/modules/Media/Model.php @@ -330,6 +330,7 @@ } if ($uploaded) { + foreach ($uploaded as $file) { MediaSearchIndex::put($file); } self::invalidateStats(); } @@ -378,37 +379,65 @@ self::renameWebpCompanion($path, $target); } + MediaSearchIndex::remove($path); + MediaSearchIndex::indexPath($target); self::invalidateStats(); return $target; } - public static function delete($path) + public static function trash($path) { $path = self::normalize($path, ''); - if (self::isProtectedPath($path)) { - throw new \RuntimeException('Этот путь нельзя удалить'); - } - + if (self::isProtectedPath($path)) { throw new \RuntimeException('Этот путь нельзя удалить'); } $abs = self::abs($path); - if (is_dir($abs)) { - if (!self::removeDir($abs)) { - throw new \RuntimeException('Не удалось удалить папку'); - } - - self::invalidateStats(); - return true; - } + if (!is_file($abs) && !is_dir($abs)) { throw new \RuntimeException('Файл или папка не найдены'); } + $related = array(); if (is_file($abs)) { - self::deleteFileCompanions($path, $abs); - } - - if (is_file($abs) && !File::delete($abs)) { - throw new \RuntimeException('Не удалось удалить файл'); + $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + if (in_array($extension, array('jpg', 'jpeg', 'png'), true)) { + $webp = dirname($path) . '/' . pathinfo($path, PATHINFO_FILENAME) . '.webp'; + if (is_file(self::abs($webp))) { $related[] = $webp; } + } } + $entry = MediaTrash::store($path, $related); + if ($entry['type'] === 'file') { self::clearGeneratedForFile($path, $abs); } + MediaSearchIndex::remove($path); self::invalidateStats(); - return true; + return $entry; + } + + public static function trashContents($dir) + { + $dir = self::normalize($dir, ''); + if ($dir === '' || self::isProtectedPath($dir)) { + throw new \RuntimeException('Эту папку нельзя очистить'); + } + + $abs = self::abs($dir); + if (!is_dir($abs)) { + throw new \RuntimeException('Папка не найдена'); + } + + $paths = array(); + foreach (@scandir($abs) ?: array() as $name) { + if (!self::isVisibleEntry($name)) { continue; } + $paths[] = rtrim($dir, '/') . '/' . $name; + } + + if (!$paths) { + throw new \RuntimeException('Папка уже пуста'); + } + + $entry = MediaTrash::storeContents($dir, $paths); + try { self::clearThumbnails($dir); } catch (\RuntimeException $e) { /* Производные можно пересоздать. */ } + $derivatives = $abs . DIRECTORY_SEPARATOR . '_derivatives'; + if (is_dir($derivatives)) { self::removeDir($derivatives); } + MediaSearchIndex::remove($dir); + self::invalidateStats(); + $entry['count'] = count($paths); + return $entry; } public static function clearThumbnails($dir) @@ -468,6 +497,7 @@ throw new \RuntimeException('Не удалось удалить превью'); } + ThumbnailStorage::invalidateDirectory(dirname($target)); $roots++; } diff --git a/adminx/modules/Media/assets/media.css b/adminx/modules/Media/assets/media.css index 899e921..3f8d3c1 100644 --- a/adminx/modules/Media/assets/media.css +++ b/adminx/modules/Media/assets/media.css @@ -4,6 +4,251 @@ gap: 14px; margin-bottom: 16px; } +.media-tabs { + margin-bottom: 18px; +} +.media-tabs .tab { + text-decoration: none; +} +.media-usage-panel { + margin-bottom: 18px; +} +.media-usage-panel .section-header { + margin-bottom: 12px; +} +.media-usage-card { + padding: 0; +} +.media-usage-row { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 54px; + padding: 8px 14px; + border-bottom: 1px solid var(--border-default); +} +.media-usage-row:last-child { + border-bottom: 0; +} +.media-usage-row .icon-tile { + width: 32px; + height: 32px; + background: var(--blue-100); + color: var(--blue-600); +} +.media-usage-row > div { + display: grid; + gap: 2px; + min-width: 0; +} +.media-usage-row small, +.table-subtitle { + display: block; + margin-top: 3px; + color: var(--text-muted); + font-size: 11px; + overflow-wrap: anywhere; +} +.media-trash-card { + padding: 0; +} +.media-audit-empty { + display: flex; + align-items: center; + gap: 14px; + padding: 20px; +} +.media-audit-empty h2, +.media-audit-empty p { + margin: 0; +} +.media-audit-empty h2 { + margin-bottom: 4px; + font-size: 16px; +} +.media-audit-empty p { + max-width: 760px; + color: var(--text-secondary); + font-size: 13px; + line-height: 1.5; +} +.media-audit-summary { + margin-bottom: 14px; +} +.media-audit-note, +.media-audit-header { + margin-bottom: 18px; +} +.media-audit-kinds { + display: grid; + grid-template-columns: repeat(7, minmax(130px, 1fr)); + gap: 8px; + margin-bottom: 14px; + overflow-x: auto; + scrollbar-width: thin; +} +.media-audit-kinds a { + min-width: 130px; + display: grid; + grid-template-columns: 24px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + min-height: 48px; + padding: 8px 10px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-surface); + color: var(--text-secondary); + text-decoration: none; +} +.media-audit-kinds a:hover, +.media-audit-kinds a.is-active { + border-color: var(--color-primary); + color: var(--color-primary); +} +.media-audit-kinds a.is-active { + background: var(--color-primary-soft); +} +.media-audit-kinds i { + font-size: 18px; +} +.media-audit-kinds span { + overflow: hidden; + font-size: 12px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} +.media-audit-kinds b { + color: var(--text-primary); + font-size: 13px; +} +.media-audit-card { + padding: 0; +} +.media-audit-toolbar form { + width: 100%; + display: flex; + align-items: center; + gap: 8px; +} +.media-audit-toolbar .input-wrap { + width: min(440px, 100%); +} +.media-audit-table th:nth-child(1) { + width: 42%; +} +.media-audit-table th:nth-child(2) { + width: 160px; +} +.media-audit-table th:last-child { + width: 52px; +} +.media-audit-path, +.media-usage-list { + display: grid; + gap: 3px; + min-width: 0; +} +.media-audit-path b { + overflow-wrap: anywhere; + font-size: 13px; +} +.media-audit-path small, +.media-usage-list small, +.media-usage-list span { + color: var(--text-muted); + font-size: 11px; +} +.media-usage-list a { + width: fit-content; + max-width: 100%; + color: var(--color-primary); + font-size: 12px; + overflow-wrap: anywhere; +} +.media-duplicate-list { + display: grid; +} +.media-duplicate-group { + border-bottom: 1px solid var(--border-default); +} +.media-duplicate-group:last-child { + border-bottom: 0; +} +.media-duplicate-group summary { + display: grid; + grid-template-columns: minmax(0, 1fr) auto 20px; + align-items: center; + gap: 12px; + min-height: 58px; + padding: 10px 20px; + cursor: pointer; + list-style: none; +} +.media-duplicate-group summary::-webkit-details-marker { + display: none; +} +.media-duplicate-group summary span { + display: grid; + gap: 2px; +} +.media-duplicate-group summary small { + color: var(--text-muted); + font-size: 12px; +} +.media-duplicate-group[open] summary > i { + transform: rotate(180deg); +} +.media-duplicate-group > div { + display: grid; + padding: 0 20px 12px 52px; +} +.media-duplicate-group > div a { + display: grid; + grid-template-columns: 20px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + min-height: 34px; + color: var(--text-primary); +} +.media-duplicate-group > div span { + overflow-wrap: anywhere; +} +.media-duplicate-group > div small { + color: var(--text-muted); +} +[data-media-audit-run].is-loading i { + animation: media-audit-spin 700ms linear infinite; +} +@keyframes media-audit-spin { + to { + transform: rotate(360deg); + } +} +@media (max-width: 760px) { + .media-audit-toolbar form { + align-items: stretch; + flex-direction: column; + } + .media-audit-toolbar .input-wrap, + .media-audit-toolbar .btn { + width: 100%; + } + .media-audit-table th:nth-child(2) { + width: auto; + } + .media-duplicate-group summary { + grid-template-columns: minmax(0, 1fr) 20px; + } + .media-duplicate-group summary .mono { + display: none; + } + .media-duplicate-group > div { + padding-left: 20px; + } +} .media-browser a, .media-file-layout a, .media-lightbox a, @@ -737,17 +982,57 @@ button.media-thumb { grid-template-columns: 1fr; } } - -.media-presets-list { display: flex; flex-direction: column; gap: 8px; } -.media-preset-row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 10px; background: var(--surface-2, #f6f8fb); } -.media-preset-row .media-preset-main { flex: 1; min-width: 0; } -.media-preset-row .media-preset-main small { color: var(--text-secondary, #667085); display: block; margin-top: 2px; } -.media-presets-form { margin-top: 16px; } - -.media-preset-pick { margin-bottom: 12px; } -.media-preset-pick-field { display: flex; flex-direction: column; gap: 4px; } -.media-preset-pick-field > span { font-size: 13px; color: var(--text-secondary, #667085); } -.media-preset-warn { margin-top: 8px; padding: 8px 10px; border-radius: 8px; background: var(--amber-100, #fef3c7); color: var(--amber-700, #b45309); font-size: 13px; } - -.media-preset-check { flex-direction: row; align-items: center; gap: 8px; } -.media-preset-check > span { color: var(--text-secondary, #667085); font-size: 13px; } +.media-presets-list { + display: flex; + flex-direction: column; + gap: 8px; +} +.media-preset-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-radius: 10px; + background: var(--surface-2, #f6f8fb); +} +.media-preset-row .media-preset-main { + flex: 1; + min-width: 0; +} +.media-preset-row .media-preset-main small { + color: var(--text-secondary, #667085); + display: block; + margin-top: 2px; +} +.media-presets-form { + margin-top: 16px; +} +.media-preset-pick { + margin-bottom: 12px; +} +.media-preset-pick-field { + display: flex; + flex-direction: column; + gap: 4px; +} +.media-preset-pick-field > span { + font-size: 13px; + color: var(--text-secondary, #667085); +} +.media-preset-warn { + margin-top: 8px; + padding: 8px 10px; + border-radius: 8px; + background: var(--amber-100, #fef3c7); + color: var(--amber-700, #b45309); + font-size: 13px; +} +.media-preset-check { + flex-direction: row; + align-items: center; + gap: 8px; +} +.media-preset-check > span { + color: var(--text-secondary, #667085); + font-size: 13px; +} diff --git a/adminx/modules/Media/assets/media.js b/adminx/modules/Media/assets/media.js index 056d1af..ac8c891 100644 --- a/adminx/modules/Media/assets/media.js +++ b/adminx/modules/Media/assets/media.js @@ -51,14 +51,22 @@ var folder = e.target.closest('[data-media-folder]'); var rename = e.target.closest('[data-media-rename]'); var del = e.target.closest('[data-media-delete]'); + var emptyFolder = e.target.closest('[data-media-empty-folder]'); var clearThumbs = e.target.closest('[data-media-clear-thumbs]'); var copy = e.target.closest('[data-copy]'); var webp = e.target.closest('[data-media-webp]'); var zoom = e.target.closest('[data-media-result-zoom]'); + var audit = e.target.closest('[data-media-audit-run]'); + var trashRestore = e.target.closest('[data-media-trash-restore]'); + var trashPurge = e.target.closest('[data-media-trash-purge]'); + if (audit) { self.runAudit(audit); return; } + if (trashRestore) { self.restoreTrash(trashRestore); return; } + if (trashPurge) { self.purgeTrash(trashPurge); return; } if (folder) { self.createFolder(); return; } if (rename) { self.rename(rename); return; } if (del) { self.remove(del); return; } + if (emptyFolder) { self.emptyFolder(emptyFolder); return; } if (clearThumbs) { self.clearThumbnails(clearThumbs); return; } if (webp) { self.convertWebp(webp); return; } if (zoom) { e.preventDefault(); self.openResultLarge(); return; } @@ -79,6 +87,29 @@ return Adminx.base(); }, + runAudit: function (button) { + var form = document.querySelector('[data-media-audit-form]'); + if (!form || button.disabled) { return; } + button.disabled = true; + button.classList.add('is-loading'); + button.innerHTML = 'Проверяем...'; + Adminx.Loader.show(); + Adminx.Ajax.post(form.action, new FormData(form)).then(function (payload) { + Adminx.Loader.hide(); + var result = payload.data || {}; + if (!result.success) { + button.disabled = false; + button.classList.remove('is-loading'); + } + Adminx.Ajax.handle(payload); + }).catch(function () { + Adminx.Loader.hide(); + button.disabled = false; + button.classList.remove('is-loading'); + Adminx.Toast.show('Не удалось выполнить проверку медиа', 'error'); + }); + }, + presetData: null, loadPresets: function () { @@ -268,16 +299,104 @@ var self = this; var name = button.getAttribute('data-name') || button.getAttribute('data-path') || ''; var kind = button.getAttribute('data-kind') === 'folder' ? 'папку и всё содержимое' : 'файл'; + var form = document.getElementById('mediaActionForm'); + if (!form) { return; } + var fd = new FormData(form); + fd.set('path', button.getAttribute('data-path') || ''); + Adminx.Loader.show(); + Adminx.Ajax.post(self.base() + '/media/delete-check', fd).then(function (payload) { + Adminx.Loader.hide(); + var response = payload.data || {}; + if (!response.success) { + Adminx.Toast.show(response.message || 'Не удалось проверить использование', 'error'); + return; + } + var usage = response.data && response.data.usage ? response.data.usage : { use_count: 0, uses: [] }; + var used = Number(usage.use_count || 0); + var message = used > 0 + ? '«' + name + '» используется в ' + used + ' местах. Объект будет скрыт с сайта и перенесён в корзину, откуда его можно восстановить.' + : '«' + name + '» будет перенесён в корзину и останется доступен для восстановления.'; + Adminx.Confirm.open({ + kind: used > 0 ? 'warning' : 'danger', + title: used > 0 ? 'Объект используется' : 'Удалить ' + kind + '?', + message: message, + confirmLabel: 'В корзину', + confirmClass: used > 0 ? 'btn-primary' : 'btn-danger', + onConfirm: function () { + submitAction(self.base() + '/media/delete', { + path: button.getAttribute('data-path') || '', + confirm_usage: used > 0 ? '1' : '0' + }); + } + }); + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка проверки медиа', 'error'); + }); + }, + + emptyFolder: function (button) { + var self = this; + var path = button.getAttribute('data-path') || ''; + var name = button.getAttribute('data-name') || path; + var form = document.getElementById('mediaActionForm'); + if (!form) { return; } + var fd = new FormData(form); + fd.set('path', path); + Adminx.Loader.show(); + Adminx.Ajax.post(self.base() + '/media/delete-check', fd).then(function (payload) { + Adminx.Loader.hide(); + var response = payload.data || {}; + if (!response.success) { + Adminx.Toast.show(response.message || 'Не удалось проверить содержимое папки', 'error'); + return; + } + var usage = response.data && response.data.usage ? response.data.usage : { use_count: 0 }; + var used = Number(usage.use_count || 0); + Adminx.Confirm.open({ + kind: used > 0 ? 'warning' : 'danger', + title: 'Очистить папку «' + name + '»?', + message: used > 0 + ? 'Внутри есть файлы, используемые в ' + used + ' местах. Всё содержимое будет перенесено в корзину, сама папка останется.' + : 'Все файлы и вложенные папки будут перенесены в корзину. Текущая папка останется на месте.', + confirmLabel: 'Очистить папку', + confirmClass: used > 0 ? 'btn-primary' : 'btn-danger', + onConfirm: function () { + submitAction(self.base() + '/media/empty-folder', { + path: path, + confirm_usage: used > 0 ? '1' : '0' + }); + } + }); + }).catch(function () { + Adminx.Loader.hide(); + Adminx.Toast.show('Ошибка проверки медиа', 'error'); + }); + }, + + restoreTrash: function (button) { + var self = this; Adminx.Confirm.open({ - kind: 'error', - title: 'Удалить ' + kind + '?', - message: '«' + name + '» будет удалён с диска.', - confirmLabel: 'Удалить', + kind: 'info', + title: 'Восстановить объект?', + message: '«' + (button.getAttribute('data-name') || '') + '» вернётся по исходному пути.', + confirmLabel: 'Восстановить', + onConfirm: function () { + submitAction(self.base() + '/media/trash/' + encodeURIComponent(button.getAttribute('data-media-trash-restore') || '') + '/restore', {}); + } + }); + }, + + purgeTrash: function (button) { + var self = this; + Adminx.Confirm.open({ + kind: 'danger', + title: 'Удалить окончательно?', + message: '«' + (button.getAttribute('data-name') || '') + '» нельзя будет восстановить.', + confirmLabel: 'Удалить навсегда', confirmClass: 'btn-danger', onConfirm: function () { - submitAction(self.base() + '/media/delete', { - path: button.getAttribute('data-path') || '' - }); + submitAction(self.base() + '/media/trash/' + encodeURIComponent(button.getAttribute('data-media-trash-purge') || '') + '/purge', {}); } }); }, diff --git a/adminx/modules/Media/language/en/analyzer.xml b/adminx/modules/Media/language/en/analyzer.xml new file mode 100644 index 0000000..d3c383c --- /dev/null +++ b/adminx/modules/Media/language/en/analyzer.xml @@ -0,0 +1,60 @@ + + + Media analyzer + Analyzer + Checks uploads, previews, and references without deleting anything automatically. + Media sections + Result type + Run again + Run check + The check has not been run yet + The analyzer matches physical files against documents, templates, blocks, navigation, and theme files. Nothing is deleted or changed. + Files + No references found + Duplicate groups + Missing original + Report from + “No references found” only means that no explicit path exists in the database or source files. The analyzer cannot recognize dynamically assembled paths. + Results + Files and references + Select an issue type, then open the file or its usage location. + Unused + Duplicates + Missing originals + Missing previews + Large files + Invalid paths + Usage map + Files with no references found + Files with identical content + References and previews without originals + Images without generated previews + Large files and images + Invalid references + File usage map + Previews are generated on demand; this state is not an error. + Potentially duplicated: + of data. + Threshold: + or image side from + Records found: + Path or source + identical files + each · recoverable up to + No duplicate files found + File or path + Usage + Preview: + review manually + original not found + not generated + invalid path + used + no references + and + No explicit references + Open file + No issues found for this filter + Checking... + Could not complete the media check + diff --git a/adminx/modules/Media/language/en/improvements.xml b/adminx/modules/Media/language/en/improvements.xml new file mode 100644 index 0000000..184c735 --- /dev/null +++ b/adminx/modules/Media/language/en/improvements.xml @@ -0,0 +1,26 @@ + + + Where this file is used + Data from the latest media analyzer run. + The analyzer has not run yet. + Run media check + to view references and enable deletion. + references + No explicit references found. + Checked + Dynamically composed paths may not be detected. + Open source + Media trash + Files are outside the public directory and can be restored. + Deleted files and folders + Restore returns an item to its original path. Permanent deletion cannot be undone. + Media trash is empty + Restore + Delete permanently + Move to trash + Failed to check usage + Media check failed + Restore item? + Delete permanently? + Delete forever + diff --git a/adminx/modules/Media/language/ru/analyzer.xml b/adminx/modules/Media/language/ru/analyzer.xml new file mode 100644 index 0000000..6b2fb87 --- /dev/null +++ b/adminx/modules/Media/language/ru/analyzer.xml @@ -0,0 +1,60 @@ + + + Анализатор медиа + Анализатор + Проверка файлов, превью и ссылок на uploads без автоматического удаления. + Разделы медиа + Тип результата + Проверить заново + Запустить проверку + Проверка ещё не запускалась + Анализатор сопоставит физические файлы с документами, шаблонами, блоками, навигацией и файлами темы. Ничего не будет удалено или изменено. + Файлов + Без найденных связей + Групп дублей + Нет оригинала + Отчёт от + «Без найденных связей» означает только отсутствие явного пути в базе и исходниках: динамически составленные пути анализатор распознать не может. + Результаты + Проверка файлов и связей + Выберите тип проблемы, затем откройте файл или место его использования. + Не используются + Одинаковые + Нет оригиналов + Нет превью + Большие + Неверные пути + Карта использования + Файлы без найденных связей + Файлы с одинаковым содержимым + Ссылки и превью без оригинала + Изображения без созданных превью + Большие файлы и изображения + Некорректные ссылки + Карта использования файлов + Превью создаются по требованию; это состояние не является ошибкой. + Потенциально повторяется + данных. + Порог: + или сторона изображения от + Найдено записей: + Путь или источник + одинаковых файла + каждый · можно освободить до + Одинаковых файлов не найдено + Файл или путь + Использование + Превью: + проверить вручную + оригинал не найден + не прогрето + неверный путь + используется + связей нет + и ещё + Явных ссылок нет + Открыть файл + Для этого фильтра проблем не найдено + Проверяем... + Не удалось выполнить проверку медиа + diff --git a/adminx/modules/Media/language/ru/improvements.xml b/adminx/modules/Media/language/ru/improvements.xml new file mode 100644 index 0000000..a17674c --- /dev/null +++ b/adminx/modules/Media/language/ru/improvements.xml @@ -0,0 +1,26 @@ + + + Где используется файл + Данные последней проверки анализатора медиа. + Анализ ещё не запускался. + Проверьте медиа + чтобы увидеть связи и разрешить удаление. + связей + Явных ссылок не найдено. + Проверка выполнена + Динамически составленные пути могут не распознаваться. + Открыть источник + Корзина медиа + Файлы находятся вне публичного каталога и могут быть восстановлены. + Удалённые файлы и папки + Восстановление возвращает объект по исходному пути. Окончательное удаление необратимо. + Корзина медиа пуста + Восстановить + Удалить окончательно + В корзину + Не удалось проверить использование + Ошибка проверки медиа + Восстановить объект? + Удалить окончательно? + Удалить навсегда + diff --git a/adminx/modules/Media/migrations/002_media_search_index.sql b/adminx/modules/Media/migrations/002_media_search_index.sql new file mode 100644 index 0000000..d59fa58 --- /dev/null +++ b/adminx/modules/Media/migrations/002_media_search_index.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS `{{prefix}}_media_search_index` ( + `path_hash` CHAR(40) CHARACTER SET ascii NOT NULL, + `path` TEXT NOT NULL, + `name` VARCHAR(190) NOT NULL DEFAULT '', + `extension` VARCHAR(20) CHARACTER SET ascii NOT NULL DEFAULT '', + `is_image` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0, + `size` BIGINT UNSIGNED NOT NULL DEFAULT 0, + `modified_at` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`path_hash`), + KEY `idx_modified` (`modified_at`), + KEY `idx_type` (`is_image`,`extension`) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; diff --git a/adminx/modules/Media/module.php b/adminx/modules/Media/module.php index 0664041..dcea04f 100644 --- a/adminx/modules/Media/module.php +++ b/adminx/modules/Media/module.php @@ -14,10 +14,12 @@ defined('BASEPATH') || die('Direct access to this location is not allowed.'); + use App\Adminx\Media\GlobalSearchProvider; + return array( 'code' => 'media', 'name' => 'Медиа', - 'version' => '0.2.3', + 'version' => '0.6.2', 'permissions' => array( 'key' => 'media', @@ -58,10 +60,16 @@ 'migrations' => array( array('id' => '001_image_presets', 'file' => 'migrations/001_image_presets.sql'), + array('id' => '002_media_search_index', 'file' => 'migrations/002_media_search_index.sql'), ), 'routes' => array( array('GET', '/media', array(\App\Adminx\Media\Controller::class, 'index')), + array('POST', '/media/saved-views', array(\App\Adminx\Media\Controller::class, 'saveSavedView')), + array('POST', '/media/saved-views/{id}/delete', array(\App\Adminx\Media\Controller::class, 'deleteSavedView')), + array('GET', '/media/audit', array(\App\Adminx\Media\Controller::class, 'audit'), array('permission' => 'view_media')), + array('GET', '/media/trash', array(\App\Adminx\Media\Controller::class, 'trash'), array('permission' => 'manage_media')), + array('POST', '/media/audit/run', array(\App\Adminx\Media\Controller::class, 'runAudit'), array('permission' => 'manage_media')), array('GET', '/media/presets', array(\App\Adminx\Media\Controller::class, 'presets')), array('POST', '/media/presets', array(\App\Adminx\Media\Controller::class, 'storePreset'), array('permission' => 'manage_media')), array('POST', '/media/presets/{id}', array(\App\Adminx\Media\Controller::class, 'updatePreset'), array('permission' => 'manage_media')), @@ -73,6 +81,10 @@ array('POST', '/media/folders', array(\App\Adminx\Media\Controller::class, 'createFolder')), array('POST', '/media/rename', array(\App\Adminx\Media\Controller::class, 'rename')), array('POST', '/media/delete', array(\App\Adminx\Media\Controller::class, 'delete')), + array('POST', '/media/delete-check', array(\App\Adminx\Media\Controller::class, 'deleteCheck')), + array('POST', '/media/empty-folder', array(\App\Adminx\Media\Controller::class, 'emptyFolder'), array('permission' => 'manage_media')), + array('POST', '/media/trash/{token}/restore', array(\App\Adminx\Media\Controller::class, 'restoreTrash'), array('permission' => 'manage_media')), + array('POST', '/media/trash/{token}/purge', array(\App\Adminx\Media\Controller::class, 'purgeTrash'), array('permission' => 'manage_media')), array('POST', '/media/clear-thumbnails', array(\App\Adminx\Media\Controller::class, 'clearThumbnails')), array('POST', '/media/transform', array(\App\Adminx\Media\Controller::class, 'transform')), array('POST', '/media/preview', array(\App\Adminx\Media\Controller::class, 'preview')), @@ -83,4 +95,10 @@ 'view_globals' => array( 'module_code' => 'media', ), + 'admin_extension' => array( + 'url' => '/media', + 'feature' => 'Файлы, изображения и превью', + 'icon' => 'ti ti-photo', + 'search' => array('code' => 'media', 'provider' => array(GlobalSearchProvider::class, 'search'), 'permission' => 'view_media', 'priority' => 35, 'limit' => 8), + ), ); diff --git a/adminx/modules/Media/view/audit.twig b/adminx/modules/Media/view/audit.twig new file mode 100644 index 0000000..efbede2 --- /dev/null +++ b/adminx/modules/Media/view/audit.twig @@ -0,0 +1,194 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Анализатор медиа{% endblock %} +{% block content %} + + + + + + + +{% if not report %} +
+ +
+

Проверка ещё не запускалась

+

Анализатор сопоставит физические файлы с документами, шаблонами, блоками, навигацией и файлами темы. Ничего не будет удалено или изменено.

+
+
+{% else %} +
+
{{ report.summary.files }}
Файлов
+
{{ report.summary.unused }}
Без найденных связей
+
{{ report.summary.duplicate_groups }}
Групп дублей
+
{{ report.summary.missing_originals }}
Нет оригинала
+
+ +
+ +
+ Отчёт от {{ report.generated_label }} +

Проверено за {{ report.duration }} сек. «Без найденных связей» означает только отсутствие явного пути в базе и исходниках: динамически составленные пути анализатор распознать не может.

+
+
+ +
+
+
+
Результаты
+

Проверка файлов и связей

+

Выберите тип проблемы, затем откройте файл или место его использования.

+
+
+ + + +
+
+
+ +
+

+ {% if kind == 'unused' %}Файлы без найденных связей + {% elseif kind == 'duplicates' %}Файлы с одинаковым содержимым + {% elseif kind == 'missing_originals' %}Ссылки и превью без оригинала + {% elseif kind == 'missing_previews' %}Изображения без созданных превью + {% elseif kind == 'large' %}Большие файлы и изображения + {% elseif kind == 'invalid' %}Некорректные ссылки + {% else %}Карта использования файлов{% endif %} +

+

+ {% if kind == 'missing_previews' %}Превью создаются по требованию; это состояние не является ошибкой. + {% elseif kind == 'duplicates' %}Потенциально повторяется {{ report.summary.duplicate_size }} данных. + {% elseif kind == 'large' %}Порог: {{ report.thresholds.large_file }} или сторона изображения от {{ report.thresholds.large_side }}. + {% else %}Найдено записей: {{ total }}.{% endif %} +

+
+
+ {{ total }} +
+ + {% if kind != 'duplicates' %} +
+
+ +
+ + {% if filters.q %}Сбросить{% endif %} +
+
+ {% endif %} + + {% if kind == 'duplicates' %} +
+ {% for group in rows %} +
+ + {{ group.count }} одинаковых файла{{ group.size_label }} каждый · можно освободить до {{ group.recoverable_label }} + {{ group.hash_short }} + + +
+ {% for file in group.files %} + {{ file.path }}{{ file.dimensions ?: file.size_label }} + {% endfor %} +
+
+ {% else %} +
Одинаковых файлов не найдено
+ {% endfor %} +
+ {% else %} +
+ + + + {% for row in rows %} + + + + + + + {% else %} + + {% endfor %} + +
Файл или путьСостояниеИспользование
+
{{ row.path }} + {% if row.size_label is defined %}{{ row.size_label }}{% if row.dimensions %} · {{ row.dimensions }}{% endif %}{% endif %} + {% if row.preview is defined %}Превью: {{ row.preview }}{% endif %} +
+
+ {% if kind == 'unused' %}проверить вручную + {% elseif kind == 'missing_originals' %}оригинал не найден + {% elseif kind == 'missing_previews' %}не прогрето + {% elseif kind == 'large' %}{{ row.size_label }} + {% elseif kind == 'invalid' %}неверный путь + {% elseif row.use_count > 0 %}используется + {% else %}связей нет{% endif %} + + {% set uses = row.uses|default([]) %} + {% if uses %} +
+ {% for use in uses|slice(0, 4) %} + {% if use.link %}{{ use.source }} #{{ use.source_id }}{% else %}{{ use.source }} · {{ use.column }}{% endif %} + {% endfor %} + {% if uses|length > 4 %}и ещё {{ uses|length - 4 }}{% endif %} +
+ {% else %}Явных ссылок нет{% endif %} +
+ {% if row.path starts with '/uploads/' and kind != 'missing_originals' and kind != 'invalid' %} + + {% endif %} +
Для этого фильтра проблем не найдено
+
+ {% endif %} + + {% if pages > 1 %} + + {% endif %} +
+{% endif %} + +{% if can_manage %} +
+ +
+{% endif %} + +{% endblock %} diff --git a/adminx/modules/Media/view/file.twig b/adminx/modules/Media/view/file.twig index 80d0f63..a219d6b 100644 --- a/adminx/modules/Media/view/file.twig +++ b/adminx/modules/Media/view/file.twig @@ -22,6 +22,33 @@
+
+
+
+
+
Связи
+

Где используется файл

+

Данные последней проверки анализатора медиа.

+
+ {% if usage.checked %}{{ usage.use_count }} связей{% endif %} +
+ {% if not usage.checked %} +
Общий анализ ещё не запускался. Проверьте медиа, чтобы увидеть полную карту связей. Перед удалением выбранный путь будет проверен отдельно.
+ {% elseif usage.uses %} +
+ {% for use in usage.uses %} +
+ +
{{ use.source }}{% if use.source_id %} #{{ use.source_id }}{% endif %}{{ use.column }}
+ {% if use.link %}{% endif %} +
+ {% endfor %} +
+ {% else %} +
Явных ссылок не найдено. Проверка выполнена {{ usage.report.generated_label }}. Динамически составленные пути могут не распознаваться.
+ {% endif %} +
+
diff --git a/adminx/modules/Media/view/index.twig b/adminx/modules/Media/view/index.twig index ba7791f..001e7d3 100644 --- a/adminx/modules/Media/view/index.twig +++ b/adminx/modules/Media/view/index.twig @@ -24,6 +24,12 @@
+ +
@@ -128,7 +137,8 @@ {% endif %} -
+ {% set saved_views_form = '.media-filter' %}{% set saved_views_save_url = ADMINX_BASE ~ '/media/saved-views' %}{% set saved_views_delete_url = ADMINX_BASE ~ '/media/saved-views/__ID__/delete' %}{% set saved_views_fields = ['dir','q','type','view'] %}{% include '@adminx/_saved_views.twig' %} +
diff --git a/adminx/modules/Media/view/trash.twig b/adminx/modules/Media/view/trash.twig new file mode 100644 index 0000000..cafb871 --- /dev/null +++ b/adminx/modules/Media/view/trash.twig @@ -0,0 +1,21 @@ +{% extends '@adminx/main.twig' %} +{% block title %}Корзина медиа{% endblock %} +{% block content %} + + + +
Медиа

Удалённые файлы и папки

Восстановление возвращает объект по исходному пути. Окончательное удаление необратимо.

+
+
+ {% for item in items %}{% else %}{% endfor %} +
ОбъектТипУдалён
{{ item.name }}{{ item.path }}{{ item.type == 'folder' ? 'Папка' : 'Файл' }}{{ item.deleted_label }}
+ + +
Корзина медиа пуста
+
+
+{% endblock %} diff --git a/adminx/modules/Modules/ModuleRepository.php b/adminx/modules/Modules/ModuleRepository.php index 49907be..837ce68 100644 --- a/adminx/modules/Modules/ModuleRepository.php +++ b/adminx/modules/Modules/ModuleRepository.php @@ -99,6 +99,25 @@ } } + /** Каталог только из кеша: для колокольчика, без сетевого запроса. */ + public static function cachedCatalog() + { + $settings = self::settings(); + if (!$settings['enabled'] || $settings['url'] === '' || $settings['public_key'] === '') { return null; } + $key = 'module-repository:' . hash('sha256', $settings['url'] . "\0" . $settings['public_key']); + try { + $catalog = Cache::get($key); + } catch (\Throwable $e) { + return null; + } + + if (!is_array($catalog) || !isset($catalog['items'])) { return null; } + $catalog['enabled'] = true; + $catalog['configured'] = true; + $catalog['error'] = ''; + return self::withLocalState($catalog); + } + public static function install($code) { $code = strtolower(trim((string) $code)); diff --git a/adminx/modules/Modules/NativeModuleAdapter.php b/adminx/modules/Modules/NativeModuleAdapter.php index 7896df6..fc963a4 100644 --- a/adminx/modules/Modules/NativeModuleAdapter.php +++ b/adminx/modules/Modules/NativeModuleAdapter.php @@ -29,6 +29,10 @@ { $items = array(); foreach (ModuleExtensions::catalog() as $extension) { + if (empty($extension['module']['managed'])) { + continue; + } + $items[] = self::normalize($extension); } @@ -81,7 +85,7 @@ public static function one($code) { foreach (ModuleExtensions::catalog() as $extension) { - if ($extension['code'] === (string) $code) { + if ($extension['code'] === (string) $code && !empty($extension['module']['managed'])) { return self::normalize($extension); } } diff --git a/adminx/modules/Modules/NotificationProvider.php b/adminx/modules/Modules/NotificationProvider.php new file mode 100644 index 0000000..9d2c3f0 --- /dev/null +++ b/adminx/modules/Modules/NotificationProvider.php @@ -0,0 +1,54 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Modules; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + /** Установленные модули, для которых в каталоге лежит версия новее. */ + class NotificationProvider + { + public static function items() + { + $count = self::updatable(); + if ($count < 1) { return array(); } + + return array(array( + 'key' => 'module_updates', + 'icon' => 'ti ti-package-import', + 'bg' => '#ede9fe', + 'fg' => '#6d28d9', + 'count' => $count, + 'title' => 'Обновления модулей', + 'text' => 'Готовы к установке: ' . $count, + 'url' => '/modules?filter=update', + )); + } + + protected static function updatable() + { + try { + $catalog = ModuleRepository::cachedCatalog(); + if (!is_array($catalog) || !empty($catalog['error']) || empty($catalog['items'])) { return 0; } + $count = 0; + foreach ($catalog['items'] as $item) { + if (isset($item['status']) && (string) $item['status'] === 'update') { $count++; } + } + + return $count; + } catch (\Throwable $e) { + return 0; + } + } + } diff --git a/adminx/modules/Modules/module.php b/adminx/modules/Modules/module.php index c13a460..4462e3f 100644 --- a/adminx/modules/Modules/module.php +++ b/adminx/modules/Modules/module.php @@ -25,7 +25,7 @@ return array( 'code' => 'modules', 'name' => 'Модули', - 'version' => '0.1.3', + 'version' => '0.2.0', 'settings' => array( 'repository_enabled' => array('label' => 'Удалённый каталог модулей', 'type' => 'bool', 'default' => false, 'group' => 'modules_repository'), @@ -33,6 +33,7 @@ 'repository_public_key' => array('label' => 'Публичный ключ каталога', 'type' => 'code', 'default' => '', 'group' => 'modules_repository', 'sensitive' => true), ), + 'notifications' => array('provider' => array(\App\Adminx\Modules\NotificationProvider::class, 'items'), 'permission' => 'view_modules', 'sort_order' => 46), 'permissions' => array( 'key' => 'modules', 'items' => array( diff --git a/adminx/modules/Navigation/Controller.php b/adminx/modules/Navigation/Controller.php index 2867b5f..3644cbb 100644 --- a/adminx/modules/Navigation/Controller.php +++ b/adminx/modules/Navigation/Controller.php @@ -18,6 +18,7 @@ use App\Adminx\Support\CodeEditor; use App\Common\AdminAssets; + use App\Common\Auth; use App\Common\Controller as BaseController; use App\Common\Permission; use App\Helpers\Request; @@ -114,6 +115,7 @@ } $id = Model::save(0, Request::postAll()); + Revisions::capture($id, 'create', Auth::id()); return $this->success('Навигация создана', array('data' => array('id' => $id), 'redirect' => $this->base() . '/navigation')); } @@ -133,7 +135,9 @@ return $this->error('Проверьте поля формы', $errors); } + Revisions::capture($id, 'baseline', Auth::id(), 'Снимок до изменения'); Model::save($id, Request::postAll()); + Revisions::capture($id, 'update', Auth::id()); return $this->success('Навигация сохранена', array('redirect' => $this->base() . '/navigation')); } @@ -144,7 +148,8 @@ } try { - Model::copy(isset($params['id']) ? (int) $params['id'] : 0, Request::postStr('title', '')); + $id = Model::copy(isset($params['id']) ? (int) $params['id'] : 0, Request::postStr('title', '')); + Revisions::capture($id, 'create', Auth::id(), 'Создано копированием'); } catch (\Throwable $e) { return $this->error($e->getMessage(), array(), 422); } @@ -152,6 +157,56 @@ return $this->success('Копия создана', array('redirect' => $this->base() . '/navigation')); } + public function revisions(array $params = array()) + { + $id = isset($params['id']) ? (int) $params['id'] : 0; + $item = Model::one($id); + if (!$item) { return $this->error('Навигация не найдена', array(), 404); } + + return $this->success('', array('data' => array( + 'navigation' => array('id' => $id, 'title' => $item['title']), + 'revisions' => Revisions::listing($id), + ))); + } + + public function revision(array $params = array()) + { + $revision = Revisions::one(isset($params['revision']) ? (int) $params['revision'] : 0); + return $revision + ? $this->success('', array('data' => $revision)) + : $this->error('Ревизия не найдена', array(), 404); + } + + public function restoreRevision(array $params = array()) + { + if (($err = $this->guard()) !== null) { return $err; } + try { + $id = Revisions::restore(isset($params['revision']) ? (int) $params['revision'] : 0, Auth::id()); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Навигация восстановлена из ревизии', array( + 'data' => array('id' => $id), + 'redirect' => $this->base() . '/navigation', + )); + } + + public function lint(array $params = array()) + { + if (($err = $this->guard()) !== null) { return $err; } + if (!class_exists('App\\Adminx\\Templates\\Syntax')) { + return $this->error('Проверка недоступна', array(), 501); + } + + $result = \App\Adminx\Templates\Syntax::check((string) Request::post('code', '')); + if (!empty($result['ok'])) { + return $this->success($result['message'], array('data' => array('checked' => !empty($result['checked'])))); + } + + return $this->error($result['message'], array('code' => $result['output']), 422); + } + public function clearCache(array $params = array()) { if (($err = $this->guard()) !== null) { diff --git a/adminx/modules/Navigation/Model.php b/adminx/modules/Navigation/Model.php index 206e1f3..751d2f2 100644 --- a/adminx/modules/Navigation/Model.php +++ b/adminx/modules/Navigation/Model.php @@ -78,6 +78,34 @@ return $row ? $row : null; } + public static function snapshot($id) + { + $row = self::raw($id); + if (!$row) { return array(); } + unset($row['navigation_id']); + return $row; + } + + public static function applySnapshot($id, array $snapshot) + { + $current = self::raw($id); + if (!$current) { throw new \RuntimeException('Навигация не найдена'); } + $data = array(); + foreach (array_merge(array('alias', 'title', 'user_group', 'expand_ext'), self::templateFields()) as $key) { + if (array_key_exists($key, $snapshot)) { $data[$key] = $snapshot[$key]; } + } + + if (!$data) { throw new \RuntimeException('Ревизия не содержит данных навигации'); } + if (isset($data['alias']) && self::aliasExists((string) $data['alias'], (int) $id)) { + throw new \RuntimeException('Алиас из ревизии уже занят другой навигацией'); + } + + DB::Update(self::table(), $data, 'navigation_id=%i', (int) $id); + self::clearCache((int) $id, isset($current['alias']) ? $current['alias'] : ''); + self::clearCache((int) $id, isset($data['alias']) ? $data['alias'] : ''); + return (int) $id; + } + public static function itemsTree($navigationId) { $rows = DB::query( diff --git a/adminx/modules/Navigation/Revisions.php b/adminx/modules/Navigation/Revisions.php new file mode 100644 index 0000000..831526a --- /dev/null +++ b/adminx/modules/Navigation/Revisions.php @@ -0,0 +1,75 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Navigation; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Content\ContentTables; + use App\Content\Revisions\JsonRevisionStore; + + class Revisions + { + protected static $store; + + public static function table() { return ContentTables::table('navigation_revisions'); } + public static function labels() { return array('create' => array('label' => 'Создание', 'badge' => 'badge-green'), 'baseline' => array('label' => 'До изменения', 'badge' => 'badge-gray'), 'update' => array('label' => 'Сохранение', 'badge' => 'badge-blue'), 'restore' => array('label' => 'Восстановление', 'badge' => 'badge-cyan'), 'restore_backup' => array('label' => 'Перед восстановлением', 'badge' => 'badge-gray')); } + + public static function listing($navigationId) + { + $out = array(); + foreach (self::store()->listing($navigationId, 80) as $row) { $out[] = self::format($row, false); } + return $out; + } + + public static function one($id) + { + $row = self::store()->one($id); + return $row ? self::format($row, true) : null; + } + + public static function capture($navigationId, $action, $authorId = 0, $comment = '', array $snapshot = null, $sourceRevisionId = 0) + { + $snapshot = $snapshot === null ? Model::snapshot($navigationId) : $snapshot; + if (!$snapshot) { return 0; } + return self::store()->capture((int) $navigationId, $action, $snapshot, (int) $authorId, $comment, array('source_revision_id' => (int) $sourceRevisionId), true); + } + + public static function restore($revisionId, $authorId = 0) + { + $revision = self::one($revisionId); + if (!$revision || empty($revision['snapshot'])) { throw new \RuntimeException('Ревизия не найдена'); } + $id = (int) $revision['navigation_id']; + self::capture($id, 'restore_backup', $authorId, 'Снимок перед восстановлением', null, $revisionId); + Model::applySnapshot($id, $revision['snapshot']); + self::capture($id, 'restore', $authorId, 'Восстановлено из ревизии #' . (int) $revisionId, null, $revisionId); + return $id; + } + + public static function delete($revisionId) { return self::store()->delete($revisionId); } + public static function deleteFor($navigationId) { return self::store()->deleteFor($navigationId); } + + protected static function format(array $row, $withSnapshot) + { + $row = self::store()->formatRow($row, $withSnapshot); + if ($withSnapshot && is_array($row['snapshot'])) { $row['comparison'] = JsonRevisionStore::compareSnapshots(Model::snapshot($row['navigation_id']), $row['snapshot']); } + return $row; + } + + protected static function store() + { + if (!self::$store) { self::$store = new JsonRevisionStore(self::table(), 'navigation_id', self::labels()); } + return self::$store; + } + } diff --git a/adminx/modules/Navigation/assets/navigation.css b/adminx/modules/Navigation/assets/navigation.css index fa2db1e..26d0d24 100644 --- a/adminx/modules/Navigation/assets/navigation.css +++ b/adminx/modules/Navigation/assets/navigation.css @@ -1072,7 +1072,77 @@ .navigation-alias-state.is-pending { color: var(--amber-600); } +.navigation-revisions-layout { + display: grid; + grid-template-columns: minmax(250px, 0.75fr) minmax(0, 1.25fr); + gap: 16px; +} +.navigation-revisions-list, +.navigation-revision-detail { + min-width: 0; +} +.navigation-revision-row { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px; + border: 0; + border-bottom: 1px solid var(--border); + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} +.navigation-revision-row:hover, +.navigation-revision-row.is-active { + background: var(--surface-hover); +} +.navigation-revision-row > span:nth-child(2) { + display: grid; + gap: 3px; + min-width: 0; +} +.navigation-revision-row small, +.navigation-revision-detail-head p { + margin: 0; + color: var(--text-secondary); +} +.navigation-revision-row em { + font-style: normal; +} +.navigation-revision-detail-head, +.navigation-revision-change > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.navigation-revision-detail-head { + margin-bottom: 12px; +} +.navigation-revision-detail-head h4 { + margin: 0 0 4px; +} +.navigation-revision-change { + padding: 12px 0; + border-top: 1px solid var(--border); +} +.navigation-revision-change pre { + max-height: 180px; + margin: 8px 0 0; + padding: 10px; + overflow: auto; + border-radius: 6px; + background: var(--surface-muted); + white-space: pre-wrap; + word-break: break-word; +} @media (max-width: 980px) { + .navigation-revisions-layout { + grid-template-columns: 1fr; + } .navigation-template-layout { grid-template-columns: 1fr; } diff --git a/adminx/modules/Navigation/assets/navigation.js b/adminx/modules/Navigation/assets/navigation.js index 9940fa0..5420ad1 100644 --- a/adminx/modules/Navigation/assets/navigation.js +++ b/adminx/modules/Navigation/assets/navigation.js @@ -27,6 +27,8 @@ dragOrderSnapshot: '', orderSaving: false, activeTemplateEditor: null, + currentRevisionNavigationId: 0, + currentRevisionId: 0, init: function () { this.form = document.getElementById('navigationForm'); @@ -42,6 +44,13 @@ var items = e.target.closest('[data-navigation-items]'); if (items) { self.openItems(items.closest('[data-navigation-row]')); } + var revisions = e.target.closest('[data-navigation-revisions]'); + if (revisions) { self.openRevisions(revisions.closest('[data-navigation-row]')); } + var revisionOpen = e.target.closest('[data-navigation-revision-open]'); + if (revisionOpen) { self.loadRevision(revisionOpen.getAttribute('data-navigation-revision-open')); } + if (e.target.closest('[data-navigation-revision-restore]')) { self.restoreRevision(); } + var lint = e.target.closest('[data-navigation-lint]'); + if (lint) { self.lintTemplate(lint.closest('.navigation-code-field')); } if (e.target.closest('[data-navigation-item-new]')) { self.fillItemNew(); } if (e.target.closest('[data-navigation-item-reset]')) { self.reloadCurrentItem(); } @@ -291,6 +300,110 @@ .finally(function () { Adminx.Loader.hide(); }); }, + lintTemplate: function (field) { + if (!field) { return; } + var textarea = field.querySelector('textarea'); + if (!textarea) { return; } + if (textarea._adminxCodeMirror) { textarea._adminxCodeMirror.save(); } + var data = new FormData(); + data.set('_csrf', this.csrf()); + data.set('code', textarea.value || ''); + Adminx.Loader.show(); + this.post(this.base() + '/navigation/lint', data).then(function (payload) { + Adminx.Loader.hide(); + Adminx.Toast.show(payload.message || 'Синтаксис без ошибок', 'success'); + }).catch(function (error) { + Adminx.Loader.hide(); + Adminx.Toast.show(error.message || 'В шаблоне есть ошибка', 'error'); + }); + }, + + openRevisions: function (row) { + if (!row) { return; } + this.currentRevisionNavigationId = parseInt(row.getAttribute('data-id'), 10) || 0; + this.currentRevisionId = 0; + var title = document.getElementById('navigationRevisionsTitle'); + var meta = document.querySelector('[data-navigation-revisions-meta]'); + var list = document.querySelector('[data-navigation-revisions-list]'); + var detail = document.querySelector('[data-navigation-revision-detail]'); + var restore = document.querySelector('[data-navigation-revision-restore]'); + if (title) { title.textContent = 'Ревизии: ' + (row.getAttribute('data-title') || ('#' + this.currentRevisionNavigationId)); } + if (meta) { meta.textContent = 'Загрузка истории шаблонов меню...'; } + if (list) { list.innerHTML = '
Загрузка...
'; } + if (detail) { detail.innerHTML = '
Выберите ревизию слева.
'; } + if (restore) { restore.disabled = true; } + if (Adminx.Drawer) { Adminx.Drawer.open('navigationRevisionsDrawer'); } + this.refreshRevisions(); + }, + + refreshRevisions: function () { + var self = this; + this.jsonRequest(this.base() + '/navigation/' + this.currentRevisionNavigationId + '/revisions').then(function (payload) { + self.renderRevisions((payload.data && payload.data.revisions) || []); + }).catch(function (error) { Adminx.Toast.show(error.message || 'Не удалось загрузить ревизии', 'error'); }); + }, + + renderRevisions: function (items) { + var self = this; + var list = document.querySelector('[data-navigation-revisions-list]'); + var meta = document.querySelector('[data-navigation-revisions-meta]'); + if (meta) { meta.textContent = items.length ? ('Снимков: ' + items.length) : 'История пока пуста'; } + if (!list) { return; } + if (!items.length) { list.innerHTML = '
Первый снимок появится после сохранения навигации.
'; return; } + list.innerHTML = items.map(function (item) { + return ''; + }).join(''); + this.loadRevision(items[0].id); + }, + + loadRevision: function (id) { + var self = this; + id = parseInt(id, 10) || 0; + if (!id) { return; } + this.jsonRequest(this.base() + '/navigation/revisions/' + id).then(function (payload) { + self.showRevision(payload.data || {}); + }).catch(function (error) { Adminx.Toast.show(error.message || 'Не удалось загрузить ревизию', 'error'); }); + }, + + showRevision: function (item) { + this.currentRevisionId = parseInt(item.id, 10) || 0; + document.querySelectorAll('[data-navigation-revision-open]').forEach(function (row) { + row.classList.toggle('is-active', row.getAttribute('data-navigation-revision-open') === String(item.id)); + }); + var labels = { alias: 'Алиас', title: 'Название', user_group: 'Группы доступа', expand_ext: 'Разворачивание', begin: 'Обёртка: начало', end: 'Обёртка: конец', level1_begin: 'Уровень 1: начало', level1: 'Уровень 1: пункт', level1_active: 'Уровень 1: активный', level1_end: 'Уровень 1: конец', level2_begin: 'Уровень 2: начало', level2: 'Уровень 2: пункт', level2_active: 'Уровень 2: активный', level2_end: 'Уровень 2: конец', level3_begin: 'Уровень 3: начало', level3: 'Уровень 3: пункт', level3_active: 'Уровень 3: активный', level3_end: 'Уровень 3: конец' }; + var comparison = item.comparison || {}; + var entries = Object.keys(comparison.items || {}).filter(function (key) { return comparison.items[key].changed; }); + var detail = document.querySelector('[data-navigation-revision-detail]'); + if (detail) { + detail.innerHTML = '' + (entries.length ? entries.map(function (key) { + var value = comparison.items[key] || {}; + return ''; + }).join('') : '
Эта ревизия совпадает с текущей навигацией.
'); + } + var restore = document.querySelector('[data-navigation-revision-restore]'); + if (restore) { restore.disabled = !this.currentRevisionId || !entries.length; } + function selfEsc(value) { return Adminx.Navigation.esc(value); } + }, + + restoreRevision: function () { + if (!this.currentRevisionId) { return; } + var self = this; + Adminx.Confirm.open({ + kind: 'warning', title: 'Восстановить навигацию?', message: 'Текущее состояние будет сохранено отдельной резервной ревизией.', confirmLabel: 'Восстановить', + onConfirm: function () { + var data = new FormData(); data.set('_csrf', self.csrf()); + self.post(self.base() + '/navigation/revisions/' + self.currentRevisionId + '/restore', data).then(function (payload) { + Adminx.Toast.show(payload.message || 'Навигация восстановлена', 'success'); + window.location.reload(); + }).catch(function (error) { Adminx.Toast.show(error.message || 'Не удалось восстановить навигацию', 'error'); }); + } + }); + }, + + jsonRequest: function (url) { + return fetch(url, { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }).then(this.json); + }, + setGroupAccess: function (value) { var selected = String(value || '').split(',').map(function (id) { return String(parseInt(id, 10) || ''); }); document.querySelectorAll('[data-navigation-group-option]').forEach(function (option) { diff --git a/adminx/modules/Navigation/migrations/002_navigation_revisions.sql b/adminx/modules/Navigation/migrations/002_navigation_revisions.sql new file mode 100644 index 0000000..d156916 --- /dev/null +++ b/adminx/modules/Navigation/migrations/002_navigation_revisions.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS `{{prefix}}_navigation_revisions` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `navigation_id` SMALLINT UNSIGNED NOT NULL DEFAULT 0, + `action` VARCHAR(32) NOT NULL DEFAULT 'update', + `snapshot_hash` CHAR(40) NOT NULL DEFAULT '', + `snapshot_json` LONGTEXT NOT NULL, + `comment` VARCHAR(255) NOT NULL DEFAULT '', + `author_id` INT UNSIGNED NOT NULL DEFAULT 0, + `author_name` VARCHAR(255) NOT NULL DEFAULT '', + `source_revision_id` INT UNSIGNED NOT NULL DEFAULT 0, + `created_at` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_navigation_created` (`navigation_id`,`created_at`), + KEY `idx_snapshot_hash` (`snapshot_hash`) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; diff --git a/adminx/modules/Navigation/module.php b/adminx/modules/Navigation/module.php index 9e8f574..fda014b 100644 --- a/adminx/modules/Navigation/module.php +++ b/adminx/modules/Navigation/module.php @@ -17,7 +17,7 @@ return array( 'code' => 'navigation', 'name' => 'Навигация', - 'version' => '0.1.0', + 'version' => '0.2.0', 'permissions' => array( 'key' => 'navigation', @@ -58,12 +58,17 @@ 'migrations' => array( array('id' => '001_create_navigation_tables', 'file' => 'migrations/001_create_navigation_tables.sql'), + array('id' => '002_navigation_revisions', 'file' => 'migrations/002_navigation_revisions.sql'), ), 'routes' => array( array('GET', '/navigation', array(\App\Adminx\Navigation\Controller::class, 'index')), array('GET', '/navigation/alias-check', array(\App\Adminx\Navigation\Controller::class, 'aliasCheck')), array('GET', '/navigation/documents/picker', array(\App\Adminx\Navigation\Controller::class, 'documentPicker')), + array('POST', '/navigation/lint', array(\App\Adminx\Navigation\Controller::class, 'lint'), array('permission' => 'manage_navigation')), + array('GET', '/navigation/{id}/revisions', array(\App\Adminx\Navigation\Controller::class, 'revisions')), + array('GET', '/navigation/revisions/{revision}', array(\App\Adminx\Navigation\Controller::class, 'revision')), + array('POST', '/navigation/revisions/{revision}/restore', array(\App\Adminx\Navigation\Controller::class, 'restoreRevision'), array('permission' => 'manage_navigation', 'sensitive' => 'stored_php.write', 'reauth' => true)), array('GET', '/navigation/{id}/items', array(\App\Adminx\Navigation\Controller::class, 'items')), array('POST', '/navigation/{id}/items', array(\App\Adminx\Navigation\Controller::class, 'storeItem')), array('POST', '/navigation/{id}/items/reorder', array(\App\Adminx\Navigation\Controller::class, 'reorderItems')), diff --git a/adminx/modules/Navigation/view/index.twig b/adminx/modules/Navigation/view/index.twig index fd36aca..16a2175 100644 --- a/adminx/modules/Navigation/view/index.twig +++ b/adminx/modules/Navigation/view/index.twig @@ -136,6 +136,7 @@ @@ -301,6 +303,21 @@ + +
-{% else %} +{% elseif placement_view == 'usage' %}
@@ -237,6 +253,46 @@ {% include '@public_site/_placement_pagination.twig' %} +{% elseif placement_view == 'dependencies' %} +
+
+
+ + + {% for item in placement_map.dependencies %} + + + + + + + + {% else %}{% endfor %} + +
ИсточникЧто требуетсяСсылокПроблемы
{{ item.source_title ?: item.source_type_title ~ ' #' ~ item.source_id }}{{ item.source_type_title }} · ID {{ item.source_id }}
{% for component in item.components %}{{ component.title }}{% endfor %}
{{ item.references }}{% if item.broken %}{{ item.broken }} ошибок{% else %}Проверено{% endif %}{% if item.source_url %}
{% endif %}
Зависимостей в текущей выборке нет.
+
+ {% include '@public_site/_placement_pagination.twig' %} +
+{% else %} +
+
+ + + + {% for item in placement_map.unused %} + + + + + + + + {% else %}{% endfor %} + +
НаименованиеТипAliasИспользование
{{ item.title ?: item.type_title ~ ' #' ~ item.id }}ID {{ item.id }}
{{ item.type_title }}{{ item.alias ?: '—' }}Нет ссылок{% if item.url %}
{% endif %}
Неиспользуемых элементов нетВсе найденные компоненты и шаблоны имеют назначения.
+
+ {% include '@public_site/_placement_pagination.twig' %} +
{% endif %} {% endblock %} diff --git a/adminx/modules/Requests/Controller.php b/adminx/modules/Requests/Controller.php index aaeba7f..d7e18b2 100644 --- a/adminx/modules/Requests/Controller.php +++ b/adminx/modules/Requests/Controller.php @@ -17,6 +17,7 @@ defined('BASEPATH') || die('Direct access to this location is not allowed.'); use App\Common\AdminAssets; + use App\Common\Auth; use App\Common\Controller as BaseController; use App\Common\Permission; use App\Common\ErrorReport; @@ -268,6 +269,7 @@ } $id = Model::create($data); + Revisions::capture($id, 'create', Auth::id()); return $this->success('Запрос создан', ['redirect' => $this->base() . '/requests/' . $id . '?saved=1']); } @@ -289,7 +291,9 @@ return $this->error('Проверьте поля', $errors); } + Revisions::capture($id, 'baseline', Auth::id(), 'Снимок до изменения'); Model::update($id, $data); + Revisions::capture($id, 'update', Auth::id()); if ((int) $request->rubric_id !== (int) $data['rubric_id']) { return $this->success('Сохранено', array( 'redirect' => $this->base() . '/requests/' . $id . '?saved=1', @@ -312,9 +316,45 @@ } $newId = Model::copy($id); + Revisions::capture($newId, 'create', Auth::id(), 'Создано копированием'); return $this->success('Создана копия', ['redirect' => $this->base() . '/requests/' . $newId]); } + public function revisions(array $params = array()) + { + $id = isset($params['id']) ? (int) $params['id'] : 0; + $request = Model::find($id); + if (!$request) { return $this->error('Запрос не найден', array(), 404); } + + return $this->success('', array('data' => array( + 'request' => array('id' => $id, 'title' => (string) $request->request_title), + 'revisions' => Revisions::listing($id), + ))); + } + + public function revision(array $params = array()) + { + $revision = Revisions::one(isset($params['revision']) ? (int) $params['revision'] : 0); + return $revision + ? $this->success('', array('data' => $revision)) + : $this->error('Ревизия не найдена', array(), 404); + } + + public function restoreRevision(array $params = array()) + { + if (($resp = $this->guard()) !== null) { return $resp; } + try { + $id = Revisions::restore(isset($params['revision']) ? (int) $params['revision'] : 0, Auth::id()); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), array(), 422); + } + + return $this->success('Запрос восстановлен из ревизии', array( + 'data' => array('id' => $id), + 'redirect' => $this->base() . '/requests/' . $id, + )); + } + /** POST /requests/{id}/delete — удалить. */ public function destroy(array $params = []) { @@ -363,8 +403,10 @@ return $this->error('Запрос не найден', [], 404); } + Revisions::capture($id, 'baseline', Auth::id(), 'Снимок до изменения условий'); $order = Request::post('items', Request::post('order', [])); Model::reorderConditions($id, is_array($order) ? $order : []); + Revisions::capture($id, 'structure', Auth::id(), 'Изменён порядок условий'); return $this->success('Порядок сохранён'); } @@ -404,6 +446,7 @@ return $this->error('Запрос не найден', [], 404); } + $before = Model::snapshot($id); try { $condId = Model::conditionSave($id, [ 'id' => Request::postInt('cond_id'), @@ -422,6 +465,9 @@ return $this->error($e->getMessage(), array(), 422); } + Revisions::capture($id, 'baseline', Auth::id(), 'Снимок до изменения условий', $before); + Revisions::capture($id, 'structure', Auth::id(), 'Условие сохранено'); + return $this->success('Условие сохранено', ['data' => ['id' => $condId]]); } @@ -432,7 +478,14 @@ return $resp; } + $condition = Model::findCondition((int) ($params['id'] ?? 0)); + $before = $condition ? Model::snapshot((int) $condition->request_id) : array(); Model::conditionDelete((int) ($params['id'] ?? 0)); + if ($condition) { + Revisions::capture((int) $condition->request_id, 'baseline', Auth::id(), 'Снимок до изменения условий', $before); + Revisions::capture((int) $condition->request_id, 'structure', Auth::id(), 'Условие удалено'); + } + return $this->success('Условие удалено'); } @@ -447,6 +500,7 @@ return $this->error('Запрос не найден', [], 404); } + $before = Model::snapshot($requestId); $groupId = Model::groupSave($requestId, array( 'id' => Request::postInt('group_id'), 'parent_id' => Request::postInt('parent_id'), @@ -457,6 +511,9 @@ return $this->error('Не удалось сохранить группу', [], 422); } + Revisions::capture($requestId, 'baseline', Auth::id(), 'Снимок до изменения условий', $before); + Revisions::capture($requestId, 'structure', Auth::id(), 'Группа условий сохранена'); + return $this->success('Группа сохранена', array('data' => array('id' => $groupId))); } @@ -468,10 +525,14 @@ $requestId = (int) ($params['id'] ?? 0); $groupId = (int) ($params['groupId'] ?? 0); + $before = Model::snapshot($requestId); if (!Model::groupDelete($requestId, $groupId)) { return $this->error('Корневую или чужую группу удалить нельзя', [], 422); } + Revisions::capture($requestId, 'baseline', Auth::id(), 'Снимок до изменения условий', $before); + Revisions::capture($requestId, 'structure', Auth::id(), 'Группа условий удалена'); + return $this->success('Группа удалена. Ее содержимое перенесено выше.'); } diff --git a/adminx/modules/Requests/Model.php b/adminx/modules/Requests/Model.php index 8f4dbe0..688ff3f 100644 --- a/adminx/modules/Requests/Model.php +++ b/adminx/modules/Requests/Model.php @@ -426,6 +426,63 @@ return DB::query('SELECT * FROM ' . self::req() . ' WHERE Id = %i LIMIT 1', (int) $id)->getObject(); } + public static function snapshot($id) + { + $request = self::find($id); + if (!$request) { return array(); } + $normalize = function ($value) { + return is_object($value) ? get_object_vars($value) : (array) $value; + }; + $row = (array) $request; + unset($row['Id']); + return array( + 'request' => $row, + 'groups' => array_map($normalize, self::groupsFlat((int) $id)), + 'conditions' => array_map($normalize, self::conditions((int) $id)), + ); + } + + public static function applySnapshot($id, array $snapshot) + { + $current = self::find($id); + if (!$current || empty($snapshot['request']) || !is_array($snapshot['request'])) { + throw new \RuntimeException('Ревизия не содержит данных запроса'); + } + + $request = $snapshot['request']; + unset($request['Id']); + if (!empty($request['request_alias']) && self::aliasExists((string) $request['request_alias'], (int) $id)) { + throw new \RuntimeException('Алиас из ревизии уже занят другим запросом'); + } + + DB::startTransaction(); + try { + DB::Update(self::req(), $request, 'Id=%i', (int) $id); + DB::Delete(self::cond(), 'request_id=%i', (int) $id); + DB::Delete(self::groups(), 'request_id=%i', (int) $id); + foreach (isset($snapshot['groups']) && is_array($snapshot['groups']) ? $snapshot['groups'] : array() as $group) { + $group['request_id'] = (int) $id; + DB::Insert(self::groups(), $group); + } + + foreach (isset($snapshot['conditions']) && is_array($snapshot['conditions']) ? $snapshot['conditions'] : array() as $condition) { + $condition['request_id'] = (int) $id; + DB::Insert(self::cond(), $condition); + } + + DB::commit(); + } catch (\Throwable $e) { + DB::rollback(); + throw $e; + } + + \App\Content\Requests\NativeRequestPlanCompiler::persist((int) $id); + \App\Frontend\RequestRepository::reset(); + FileCacheInvalidator::request((int) $id, isset($current->request_alias) ? (string) $current->request_alias : ''); + FileCacheInvalidator::request((int) $id, isset($request['request_alias']) ? (string) $request['request_alias'] : ''); + return (int) $id; + } + public static function aliasExists($alias, $exceptId = 0) { return (bool) DB::query( diff --git a/adminx/modules/Requests/Revisions.php b/adminx/modules/Requests/Revisions.php new file mode 100644 index 0000000..7bc03b3 --- /dev/null +++ b/adminx/modules/Requests/Revisions.php @@ -0,0 +1,75 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Requests; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Content\ContentTables; + use App\Content\Revisions\JsonRevisionStore; + + class Revisions + { + protected static $store; + + public static function table() { return ContentTables::table('request_revisions'); } + public static function labels() { return array('create' => array('label' => 'Создание', 'badge' => 'badge-green'), 'baseline' => array('label' => 'До изменения', 'badge' => 'badge-gray'), 'update' => array('label' => 'Сохранение', 'badge' => 'badge-blue'), 'structure' => array('label' => 'Условия', 'badge' => 'badge-violet'), 'restore' => array('label' => 'Восстановление', 'badge' => 'badge-cyan'), 'restore_backup' => array('label' => 'Перед восстановлением', 'badge' => 'badge-gray')); } + + public static function listing($requestId) + { + $out = array(); + foreach (self::store()->listing($requestId, 80) as $row) { $out[] = self::format($row, false); } + return $out; + } + + public static function one($id) + { + $row = self::store()->one($id); + return $row ? self::format($row, true) : null; + } + + public static function capture($requestId, $action, $authorId = 0, $comment = '', array $snapshot = null, $sourceRevisionId = 0) + { + $snapshot = $snapshot === null ? Model::snapshot($requestId) : $snapshot; + if (!$snapshot) { return 0; } + return self::store()->capture((int) $requestId, $action, $snapshot, (int) $authorId, $comment, array('source_revision_id' => (int) $sourceRevisionId), true); + } + + public static function restore($revisionId, $authorId = 0) + { + $revision = self::one($revisionId); + if (!$revision || empty($revision['snapshot'])) { throw new \RuntimeException('Ревизия не найдена'); } + $id = (int) $revision['request_id']; + self::capture($id, 'restore_backup', $authorId, 'Снимок перед восстановлением', null, $revisionId); + Model::applySnapshot($id, $revision['snapshot']); + self::capture($id, 'restore', $authorId, 'Восстановлено из ревизии #' . (int) $revisionId, null, $revisionId); + return $id; + } + + public static function delete($revisionId) { return self::store()->delete($revisionId); } + public static function deleteFor($requestId) { return self::store()->deleteFor($requestId); } + + protected static function format(array $row, $withSnapshot) + { + $row = self::store()->formatRow($row, $withSnapshot); + if ($withSnapshot && is_array($row['snapshot'])) { $row['comparison'] = JsonRevisionStore::compareSnapshots(Model::snapshot($row['request_id']), $row['snapshot']); } + return $row; + } + + protected static function store() + { + if (!self::$store) { self::$store = new JsonRevisionStore(self::table(), 'request_id', self::labels()); } + return self::$store; + } + } diff --git a/adminx/modules/Requests/assets/requests.css b/adminx/modules/Requests/assets/requests.css index df7dc02..5b4dc9e 100644 --- a/adminx/modules/Requests/assets/requests.css +++ b/adminx/modules/Requests/assets/requests.css @@ -2369,6 +2369,84 @@ tr.cond-dragging { } } /* Массовая сверка Legacy и Native */ +.request-revisions-layout { + display: grid; + grid-template-columns: minmax(250px, 0.75fr) minmax(0, 1.25fr); + gap: 16px; +} +.request-revision-row { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px; + border: 0; + border-bottom: 1px solid var(--border); + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} +.request-revision-row:hover, +.request-revision-row.is-active { + background: var(--surface-hover); +} +.request-revision-row > span:nth-child(2) { + display: grid; + gap: 3px; + min-width: 0; +} +.request-revision-row small, +.request-revision-detail-head p { + margin: 0; + color: var(--text-secondary); +} +.request-revision-row em { + font-style: normal; +} +.request-revision-detail-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} +.request-revision-detail-head h4 { + margin: 0 0 4px; +} +.request-revision-parts { + display: grid; + gap: 8px; +} +.request-revision-parts article { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 6px; +} +.request-revision-parts article > div { + display: grid; + gap: 3px; +} +.request-revision-parts article small { + color: var(--text-secondary); +} +.request-revision-parts article.is-unchanged { + opacity: 0.64; +} +.request-revision-parts article.is-changed { + border-color: var(--amber-300); +} +@media (max-width: 900px) { + .request-revisions-layout { + grid-template-columns: 1fr; + } +} +/* Массовая сверка Legacy и Native */ .request-audit { display: grid; gap: 18px; diff --git a/adminx/modules/Requests/assets/requests.js b/adminx/modules/Requests/assets/requests.js index 660f09d..3111717 100644 --- a/adminx/modules/Requests/assets/requests.js +++ b/adminx/modules/Requests/assets/requests.js @@ -12,6 +12,7 @@ activeField: null, dragRow: null, explainSearchTimer: null, + currentRevisionId: 0, init: function () { this.form = document.getElementById('requestForm'); @@ -79,6 +80,11 @@ }, base: function () { return (this.form && this.form.getAttribute('data-base')) || Adminx.base(); }, + escape: function (value) { + return String(value == null ? '' : value).replace(/[&<>"']/g, function (character) { + return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]; + }); + }, initEditorMode: function () { if (!document.querySelector('[data-request-editor-mode]')) { return; } @@ -331,6 +337,10 @@ if (copy) { return this.copy(copy.closest('tr')); } var del = e.target.closest('[data-request-delete]'); if (del) { return this.remove(del.closest('tr')); } + if (e.target.closest('[data-request-revisions]')) { return this.openRevisions(); } + var revision = e.target.closest('[data-request-revision-open]'); + if (revision) { return this.loadRevision(revision.getAttribute('data-request-revision-open')); } + if (e.target.closest('[data-request-revision-restore]')) { return this.restoreRevision(); } // массовая сверка Legacy и Native var auditOne = e.target.closest('[data-native-audit-one]'); @@ -684,6 +694,91 @@ }); }, + openRevisions: function () { + var id = parseInt(this.form ? this.form.getAttribute('data-id') : '0', 10) || 0; + if (!id) { return; } + this.currentRevisionId = 0; + var list = document.querySelector('[data-request-revisions-list]'); + var detail = document.querySelector('[data-request-revision-detail]'); + var restore = document.querySelector('[data-request-revision-restore]'); + if (list) { list.innerHTML = '
Загрузка...
'; } + if (detail) { detail.innerHTML = '
Выберите ревизию слева.
'; } + if (restore) { restore.disabled = true; } + if (Adminx.Drawer) { Adminx.Drawer.open('requestRevisionsDrawer'); } + var self = this; + Adminx.Ajax.request(this.base() + '/requests/' + id + '/revisions').then(function (payload) { + var response = payload.data || {}; + if (!response.success) { throw new Error(response.message || 'Не удалось загрузить ревизии'); } + self.renderRevisions((response.data && response.data.revisions) || []); + }).catch(function (error) { Adminx.Toast.show(error.message || 'Не удалось загрузить ревизии', 'error'); }); + }, + + renderRevisions: function (items) { + var self = this; + var list = document.querySelector('[data-request-revisions-list]'); + var meta = document.querySelector('[data-request-revisions-meta]'); + if (meta) { meta.textContent = items.length ? ('Снимков: ' + items.length + '. Параметры, группы и условия сохраняются вместе.') : 'История пока пуста'; } + if (!list) { return; } + if (!items.length) { list.innerHTML = '
Первый снимок появится после сохранения запроса.
'; return; } + list.innerHTML = items.map(function (item) { + return ''; + }).join(''); + this.loadRevision(items[0].id); + }, + + loadRevision: function (id) { + var self = this; + id = parseInt(id, 10) || 0; + if (!id) { return; } + Adminx.Ajax.request(this.base() + '/requests/revisions/' + id).then(function (payload) { + var response = payload.data || {}; + if (!response.success) { throw new Error(response.message || 'Не удалось загрузить ревизию'); } + self.showRevision(response.data || {}); + }).catch(function (error) { Adminx.Toast.show(error.message || 'Не удалось загрузить ревизию', 'error'); }); + }, + + showRevision: function (item) { + this.currentRevisionId = parseInt(item.id, 10) || 0; + document.querySelectorAll('[data-request-revision-open]').forEach(function (row) { + row.classList.toggle('is-active', row.getAttribute('data-request-revision-open') === String(item.id)); + }); + var snapshot = item.snapshot || {}; + var comparison = item.comparison || {}; + var parts = [ + {key: 'request', title: 'Параметры и шаблоны', count: Object.keys(snapshot.request || {}).length}, + {key: 'groups', title: 'Группы условий', count: (snapshot.groups || []).length}, + {key: 'conditions', title: 'Условия', count: (snapshot.conditions || []).length} + ]; + var changedCount = parts.filter(function (part) { return comparison.items && comparison.items[part.key] && comparison.items[part.key].changed; }).length; + var detail = document.querySelector('[data-request-revision-detail]'); + var self = this; + if (detail) { + detail.innerHTML = '

Ревизия #' + this.currentRevisionId + '

' + this.escape(item.created_label || '-') + (item.author_name ? ' · ' + this.escape(item.author_name) : '') + '

' + (changedCount ? ('изменятся разделы: ' + changedCount) : 'совпадает') + '
' + parts.map(function (part) { + var changed = !!(comparison.items && comparison.items[part.key] && comparison.items[part.key].changed); + return '
' + self.escape(part.title) + 'Элементов: ' + part.count + '
' + (changed ? 'изменится' : 'совпадает') + '
'; + }).join('') + '
'; + } + var restore = document.querySelector('[data-request-revision-restore]'); + if (restore) { restore.disabled = !this.currentRevisionId || !changedCount; } + }, + + restoreRevision: function () { + if (!this.currentRevisionId) { return; } + var self = this; + Adminx.Confirm.open({ + kind: 'warning', title: 'Восстановить запрос?', message: 'Параметры, шаблоны и всё дерево условий будут возвращены вместе. Текущее состояние сохранится резервной ревизией.', confirmLabel: 'Восстановить', + onConfirm: function () { + var data = new FormData(); data.set('_csrf', Adminx.csrf()); + Adminx.Ajax.post(self.base() + '/requests/revisions/' + self.currentRevisionId + '/restore', data).then(function (payload) { + var response = payload.data || {}; + if (!response.success) { throw new Error(response.message || 'Не удалось восстановить запрос'); } + Adminx.Toast.show(response.message || 'Запрос восстановлен', 'success'); + window.location.reload(); + }).catch(function (error) { Adminx.Toast.show(error.message || 'Не удалось восстановить запрос', 'error'); }); + } + }); + }, + filterList: function (query) { var root = document.querySelector('[data-requests-page]'); if (!root) { return; } diff --git a/adminx/modules/Requests/migrations/014_request_revisions.sql b/adminx/modules/Requests/migrations/014_request_revisions.sql new file mode 100644 index 0000000..c5f6002 --- /dev/null +++ b/adminx/modules/Requests/migrations/014_request_revisions.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS `{{prefix}}_request_revisions` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `request_id` SMALLINT UNSIGNED NOT NULL DEFAULT 0, + `action` VARCHAR(32) NOT NULL DEFAULT 'update', + `snapshot_hash` CHAR(40) NOT NULL DEFAULT '', + `snapshot_json` LONGTEXT NOT NULL, + `comment` VARCHAR(255) NOT NULL DEFAULT '', + `author_id` INT UNSIGNED NOT NULL DEFAULT 0, + `author_name` VARCHAR(255) NOT NULL DEFAULT '', + `source_revision_id` INT UNSIGNED NOT NULL DEFAULT 0, + `created_at` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_request_created` (`request_id`,`created_at`), + KEY `idx_snapshot_hash` (`snapshot_hash`) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; diff --git a/adminx/modules/Requests/module.php b/adminx/modules/Requests/module.php index c0a43ce..42bd8e3 100644 --- a/adminx/modules/Requests/module.php +++ b/adminx/modules/Requests/module.php @@ -22,7 +22,7 @@ return [ 'code' => 'requests', 'name' => 'Запросы', - 'version' => '0.1.0', + 'version' => '0.2.0', 'permissions' => [ 'key' => 'requests', @@ -75,6 +75,7 @@ ['id' => '011_order_tiebreaker', 'file' => 'migrations/011_order_tiebreaker.php'], ['id' => '012_condition_value_sources', 'file' => 'migrations/012_condition_value_sources.php'], ['id' => '013_sort_rules', 'file' => 'migrations/013_sort_rules.php'], + ['id' => '014_request_revisions', 'file' => 'migrations/014_request_revisions.sql'], ], 'routes' => array( @@ -88,6 +89,9 @@ array('GET', '/requests/documents/picker', array(\App\Adminx\Requests\Controller::class, 'documentPicker'), array('permission' => 'manage_requests')), array('POST', '/requests', array(\App\Adminx\Requests\Controller::class, 'store'), array('permission' => 'manage_requests', 'sensitive' => 'stored_php.write', 'reauth' => true)), array('POST', '/requests/lint', array(\App\Adminx\Requests\Controller::class, 'lint')), + array('GET', '/requests/{id}/revisions', array(\App\Adminx\Requests\Controller::class, 'revisions')), + array('GET', '/requests/revisions/{revision}', array(\App\Adminx\Requests\Controller::class, 'revision')), + array('POST', '/requests/revisions/{revision}/restore', array(\App\Adminx\Requests\Controller::class, 'restoreRevision'), array('permission' => 'manage_requests', 'sensitive' => 'stored_php.write', 'reauth' => true)), array('POST', '/requests/conditions/{id}/delete', array(\App\Adminx\Requests\Controller::class, 'conditionDelete')), array('POST', '/requests/{id}/conditions/reorder', array(\App\Adminx\Requests\Controller::class, 'conditionReorder')), array('POST', '/requests/{id}/condition-groups', array(\App\Adminx\Requests\Controller::class, 'groupSave')), diff --git a/adminx/modules/Requests/view/edit.twig b/adminx/modules/Requests/view/edit.twig index be8959c..4664d31 100644 --- a/adminx/modules/Requests/view/edit.twig +++ b/adminx/modules/Requests/view/edit.twig @@ -117,6 +117,7 @@
К списку + {% if request %}{% endif %} {% if can_manage %}{% endif %}
@@ -482,4 +483,21 @@ +{% if request %} + +{% endif %} + {% endblock %} diff --git a/adminx/modules/Rubrics/FieldEditors/MediaFieldEditor.php b/adminx/modules/Rubrics/FieldEditors/MediaFieldEditor.php index 8b01a8f..1aeecba 100644 --- a/adminx/modules/Rubrics/FieldEditors/MediaFieldEditor.php +++ b/adminx/modules/Rubrics/FieldEditors/MediaFieldEditor.php @@ -315,7 +315,13 @@ $pickerDir = isset($field['media_picker_dir']) && $field['media_picker_dir'] !== '' ? (string) $field['media_picker_dir'] : '/uploads'; $fileAccept = $accept === 'image' ? ' accept="image/*"' : ''; + $uploadAction = $isImage + ? '' + . '' + : ''; + return '
' @@ -328,9 +334,10 @@ . '' . '
' . '
' - . '' + . $uploadAction . '' . '
' + . '' . '' . ''; @@ -388,6 +395,7 @@ } $fileAccept = $accept === 'image' ? ' accept="image/*"' : ''; + $isImageList = $type === 'image_multi' || $type === 'image_mega'; return '
' . '
' . '' - . '' + . '' . '' - . '' + . ($isImageList ? '' : '') + . '' . '' . '
' + . '' . '
Перетащите файлы сюда — после сохранения: ' . $this->e($targetDir) . '
' . '
' . $rows . '
' . '
Нет добавленных элементов.
' diff --git a/adminx/modules/Rubrics/language/en/interface.xml b/adminx/modules/Rubrics/language/en/interface.xml index c54735e..573e7ff 100644 --- a/adminx/modules/Rubrics/language/en/interface.xml +++ b/adminx/modules/Rubrics/language/en/interface.xml @@ -1136,4 +1136,8 @@ This set is already associated with the category. Use the update in the list of related sets. This field type is disabled in the registry This restore point cannot be returned. + Add photos + Replace all + The old file will be moved to the trash after saving. + The current set will be moved to the trash after saving. diff --git a/adminx/modules/Rubrics/language/ru/interface.xml b/adminx/modules/Rubrics/language/ru/interface.xml index 4a79571..58195e0 100644 --- a/adminx/modules/Rubrics/language/ru/interface.xml +++ b/adminx/modules/Rubrics/language/ru/interface.xml @@ -1136,4 +1136,8 @@ Этот набор уже связан с рубрикой. Используйте обновление в списке связанных наборов. Этот тип поля отключён в реестре Эту точку восстановления нельзя будет вернуть. + Добавить фото + Заменить все + Старый файл будет перемещён в корзину после сохранения. + Текущий комплект будет перемещён в корзину после сохранения. diff --git a/adminx/modules/Rubrics/module.php b/adminx/modules/Rubrics/module.php index ff915ed..23d5f33 100644 --- a/adminx/modules/Rubrics/module.php +++ b/adminx/modules/Rubrics/module.php @@ -17,7 +17,7 @@ return array( 'code' => 'rubrics', 'name' => 'Рубрики и поля', - 'version' => '0.3.2', + 'version' => '0.3.4', 'field_sets' => \App\Adminx\Rubrics\RubricFieldPresets::definitions(), 'hook_definitions' => array( array( diff --git a/adminx/modules/Rubrics/view/index.twig b/adminx/modules/Rubrics/view/index.twig index a937d2d..43129ae 100644 --- a/adminx/modules/Rubrics/view/index.twig +++ b/adminx/modules/Rubrics/view/index.twig @@ -855,7 +855,7 @@ - +
diff --git a/adminx/modules/Settings/ProductionDiagnostics.php b/adminx/modules/Settings/ProductionDiagnostics.php index 238043e..73cb764 100644 --- a/adminx/modules/Settings/ProductionDiagnostics.php +++ b/adminx/modules/Settings/ProductionDiagnostics.php @@ -26,6 +26,7 @@ use App\Content\PublicShellTables; use App\Content\PublicUserTables; use App\Frontend\PublicSettings; + use App\Common\SiteOrigin; /** Read-only production readiness checks. No mail, payment or data mutations. */ class ProductionDiagnostics @@ -63,6 +64,14 @@ protected static function checkRuntime(array &$checks, array $database) { + try { $publicUrl = SiteOrigin::configuredUrl(); } catch (\Throwable $e) { $publicUrl = ''; } + self::add( + $checks, + 'Публичный адрес сайта', + $publicUrl !== '' ? 'ok' : 'error', + $publicUrl !== '' ? $publicUrl : 'задайте PUBLIC_SITE_URL: без него защищённые ссылки в письмах не отправляются', + 'environment' + ); self::add($checks, 'PHP', PHP_VERSION_ID >= 70300 && PHP_VERSION_ID < 80000 ? 'ok' : 'warning', PHP_VERSION, 'environment'); self::add( $checks, diff --git a/adminx/modules/Settings/migrations/007_public_site_url.sql b/adminx/modules/Settings/migrations/007_public_site_url.sql new file mode 100644 index 0000000..e611343 --- /dev/null +++ b/adminx/modules/Settings/migrations/007_public_site_url.sql @@ -0,0 +1,5 @@ +INSERT INTO `{{system_prefix}}_constants` +(`name`,`value`,`type`,`group_code`,`label`,`description`,`options`,`is_system`,`sort_order`,`updated_at`) +VALUES +('PUBLIC_SITE_URL','','string','system','PUBLIC_SITE_URL','Канонический публичный URL сайта для проверки Host и защищённых ссылок.','',1,20,NOW()) +ON DUPLICATE KEY UPDATE `description`=VALUES(`description`),`is_system`=1; diff --git a/adminx/modules/Settings/module.php b/adminx/modules/Settings/module.php index 702e277..2ff0612 100644 --- a/adminx/modules/Settings/module.php +++ b/adminx/modules/Settings/module.php @@ -17,7 +17,7 @@ return array( 'code' => 'settings', 'name' => 'Настройки', - 'version' => '0.1.1', + 'version' => '0.1.2', 'permissions' => array( 'key' => 'settings', @@ -169,6 +169,7 @@ array('id' => '004_seed_core_display_defaults', 'file' => 'migrations/004_seed_core_display_defaults.sql'), array('id' => '005_order_system_navigation', 'file' => 'migrations/005_order_system_navigation.php'), array('id' => '006_create_admin_saved_views', 'file' => 'migrations/006_create_admin_saved_views.sql'), + array('id' => '007_public_site_url', 'file' => 'migrations/007_public_site_url.sql'), ), 'routes' => array( diff --git a/adminx/modules/Templates/Revisions.php b/adminx/modules/Templates/Revisions.php index 33f6031..8feed38 100644 --- a/adminx/modules/Templates/Revisions.php +++ b/adminx/modules/Templates/Revisions.php @@ -135,6 +135,10 @@ $text = $withSnapshot && is_array($snapshot) && isset($snapshot['template_text']) ? Model::decodeText((string) $snapshot['template_text']) : ''; + $current = $withSnapshot ? Model::raw((int) $row['template_id']) : array(); + $comparison = $withSnapshot && is_array($snapshot) + ? JsonRevisionStore::compareSnapshots($current ? Model::snapshot($current) : array(), $snapshot) + : array(); return array( 'id' => (int) $row['id'], @@ -154,6 +158,7 @@ 'text_size_label' => $withSnapshot ? JsonRevisionStore::formatBytes(strlen($text)) : '', 'snapshot' => $snapshot, 'code' => $text, + 'comparison' => $comparison, ); } diff --git a/adminx/modules/Templates/assets/templates.js b/adminx/modules/Templates/assets/templates.js index 4549794..5cd8cc5 100644 --- a/adminx/modules/Templates/assets/templates.js +++ b/adminx/modules/Templates/assets/templates.js @@ -502,6 +502,8 @@ var fields = document.querySelector('[data-template-revision-fields]'); var restore = document.querySelector('[data-template-revision-restore]'); var remove = document.querySelector('[data-template-revision-delete]'); + var comparison = item.comparison || {}; + var changed = comparison.summary ? parseInt(comparison.summary.total_changed || 0, 10) : 0; var clear = document.querySelector('[data-template-revisions-clear]'); if (title) { title.textContent = 'Ревизии: ' + (row.dataset.title || ('#' + row.dataset.id)); } @@ -613,7 +615,7 @@ if (title) { title.textContent = '#' + item.id + ' · ' + (item.action_label || item.action || 'Ревизия'); } if (meta) { - meta.textContent = (item.created_label || '-') + (item.author_name ? ' · ' + item.author_name : '') + (item.text_size_label ? ' · ' + item.text_size_label : ''); + meta.textContent = (item.created_label || '-') + (item.author_name ? ' · ' + item.author_name : '') + (item.text_size_label ? ' · ' + item.text_size_label : '') + ' · ' + (changed ? ('изменений: ' + changed) : 'совпадает с текущим'); } if (fields) { fields.innerHTML = @@ -621,7 +623,7 @@ 'ID#' + esc(snapshot.Id || '-') + '' + 'Размер' + esc(item.text_size_label || '-') + ''; } - if (restore) { restore.disabled = !this.currentRevisionId; } + if (restore) { restore.disabled = !this.currentRevisionId || !changed; } if (remove) { remove.disabled = !this.currentRevisionId; if (this.currentRevisionId) { remove.setAttribute('data-template-revision-delete', String(this.currentRevisionId)); } diff --git a/adminx/modules/Themes/Revisions.php b/adminx/modules/Themes/Revisions.php index d613880..8f8ceb8 100644 --- a/adminx/modules/Themes/Revisions.php +++ b/adminx/modules/Themes/Revisions.php @@ -62,6 +62,15 @@ if (!$row) { return null; } $row = self::format($row); $row['content'] = (string) $row['raw']['content']; + try { + $current = Model::file($row['theme'], $row['path']); + $row['current_exists'] = true; + $row['changed'] = (string) $current['content'] !== $row['content']; + } catch (\Throwable $e) { + $row['current_exists'] = false; + $row['changed'] = true; + } + unset($row['raw']); return $row; } diff --git a/adminx/modules/Themes/assets/themes.js b/adminx/modules/Themes/assets/themes.js index aba07dc..8cb6fa2 100644 --- a/adminx/modules/Themes/assets/themes.js +++ b/adminx/modules/Themes/assets/themes.js @@ -360,10 +360,12 @@ var item = response.data || {}; self.currentRevisionId = item.id || 0; document.querySelector('[data-revision-title]').textContent = item.path || 'Ревизия'; - document.querySelector('[data-revision-meta]').textContent = (item.action_label || '') + ' · ' + (item.author_name || 'Система') + ' · ' + (item.created_label || ''); + document.querySelector('[data-revision-meta]').textContent = (item.action_label || '') + ' · ' + (item.author_name || 'Система') + ' · ' + (item.created_label || '') + ' · ' + (item.changed ? 'файл изменится' : 'совпадает с текущим'); var textarea = document.querySelector('[data-revision-content]'); textarea.value = item.content || ''; if (textarea._adminxCodeMirror) { textarea._adminxCodeMirror.setValue(item.content || ''); } + var restore = document.querySelector('[data-theme-revision-restore]'); + if (restore) { restore.disabled = !item.changed; } Adminx.Drawer.open('themeRevisionDrawer'); window.setTimeout(function () { if (Adminx.CodeEditor) { Adminx.CodeEditor.refreshAll(); } }, 80); }); diff --git a/adminx/modules/Themes/assets/view-overrides.css b/adminx/modules/Themes/assets/view-overrides.css new file mode 100644 index 0000000..61591ea --- /dev/null +++ b/adminx/modules/Themes/assets/view-overrides.css @@ -0,0 +1,364 @@ +.view-override-intro { + margin: 0 0 24px; +} +.view-override-layout { + display: grid; + grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); + align-items: start; + gap: 20px; +} +.view-override-list, +.view-override-editor { + overflow: hidden; + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + background: var(--background-card); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); +} +.view-override-list-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 54px; + padding: 12px 16px; + border-bottom: 1px solid var(--border-default); +} +.view-override-group { + padding: 14px 16px 6px; + color: var(--text-muted); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +} +.view-override-list-head b { + font-size: 14px; +} +.view-override-list-head span { + color: var(--text-secondary); + font-size: 12px; +} +.view-override-item { + display: grid; + grid-template-columns: 38px minmax(0, 1fr) auto; + align-items: center; + gap: 11px; + min-height: 76px; + padding: 10px 14px; + color: var(--text-primary); + text-decoration: none; + transition-property: background-color, box-shadow, transform; + transition-duration: 140ms; +} +.view-override-item + .view-override-item { + border-top: 1px solid var(--border-default); +} +.view-override-item:hover { + background: var(--background-subtle); +} +.view-override-item:active { + transform: scale(0.96); +} +.view-override-item.is-active { + background: var(--blue-50); + box-shadow: inset 0 0 0 1px var(--blue-200); +} +.view-override-item .icon-tile { + width: 38px; + height: 38px; + --tile-bg: var(--blue-100); + --tile-fg: var(--blue-600); +} +.view-override-item-copy, +.view-override-item-copy b, +.view-override-item-copy small { + display: block; + min-width: 0; +} +.view-override-item-copy b { + font-size: 13px; + line-height: 1.3; + text-wrap: pretty; +} +.view-override-item-copy small { + display: -webkit-box; + overflow: hidden; + margin-top: 3px; + color: var(--text-secondary); + font-size: 11px; + line-height: 1.35; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} +.view-override-editor-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + min-height: 72px; + padding: 14px 18px; + border-bottom: 1px solid var(--border-default); +} +.view-override-title { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} +.view-override-title .icon-tile { + width: 40px; + height: 40px; + flex: none; + --tile-bg: var(--violet-100); + --tile-fg: var(--violet-600); +} +.view-override-title h2, +.view-override-title p { + margin: 0; +} +.view-override-title h2 { + font-size: 17px; + letter-spacing: 0; + text-wrap: balance; +} +.view-override-title p { + margin-top: 3px; + color: var(--text-secondary); + font-size: 12px; + text-wrap: pretty; +} +.view-override-source { + min-width: 180px; + text-align: right; +} +.view-override-source small { + display: block; + max-width: 320px; + overflow: hidden; + margin-top: 5px; + color: var(--text-secondary); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} +.view-override-warning { + margin: 16px 18px 0; +} +.view-override-fallback { + display: flex; + align-items: flex-start; + gap: 9px; + margin: 14px 18px 0; + padding: 10px 12px; + border-radius: var(--radius-md); + background: var(--blue-50); + color: var(--blue-800); + font-size: 12px; + line-height: 1.45; +} +.view-override-fallback i { + margin-top: 2px; + flex: none; +} +.view-override-form { + padding: 16px 18px 18px; +} +.view-override-toolbar, +.view-override-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.view-override-toolbar { + min-height: 42px; + margin-bottom: 10px; +} +.view-override-toolbar b, +.view-override-toolbar small { + display: block; +} +.view-override-toolbar b { + font-size: 13px; +} +.view-override-toolbar small { + margin-top: 2px; + color: var(--text-secondary); + font-size: 11px; +} +.view-override-form .CodeMirror { + height: min(640px, calc(100vh - 270px)) !important; + min-height: 440px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + font-size: 13px; + line-height: 1.55; +} +.view-override-lint { + display: block; + min-height: 20px; + padding-top: 5px; +} +.view-override-lint.is-ok { + color: var(--green-600); +} +.view-override-lint.is-error { + color: var(--red-600); +} +.view-override-variables { + margin-top: 10px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-subtle); +} +.view-override-variables summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 52px; + padding: 8px 12px; + cursor: pointer; + list-style: none; +} +.view-override-variables summary::-webkit-details-marker { + display: none; +} +.view-override-variables summary > span { + display: grid; + grid-template-columns: 24px minmax(0, 1fr); + align-items: center; +} +.view-override-variables summary > span > i { + grid-row: 1 / span 2; + color: var(--blue-600); + font-size: 17px; +} +.view-override-variables summary b, +.view-override-variables summary small { + display: block; +} +.view-override-variables summary b { + font-size: 13px; +} +.view-override-variables summary small { + margin-top: 2px; + color: var(--text-secondary); + font-size: 11px; +} +.view-override-variables[open] summary > .ti-chevron-down { + transform: rotate(180deg); +} +.view-override-variables summary > .ti-chevron-down { + transition-property: transform; + transition-duration: 140ms; +} +.view-override-variable-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + padding: 10px 12px 12px; + border-top: 1px solid var(--border-default); +} +.view-override-variable-list button { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(130px, 0.8fr) 22px; + align-items: center; + gap: 9px; + min-height: 42px; + padding: 6px 9px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-card); + color: var(--text-primary); + text-align: left; + cursor: pointer; + transition-property: border-color, box-shadow, transform; + transition-duration: 140ms; +} +.view-override-variable-list button:hover:not(:disabled) { + border-color: var(--blue-300); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); +} +.view-override-variable-list button:active:not(:disabled) { + transform: scale(0.96); +} +.view-override-variable-list button:disabled { + cursor: default; + opacity: 0.55; +} +.view-override-variable-list code { + overflow: hidden; + color: var(--blue-700); + text-overflow: ellipsis; +} +.view-override-variable-list span { + color: var(--text-secondary); + font-size: 11px; +} +.view-override-variable-list i { + color: var(--blue-600); +} +.view-override-footer { + justify-content: flex-end; + padding-top: 14px; +} +.view-override-save-state { + margin-right: auto; + color: var(--text-secondary); + font-size: 12px; +} +.view-override-save-state.is-dirty { + padding: 5px 9px; + border-radius: var(--radius-md); + background: var(--amber-100); + color: var(--amber-700); + font-weight: 600; +} +@media (max-width: 1100px) { + .view-override-layout { + grid-template-columns: 250px minmax(0, 1fr); + } + .view-override-item { + grid-template-columns: 38px minmax(0, 1fr); + } + .view-override-item > .badge { + grid-column: 2; + justify-self: start; + } + .view-override-variable-list { + grid-template-columns: 1fr; + } +} +@media (max-width: 760px) { + .view-override-layout { + grid-template-columns: 1fr; + } + .view-override-item { + grid-template-columns: 38px minmax(0, 1fr) auto; + } + .view-override-item > .badge { + grid-column: auto; + } + .view-override-editor-head, + .view-override-toolbar, + .view-override-footer { + align-items: flex-start; + flex-wrap: wrap; + } + .view-override-source { + width: 100%; + text-align: left; + } + .view-override-source small { + max-width: 100%; + } + .view-override-form .CodeMirror { + height: 520px !important; + min-height: 360px; + } + .view-override-save-state { + width: 100%; + margin-right: 0; + } +} diff --git a/adminx/modules/Themes/assets/view-overrides.js b/adminx/modules/Themes/assets/view-overrides.js new file mode 100644 index 0000000..79db01b --- /dev/null +++ b/adminx/modules/Themes/assets/view-overrides.js @@ -0,0 +1,140 @@ +(function (window, document) { + 'use strict'; + + var Adminx = window.Adminx || (window.Adminx = {}); + + Adminx.ViewOverrides = { + root: null, + form: null, + field: null, + clean: '', + dirty: false, + + init: function () { + this.root = document.querySelector('[data-view-overrides]'); + this.form = document.querySelector('[data-view-override-form]'); + this.field = this.form ? this.form.elements.content : null; + if (!this.root || !this.form || !this.field) { return; } + this.clean = this.field.value || ''; + this.bind(); + }, + + base: function () { return this.root.getAttribute('data-base') || Adminx.base(); }, + route: function () { return this.root.getAttribute('data-route') || ''; }, + code: function () { return this.form.getAttribute('data-code') || ''; }, + + bind: function () { + var self = this; + this.form.addEventListener('submit', function (event) { + event.preventDefault(); + self.save(); + }); + this.form.addEventListener('input', function () { self.setDirty((self.field.value || '') !== self.clean); }); + this.form.addEventListener('click', function (event) { + if (event.target.closest('[data-view-override-lint]')) { self.lint(); return; } + if (event.target.closest('[data-view-override-fullscreen]')) { self.fullscreen(); return; } + if (event.target.closest('[data-view-override-delete]')) { self.removeOverride(); } + }); + window.addEventListener('beforeunload', function (event) { + if (!self.dirty) { return; } + event.preventDefault(); + event.returnValue = ''; + }); + }, + + request: function (url, data) { + return Adminx.Ajax.post(url, data).then(function (response) { + var payload = response.data || {}; + if (!response.ok || !payload.success) { + var error = new Error(payload.message || 'Не удалось выполнить действие'); + error.payload = payload; + throw error; + } + return payload; + }); + }, + + sync: function () { + if (Adminx.CodeEditor) { Adminx.CodeEditor.syncAll(this.form); } + }, + + save: function () { + var self = this; + this.sync(); + Adminx.Loader.show(); + this.request(this.base() + this.route() + '/' + encodeURIComponent(this.code()), new FormData(this.form)) + .then(function (payload) { + self.clean = self.field.value || ''; + self.setDirty(false); + Adminx.Toast.show(payload.message || 'Шаблон сохранён', 'success'); + window.setTimeout(function () { window.location.reload(); }, 350); + }) + .catch(function (error) { self.fail(error); }) + .finally(function () { Adminx.Loader.hide(); }); + }, + + lint: function () { + var self = this; + var result = this.form.querySelector('[data-view-override-lint-result]'); + this.sync(); + var data = new FormData(); + data.set('_csrf', this.form.elements._csrf.value); + data.set('content', this.field.value || ''); + if (result) { result.className = 'field-hint view-override-lint'; result.textContent = 'Проверяем Twig...'; } + this.request(this.base() + this.route() + '/lint', data) + .then(function (payload) { + if (result) { result.classList.add('is-ok'); result.textContent = payload.message || 'Twig-синтаксис корректен'; } + }) + .catch(function (error) { + if (result) { result.classList.add('is-error'); result.textContent = error.message; } + self.fail(error, false); + }); + }, + + fullscreen: function () { + if (!this.field._adminxCodeMirror || !Adminx.CodeEditor) { return; } + Adminx.CodeEditor.toggleFullscreen(this.field._adminxCodeMirror); + }, + + removeOverride: function () { + var self = this; + Adminx.Confirm.open({ + kind: 'warning', + title: 'Вернуться к резервному шаблону?', + message: 'Файл активной темы будет удалён. Его последняя версия останется в ревизиях темы, а сайт сразу начнёт использовать резерв компонента.', + confirmLabel: 'Вернуться к резерву', + onConfirm: function () { + var data = new FormData(); + data.set('_csrf', self.form.elements._csrf.value); + Adminx.Loader.show(); + self.request(self.base() + self.route() + '/' + encodeURIComponent(self.code()) + '/delete', data) + .then(function (payload) { + self.setDirty(false); + Adminx.Toast.show(payload.message || 'Переопределение удалено', 'success'); + window.setTimeout(function () { window.location.reload(); }, 350); + }) + .catch(function (error) { self.fail(error); }) + .finally(function () { Adminx.Loader.hide(); }); + } + }); + }, + + setDirty: function (dirty) { + this.dirty = !!dirty; + var state = this.form.querySelector('[data-view-override-save-state]'); + if (!state) { return; } + state.classList.toggle('is-dirty', this.dirty); + state.textContent = this.dirty ? 'Есть несохранённые изменения' : ''; + }, + + fail: function (error, toast) { + if (toast !== false) { Adminx.Toast.show(error && error.message ? error.message : 'Не удалось выполнить действие', 'error'); } + } + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { Adminx.ViewOverrides.init(); }); + } else { + Adminx.ViewOverrides.init(); + } +})(window, document); diff --git a/adminx/modules/Themes/module.php b/adminx/modules/Themes/module.php index 03d69c1..0a5fd5f 100644 --- a/adminx/modules/Themes/module.php +++ b/adminx/modules/Themes/module.php @@ -17,7 +17,7 @@ return array( 'code' => 'themes', 'name' => 'Темы', - 'version' => '0.1.0', + 'version' => '0.2.0', 'permissions' => array( 'key' => 'themes', diff --git a/adminx/modules/Themes/view/view-overrides.twig b/adminx/modules/Themes/view/view-overrides.twig new file mode 100644 index 0000000..060bf71 --- /dev/null +++ b/adminx/modules/Themes/view/view-overrides.twig @@ -0,0 +1,72 @@ +{% extends '@adminx/main.twig' %} +{% block title %}{{ page_title }}{% endblock %} +{% block content %} + + + + +{% if tabs_template %}{% include tabs_template %}{% endif %} + +
Редактируется активная тема «{{ selected.theme }}»

Если файла в теме нет, показан резерв компонента. Первое сохранение создаст переопределение, последующие изменения останутся в ревизиях темы.

+ +
+ + +
+
+

{{ selected.title }}

{{ selected.description }}

+
{{ selected.source_label }}{{ selected.has_theme_file ? selected.theme_path : selected.fallback_path }}
+
+ + {% if selected.has_theme_file and not selected.namespace_enabled %} +
Файл темы пока не подключён

После сохранения пространство {{ selected.namespace }} будет добавлено в реестр темы автоматически.

+ {% elseif not selected.has_theme_file and selected.has_fallback %} +
Сейчас сайт использует резерв компонента. Сохранение создаст файл {{ selected.theme_path }} в теме.
+ {% elseif not selected.has_theme_file %} +
Исходный файл отсутствует

Создайте шаблон в активной теме и заполните его Twig-разметкой.

+ {% endif %} + +
+ +
+
Twig-разметка{{ selected.modified_label ? 'Изменён: ' ~ selected.modified_label : 'Без сохранённого override' }}
+
+ + +
+
+ + + +
+ Данные этого шаблонаПеременные, которые можно вставлять в код +
+ {% for variable in selected.variables %} + + {% else %}

Специальные переменные не объявлены. Доступны глобальные данные публичного шаблона.

{% endfor %} +
+
+ + +
+
+
+{% endblock %} diff --git a/adminx/modules/Updates/NotificationProvider.php b/adminx/modules/Updates/NotificationProvider.php new file mode 100644 index 0000000..63e822a --- /dev/null +++ b/adminx/modules/Updates/NotificationProvider.php @@ -0,0 +1,57 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Updates; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\CoreUpdate\UpdateRepository; + + /** + * Доступные патчи ядра. + * + * Читаем только прогретый кеш каталога: сетевой запрос на отрисовке + * страницы недопустим. Кеш обновляет проверка «Обновления ядра» модуля + * мониторинга, а также открытие самого раздела обновлений. + */ + class NotificationProvider + { + public static function items() + { + $available = self::available(); + if ($available < 1) { return array(); } + + return array(array( + 'key' => 'core_updates', + 'icon' => 'ti ti-download', + 'bg' => '#dbeafe', + 'fg' => '#1d4ed8', + 'count' => $available, + 'title' => 'Обновление ядра', + 'text' => $available > 1 ? 'Доступно патчей: ' . $available : 'Доступен новый патч', + 'url' => '/system/updates', + )); + } + + protected static function available() + { + try { + $catalog = UpdateRepository::cachedCatalog(); + if (!is_array($catalog) || !empty($catalog['error'])) { return 0; } + return isset($catalog['available']) ? (int) $catalog['available'] : 0; + } catch (\Throwable $e) { + return 0; + } + } + } diff --git a/adminx/modules/Updates/module.php b/adminx/modules/Updates/module.php index be1cd98..9a71614 100644 --- a/adminx/modules/Updates/module.php +++ b/adminx/modules/Updates/module.php @@ -19,7 +19,8 @@ return array( 'code' => 'updates', 'name' => 'Обновления', - 'version' => '0.1.0', + 'version' => '0.2.0', + 'notifications' => array('provider' => array(\App\Adminx\Updates\NotificationProvider::class, 'items'), 'permission' => 'view_core_updates', 'sort_order' => 45), 'permissions' => array( 'key' => 'updates', 'items' => array( diff --git a/adminx/modules/Users/Controller.php b/adminx/modules/Users/Controller.php index 3028772..49112eb 100644 --- a/adminx/modules/Users/Controller.php +++ b/adminx/modules/Users/Controller.php @@ -34,6 +34,10 @@ /** GET /users */ public function index(array $params = []) { + if (!Permission::check('view_users')) { + return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); + } + AdminAssets::addStyle($this->base() . '/modules/Users/assets/users.css', 50); AdminAssets::addScript($this->base() . '/modules/Users/assets/users.js', 50); @@ -54,6 +58,10 @@ /** GET /users/{id} — данные пользователя (JSON, для формы правки). */ public function show(array $params = []) { + if (!Permission::check('view_users')) { + return $this->error('Недостаточно прав', array(), 403); + } + $user = Model::find($params['id'] ?? 0); if (!$user) { return $this->error('Пользователь не найден', [], 404); @@ -67,6 +75,8 @@ 'phone' => $user->phone, 'role' => $user->role, 'is_active' => (int) $user->is_active, + 'must_change_password' => (int) $user->must_change_password, + 'password_changed_at' => $user->password_changed_at, 'created_at' => $user->created_at, 'updated_at' => $user->updated_at, ]]); @@ -86,6 +96,9 @@ } $id = Model::create($data); + AuditLog::record('user.created', array( + 'actor_id' => Auth::id(), 'target_type' => 'user', 'target_id' => $id, + )); return $this->success('Пользователь создан', ['redirect' => $this->base() . '/users']); } @@ -114,6 +127,13 @@ } Model::update($id, $data); + AuditLog::record('user.updated', array( + 'actor_id' => Auth::id(), 'target_type' => 'user', 'target_id' => $id, + 'meta' => array( + 'password_changed' => $data['password'] !== '', + 'must_change_password' => !empty($data['must_change_password']), + ), + )); return $this->success('Изменения сохранены', ['redirect' => $this->base() . '/users']); } @@ -134,6 +154,10 @@ } $active = Model::toggle($id); + AuditLog::record('user.status_updated', array( + 'actor_id' => Auth::id(), 'target_type' => 'user', 'target_id' => $id, + 'meta' => array('is_active' => $active), + )); return $this->success($active ? 'Пользователь включён' : 'Пользователь отключён', [ 'data' => ['is_active' => $active], ]); @@ -156,9 +180,70 @@ } Model::delete($id); + AuditLog::record('user.deleted', array( + 'actor_id' => Auth::id(), 'target_type' => 'user', 'target_id' => $id, + )); return $this->success('Пользователь удалён', ['redirect' => $this->base() . '/users']); } + /** GET /users/{id}/security — active browser sessions and failed logins. */ + public function security(array $params = array()) + { + if (!Permission::check('manage_users')) { + return $this->error('Недостаточно прав', array(), 403); + } + + $id = (int) (isset($params['id']) ? $params['id'] : 0); + $user = Model::find($id); + if (!$user) { return $this->error('Пользователь не найден', array(), 404); } + + $data = Model::security($id); + $data['password_changed_at'] = isset($user->password_changed_at) ? (string) $user->password_changed_at : ''; + $data['must_change_password'] = !empty($user->must_change_password); + $data['is_self'] = $id === (int) Auth::id(); + + return $this->success('', array('data' => $data)); + } + + /** POST /users/{id}/sessions/{session}/revoke. */ + public function revokeSession(array $params = array()) + { + if (($response = $this->guard()) !== null) { return $response; } + $id = (int) (isset($params['id']) ? $params['id'] : 0); + $result = Model::revokeSession($id, isset($params['session']) ? $params['session'] : 0); + if (empty($result['removed'])) { return $this->error('Сессия не найдена', array(), 404); } + + AuditLog::record('user.session_revoked', array( + 'actor_id' => Auth::id(), 'target_type' => 'user', 'target_id' => $id, + 'meta' => array('session_id' => (int) $params['session']), + )); + if (!empty($result['current'])) { + Auth::logout(); + return $this->success('Текущая сессия завершена', array('redirect' => $this->base() . '/login')); + } + + return $this->success('Сессия завершена'); + } + + /** POST /users/{id}/sessions/revoke-others. */ + public function revokeOtherSessions(array $params = array()) + { + if (($response = $this->guard()) !== null) { return $response; } + $id = (int) (isset($params['id']) ? $params['id'] : 0); + if (!Model::find($id)) { return $this->error('Пользователь не найден', array(), 404); } + $preserve = $id === (int) Auth::id() ? Auth::currentBrowserTokenHash() : ''; + $count = Model::revokeSessions($id, $preserve); + + AuditLog::record('user.sessions_revoked', array( + 'actor_id' => Auth::id(), 'target_type' => 'user', 'target_id' => $id, + 'meta' => array('count' => $count, 'current_preserved' => $preserve !== ''), + )); + + return $this->success($preserve !== '' ? 'Остальные сессии завершены' : 'Все сессии завершены', array( + 'data' => array('count' => $count), + )); + } + public function bulk(array $params = array()) { if (($response = $this->guard()) !== null) { @@ -217,6 +302,7 @@ 'role' => Request::postStr('role'), 'password' => (string) Request::post('password', ''), 'is_active' => Request::postBool('is_active', false), + 'must_change_password' => Request::postBool('must_change_password', false), ]; } @@ -247,10 +333,10 @@ $errors['role'] = 'Выберите роль.'; } - if ($isCreate && strlen($data['password']) < 6) { - $errors['password'] = 'Пароль не короче 6 символов.'; - } elseif (!$isCreate && $data['password'] !== '' && strlen($data['password']) < 6) { - $errors['password'] = 'Пароль не короче 6 символов.'; + if ($isCreate && mb_strlen($data['password'], 'UTF-8') < 8) { + $errors['password'] = 'Пароль не короче 8 символов.'; + } elseif (!$isCreate && $data['password'] !== '' && mb_strlen($data['password'], 'UTF-8') < 8) { + $errors['password'] = 'Пароль не короче 8 символов.'; } return $errors; diff --git a/adminx/modules/Users/Model.php b/adminx/modules/Users/Model.php index edc7b69..8c64cbe 100644 --- a/adminx/modules/Users/Model.php +++ b/adminx/modules/Users/Model.php @@ -17,6 +17,8 @@ defined('BASEPATH') || die('Direct access to this location is not allowed.'); use DB; + use App\Common\AuditLog; + use App\Common\Auth; use App\Common\Auth\IdentityLinker; use App\Common\SystemTables; @@ -37,6 +39,11 @@ return SystemTables::table('users'); } + protected static function sessionTable() + { + return SystemTables::table('users_session'); + } + /** Пустую строку сохраняем как NULL. */ protected static function nullable(array $data, $key) { @@ -47,7 +54,8 @@ /** Список с фильтром по поиску (имя/email/логин) и роли. */ public static function all($search = '', $role = '') { - $sql = 'SELECT id, name, email, login, phone, role, is_active, created_at, updated_at, last_login_at FROM ' . self::table() . ' WHERE 1=1'; + $sql = 'SELECT id,name,email,login,phone,role,is_active,created_at,updated_at,last_login_at,' + . 'password_changed_at,must_change_password FROM ' . self::table() . ' WHERE 1=1'; $args = []; $search = trim((string) $search); @@ -110,12 +118,14 @@ } /** Проставить bcrypt-хеш и очистить legacy-пароль (после первого входа). */ - public static function setPassword($id, $hash) + public static function setPassword($id, $hash, $mustChange = false) { DB::Update(self::table(), [ 'password_hash' => (string) $hash, 'legacy_password' => null, 'legacy_salt' => null, + 'password_changed_at' => date('Y-m-d H:i:s'), + 'must_change_password' => $mustChange ? 1 : 0, 'updated_at' => date('Y-m-d H:i:s'), ], 'id = %i', (int) $id); } @@ -129,6 +139,8 @@ 'login' => self::nullable($data, 'login'), 'phone' => self::nullable($data, 'phone'), 'password_hash' => password_hash((string) $data['password'], PASSWORD_BCRYPT, ['cost' => 12]), + 'password_changed_at' => $now, + 'must_change_password' => !empty($data['must_change_password']) ? 1 : 0, 'role' => (string) $data['role'], 'is_active' => !empty($data['is_active']) ? 1 : 0, 'created_at' => $now, @@ -148,24 +160,48 @@ 'phone' => self::nullable($data, 'phone'), 'role' => (string) $data['role'], 'is_active' => !empty($data['is_active']) ? 1 : 0, + 'must_change_password' => !empty($data['must_change_password']) ? 1 : 0, 'updated_at' => date('Y-m-d H:i:s'), ]; //-- Пароль меняем только если задан новый. if (!empty($data['password'])) { $fields['password_hash'] = password_hash((string) $data['password'], PASSWORD_BCRYPT, ['cost' => 12]); + $fields['password_changed_at'] = date('Y-m-d H:i:s'); } DB::Update(self::table(), $fields, 'id = %i', (int) $id); + if (!empty($data['password'])) { + self::revokeSessions((int) $id, (int) Auth::id() === (int) $id ? Auth::currentBrowserTokenHash() : ''); + } + IdentityLinker::ensurePublicForSystem((int) $id, !empty($data['password']) ? (string) $data['password'] : ''); } + public static function completePasswordChange($id, $password) + { + $hash = password_hash((string) $password, PASSWORD_BCRYPT, array('cost' => 12)); + self::setPassword((int) $id, $hash, false); + self::revokeSessions((int) $id, Auth::currentBrowserTokenHash()); + IdentityLinker::ensurePublicForSystem((int) $id, (string) $password); + } + + public static function recordSuccessfulLogin($id) + { + DB::Update(self::table(), array( + 'last_login_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ), 'id=%i', (int) $id); + } + public static function toggle($id) { DB::query( 'UPDATE ' . self::table() . ' SET is_active = 1 - is_active, updated_at = %s WHERE id = %i', date('Y-m-d H:i:s'), (int) $id ); - return (int) DB::query('SELECT is_active FROM ' . self::table() . ' WHERE id = %i', (int) $id)->getValue(); + $active = (int) DB::query('SELECT is_active FROM ' . self::table() . ' WHERE id = %i', (int) $id)->getValue(); + if (!$active) { self::revokeSessions((int) $id); } + return $active; } public static function setActive($id, $active) @@ -174,13 +210,126 @@ 'is_active' => $active ? 1 : 0, 'updated_at' => date('Y-m-d H:i:s'), ), 'id = %i', (int) $id); + if (!$active) { self::revokeSessions((int) $id); } } public static function delete($id) { + self::revokeSessions((int) $id); DB::Delete(self::table(), 'id = %i', (int) $id); } + public static function security($userId) + { + $userId = (int) $userId; + $currentHash = Auth::currentBrowserTokenHash(); + $ttl = defined('COOKIE_LIFETIME') ? max(3600, (int) COOKIE_LIFETIME) : 1209600; + $rows = DB::query( + 'SELECT id,user_id,token_hash,agent,ip,created_at,last_active,expires_at FROM ' . self::sessionTable() + . ' WHERE user_id=%i AND last_active>=%s AND expires_at>=%s ORDER BY last_active DESC,id DESC', + $userId, + date('Y-m-d H:i:s', time() - $ttl), + date('Y-m-d H:i:s') + )->getAll() ?: array(); + $sessions = array(); + foreach ($rows as $row) { + $device = self::device((string) $row['agent']); + $sessions[] = array( + 'id' => (int) $row['id'], + 'ip' => (string) $row['ip'], + 'created_at' => (string) $row['created_at'], + 'last_active' => (string) $row['last_active'], + 'expires_at' => (string) $row['expires_at'], + 'is_current' => $currentHash !== '' && hash_equals($currentHash, (string) $row['token_hash']), + 'browser' => $device['browser'], + 'platform' => $device['platform'], + 'device' => $device['device'], + ); + } + + return array( + 'sessions' => $sessions, + 'failed' => self::failedLoginSummary($userId), + ); + } + + public static function revokeSession($userId, $sessionId) + { + $row = DB::query( + 'SELECT id,token_hash FROM ' . self::sessionTable() . ' WHERE id=%i AND user_id=%i LIMIT 1', + (int) $sessionId, + (int) $userId + )->getAssoc(); + if (!$row) { return array('removed' => false, 'current' => false); } + $currentHash = Auth::currentBrowserTokenHash(); + $current = $currentHash !== '' && hash_equals($currentHash, (string) $row['token_hash']); + DB::Delete(self::sessionTable(), 'id=%i AND user_id=%i', (int) $sessionId, (int) $userId); + return array('removed' => true, 'current' => $current); + } + + public static function revokeSessions($userId, $exceptTokenHash = '') + { + $exceptTokenHash = trim((string) $exceptTokenHash); + if (preg_match('/^[a-f0-9]{64}$/', $exceptTokenHash)) { + DB::Delete(self::sessionTable(), 'user_id=%i AND token_hash!=%s', (int) $userId, $exceptTokenHash); + } else { + DB::Delete(self::sessionTable(), 'user_id=%i', (int) $userId); + } + + return max(0, (int) DB::affectedRows()); + } + + public static function failedLoginSummary($userId) + { + $since = date('Y-m-d H:i:s', time() - 86400); + $count = (int) DB::query( + 'SELECT COUNT(*) FROM ' . AuditLog::table() + . ' WHERE action=%s AND target_id=%i AND created_at>=%s', + 'auth.login_failed', + (int) $userId, + $since + )->getValue(); + $last = DB::query( + 'SELECT created_at,ip FROM ' . AuditLog::table() + . ' WHERE action=%s AND target_id=%i ORDER BY id DESC LIMIT 1', + 'auth.login_failed', + (int) $userId + )->getAssoc(); + + return array( + 'count_24h' => $count, + 'last_at' => $last ? (string) $last['created_at'] : '', + 'last_ip' => $last ? (string) $last['ip'] : '', + ); + } + + public static function failedLoginSpike($minutes = 10) + { + return (int) DB::query( + 'SELECT COUNT(*) FROM ' . AuditLog::table() . ' WHERE action=%s AND created_at>=%s', + 'auth.login_failed', + date('Y-m-d H:i:s', time() - max(1, (int) $minutes) * 60) + )->getValue(); + } + + protected static function device($agent) + { + $agent = (string) $agent; + $browser = strpos($agent, 'Edg/') !== false ? 'Edge' + : (strpos($agent, 'OPR/') !== false ? 'Opera' + : (strpos($agent, 'Firefox/') !== false ? 'Firefox' + : (strpos($agent, 'Chrome/') !== false ? 'Chrome' + : (strpos($agent, 'Safari/') !== false ? 'Safari' : '')))); + $platform = strpos($agent, 'Windows') !== false ? 'Windows' + : (strpos($agent, 'Android') !== false ? 'Android' + : (strpos($agent, 'iPhone') !== false || strpos($agent, 'iPad') !== false ? 'iOS' + : (strpos($agent, 'Macintosh') !== false ? 'macOS' + : (strpos($agent, 'Linux') !== false ? 'Linux' : '')))); + $device = strpos($agent, 'Mobile') !== false || strpos($agent, 'Android') !== false ? 'mobile' : 'desktop'; + + return array('browser' => $browser, 'platform' => $platform, 'device' => $device); + } + public static function count() { return (int) DB::query('SELECT COUNT(*) FROM ' . self::table())->getValue(); diff --git a/adminx/modules/Users/NotificationProvider.php b/adminx/modules/Users/NotificationProvider.php new file mode 100644 index 0000000..da946f1 --- /dev/null +++ b/adminx/modules/Users/NotificationProvider.php @@ -0,0 +1,37 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Adminx\Users; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + class NotificationProvider + { + public static function data() + { + $count = Model::failedLoginSpike(10); + if ($count < 5) { return array(); } + + return array(array( + 'key' => 'failed_logins', + 'icon' => 'ti ti-shield-exclamation', + 'bg' => '#fee2e2', + 'fg' => '#b91c1c', + 'count' => $count, + 'title' => 'Неудачные попытки входа', + 'text' => 'За последние 10 минут. Проверьте пользователей и аудит.', + 'url' => '/users', + )); + } + } diff --git a/adminx/modules/Users/assets/users.css b/adminx/modules/Users/assets/users.css index 387e074..c95a550 100644 --- a/adminx/modules/Users/assets/users.css +++ b/adminx/modules/Users/assets/users.css @@ -30,6 +30,19 @@ display: grid; gap: 14px; } +.users-editor-tabs { + margin: 0; +} +.users-editor-panel { + display: grid; + gap: 14px; +} +.users-editor-panel[hidden] { + display: none; +} +.users-drawer [data-user-save][hidden] { + display: none; +} .users-field-input { width: 100%; } @@ -100,6 +113,16 @@ gap: 12px; align-items: end; } +.users-password-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(280px, 0.8fr); + gap: 16px; + align-items: start; +} +.users-password-required { + min-height: 76px; + margin-top: 22px; +} .users-active-card { display: flex; align-items: center; @@ -134,6 +157,67 @@ align-items: center; gap: 6px; } +.users-security-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} +.users-security-stats > div { + display: flex; + min-height: 92px; + flex-direction: column; + gap: 5px; + padding: 13px 14px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--background-inset); +} +.users-security-stats span, +.users-security-stats small { + color: var(--text-secondary); + font-size: 12px; +} +.users-security-stats b { + font-size: 16px; +} +.users-sessions-head .btn { + margin-left: auto; +} +.users-session-list { + display: grid; + gap: 8px; +} +.users-session { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); +} +.users-session.is-current { + border-color: var(--green-500); + background: var(--green-50); +} +.users-session-main { + display: flex; + min-width: 0; + flex: 1 1 auto; + flex-direction: column; + gap: 4px; +} +.users-session-main > div { + display: flex; + align-items: center; + gap: 8px; +} +.users-session-main > span { + overflow: hidden; + color: var(--text-secondary); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} @media (max-width: 680px) { .users-filter { width: 100%; @@ -153,4 +237,25 @@ .users-access-grid { grid-template-columns: 1fr; } + .users-password-grid, + .users-security-stats { + grid-template-columns: 1fr; + } + .users-password-required { + margin-top: 0; + } + .users-sessions-head { + align-items: flex-start; + flex-wrap: wrap; + } + .users-sessions-head .btn { + width: 100%; + margin-left: 0; + } + .users-session { + align-items: flex-start; + } + .users-session-main > span { + white-space: normal; + } } diff --git a/adminx/modules/Users/assets/users.js b/adminx/modules/Users/assets/users.js index ccf7e08..2921baf 100644 --- a/adminx/modules/Users/assets/users.js +++ b/adminx/modules/Users/assets/users.js @@ -8,8 +8,13 @@ var Adminx = window.Adminx || (window.Adminx = {}); - Adminx.Users = { - form: null, + Adminx.Users = { + form: null, + currentId: '', + + t: function (key, fallback) { + return typeof Adminx.t === 'function' ? Adminx.t(key, fallback) : fallback; + }, init: function () { this.form = document.getElementById('userForm'); @@ -22,10 +27,15 @@ if (editBtn) { self.fillEdit(editBtn.closest('tr')); } var delBtn = e.target.closest('[data-user-delete]'); if (delBtn) { self.remove(delBtn.closest('tr')); } - var pwBtn = e.target.closest('[data-pw-toggle]'); + var pwBtn = e.target.closest('[data-pw-toggle]'); if (pwBtn) { self.togglePw(pwBtn); } if (e.target.closest('[data-pw-generate]')) { self.generatePassword(); } - if (e.target.closest('[data-pw-copy]')) { self.copyPassword(); } + if (e.target.closest('[data-pw-copy]')) { self.copyPassword(); } + var tab = e.target.closest('[data-user-tab]'); + if (tab) { self.activateTab(tab.getAttribute('data-user-tab')); } + var revoke = e.target.closest('[data-session-revoke]'); + if (revoke) { self.revokeSession(revoke); } + if (e.target.closest('[data-user-revoke-others]')) { self.revokeOthers(); } }); document.addEventListener('change', function (e) { @@ -61,11 +71,15 @@ if (el) { el.value = value; } }, - fillNew: function () { + fillNew: function () { this.clearErrors(); this.form.reset(); - this.setField('id', ''); - this.form.querySelector('[name="is_active"]').checked = true; + this.setField('id', ''); + this.currentId = ''; + this.form.querySelector('[name="is_active"]').checked = true; + this.form.querySelector('[name="must_change_password"]').checked = true; + this.form.querySelector('[data-user-tab="security"]').hidden = true; + this.activateTab('account'); this.setSelfLock(false); this.resetPassword(); document.getElementById('userDrawerTitle').textContent = 'Новый пользователь'; @@ -76,10 +90,11 @@ this.updatePreview(); }, - fillEdit: function (row) { + fillEdit: function (row) { if (!row) { return; } this.clearErrors(); - this.setField('id', row.dataset.id); + this.setField('id', row.dataset.id); + this.currentId = row.dataset.id; this.setField('name', row.dataset.name); this.setField('email', row.dataset.email); this.setField('login', row.dataset.login || ''); @@ -87,7 +102,10 @@ this.setField('role', row.dataset.role); this.setField('password', ''); this.resetPassword(); - this.form.querySelector('[name="is_active"]').checked = row.dataset.active === '1'; + this.form.querySelector('[name="is_active"]').checked = row.dataset.active === '1'; + this.form.querySelector('[name="must_change_password"]').checked = row.dataset.mustChangePassword === '1'; + this.form.querySelector('[data-user-tab="security"]').hidden = false; + this.activateTab('account'); this.setSelfLock(row.hasAttribute('data-self')); document.getElementById('userDrawerTitle').textContent = 'Изменение: ' + row.dataset.name; var hint = this.form.querySelector('[data-pass-hint]'); @@ -98,8 +116,117 @@ var updated = this.form.querySelector('[data-user-updated]'); if (created) { created.textContent = this.formatDate(row.dataset.created); } if (updated) { updated.textContent = this.formatDate(row.dataset.updated); } - this.updatePreview(row.dataset.roleLabel || row.dataset.role); - }, + this.updatePreview(row.dataset.roleLabel || row.dataset.role); + this.loadSecurity(); + }, + + activateTab: function (name) { + name = name === 'security' && this.currentId ? 'security' : 'account'; + this.form.querySelectorAll('[data-user-tab]').forEach(function (button) { + var active = button.getAttribute('data-user-tab') === name; + button.classList.toggle('active', active); + button.setAttribute('aria-selected', active ? 'true' : 'false'); + }); + this.form.querySelectorAll('[data-user-panel]').forEach(function (panel) { + panel.hidden = panel.getAttribute('data-user-panel') !== name; + }); + var save = this.form.querySelector('[data-user-save]'); + if (save) { save.hidden = name !== 'account'; } + }, + + loadSecurity: function () { + if (!this.currentId) { return; } + var list = this.form.querySelector('[data-user-sessions]'); + if (list) { list.innerHTML = '
' + this.t('users_sessions_loading', 'Загружаем сессии') + '
'; } + var self = this; + Adminx.Ajax.request(this.base() + '/users/' + this.currentId + '/security').then(function (payload) { + var response = payload.data || {}; + if (!response.success) { + if (list) { list.innerHTML = '
' + self.t('users_sessions_load_failed', 'Не удалось загрузить сессии') + '
'; } + return; + } + self.renderSecurity(response.data || {}); + }); + }, + + renderSecurity: function (data) { + var failed = data.failed || {}; + var set = function (selector, value) { + var node = Adminx.Users.form.querySelector(selector); + if (node) { node.textContent = value; } + }; + set('[data-user-failed-count]', String(failed.count_24h || 0)); + set('[data-user-failed-last]', failed.last_at ? this.formatDate(failed.last_at) : this.t('users_never', 'Не было')); + set('[data-user-failed-ip]', failed.last_ip ? 'IP ' + failed.last_ip : ''); + set('[data-user-password-changed]', data.password_changed_at ? this.formatDate(data.password_changed_at) : this.t('users_no_data', 'Нет данных')); + set('[data-user-password-state]', data.must_change_password + ? this.t('users_password_change_pending', 'При следующем входе потребуется новый пароль') + : this.t('users_password_change_clear', 'Обязательная смена не назначена')); + set('[data-user-revoke-label]', data.is_self + ? this.t('users_revoke_others', 'Завершить остальные') + : this.t('users_revoke_all', 'Завершить все')); + + var list = this.form.querySelector('[data-user-sessions]'); + if (!list) { return; } + list.innerHTML = ''; + var sessions = Array.isArray(data.sessions) ? data.sessions : []; + if (!sessions.length) { + list.innerHTML = '
' + + this.t('users_sessions_empty', 'Активных сессий нет') + '' + + this.t('users_sessions_empty_hint', 'Пользователь войдёт снова с действующим паролем.') + '
'; + return; + } + + var self = this; + sessions.forEach(function (session) { + var item = document.createElement('article'); + item.className = 'users-session' + (session.is_current ? ' is-current' : ''); + var icon = document.createElement('span'); + icon.className = 'icon-tile'; + icon.innerHTML = ''; + var content = document.createElement('div'); + content.className = 'users-session-main'; + var title = document.createElement('div'); + var strong = document.createElement('b'); + strong.textContent = (session.browser || self.t('users_browser', 'Браузер')) + ' · ' + + (session.platform || self.t('users_device', 'Устройство')); + title.appendChild(strong); + if (session.is_current) { + var badge = document.createElement('span'); badge.className = 'badge badge-green'; badge.textContent = self.t('users_session_current', 'Текущая'); title.appendChild(badge); + } + var meta = document.createElement('span'); + meta.textContent = 'IP ' + (session.ip || '—') + ' · ' + self.t('users_session_login', 'вход') + ' ' + + self.formatDate(session.created_at) + ' · ' + self.t('users_session_activity', 'активность') + ' ' + + self.formatDate(session.last_active); + content.appendChild(title); content.appendChild(meta); + var button = document.createElement('button'); + button.type = 'button'; button.className = 'btn btn-ghost btn-icon ax-act ax-act-danger'; + button.setAttribute('data-session-revoke', String(session.id)); button.setAttribute('data-tooltip', self.t('users_revoke_session', 'Завершить сессию')); + button.setAttribute('aria-label', self.t('users_revoke_session', 'Завершить сессию')); button.innerHTML = ''; + item.appendChild(icon); item.appendChild(content); item.appendChild(button); list.appendChild(item); + }); + }, + + revokeSession: function (button) { + if (!this.currentId) { return; } + var self = this; + var sessionId = button.getAttribute('data-session-revoke'); + Adminx.Ajax.post(this.base() + '/users/' + this.currentId + '/sessions/' + sessionId + '/revoke').then(function (payload) { + var data = payload.data || {}; + if (data.success) { Adminx.Ajax.handle(payload); if (!data.redirect) { self.loadSecurity(); } } + else { Adminx.Toast.show(data.message || self.t('users_revoke_failed', 'Не удалось завершить сессию'), 'error'); } + }); + }, + + revokeOthers: function () { + if (!this.currentId) { return; } + var self = this; + Adminx.Ajax.post(this.base() + '/users/' + this.currentId + '/sessions/revoke-others').then(function (payload) { + var data = payload.data || {}; + if (data.success) { Adminx.Toast.show(data.message, 'success'); self.loadSecurity(); } + else { Adminx.Toast.show(data.message || self.t('users_revoke_all_failed', 'Не удалось завершить сессии'), 'error'); } + }); + }, setSelfLock: function (locked) { var role = this.form.querySelector('[name="role"]'); @@ -159,7 +286,7 @@ } var s = 0; - if (value.length >= 6) { s++; } + if (value.length >= 8) { s++; } if (value.length >= 10) { s++; } if (/[a-z]/.test(value) && /[A-Z]/.test(value)) { s++; } if (/\d/.test(value) && /[^A-Za-z0-9]/.test(value)) { s++; } diff --git a/adminx/modules/Users/language/en/client.xml b/adminx/modules/Users/language/en/client.xml index b728385..b755b27 100644 --- a/adminx/modules/Users/language/en/client.xml +++ b/adminx/modules/Users/language/en/client.xml @@ -26,4 +26,22 @@ average excellent Change: + Browser + Device + Never + No data + No mandatory change scheduled + A new password will be required at the next login + End all sessions + Could not end the sessions + Could not end the session + End other sessions + End session + activity + Current + login + No active sessions + The user can sign in again with a valid password. + Could not load sessions + Loading sessions diff --git a/adminx/modules/Users/language/en/interface.xml b/adminx/modules/Users/language/en/interface.xml index 72b309c..b6e2759 100644 --- a/adminx/modules/Users/language/en/interface.xml +++ b/adminx/modules/Users/language/en/interface.xml @@ -82,4 +82,33 @@ good This email is already taken. This login is already taken. + Account + Sessions and logins + Require change at next login + Use this for a new employee's temporary password. + Login attempts + Events from the last 24 hours and the password change date. + Failed in 24 hours + Last attempt + Password changed + Active sessions + Browsers with a saved control panel login. + Loading sessions + Could not load sessions + Never + No data + A new password will be required at the next login + No mandatory change scheduled + End other sessions + End all sessions + No active sessions + The user can sign in again with a valid password. + Browser + Device + Current + login + activity + End session + Could not end the session + Could not end the sessions diff --git a/adminx/modules/Users/language/en/runtime.xml b/adminx/modules/Users/language/en/runtime.xml index 5cc9ee9..714cd96 100644 --- a/adminx/modules/Users/language/en/runtime.xml +++ b/adminx/modules/Users/language/en/runtime.xml @@ -22,4 +22,11 @@ User management This email is already taken. This login is already taken. + Session not found + Current session ended + Session ended + Other sessions ended + All sessions ended + Failed login attempts + During the last 10 minutes. Check users and the audit log. diff --git a/adminx/modules/Users/language/ru/client.xml b/adminx/modules/Users/language/ru/client.xml index d490b0a..ee37675 100644 --- a/adminx/modules/Users/language/ru/client.xml +++ b/adminx/modules/Users/language/ru/client.xml @@ -26,4 +26,22 @@ средний отличный Изменение: + Браузер + Устройство + Не было + Нет данных + Обязательная смена не назначена + При следующем входе потребуется новый пароль + Завершить все + Не удалось завершить сессии + Не удалось завершить сессию + Завершить остальные + Завершить сессию + активность + Текущая + вход + Активных сессий нет + Пользователь войдёт снова с действующим паролем. + Не удалось загрузить сессии + Загружаем сессии diff --git a/adminx/modules/Users/language/ru/interface.xml b/adminx/modules/Users/language/ru/interface.xml index 62cdf29..ecaf3f6 100644 --- a/adminx/modules/Users/language/ru/interface.xml +++ b/adminx/modules/Users/language/ru/interface.xml @@ -82,4 +82,33 @@ хороший Этот email уже занят. Этот логин уже занят. + Учётная запись + Сессии и входы + Сменить при следующем входе + Подходит для временного пароля нового сотрудника. + Попытки входа + События за последние сутки и дата смены пароля. + Неудачных за 24 часа + Последняя попытка + Пароль изменён + Активные сессии + Браузеры, в которых сохранён вход в панель. + Загружаем сессии + Не удалось загрузить сессии + Не было + Нет данных + При следующем входе потребуется новый пароль + Обязательная смена не назначена + Завершить остальные + Завершить все + Активных сессий нет + Пользователь войдёт снова с действующим паролем. + Браузер + Устройство + Текущая + вход + активность + Завершить сессию + Не удалось завершить сессию + Не удалось завершить сессии diff --git a/adminx/modules/Users/language/ru/runtime.xml b/adminx/modules/Users/language/ru/runtime.xml index 7ff5048..5232fb9 100644 --- a/adminx/modules/Users/language/ru/runtime.xml +++ b/adminx/modules/Users/language/ru/runtime.xml @@ -22,4 +22,11 @@ Управление пользователями Этот email уже занят. Этот логин уже занят. + Сессия не найдена + Текущая сессия завершена + Сессия завершена + Остальные сессии завершены + Все сессии завершены + Неудачные попытки входа + За последние 10 минут. Проверьте пользователей и аудит. diff --git a/adminx/modules/Users/migrations/003_system_user_security.php b/adminx/modules/Users/migrations/003_system_user_security.php new file mode 100644 index 0000000..0534c7d --- /dev/null +++ b/adminx/modules/Users/migrations/003_system_user_security.php @@ -0,0 +1,54 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\AuditLog; + use App\Common\DatabaseSchema; + use App\Common\SystemTables; + + return function (array $context = array()) { + $users = SystemTables::table('users'); + $sessions = SystemTables::table('users_session'); + $operations = 0; + + if (!DatabaseSchema::columnExists($users, 'password_changed_at')) { + DatabaseSchema::alter('ALTER TABLE `' . $users . '` ADD COLUMN `password_changed_at` DATETIME NULL AFTER `last_login_at`'); + $operations++; + } + + if (!DatabaseSchema::columnExists($users, 'must_change_password')) { + DatabaseSchema::alter('ALTER TABLE `' . $users . '` ADD COLUMN `must_change_password` TINYINT(1) NOT NULL DEFAULT 0 AFTER `password_changed_at`'); + $operations++; + } + + DB::query( + 'UPDATE `' . $users . '` SET `password_changed_at`=COALESCE(`updated_at`,`created_at`)' + . ' WHERE `password_changed_at` IS NULL AND `password_hash`<>%s', + '' + ); + + if (DatabaseSchema::tableExists($sessions) && !DatabaseSchema::indexExists($sessions, 'idx_user_active')) { + DatabaseSchema::alter('ALTER TABLE `' . $sessions . '` ADD KEY `idx_user_active` (`user_id`,`last_active`)'); + $operations++; + } + + $audit = AuditLog::table(); + if (DatabaseSchema::tableExists($audit) && !DatabaseSchema::indexExists($audit, 'idx_target_action')) { + DatabaseSchema::alter('ALTER TABLE `' . $audit . '` ADD KEY `idx_target_action` (`target_id`,`action`,`created_at`)'); + $operations++; + } + + return $operations; + }; diff --git a/adminx/modules/Users/migrations/004_system_session_expiry.php b/adminx/modules/Users/migrations/004_system_session_expiry.php new file mode 100644 index 0000000..cfd9ba1 --- /dev/null +++ b/adminx/modules/Users/migrations/004_system_session_expiry.php @@ -0,0 +1,36 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Common\SystemTables; + + return function (array $context) { + $table = SystemTables::table('users_session'); + if (!DB::query('SHOW COLUMNS FROM ' . $table . ' LIKE %s', 'expires_at')->getAssoc()) { + DB::query('ALTER TABLE ' . $table . ' ADD expires_at DATETIME NULL AFTER last_active'); + } + + $ttl = defined('COOKIE_LIFETIME') ? max(3600, (int) COOKIE_LIFETIME) : 1209600; + DB::query( + 'UPDATE ' . $table . ' SET expires_at=DATE_ADD(created_at,INTERVAL %i SECOND) WHERE expires_at IS NULL', + $ttl + ); + if (!DB::query('SHOW INDEX FROM ' . $table . ' WHERE Key_name=%s', 'idx_expiry')->getAssoc()) { + DB::query('ALTER TABLE ' . $table . ' ADD KEY idx_expiry (expires_at)'); + } + + DB::query('ALTER TABLE ' . $table . ' MODIFY expires_at DATETIME NOT NULL'); + return 1; + }; diff --git a/adminx/modules/Users/module.php b/adminx/modules/Users/module.php index c6f809a..5649b14 100644 --- a/adminx/modules/Users/module.php +++ b/adminx/modules/Users/module.php @@ -21,7 +21,7 @@ return [ 'code' => 'users', 'name' => 'Пользователи', - 'version' => '0.2.0', + 'version' => '0.3.1', 'permissions' => [ 'key' => 'users', @@ -64,8 +64,11 @@ array('GET', '/users', array(\App\Adminx\Users\Controller::class, 'index')), array('POST', '/users/bulk', array(\App\Adminx\Users\Controller::class, 'bulk')), array('GET', '/users/{id}', array(\App\Adminx\Users\Controller::class, 'show')), + array('GET', '/users/{id}/security', array(\App\Adminx\Users\Controller::class, 'security')), array('POST', '/users', array(\App\Adminx\Users\Controller::class, 'store')), array('POST', '/users/{id}', array(\App\Adminx\Users\Controller::class, 'update')), + array('POST', '/users/{id}/sessions/revoke-others', array(\App\Adminx\Users\Controller::class, 'revokeOtherSessions')), + array('POST', '/users/{id}/sessions/{session}/revoke', array(\App\Adminx\Users\Controller::class, 'revokeSession')), array('POST', '/users/{id}/toggle', array(\App\Adminx\Users\Controller::class, 'toggle')), array('POST', '/users/{id}/delete', array(\App\Adminx\Users\Controller::class, 'destroy')), ), @@ -73,8 +76,18 @@ 'migrations' => [ ['id' => '001_normalize_public_session_activity', 'file' => 'migrations/001_normalize_public_session_activity.sql'], ['id' => '002_harden_public_remember_tokens', 'file' => 'migrations/002_harden_public_remember_tokens.php'], + ['id' => '003_system_user_security', 'file' => 'migrations/003_system_user_security.php'], + ['id' => '004_system_session_expiry', 'file' => 'migrations/004_system_session_expiry.php'], ], + 'admin_extension' => array( + 'notifications' => array( + 'provider' => array(\App\Adminx\Users\NotificationProvider::class, 'data'), + 'permission' => 'manage_users', + 'sort_order' => 8, + ), + ), + 'view_globals' => [ 'module_code' => 'users', ], diff --git a/adminx/modules/Users/view/list.twig b/adminx/modules/Users/view/list.twig index 9bb22cf..cb672e6 100644 --- a/adminx/modules/Users/view/list.twig +++ b/adminx/modules/Users/view/list.twig @@ -79,7 +79,7 @@ {% for u in users %} {% set is_self = u.id == current_id %} - + {% if can_manage %}{% endif %} {{ u.id }} @@ -137,6 +137,8 @@
НП
Новый пользовательУчётная запись ещё не сохранена
Пользователь
+
+
Основные данныеИмя и контакты сотрудника.
@@ -150,14 +152,19 @@
-
БезопасностьПароль обязателен для новой учётной записи.
Новый пароль
Минимум 6 символов, желательно буквы разных регистров и цифры
+
БезопасностьПароль обязателен для новой учётной записи.
Новый пароль
Минимум 8 символов, желательно буквы разных регистров и цифры
+
+
diff --git a/configs/public.config.php b/configs/public.config.php index 8524aab..7b0ea6d 100644 --- a/configs/public.config.php +++ b/configs/public.config.php @@ -36,6 +36,8 @@ // На сервере без reverse proxy оставьте список пустым. 'trusted_proxies' => array(), 'trusted_proxy_headers' => array('x-forwarded-for'), + // Дополнительные домены, разрешённые помимо PUBLIC_SITE_URL. + 'allowed_hosts' => array(), 'debug' => array( 'enabled' => true, 'groups' => array(1), diff --git a/help/administration/README.md b/help/administration/README.md index 940535e..f7b9149 100644 --- a/help/administration/README.md +++ b/help/administration/README.md @@ -23,8 +23,9 @@ | Каталог | Универсальные деревья разделов и фильтры. | [Каталог](../content/catalog.md) | | Медиа | Файлы, изображения, WebP и миниатюры. | [Медиа](../media/README.md) | | Роли и права | Разрешения сотрудников панели. | [Пользователи и доступ](users.md) | +| Симулятор прав | Объяснение доступа выбранной роли или пользователя. | [Симулятор прав](permission-simulator.md) | | Пользователи | Сотрудники, которые могут работать в панели. | [Пользователи и доступ](users.md) | -| Пользователи сайта | Регистрация, личный кабинет и публичный профиль. | [Пользователи и доступ](users.md) | +| Пользователи сайта | Регистрация, профиль и история покупателя. | [Пользователи и доступ](users.md), [Центр покупателей](customer-center.md) | | Настройки | Параметры сайта, интерфейс, безопасность, кеш и диагностика. | [Настройки](settings.md) | | База данных | Схема, InnoDB, резервные копии и миграции. | [База данных и события](operations.md) | | События | Аудит, runtime, 404, SQL-ошибки и переходы. | [База данных и события](operations.md) | diff --git a/help/administration/customer-center.md b/help/administration/customer-center.md new file mode 100644 index 0000000..10a34ce --- /dev/null +++ b/help/administration/customer-center.md @@ -0,0 +1,70 @@ +# Центр покупателей + +## Поиск, представления и CSV + +Покупателя можно найти из любого раздела панели через `Ctrl+K` по имени, +логину, email, телефону, компании или ID. Результат ведёт в список публичных +аккаунтов. + +На вкладке **Центр покупателей** текущий поиск и сегмент можно сохранить как +личное представление. На вкладке **Пользователи** кнопка **CSV** выгружает +текущую поисковую выборку, а не всю таблицу. Файл формируется частями и подходит +для больших списков; значения, похожие на формулы таблиц, обезвреживаются. + +Центр покупателей находится в разделе **Система -> Пользователи сайта**. Он не +создаёт второй список людей: главной записью остаётся обычный публичный аккаунт, +к которому собираются данные установленных модулей. + +## Что видно в центре + +- контактные данные и состояние аккаунта; +- количество и сумма заказов; +- последние заказы и даты покупок; +- обращения контактных форм, найденные по email; +- избранные и просмотренные товары; +- email, телефон и подключённые способы входа; +- внутренние заметки менеджеров; +- вычисляемые сегменты покупателей. + +Если Commerce или Контактные формы не установлены, соответствующий блок остаётся +пустым. Сам раздел пользователей сайта продолжает работать. + +## Сегменты + +| Сегмент | Условие | +| --- | --- | +| Новые | регистрация за последние 30 дней | +| С заказами | есть хотя бы один связанный заказ | +| Повторные | два заказа или больше | +| От 100 000 ₽ | общая сумма заказов не меньше 100 000 ₽ | +| Неактивные | не входил либо последний вход был более 180 дней назад | +| Без заказов | ни одного связанного заказа | + +Сегмент нужен для быстрого отбора и понимания контекста. Он не меняет права и не +запускает скидки, рассылки или другие действия сам по себе. + +## Заметки менеджеров + +Откройте карточку покупателя и добавьте заметку. Заметка видна только в панели; +в публичный профиль и API пользователя она не выводится. Сохраняются автор и +время записи. + +## Объединение дублей + +Центр ищет активные аккаунты с одинаковым нормализованным email или телефоном. +При объединении выберите основной аккаунт. В него переходят заказы, незаполненные +значения профиля, избранное, просмотры, способы входа и заметки. + +Исходный аккаунт отключается и помечается удалённым. История заказов сохраняется. +Аккаунт, связанный с доступом в панель, объединять нельзя. Если у обоих аккаунтов +подключён один OAuth-провайдер, операция блокируется, чтобы не потерять вход. + +## Шаблоны входа и личного кабинета + +На вкладке **Страницы личного кабинета** редактируются основные страницы входа, +регистрации и профиля. В блоке **Служебные маршруты и фрагменты** дополнительно +доступны панель пользователя в шапке, системные сообщения, вход по телефону, +кнопки OAuth и управление привязанными способами входа. + +Если способ входа не установлен, его фрагмент не выводится. Сброс шаблона +возвращает файл активной темы или штатный системный резерв. diff --git a/help/administration/permission-simulator.md b/help/administration/permission-simulator.md new file mode 100644 index 0000000..dd3fa0b --- /dev/null +++ b/help/administration/permission-simulator.md @@ -0,0 +1,23 @@ +# Симулятор прав + +Раздел **Роли и права → Симулятор** отвечает на вопрос «что увидит этот +сотрудник или эта роль», не выполняя вход от его имени и не меняя текущую +сессию. + +Можно выбрать роль для проверки базовых разрешений или конкретного +пользователя, чтобы учесть его роль, активность аккаунта и связанную публичную +группу. + +| Вкладка | Результат | +| --- | --- | +| Разделы | Видимость пунктов панели и требуемое право. | +| Действия | Все зарегистрированные права модулей, сгруппированные по разделам. | +| Рубрики | Просмотр и управление в панели, публичные `docread`, создание и редактирование. | +| Документы | Проверка последних документов с объяснением доступа панели и публичной группы. | + +У каждой строки есть причина результата: право назначено, право отсутствует, +раздел скрыт состоянием системы, роль администратора имеет полный доступ, +публичной группе назначено `docread` и так далее. + +Симулятор является диагностикой. Он не заменяет фактическую проверку права в +контроллере и не предоставляет пользователю новые разрешения. diff --git a/help/administration/search-and-activity.md b/help/administration/search-and-activity.md index cb0480a..531266e 100644 --- a/help/administration/search-and-activity.md +++ b/help/administration/search-and-activity.md @@ -11,6 +11,10 @@ навигации по названию, URL или ID. Результаты учитывают права пользователя. Выбор открывает штатный редактор и ничего не сохраняет автоматически. +Поиск также находит публичные аккаунты по контактам и медиафайлы по имени или +пути. Уже существующие медиа появляются после первого запуска анализатора; +новые загрузки и переименования индексируются сразу. + ## Аудит действий Откройте **Система → События → Аудит**. Здесь видно, кто выполнил действие, diff --git a/help/administration/settings.md b/help/administration/settings.md index a542cc4..9596342 100644 --- a/help/administration/settings.md +++ b/help/administration/settings.md @@ -12,6 +12,13 @@ крошки и другие общие значения публичного runtime. После изменения проверьте страницу сайта, отправку тестового письма и canonical URL. +В `Настройки -> Константы` обязательно укажите `PUBLIC_SITE_URL` полным адресом, +например `https://example.ru` или `https://example.ru/cms`. Движок принимает +только этот домен в заголовке `Host` и использует его для OAuth, подтверждения +email и восстановления пароля. Это не позволяет посетителю подменить домен в +ссылке с секретным токеном. Дополнительные технические домены при необходимости +задаются в `allowed_hosts` файла `configs/public.config.php`. + ### Режим разработки В группе `Доступ к сайту` можно временно закрыть публичную часть. В этом режиме: diff --git a/help/administration/users.md b/help/administration/users.md index 38bad8e..cf7baac 100644 --- a/help/administration/users.md +++ b/help/administration/users.md @@ -15,6 +15,32 @@ - отключение запрещает новый вход, но сохраняет автора документов и аудит; - удаление сотрудника не должно уничтожать созданный им контент. +### Временный пароль + +При создании сотрудника включён переключатель **Сменить при следующем входе**. +Передайте человеку сгенерированный временный пароль. После первого входа вместо +панели откроется отдельный экран, где нужно указать временный и новый пароли. +Пока новый пароль не сохранён, остальные разделы панели недоступны. + +При редактировании существующего сотрудника этот режим можно включить повторно. +Это полезно после выдачи нового временного пароля, но не заменяет завершение +подозрительных сессий. + +### Активные сессии + +Откройте пользователя и перейдите на вкладку **Сессии и входы**. Для каждого +сохранённого входа показаны браузер, устройство, IP, время входа и последняя +активность. Текущий браузер отмечен отдельно. + +- **Завершить** отзывает один вход; +- **Завершить остальные** оставляет текущий браузер и отзывает остальные; +- при просмотре другого сотрудника **Завершить все** отзывает все его входы; +- отключение пользователя и смена его пароля также завершают старые сессии. + +На той же вкладке показаны последняя неудачная попытка и число ошибок входа за +24 часа. При пяти и более ошибках за десять минут предупреждение появляется в +колокольчике. Полная история хранится в системном аудите. + Системный администратор использует общую авторизацию сайта и панели. После входа в одном контексте он не должен повторно регистрироваться или вводить пароль в другом; доступ к панели всё равно проверяется отдельным правом `admin_panel`. @@ -41,8 +67,8 @@ Здесь настраиваются: -- включение регистрации и группа новых аккаунтов; -- минимальная длина пароля и подтверждение email; +- включение регистрации, способы `email`/`телефон` и группа новых аккаунтов; +- минимальная длина пароля и подтверждение email для email-регистрации; - сброс пароля; - URL страниц входа, регистрации, восстановления и кабинета; - HTML-шаблоны публичных форм; @@ -52,6 +78,11 @@ Назначенные URL резервируются в публичном реестре, поэтому документ с таким alias создать нельзя. +При регистрации по телефону email не запрашивается и хранится как пустое +значение. Его можно добавить позже в личном кабинете и подтвердить письмом. +SMS-провайдер остаётся отдельным модулем: без него вход существующих +пользователей по паролю продолжает работать, но телефонная форма не показывается. + ## Дополнительные поля профиля Поле имеет тип, подпись, обязательность, порядок и области показа. Не собирайте diff --git a/help/content/README.md b/help/content/README.md index a1473b3..cb56b23 100644 --- a/help/content/README.md +++ b/help/content/README.md @@ -47,6 +47,7 @@ URL | Раздел панели | Руководство | | --- | --- | | Документы | [Создание, публикация, URL, ревизии и системные страницы](documents.md) | +| Массовый редактор | [Предпросмотр и пакетное изменение документов и товаров](bulk-editor.md) | | Рубрики и поля | [Проектирование типа контента, формы и публичного вывода](rubrics.md) | | Справочники | [Общие списки значений без документов, URL и SEO](directories.md) | | Шаблоны | [Внешняя оболочка сайта, теги, кеш и ревизии](templates.md) | diff --git a/help/content/bulk-editor.md b/help/content/bulk-editor.md new file mode 100644 index 0000000..12fd0ab --- /dev/null +++ b/help/content/bulk-editor.md @@ -0,0 +1,48 @@ +# Массовый редактор документов и товаров + +Раздел **Документы → Массовый редактор** изменяет большой набор записей через +тот же штатный сервис сохранения, который используют редактор документа, JSON +API, импорт и быстрое заполнение товаров. + +Товар остаётся документом товарной рубрики. Поэтому отдельного дублирующего +редактора для товаров нет: при установленном товарном модуле можно выбрать +контур **Только товары**, а товарные индексы обновятся через обычные хуки. + +У товара могут отличаться **Заголовок документа** и название на витрине. +Заголовок относится к основной записи AVE.cms, а публичное название берётся из +поля, назначенного в настройках товарного каталога. После выбора рубрики +массовый редактор помечает такие поля подписью **витрина: название товара на +сайте**, **витрина: текущая цена** и так далее. Для изменения данных на сайте +выбирайте помеченное поле. Сохранение такого поля сразу пересобирает товарный +индекс и очищает связанный публичный кеш. + +| Действие | Поведение | +| --- | --- | +| Заполнить пустые | Записывает значение только туда, где поле пустое. | +| Установить значение | Заменяет текущее значение у всех документов набора. | +| Очистить | Сохраняет пустое значение. Обязательное поле может остановить конкретный документ валидацией. | +| Найти и заменить | Заменяет точное вхождение внутри выбранного текстового значения. | +| Перенести в рубрику | Меняет рубрику и переносит поля с одинаковым системным именем и типом. | +| Опубликовать / снять | Меняет состояние документа. Системные главная и 404 пропускаются. | +| Пересчитать | Повторно применяет вычисляемые поля, сохранение, кеш, JSON-снимок и модульные индексы. | + +## Порядок работы + +1. Выберите контур, рубрику, состояние и при необходимости поиск. +2. Выберите действие. Для поля рубрики обязательно укажите одну рубрику. +3. Нажмите **Проверить изменения**. +4. Проверьте первые 20 примеров «было → станет» и общее количество. +5. Нажмите **Применить ко всему набору**. + +Предпросмотр сохраняет на сервере точный список ID на два часа. Изменение формы +после проверки не меняет уже подготовленный план. Один запуск обрабатывает до +5000 документов порциями по 20, показывает прогресс и может быть остановлен. + +Для каждого реально изменённого документа создаётся ревизия, выполняются код +рубрики и document hooks, пересобираются кеш и JSON-снимок. Ошибка одного +документа записывается в журнал задания и не отменяет успешно обработанные +документы. Итог попадает в аудит. + +При переносе поля сопоставляются только по непустому системному имени (alias) и +одинаковому типу. Несовпадающие значения остаются в ревизии исходного +документа, но в новой рубрике не создаются. diff --git a/help/content/catalog.md b/help/content/catalog.md index 5fb33b8..1241b10 100644 --- a/help/content/catalog.md +++ b/help/content/catalog.md @@ -29,7 +29,8 @@ 2. **Наборы разделов** определяют, какие свойства нужны в каждой ветке. 3. **Цвета и комплектации** связывают отдельные документы-варианты. 4. **Доставка** хранит одно или несколько грузовых мест товара. -5. **Карточки** и **Фильтры** управляют Twig-разметкой и CSS. +5. **Фильтры** определяют состав и вид элементов для каждого раздела. +6. **Карточки** и **Вид фильтров** управляют Twig-разметкой и CSS. Блок состояния показывает, сколько разделов получили активный набор и какие режимы сейчас включены. Разделы без рабочей схемы перечислены отдельно и @@ -43,6 +44,12 @@ ## Сохранённые фильтры товаров +Сводка центра качества пересчитывается планировщиком раз в час. Последний +результат выводится отдельным виджетом дашборда. Если число товаров с +обязательными проблемами выросло, в колокольчике появляется уведомление со +ссылкой на центр качества. Первый снимок является точкой отсчёта и сам по себе +не создаёт тревогу. + В списке товаров и в центре качества можно сохранить текущий набор фильтров. Это удобно для постоянных очередей: «без фото», «не заполнена упаковка», «выключенные товары» или «товары ТСР». @@ -136,9 +143,146 @@ список — по ключу значения, связь — по ID связанной сущности. После изменения условий пересоберите индекс, если экран предлагает эту операцию. +В товарном контуре настройки разделены на три уровня: + +1. **Товары → Характеристики** — что можно хранить у товара. +2. **Товары → Фильтры** — что выводить в конкретном разделе, в каком порядке и каким элементом. +3. **Товары → Вид фильтров** — общая Twig-разметка и CSS. + +Разделу сначала назначается набор характеристик. Сохранение состава фильтров перестраивает индекс только этого раздела. Пока его режим фильтров остаётся **Legacy**, публичный сайт не меняется. + +### Настройка от характеристики до фильтра + +Рабочий маршрут состоит из пяти последовательных шагов: + +1. В **Товары → Характеристики → Характеристики** создайте свойство один раз. + Код свойства должен быть постоянным: например, `seat_width` или + `motor_count`. Этот код станет именем параметра в публичном URL. +2. В **Наборах** добавьте свойство только в те схемы товаров, которым оно + действительно нужно. Набор отвечает за форму редактирования товара, но ещё + ничего не выводит покупателю. Внутри набора создайте группы, например + «Основные параметры», «Размеры» и «Функции», и назначьте характеристику + нужной группе. Порядок групп и характеристик определяет публичную карточку. +3. В **Назначении разделам** свяжите набор с нужной веткой каталога. Один и тот + же набор можно применять в нескольких разделах. +4. В **Фильтрах разделов** включите нужные свойства, перетащите их в правильном + порядке и выберите элемент управления. +5. В **Виде фильтров** настройте общую Twig-разметку и CSS. Этот экран меняет + оформление, но не алгоритм отбора товаров. + +Выключенный фильтр остаётся в редакторе вместе со своим положением, подписью и +видом. Его можно заранее настроить и включить позже. Сайт читает только +включённые строки. + +### Группы в карточке товара + +Публичный товарный view model содержит два совместимых представления: + +- `characteristics` — прежний плоский список; +- `characteristic_groups` — группы в порядке набора, каждая с массивом `items`. + +Штатный шаблон карточки выводит заголовок группы, а под ним её характеристики. +Свойства без назначенной группы попадают в «Основные характеристики». Если +товар состоит в нескольких разделах, сначала используется набор основного +раздела, затем порядок привязки разделов. Поэтому один товар не получает +случайные подписи из разных наборов. + +```twig +{% for group in characteristic_groups %} +

{{ group.name }}

+ {% for item in group.items %} +
{{ item.name }}: {{ item.value }} {{ item.unit }}
+ {% endfor %} +{% endfor %} +``` + +### Шаблоны страницы товара + +Раздел **Товары → Другие разделы → Шаблоны страницы** собирает в одном месте +все 19 файлов публичного товарного контура. Они сгруппированы по назначению: + +- каталог и листинги: главная каталога, меню, категории, фильтры и сортировка; +- карточки и подборки: резервная карточка, ручные списки, новинки и акции; +- страница товара: основная разметка, варианты, матрица, связи и комплекты; +- сравнение и обращения: страница и счётчик сравнения, запрос цены, предзаказ и кабинет обращений. + +Каждый такой шаблон имеет два возможных источника. **Резерв модуля** поставляется +вместе с модулем «Товары» и позволяет паблику работать без специальной темы. +**Активная тема** может переопределить конкретный шаблон и оформить его под сайт. +Одновременно используется только один источник: файл темы имеет приоритет, а +при его отсутствии движок берёт резерв модуля. + +Если override ещё не создан, редактор показывает код модуля только как основу. +Кнопка **Создать в теме** записывает копию в +`templates/<тема>/views/products_public/` и при необходимости подключает +пространство `products_public` в `theme.json`. Последующие сохранения создают +ревизии темы и сразу очищают публичный кеш. Кнопка **Вернуться к шаблону +модуля** удаляет override; удалённая версия остаётся в ревизиях темы. + +Под редактором находится блок **Данные этого шаблона**. В нём перечислены +доступные переменные с пояснениями. Нажатие по переменной вставляет готовое +Twig-выражение в код. Перед сохранением используйте кнопку **Проверить**. + +Изменение этих файлов влияет только на разметку. Характеристики, варианты, +связи и комплекты по-прежнему заполняются в своих разделах товарного контура. +Сохранённый шаблон применяется сразу, поэтому после изменения проверьте живую +страницу товара на широком и мобильном экране. + +### Как выбрать вид фильтра + +- **Автоматически** — безопасный вариант по типу данных: диапазон для числа, + флажок для «Да / нет», флажки для справочника и строка поиска для текста. +- **Флажки** — покупатель может выбрать несколько значений. Подходит для + функций, цветов, размеров и других коротких списков. +- **Выпадающий список** — выбирается одно значение. Подходит для длинного + перечня, когда одновременный выбор нескольких вариантов не нужен. +- **Диапазон от и до** — доступен числовым характеристикам. Значения сравниваются + как числа, а не как текст. +- **Строка поиска** — ищет введённую часть сохранённого значения. Используйте её + для текста, а не вместо справочника с устойчивыми ключами. + +Порядок меняется за ручку слева. С клавиатуры ту же ручку можно перемещать +клавишами вверх и вниз. Ручной номер позиции вводить не требуется. + +### Как покупатель сужает выдачу + +Код характеристики становится именем GET-параметра. Например: + +```text +?seat_width[]=40&seat_width[]=45 +?motor_count=2 +?load_capacity[min]=100&load_capacity[max]=180 +``` + +Два значения `seat_width` внутри одного фильтра объединяются по правилу +**«40 ИЛИ 45»**. Одновременно выбранный `motor_count` добавляется по правилу +**«И»**. Поэтому результат означает: ширина 40 или 45 **и** два мотора. + +До пользовательских фильтров движок уже ограничивает выборку разделом каталога, +товарной рубрикой, публикацией документа и постоянными условиями назначенного +запроса. Затем каждый активный фильтр добавляет параметризованное условие к этой +выборке. Администратор не пишет SQL на экране фильтров. + +Для native-режима значения читаются из индекса +`catalog_attribute_filter_index`. Индекс хранит связь раздела, товара, +характеристики и нормализованного значения. Он обновляется после сохранения или +импорта товара и перестраивается для выбранного раздела при сохранении его +фильтров. Поэтому фильтрация не перебирает JSON каждого документа при каждом +открытии страницы. + +Количество рядом с вариантом рассчитывается с учётом раздела и остальных +активных условий, но без применения самого рассчитываемого фильтра. Покупатель +видит, сколько товаров останется после выбора. Результаты и фасеты кешируются на +10 минут и сбрасываются по тегу раздела при изменении товарных данных. + +Режимы **Legacy**, **Shadow** и **Native** переключаются отдельно по разделам. +Legacy продолжает использовать прежние поля и условия. Shadow строит и +сравнивает новый индекс без изменения сайта. Native включает новые +характеристики и настроенный порядок фильтров. + ### Проверка перед Native -В **Каталог → Характеристики → Режимы разделов** сначала назначьте набор и +В **Товары → Характеристики → Назначение разделам** сначала назначьте набор и оставьте оба режима в состоянии **Legacy**. Кнопка обновления строит теневой индекс, а кнопка отчёта сравнивает старый и новый контуры без изменения сайта. @@ -254,7 +398,7 @@ CommerceML: интеграции не должны писать прямо в т ### Внешний вид фильтров -В товарном модуле откройте **Каталог → Фильтры**, чтобы изменить Twig-разметку +В товарном модуле откройте **Товары → Вид фильтров**, чтобы изменить Twig-разметку и CSS фильтров. Черновик можно проверить на реальном разделе, не затрагивая сайт. После публикации выберите представление в настройках нужного каталога. @@ -301,6 +445,12 @@ CommerceML: интеграции не должны писать прямо в т стабильный ключ и, при необходимости, HEX-цвет. Тогда во всех группах выбирается одна и та же запись, а переименование не приходится повторять у каждого товара. +Если исполнение сочетает два цвета, нажмите кнопку с разделённым квадратом рядом +с первым образцом и выберите второй цвет. В карточках, фильтрах и переключателе +вариантов такой образец показывается квадратом, разделённым по диагонали. Это одно +сочетание цветов. Если покупатель должен выбирать каждый цвет независимо, создайте +отдельные варианты товара, а не один двухцветный образец. + Если список значений пуст, редактор оставляет ручной ввод подписи и цвета. Это режим совместимости для существующих групп, а не рекомендуемый способ настройки новых цветов и размеров. @@ -326,8 +476,16 @@ CommerceML: интеграции не должны писать прямо в т - **Карточки** — прежний вид, где каждый вариант идёт одним пунктом; - **По характеристикам** — отдельные ряды для цвета, размера, материала или комплектации. -Там же можно включить показ цены, наличия и артикула. По умолчанию стоит режим **Карточки**, -поэтому обновление модуля не меняет вид сайта без действия администратора. +Там же можно включить показ цены, наличия и артикула. Для новых установок по +умолчанию выбран режим **По характеристикам**. Если администратор раньше явно +сохранил режим **Карточки**, обновление не переключит его самовольно. + +Ниже настраивается короткое превью вариантов в товарной карточке. Можно полностью +его отключить или выбрать, сколько значений показывать до ссылки **ещё N** +(от одного до пяти). Цвет выводится образцом, остальные характеристики — +короткими текстовыми кнопками. Для группы карточка показывает минимальную цену, +если цены различаются — с подписью **от**, и ведёт покупателя к выбору конкретного +варианта вместо добавления в корзину случайного основного товара. Каждый пункт остаётся обычной ссылкой на отдельный товар. Поэтому URL, корзина, цена и артикул всегда относятся к реально выбранному варианту. Если характеристики группы ещё не заполнены, движок покажет @@ -339,9 +497,26 @@ CommerceML: интеграции не должны писать прямо в т останется видимой, но недоступной. Движок не подменяет её случайным товаром другого цвета. -Публичная модель `VariantRepository::forProduct()` отдаёт `items`, `attributes` и подготовленную `matrix`. -Шаблоны находятся в `modules/products/app/view/variants.twig` и -`modules/products/app/view/variant-matrix.twig`. Назначение характеристик само по себе не включает новый шаблон карточек каталога. +Публичная модель `VariantRepository::forProduct()` отдаёт `items`, `attributes`, +`count_label` и подготовленную `matrix`. +Системные шаблоны находятся в `modules/products/app/view/variants.twig` и +`modules/products/app/view/variant-matrix.twig`. Для своего дизайна создайте в +активной теме переопределения `views/products_public/variants.twig` и +`views/products_public/variant-matrix.twig`. Открыть нужный каталог можно прямо +из **Товары → Группы вариантов → Шаблоны вариантов**. Файлы `.twig` +редактируются встроенным редактором темы. Пространство `products_public` должно +быть указано в `view_overrides` файла `theme.json`. Назначение характеристик само +по себе не включает новый шаблон карточек каталога. + +В собственном шаблоне товарной карточки доступны: + +- `item.variants_count` — общее количество товаров в группе; +- `item.group_price_min` и `item.group_price_max` — диапазон цен; +- `item.group_in_stock` — есть ли в наличии хотя бы один вариант; +- `item.variants.items` — отдельные товары с URL, артикулом, ценой, наличием, + изображением и назначенными характеристиками; +- `item.variants.matrix.axes` — готовые оси выбора и их значения; +- `item.variant_settings.card_preview_limit` — настроенный предел превью. ### Как быстро заполнить варианты @@ -383,6 +558,7 @@ CommerceML: интеграции не должны писать прямо в т `packagesForQuantity($productId, $quantity)`: количество мест умножается на количество товара. Публичный дизайн не выводит упаковку автоматически; сайт показывает её только там, где шаблон или модуль явно использует этот сервис. +Способы доставки настраиваются в **Магазин → Настройки → Доставка**, а их разметка в оформлении заказа — в **Магазин → Настройки → Шаблоны**. ### Связи и комплекты @@ -392,8 +568,14 @@ CommerceML: интеграции не должны писать прямо в т не нужно. Комплект объединяет минимум два товара и хранит количество каждой позиции. +Выберите один основной товар; остальные позиции отметьте как обязательные или +подарочные. Скидку можно задать процентом либо фиксированной суммой. Она и +подарки срабатывают только при полном обязательном составе и одинаково +рассчитываются в публичной корзине и в заказе, который собирает менеджер. Название, цена, остаток и изображения всегда читаются из самих товаров, поэтому после изменения товара комплект не нужно пересохранять. +В таблице комплектов панель показывает, в скольких заказах сработал каждый +комплект, общую сумму предоставленной скидки и дату последнего применения. Публичный сайт после создания связей не меняется. Вывод нужно явно вставить в шаблон страницы товара: @@ -423,6 +605,17 @@ CommerceML: интеграции не должны писать прямо в т Техническое описание и JSON API находятся в `docs/development/product-relations.md`. +### Качество каталога + +Раздел **Товары → Качество** ничего не исправляет автоматически. Он собирает +рабочие очереди: товары без цены, раздела, изображения и упаковки, дубли +артикулов, остатки старых характеристик, пропуски нативного индекса фильтров и +ошибки групп вариантов. + +Очередь **РУ без реквизитов** не требует РУ для каждого товара. Она показывает +только противоречие: отметка о наличии РУ включена, но реквизиты не заполнены. +**Без видео** является рекомендацией и не влияет на публикацию. + ### Сравнение товаров Откройте **Товары → Сравнение**, включите функцию и выберите: diff --git a/help/content/documents.md b/help/content/documents.md index 903497b..ec13492 100644 --- a/help/content/documents.md +++ b/help/content/documents.md @@ -18,6 +18,16 @@ Рубрику существующего документа нельзя свободно поменять: у другой рубрики иной набор полей и правила хранения. Такой перенос выполняется отдельной миграцией. +## Сохранённые представления списка + +В блоке фильтров можно сохранить текущую рубрику, состояние, поле поиска, +поисковую строку и число строк на странице. Представление принадлежит текущему +сотруднику: оно не меняет документы и не появляется у коллег. + +Выберите сохранённое представление, чтобы сразу применить весь набор фильтров. +Кнопка с закладкой сохраняет текущий набор, кнопка корзины удаляет только само +представление. + ## Состояния | Состояние | Что означает | @@ -68,6 +78,25 @@ - в hooks до и после сохранения; - в нативном snapshot, который объединяет документ и его поля. +### Замена изображений + +В поле галереи доступны разные действия: + +- **Добавить фото** сохраняет уже выбранные изображения и дополняет список; +- **Заменить все** сначала загружает новый комплект и только после успешной + загрузки убирает прежний; +- **Удалить все** очищает поле после подтверждения. + +До сохранения документа новые файлы находятся в его защищённом черновике, а +старые продолжают работать на сайте. После успешного сохранения старые файлы +проверяются по живым ссылкам. Файл, используемый другим документом, блоком, +шаблоном или модулем, остаётся на месте; неиспользуемый переносится в корзину +раздела **Медиа**, откуда его можно восстановить. + +Ревизия документа хранит значения полей, но не копии бинарных файлов. Если после +замены нужно вернуть старую ревизию вместе с изображениями, сначала восстановите +соответствующие файлы из медиакорзины. + ## SEO и анонс Название документа не заменяет все SEO-поля. Отдельно проверяйте title, diff --git a/help/content/public-site.md b/help/content/public-site.md index 1b219ca..b295eee 100644 --- a/help/content/public-site.md +++ b/help/content/public-site.md @@ -148,6 +148,14 @@ Twig, удалённые цели назначений и предпросмот **Где используется** объединяет одинаковые компоненты. Этот режим удобен, чтобы быстро узнать, сколько раз используется блок, запрос, меню или модульный тег. +Карта также читает `[tag:fld:*]` и `[tag:rfld:*]`. В шаблоне конкретной рубрики +она проверяет, существует ли поле. В общем блоке поле помечается как контекстное: +оно будет проверено уже для документа, с которым выполняется блок. + +**Зависимости** группируют все нужные поля, блоки, подборки, меню и модули по +исходному шаблону. **Не используется** показывает шаблоны и компоненты без +назначений и ссылок. Это подсказка для ручной проверки, а не команда на удаление. + ### Что открывают кнопки справа В строке размещения могут быть две кнопки: @@ -265,3 +273,37 @@ AVE.cms покажет базовые ограничения запроса, д Такое разделение сохраняет предсказуемое поведение: карта помогает найти нужное место, а изменение выполняется в редакторе компонента, который действительно владеет этими данными. + +## Шаблоны страницы + +Вкладка **Публичный сайт → Шаблоны страницы** показывает файловые +Twig-компоненты, которые раньше можно было найти только в каталоге активной +темы. Здесь находятся резервный шаблон общего списка материалов, оболочка +страницы, хлебные крошки, служебные полосы и шаблоны отдельных типов страниц, +если их объявляет тема. + +Этот экран не заменяет шаблоны сайта, рубрик и запросов. Он покрывает только +файловые компоненты общей сборки страницы. Сохранение проверяет Twig, создаёт +ревизию темы и очищает публичный кеш. Если есть системный резерв, override можно +удалить кнопкой возврата. Компоненты, принадлежащие только теме, здесь можно +изменить, а удаляются они из полного файлового менеджера **Темы**. + +## Где редактировать разные шаблоны + +| Что нужно изменить | Раздел панели | +| --- | --- | +| Общая HTML-оболочка сайта | **Контент → Шаблоны** | +| Вывод документа, анонса, Open Graph и отдельного поля | **Контент → Рубрики и поля → Шаблоны рубрики** | +| Список материалов и карточка одного результата | **Контент → Запросы → Шаблоны** | +| Повторяемый фрагмент страницы | **Контент → Блоки** | +| Обёртки и уровни меню | **Контент → Навигация → Шаблоны** | +| Оболочка страницы, хлебные крошки и компоненты активной темы | **Контент → Публичный сайт → Шаблоны страницы** | +| Каталог, карточка и страница товара | **Магазин → Товары → Шаблоны страницы** | +| Корзина, оформление, кабинет заказов и письма магазина | **Магазин → Настройки → Шаблоны** | +| Вход, регистрация, профиль и подключаемые способы входа | **Система → Пользователи сайта → Страницы личного кабинета** | +| Поиск, опросы, галереи, отзывы и другие функции модулей | Вкладка **Шаблоны** или кнопка **Шаблон страницы** внутри соответствующего модуля | + +Раздел **Темы** остаётся полным файловым менеджером. Он нужен разработчику для +ассетов и редких файлов, но обычное изменение публичного компонента безопаснее +начинать из раздела, которому этот компонент принадлежит: там видны назначение, +доступные переменные и системный резерв. diff --git a/help/core/auth.md b/help/core/auth.md index 245ec4b..34598de 100644 --- a/help/core/auth.md +++ b/help/core/auth.md @@ -86,7 +86,8 @@ Email-регистрация является частью AVE.cms и не тр Откройте `Система -> Пользователи сайта -> Регистрация`. На одной странице настраиваются: -- доступность регистрации и способ активации аккаунта; +- доступность регистрации и разрешённые способы: email, телефон или оба; +- способ активации аккаунта, созданного по email; - группа, в которую попадёт новый пользователь; - базовые поля формы, длина пароля и сроки одноразовых ссылок; - запрещённые email и домены; @@ -98,10 +99,13 @@ Email-регистрация является частью AVE.cms и не тр VK ID, Яндекс и вход по SMS являются дополнительными устанавливаемыми способами входа, но создают или связывают ту же учётную запись пользователя сайта. -Телефонный аккаунт может существовать без email. Подтверждённый номер хранится -в нормализованном международном формате и является уникальным. Такой -пользователь входит одноразовым SMS-кодом; email при необходимости добавляет -администратор в профиле пользователя сайта. +Телефонный аккаунт может существовать без email. Для его создания нужен +установленный и настроенный SMS-провайдер с разрешённой регистрацией. +Подтверждённый номер хранится в нормализованном международном формате и является +уникальным. Такой пользователь входит одноразовым SMS-кодом. Email можно +добавить позже в личном кабинете, но вход и восстановление через него станут +доступны только после перехода по ссылке подтверждения. Если адрес не нужен, +поле остаётся пустым. ## Страницы личного кабинета diff --git a/help/en/administration/README.md b/help/en/administration/README.md index 9c898eb..6aa82a4 100644 --- a/help/en/administration/README.md +++ b/help/en/administration/README.md @@ -18,8 +18,9 @@ to [modular system](../modules/README.md). | Catalog | Universal partition trees and filters. | [Catalogue](../content/catalog.md) | | Media | Files, images, WebP and thumbnails. | [Media](../media/README.md) | | Roles and rights | Panel staff permissions. | [Users and access](users.md) | +| Permission simulator | Explain effective access for a role or user. | [Permission simulator](permission-simulator.md) | | Users | Employees who can work in the panel. | [Users and access](users.md) | -| Site users | Registration, personal account and public profile. | [Users and access](users.md) | +| Site users | Registration, profile, and customer history. | [Users and access](users.md), [Customer center](customer-center.md) | | Settings | Site parameters, interface, security, cache and diagnostics. | [Settings](settings.md) | | Database | Schema, InnoDB, backups and migrations. | [Database and Events](operations.md) | | Events | Audit, runtime, 404, SQL errors and transitions. | [Database and Events](operations.md) | diff --git a/help/en/administration/customer-center.md b/help/en/administration/customer-center.md new file mode 100644 index 0000000..5f00d18 --- /dev/null +++ b/help/en/administration/customer-center.md @@ -0,0 +1,37 @@ +# Customer center + +## Search, saved views, and CSV + +`Ctrl+K` finds public accounts by name, login, email, phone, company, or ID. +Customer Center search and segment filters can be saved as a personal view. The +**CSV** button on the Users tab exports the current search result in chunks and +protects spreadsheet applications from formula-like values. + +The customer center is available under **System -> Site users**. It does not +create a second person registry. A public site account remains the primary +record, while installed modules contribute related information. + +The card can show account contacts, orders, contact form submissions, +favorites, viewed products, login identities, manager notes, and calculated +segments. Missing optional modules simply leave their sections empty. + +Segments identify new, buying, repeat, high-value, inactive, and no-order +accounts. They are filters only and do not change permissions or trigger +automatic marketing actions. + +Manager notes are private to the control panel. Duplicate merge moves orders, +profile values, engagement data, external identities, and notes to the target +account, then disables the source account. Accounts linked to control-panel +access cannot be consumed. A conflicting identity provider also blocks the +merge to avoid losing a login method. + +## Login and account templates + +The **Account pages** tab covers the main login, registration, and profile +pages. Its **Service routes and fragments** block also exposes the header user +panel, system messages, phone login, OAuth provider buttons, and connected +account controls. + +A fragment is rendered only when its provider is installed and enabled. +Resetting a customized Twig template returns to the active-theme file or the +built-in system fallback. diff --git a/help/en/administration/permission-simulator.md b/help/en/administration/permission-simulator.md new file mode 100644 index 0000000..5fd02a6 --- /dev/null +++ b/help/en/administration/permission-simulator.md @@ -0,0 +1,14 @@ +# Permission simulator + +Open **Roles and permissions → Simulator** to see what a role or a specific +user can access without impersonating that user or changing the current +session. + +The report covers panel sections, registered module actions, rubric +permissions, and recent documents. Every result includes a reason such as a +granted or missing permission, an item hidden by system state, administrator +access, or the public group `docread` rule. + +Selecting a user also includes account state and the linked public user group. +The simulator is diagnostic only: it does not grant permissions and does not +replace permission checks in controllers. diff --git a/help/en/administration/search-and-activity.md b/help/en/administration/search-and-activity.md index 1855825..c3ecea2 100644 --- a/help/en/administration/search-and-activity.md +++ b/help/en/administration/search-and-activity.md @@ -12,6 +12,10 @@ It searches documents, rubrics, directories, blocks, queries, and navigation items by title, URL, or ID. Results respect the current user permissions and open the regular editor without saving anything automatically. +The same search includes public accounts and media files. Existing media +appears after the first analyzer run; uploads and renames are indexed +immediately. + ## Activity timeline Open **System → Events → Activity**. The technical audit is presented as a diff --git a/help/en/administration/settings.md b/help/en/administration/settings.md index cbc1e26..dfd5611 100644 --- a/help/en/administration/settings.md +++ b/help/en/administration/settings.md @@ -12,6 +12,13 @@ Here you can find the name and address of the site, mail, document parameters, b crumbs and other general public runtime values. After changing, check site page, sending a test letter and canonical URL. +Set `PUBLIC_SITE_URL` under `Settings -> Constants` to the complete public URL, +for example `https://example.com` or `https://example.com/cms`. AVE.cms accepts +that host and uses the URL for OAuth, email verification and password reset. +This prevents a visitor from replacing the domain in a secret-bearing link. +Additional technical hosts can be listed in `allowed_hosts` in +`configs/public.config.php`. + ### Development mode In the 'Site Access' group you can temporarily close the public part. In this mode: diff --git a/help/en/administration/users.md b/help/en/administration/users.md index 438d444..c6e72ef 100644 --- a/help/en/administration/users.md +++ b/help/en/administration/users.md @@ -15,6 +15,30 @@ phone, activity and role. The role determines the sections and actions available - disabling prohibits new entry, but preserves the document author and audit; - deleting an employee should not destroy the content created by him. +### Temporary password + +The **Require change on next login** switch is enabled for a new employee. +Give the employee the generated temporary password. Their first login opens a +separate password screen; the rest of the control panel stays unavailable until +they save a different password. + +You can enable the same requirement again while editing an existing employee. + +### Active sessions + +Open a user and select **Sessions and logins**. Each saved login shows its +browser, device, IP address, login time, and last activity. The current browser +is marked separately. + +- **End** revokes one login; +- **End other sessions** preserves the current browser; +- for another employee, **End all sessions** revokes every saved login; +- disabling an account or resetting its password also revokes old sessions. + +The same tab shows the last failed login and the number of failures over 24 +hours. Five or more failures within ten minutes add a notification to the +control panel. The complete history remains available in the audit log. + The system administrator uses general authorization for the site and panel. After login in one context he should not re-register or enter a password in friend; access to the panel is still checked by a separate right `admin_panel`. @@ -41,8 +65,8 @@ to the store. Here are the settings: -- enable registration and group of new accounts; -- minimum password length and confirmation email; +- enable registration, choose email/phone methods, and select the group for new accounts; +- minimum password length and email confirmation for email registration; - password reset; - URL of login, registration, recovery and account pages; - HTML templates for public forms; @@ -52,6 +76,11 @@ Here are the settings: Assigned URLs are reserved in a public registry, so a document with such alias cannot be created. +Phone registration does not request an email and stores it as an empty value. +The user may add an email later in the profile and confirm it by mail. The SMS +provider remains an optional module: without it password login still works, but +the phone form is not shown. + ## Additional profile fields A field has a type, label, mandatory, order, and display areas. Don't collect diff --git a/help/en/content/README.md b/help/en/content/README.md index 5e3fb28..0b44dec 100644 --- a/help/en/content/README.md +++ b/help/en/content/README.md @@ -44,6 +44,7 @@ start with the in-depth chapter [Content Studio: From Category to Publishing](co | Panel Section | Guide | | --- | --- | | Documents | [Creation, publication, URL, revisions and system pages](documents.md) | +| Bulk editor | [Previewed batch changes for documents and products](bulk-editor.md) | | Categories and fields | [Design content type, form and public output](rubrics.md) | | Dictionaries | [Shared value lists without documents, URLs, or SEO](directories.md) | | Templates | [External shell of the site, tags, cache and revisions](templates.md) | diff --git a/help/en/content/bulk-editor.md b/help/en/content/bulk-editor.md new file mode 100644 index 0000000..5d9d21a --- /dev/null +++ b/help/en/content/bulk-editor.md @@ -0,0 +1,27 @@ +# Bulk editor for documents and products + +Open **Documents → Bulk editor** to update documents through the same native +mutation service used by document editing, JSON API, imports, and product quick +editing. A product remains a document in a commerce rubric, so product indexes +are refreshed by normal document hooks. + +A product can have a different **Document title** and storefront title. The +document title belongs to the primary AVE.cms record, while the public title is +read from the field assigned in the product catalog settings. After selecting +a rubric, the bulk editor marks these fields as **storefront: product title**, +**storefront: current price**, and so on. Select the marked field when changing +public data. Saving it rebuilds the product projection and invalidates the +related public cache. + +The editor can fill empty values, set, clear, find and replace, move documents +to another rubric, publish or unpublish, and recalculate computed fields, +snapshots, caches, and module indexes. + +Always run **Preview changes** first. The server freezes the exact ID list for +two hours and displays up to 20 before/after examples. A run is limited to 5000 +documents and advances in chunks of 20. It can be stopped, and an error in one +document does not discard successful documents. + +Every changed document receives a revision and passes validation, rubric code, +document hooks, snapshot generation, and index refresh. During a rubric move, +only fields with the same non-empty alias and compatible type are preserved. diff --git a/help/en/content/catalog.md b/help/en/content/catalog.md index 785147b..c2ec9c9 100644 --- a/help/en/content/catalog.md +++ b/help/en/content/catalog.md @@ -21,6 +21,10 @@ entities or an empty list of “product catalogs”. ## Product catalog overview +The scheduler refreshes the product quality summary hourly. Its latest snapshot +is shown on the dashboard, and an admin notification appears only when the +number of required issues grows. The first snapshot establishes the baseline. + **Catalog → Attributes → Overview** is the starting point for the product subsystem. It does not create a second catalog or switch public output. It connects the existing editors in one workflow: @@ -29,7 +33,8 @@ connects the existing editors in one workflow: 2. **Section sets** decide which properties are relevant in each branch. 3. **Colors and configurations** group separate product documents as variants. 4. **Shipping** stores one or more cargo places per product. -5. **Cards** and **Filters** control Twig markup and CSS. +5. **Filters** define which attributes are exposed in each catalog section. +6. **Cards** and **Filter appearance** control Twig markup and CSS. The status area shows which sections have an active set and which runtime modes are enabled. Sections without a working set are listed with a direct editor @@ -131,9 +136,128 @@ Filter conditions must take into account the data type. The number is compared a list - by value key, relationship - by ID of the related entity. After change conditions, rebuild the index if the screen prompts you to do so. +Product filtering has three separate levels: + +1. **Products → Attributes** defines the data that products may store. +2. **Products → Filters** selects the attributes shown in each section, their order, and whether they use a range, checkboxes, a select, or a search input. +3. **Products → Filter appearance** edits the shared Twig markup and CSS. + +A section must have an attribute set before its filters can be selected. Saving +the filter list rebuilds that section's index. The public site remains unchanged +while the section's filter runtime is **Legacy**. + +### From an attribute to a public filter + +Use the five setup steps in order: + +1. Create a reusable property under **Products → Attributes → Attributes**. Its + stable code, such as `seat_width`, becomes the public query parameter name. +2. Add the property to the relevant **Attribute sets**. A set controls the + product form and does not expose anything publicly by itself. Create groups + such as "Main parameters", "Dimensions", and "Functions" inside the set, + then assign each attribute to the appropriate group. Group and item order is + used by the public product page. +3. Under **Assign to sections**, connect the set to the required catalog branch. +4. Under **Section filters**, enable properties, drag them into order, and choose + a control type. +5. Under **Filter appearance**, edit shared Twig markup and CSS. Appearance does + not change product selection rules. + +A disabled filter keeps its order, label, unit, and control type. It can be +prepared in advance and enabled later; only enabled rows are read publicly. + +### Groups on the product page + +The public product view model exposes two compatible structures: + +- `characteristics` is the existing flat list; +- `characteristic_groups` contains ordered groups with an `items` array. + +The standard product template renders a group heading followed by its +attributes. Ungrouped values appear under "Main characteristics". If a product +belongs to several sections, the primary section's set wins, followed by the +configured section order. + +```twig +{% for group in characteristic_groups %} +

{{ group.name }}

+ {% for item in group.items %} +
{{ item.name }}: {{ item.value }} {{ item.unit }}
+ {% endfor %} +{% endfor %} +``` + +### Product public templates + +Open **Products → More sections → Page templates** to edit all 19 public product +views: catalog navigation and listings, cards and selections, product details +and variants, comparison, bundles, and product-demand pages. + +Each view has one active source. The module fallback keeps the public feature +working when a theme has no override. A file under +`templates//views/products_public/` takes priority when it exists and +the namespace is enabled in `theme.json`. + +When no override exists, the editor displays the module fallback as a starting +point. **Create in theme** writes the fixed view path, enables the +`products_public` namespace when necessary, records theme revisions, and +invalidates the public presentation cache. **Return to module template** +removes the override while preserving its last content in theme revisions. + +Expand **Template data** to see the variables accepted by the selected Twig +view. Clicking a variable inserts its expression into the editor. Validate the +Twig source before saving and check a live product page on desktop and mobile, +because a saved override becomes active immediately. + +### Control types + +- **Automatic** uses a safe default: range for numbers, a yes checkbox for + booleans, checkboxes for choices, and a search input for text. +- **Checkboxes** allow several values and are suited to features, colors, and + compact option lists. +- **Select** allows one value and keeps long option lists compact. +- **Range** compares numeric minimum and maximum values as numbers. +- **Search input** matches part of a stored value and is mainly intended for + text. + +Drag the handle on the left to reorder filters. The same handle supports the Up +and Down arrow keys. No position number has to be entered manually. + +### Public selection rules + +The attribute code becomes a GET parameter. Examples: + +```text +?seat_width[]=40&seat_width[]=45 +?motor_count=2 +?load_capacity[min]=100&load_capacity[max]=180 +``` + +Several values of the same filter use **OR**. Different filters use **AND**. In +the example, the result must have seat width 40 or 45 **and** two motors. + +Before these inputs are applied, the engine already limits products by catalog +section, product rubric, publication state, and static conditions of the +assigned request. Each active filter then adds a parameterized condition. The +administrator does not write SQL on the filter screen. + +Native values are read from `catalog_attribute_filter_index`, which stores the +section, product, attribute, and normalized value. It is updated after product +save/import and rebuilt for a section when its filter list is saved. Documents' +JSON values are therefore not scanned on every public request. + +Facet counts respect the section and the other active conditions while omitting +the filter whose options are currently being counted. Listing and facet results +are cached for ten minutes and invalidated by the section tag when product data +changes. + +Runtime is selected per section: **Legacy** keeps the old fields and conditions, +**Shadow** builds and compares the new index without changing the site, and +**Native** enables the new attributes and configured filter order. + ### Check before Native -In **Catalog → Characteristics → Partition Modes** first assign a set and +Under **Products → Attributes → Assign to sections**, first assign a set and leave both modes in **Legacy** state. Refresh button builds shadow index, and the report button compares the old and new contours without changing the site. @@ -230,7 +354,8 @@ commodity index. It helps you find products: - without a brief announcement and SEO description; - with incomplete packaging with delivery calculation included; - with zero balance; -- with an outdated product projection. +- with an outdated product projection; +- with duplicate articles, legacy attribute remnants, native filter gaps, or invalid variant groups. Click the required queue card - the table will immediately be filtered by this issue. Search, product status, number of lines and pagination work without rebooting @@ -247,7 +372,9 @@ its product projection will be updated by a regular system event. If she changed field layout or external migration was performed, use point update index or general restructuring on the products tab. -### Appearance of filtersIn the product module, open **Catalog → Filters** to change the Twig markup +### Appearance of filters + +In the product module, open **Products → Filter appearance** to change the Twig markup and CSS filters. The draft can be checked on the real partition without affecting website. After publishing, select the view in the settings of the desired directory. @@ -294,6 +421,12 @@ such as size or material. U type stable key and, if necessary, HEX color. Then in all groups it is selected the same entry, and the renaming does not have to be repeated for each product. +When one finish combines two colors, use the split-square button next to the +first swatch and choose the second color. Product cards, filters, and the variant +selector display the result as a diagonally split square. This represents one +fixed color combination. Create separate product variants when customers must +choose each color independently. + If the list of values ​​is empty, the editor leaves manual entry of the label and color. This Compatibility mode for existing groups, not the recommended setup method new colors and sizes.The same list is used for the usual product characteristics. In the product card @@ -317,8 +450,16 @@ The appearance of the switch is set in **Products → Groups of options**: - **Cards** - the same view, where each option comes with one point; - **By characteristics** - separate rows for color, size, material or configuration. -There you can also enable display of price, availability and article. The default mode is **Cards**, -therefore, updating the module does not change the appearance of the site without administrator action. +You can also enable price, stock, and article display there. New installations +default to **By characteristics**. If an administrator explicitly saved the +**Cards** mode earlier, an update keeps that choice. + +The next setting controls the compact variant preview in product cards. It can +be disabled or limited to one through five values before the **more N** link. +Colors are shown as swatches and other attributes as compact text buttons. A +group card uses the minimum group price (with **from** when prices differ) and +asks the buyer to select a concrete variant instead of adding an arbitrary +primary product to the cart. Each item remains a regular link to a separate product. Therefore, the URL, cart, price and article are always refer to the actual chosen option. If the group's characteristics are not yet filled in, the engine will show @@ -330,9 +471,25 @@ as standard, but there is no blue product with pedal, “With pedal” will remain visible but inaccessible. The engine does not replace it with a random product different color. -The public model `VariantRepository::forProduct()` gives away `items`, `attributes` and the prepared `matrix`. -The templates are in `modules/products/app/view/variants.twig` and -`modules/products/app/view/variant-matrix.twig`. Assigning characteristics alone does not enable the new catalog card template. +The public model `VariantRepository::forProduct()` exposes `items`, `attributes`, +`count_label`, and the prepared `matrix`. +The system templates are in `modules/products/app/view/variants.twig` and +`modules/products/app/view/variant-matrix.twig`. Keep project-specific markup +in the active theme overrides `views/products_public/variants.twig` and +`views/products_public/variant-matrix.twig`. Open that directory directly from +**Products → Variant groups → Variant templates**. The built-in theme editor can +edit `.twig` files. The `products_public` namespace must be listed in +`theme.json` under `view_overrides`. Assigning attributes alone does not enable +a new catalog card template. + +Custom product-card templates can use: + +- `item.variants_count` — total products in the group; +- `item.group_price_min` and `item.group_price_max` — group price range; +- `item.group_in_stock` — whether at least one variant is in stock; +- `item.variants.items` — products with URL, article, price, stock, image, and attributes; +- `item.variants.matrix.axes` — prepared selection axes and options; +- `item.variant_settings.card_preview_limit` — configured preview limit. ### How to quickly fill out options @@ -372,6 +529,8 @@ summary `summary` and characteristic `complete`. To calculate the order it is us `packagesForQuantity($productId, $quantity)`: number of seats multiplied by quantity of goods. Public design does not automatically output packaging; website only shows it where the template or module explicitly uses this service. +Delivery methods are configured under **Shop → Settings → Delivery**. +Their checkout markup is edited under **Shop → Settings → Templates**. ### Connections and kits @@ -381,8 +540,13 @@ in both directions, turn on the corresponding switch: create a second record no need. A bundle combines at least two products and stores the quantity of each item. +Select one primary product and mark other items as required or gifts. A complete +bundle may apply a percentage or fixed discount. Public checkout and +manager-created orders use the same promotion calculation. The name, price, balance and images are always read from the products themselves, so After changing the product, the kit does not need to be re-saved. +The bundle table shows how many orders used each bundle, the total discount +granted, and the most recent usage date. The public site does not change after creating links. The output must be explicitly pasted into product page template: diff --git a/help/en/content/documents.md b/help/en/content/documents.md index 5b2ce12..1cd4346 100644 --- a/help/en/content/documents.md +++ b/help/en/content/documents.md @@ -18,6 +18,12 @@ category, and the appearance is a category template and a site template. The heading of an existing document cannot be freely changed: another heading has a different a set of fields and storage rules. This transfer is performed as a separate migration. +## Saved list views + +The current rubric, state, search field, query, and page size can be stored as a +personal view. Applying or deleting a view never changes documents and does not +affect other administrators. + ## States | State | What does | @@ -66,6 +72,25 @@ Document data is available:- in the category template via `[tag:fld:alias]`; - in hooks before and after saving; - in a native snapshot, which combines the document and its fields. +### Replacing images + +Gallery fields provide separate actions: + +- **Add photos** keeps existing images and appends new ones; +- **Replace all** uploads the new set first and removes the old set only after a + successful upload; +- **Remove all** clears the field after confirmation. + +Until the document is saved, new files remain in its protected media draft and +the current files continue to work on the site. After a successful save, live +references are checked. Files still used by another document, block, template, +or module remain in place; unused files are moved to the recoverable **Media** +trash. + +Document revisions store field values, not copies of binary files. To restore an +old revision together with its images, restore the corresponding files from the +media trash first. + ## SEO and announcement The document title does not replace all SEO fields. Check the title separately diff --git a/help/en/content/public-site.md b/help/en/content/public-site.md index b6970c8..7b7cd59 100644 --- a/help/en/content/public-site.md +++ b/help/en/content/public-site.md @@ -87,6 +87,14 @@ A placement is a component tag found in saved markup, for example: **Placements** shows every occurrence. **Used by** groups equal components and shows their usage count. +The map also reads `[tag:fld:*]` and `[tag:rfld:*]`. In a category-specific +template it verifies that the field exists. In a generic block the field is +marked as contextual because the actual document supplies the category. + +**Dependencies** groups required fields, blocks, requests, navigation, and +modules by source. **Unused** lists saved templates and components without a +native assignment or reference. It is a review queue and never deletes data. + The first action opens the exact source containing the tag. The second action opens the exact block, request, navigation, or owning module. If a module cannot be resolved, AVE.cms opens the general module registry. @@ -146,3 +154,35 @@ and future integrations. This keeps ownership clear: the map finds a relationship, and the component editor changes the data that actually owns it. + +## Page templates + +The **Public site → Page templates** tab exposes file-based Twig components +that previously required browsing the active theme directory. It includes the +fallback content-list view and general components owned by the active theme, +such as the page shell, breadcrumbs, account navigation, and type-specific +page views. + +This does not replace site, rubric, or request templates. Saving validates Twig +syntax, records a theme revision, and clears the public cache. A component with +a system fallback can be returned to that fallback. Theme-only components are +edited here and deleted only from the full **Themes** file manager. + +## Where each template is edited + +| What you need to change | Control-panel section | +| --- | --- | +| Main site HTML shell | **Content → Templates** | +| Document, teaser, Open Graph, and field output | **Content → Rubrics and fields → Rubric templates** | +| Material list and one result card | **Content → Requests → Templates** | +| Reusable page fragment | **Content → Blocks** | +| Menu wrappers and levels | **Content → Navigation → Templates** | +| Page shell, breadcrumbs, and active-theme components | **Content → Public site → Page templates** | +| Catalog, product card, and product page | **Store → Products → Page templates** | +| Cart, checkout, order account, and store email | **Store → Settings → Templates** | +| Login, registration, profile, and connected login methods | **System → Site users → Account pages** | +| Search, polls, galleries, reviews, and other module features | The **Templates** tab or **Page template** action in the owning module | + +**Themes** remains the complete file manager for developers and rare assets. +For normal editing, start in the owning section: it explains the component, +lists its available variables, and preserves the system fallback. diff --git a/help/en/core/auth.md b/help/en/core/auth.md index b67c016..7a8996e 100644 --- a/help/en/core/auth.md +++ b/help/en/core/auth.md @@ -87,7 +87,8 @@ Email registration is part of AVE.cms and does not require a separate module. Open `System -> Site Users -> Registration`. On one page configured: -- availability of registration and method of account activation; +- registration availability and allowed methods: email, phone, or both; +- activation method for accounts created by email; - the group into which the new user will be assigned; - basic form fields, password length and terms of one-time links; - prohibited email and domains; @@ -99,10 +100,12 @@ Additional registration and profile fields are collected in the `Profile Fields` VK ID, Yandex and SMS login are additional installable methods login, but create or link the same site user account. -A phone account can exist without an email. The confirmed number is stored -in a normalized international format and is unique. Such -the user logs in with a one-time SMS code; email is added if necessary -administrator in the site user profile. +A phone account can exist without an email. Creating one requires an installed, +configured SMS provider that allows registration. The confirmed number is stored +in a normalized international format and is unique. The user logs in with a +one-time SMS code. An email can be added later in the profile, but email login +and password recovery become available only after confirmation. The field may +remain empty when email is not needed. ## Personal account pages diff --git a/help/en/fields/settings.md b/help/en/fields/settings.md index 665317b..7a926bb 100644 --- a/help/en/fields/settings.md +++ b/help/en/fields/settings.md @@ -53,6 +53,22 @@ $unit = (string) $ctx->setting('unit', ''); $all = $ctx->settings(); ``` +## Editor hint + +Every field has a shared **“Editor hint (HTML)”** setting. Its content appears +below the field when an editor creates or changes a document. The hint is not +part of the field value and is not rendered on the public site. + +Safe HTML formatting is supported, for example: + +```html +Important: enter the width in centimetres.
+Allowed value: from 40 to 120. +``` + +Links and lists can also be used. Scripts, event handlers, and unsafe URLs are +removed automatically before rendering. + ## validationSchema() `AbstractFieldType` already adds suitable basic rules depending on diff --git a/help/en/media/README.md b/help/en/media/README.md index 1ba8d5c..dc70bf2 100644 --- a/help/en/media/README.md +++ b/help/en/media/README.md @@ -5,6 +5,23 @@ renaming, deleting, converting images and automatic miniatures. To view, you need the `Media: view` right, for changes - `Media: file management`. +Use the [Media analyzer](analyzer.md) to inspect unused and duplicate files, +missing originals or previews, oversized images, invalid paths, and usage. + +## Search, references, and trash + +The admin global search (`Ctrl+K`) finds media by file name and path. Uploads, +renames, and restores update the index immediately. After importing an existing +media library, run **Media → Analyzer** once to index all files. + +The file page shows references found during the latest analyzer run. Deleting +from the file browser always moves the object to **Media Trash**. A separate +live check reads current references before confirmation and repeats immediately +before the move, so a previous analyzer report is not required. Trash can +restore the original path or permanently purge the item. + +Folder, query, file type, and view mode can be stored as a personal saved view. + SVG and other active web formats are not accepted in custom storage: when opened directly from a site's domain, they are capable of executing embedded code. Store vector icons of the theme in its own assets, and for the media library diff --git a/help/en/media/analyzer.md b/help/en/media/analyzer.md new file mode 100644 index 0000000..924428a --- /dev/null +++ b/help/en/media/analyzer.md @@ -0,0 +1,30 @@ +# Media analyzer + +Open **Media → Analyzer** to inspect `/uploads` and match files against explicit +references in documents, fields, rubrics, templates, blocks, navigation, +requests, settings, theme sources, and installed modules. + +The scan is read-only. It never deletes, renames, or recompresses a file. + +The run also rebuilds the compact usage map shown on file pages and the +file-name index used by admin global search. Deletion does not trust the age of +that map: it checks the selected path against current references. Run the full +analyzer after a bulk FTP upload or a backup restore; ordinary admin uploads and +renames update search immediately. + +| Group | Meaning | +| --- | --- | +| Unused | No explicit reference was found. This is a review candidate, not an automatic deletion decision. | +| Duplicates | Multiple files have the same size and SHA-256 content hash. | +| Missing originals | Stored data points to a missing file, or a generated preview has no source image. | +| Missing previews | No generated thumbnail was found. This can be normal with lazy generation. | +| Large | The file is at least 5 MB or an image side is at least 4000 px. | +| Invalid paths | A reference contains unsafe segments or points outside `/uploads`. | +| All files | A complete usage map with sizes and image dimensions. | + +The usage map shows the entity type, ID, source column, and a link to the +relevant panel section. Dynamically assembled URLs cannot always be discovered, +so always review an “unused” file before deleting it. + +Run the scan from **Media → Analyzer → Run scan**. The latest report is stored +in `storage/reports/media-audit.json`. diff --git a/help/en/modules/README.md b/help/en/modules/README.md index 23968b8..a25ad2c 100644 --- a/help/en/modules/README.md +++ b/help/en/modules/README.md @@ -87,6 +87,8 @@ administrative extensions are physically located in | [Polls](polls.md) | Single and multiple voting, archive, tags and API. | | [Galleries](galleries.md) | Media collections, grid, slider, thumbnails and template tags. | | [Site search](search.md) | Index, scopes, query output, JSON/HTML API, live search and hooks. | +| [Product quiz](quiz.md) | Step-by-step catalog selection and its public page template. | +| [Public widgets](public-widgets.md) | Exchange rates, subscription, and active-theme Twig overrides. | | [Banners](banners.md) | Advertising slots, rotation strategies, scheduling, templates and statistics. | | [Q&A](faq.md) | Collections of FAQs, rich-text answers, templates, JSON API and Schema.org. | | [Related content](related.md) | Relevance, circular inference, manual connections, templates, queries and APIs. | @@ -113,6 +115,8 @@ administrative extensions are physically located in | [A/B tests](experiments.md) | Variants of blocks and elements, stable distribution and conversions. | | [Reliable events](reliable-events.md) | Signed webhook outbox, retries, and delivery history. | | [Cache warmup](cache-warmup.md) | Controlled background warming of published pages. | +| [Crossposting](crossposting.md) | Telegram, VK, Zen, and custom gateways with templates, scheduling, and deduplication. | +| [System comparison](environment-comparison.md) | Core, modules, migrations, database schema, settings, themes, and templates without exporting secrets. | | [Step by step: your module](tutorial.md) | Mini-module from scratch to working screen. | ## Installable AVE.cms modules @@ -148,13 +152,15 @@ widgets and “Document Operations” are deliberately not included in this list | `site_readiness` | Panel | `view_site_readiness`, `run_site_readiness`, `manage_site_readiness` | [Site readiness](site-readiness.md) | | `scheduler` | Public + panel | `view_scheduler`, `run_scheduler`, `manage_scheduler` | [Scheduler](scheduler.md) | | `system_health` | Panel | `view_system_health`, `run_system_health`, `manage_system_health` | [Health Monitoring](system-health.md) | -| `popups` | Public + panel | `view_popups`, `manage_popups` | [Pop-ups](popups.md) | +| `popups` | Public + panel | `view_popups`, `manage_popups`, `manage_popup_code` | [Pop-ups](popups.md) | | `commerceml` | Public + panel | `view_commerceml`, `manage_commerceml` | [Exchange with 1C](commerceml.md) | | `traffic_analytics` | Public + panel | `view_traffic_analytics`, `manage_traffic_analytics` | [Traffic sources](traffic-analytics.md) | | `search_analytics` | Public + panel | `view_search_analytics`, `manage_search_analytics` | [Search queries](search-analytics.md) | -| `experiments` | Public + panel | `view_experiments`, `manage_experiments` | [A/B tests](experiments.md) | +| `experiments` | Public + panel | `view_experiments`, `manage_experiments`, `manage_experiment_code` | [A/B tests](experiments.md) | | `reliable_events` | Public + panel | `view_reliable_events`, `manage_reliable_events` | [Reliable events](reliable-events.md) | | `cache_warmup` | Public + panel | `view_cache_warmup`, `manage_cache_warmup` | [Cache warmup](cache-warmup.md) | +| `crossposting` | Public + panel | `view_crossposting`, `manage_crossposting` | [Crossposting](crossposting.md) | +| `environment_compare` | Panel | `view_environment_compare`, `run_environment_compare` | [System comparison](environment-comparison.md) | | `todo` | Panel | `view_todos`, `manage_todos` | [Personal Tools](personal-tools.md#todo) | | `vk_oauth` | Public + panel | `view_vk_oauth`, `manage_vk_oauth` | [Login via VK ID](oauth-providers.md#vk-id) | | `yandex_oauth` | Public + panel | `view_yandex_oauth`, `manage_yandex_oauth` | [Login via Yandex](oauth-providers.md#яндекс) | diff --git a/help/en/modules/cache-warmup.md b/help/en/modules/cache-warmup.md index b26e7ec..7c1f59f 100644 --- a/help/en/modules/cache-warmup.md +++ b/help/en/modules/cache-warmup.md @@ -4,15 +4,20 @@ The `cache_warmup` module opens public pages in advance so the first visitor does not wait for expensive cache generation. The queue is updated after a full cache flush, saving a published document, a -daily task, or a manual rebuild. A full plan includes the home page and all -published documents. +daily task, or a manual rebuild. A full plan includes the home page, all +published documents, and every active product feed. At about 80% of its own +TTL, the module directly and forcibly refreshes the feed's internal cache, so +a marketplace crawler does not pay for cold generation. Protected feeds work +the same way, but their tokens are neither stored nor shown in the queue. A +feed with TTL `0` is warmed again only by a full plan. **URLs per run** limits server load. A value of `10–15` suits a typical site: Scheduler processes another small batch every minute instead of opening the whole site at once. After three failures, a URL waits for a new full plan. The history retains its -HTTP status, response time, and error message. +HTTP status, response time, and error message. A terminal failure after the +third attempt is also shown in control-panel notifications. The base URL may be left empty to use the Scheduler request host. Production sites should set their canonical HTTPS URL. The outbound HTTP client validates diff --git a/help/en/modules/comments.md b/help/en/modules/comments.md index eed256e..e2ffb87 100644 --- a/help/en/modules/comments.md +++ b/help/en/modules/comments.md @@ -80,12 +80,18 @@ POST /api/v1/comments/37/delete Creation accepts `body`, `parent_id`, and for guest `author_name` and `author_email`; CSRF is carried with the `_csrf` field or the `X-CSRF-Token` header. -The response reports the status `published` or `pending`. +The response reports the resulting comment status. A suspicious submission is +reported to the visitor as awaiting moderation without exposing protection +rules. You cannot create a discussion for a missing or deleted object. Antispam checks the contents of the form, but does not replace the frequency limit: always for guest There are separate limits for the actor cookie and the network/target HMAC prefix. Therefore -changing cookies does not create an unlimited flow of comments. +changing cookies does not create an unlimited flow of comments. When the active +antispam profile detects an automated submission, the comment is retained with +the `spam` status and a reason for the moderator. A false positive can therefore +be reviewed and published. A runtime failure in antispam stops the request and +is not silently treated as spam. Setting up pre-moderation has modes: without moderation, only guests, everyone. When When a document is deleted, its discussion is closed. Hooks `comments.creating`, diff --git a/help/en/modules/commerce.md b/help/en/modules/commerce.md index f742c73..125f7e3 100644 --- a/help/en/modules/commerce.md +++ b/help/en/modules/commerce.md @@ -3,6 +3,12 @@ The Commerce module manages the cart, checkout, delivery, payment, coupons, favorites, viewed products and customer order history. +## Order export + +Set the search, order state, and payment filter, then click **CSV**. The current +filtered result is streamed in chunks of 500 rows and the operation is recorded +in the audit log. + ## Cart promotions Open `Store -> Settings -> Promotions` to create automatic rules that do not @@ -75,6 +81,7 @@ An employee with the right `manage_orders` can create an order without a public 1. Open `Orders` and click **Create Order** to the right of the header. 2. Find an existing site user by name, phone, email or ID. His profile will be linked to the order, and his contacts will be filled in automatically. + The form also shows the customer's order count, total, and recent orders. If the buyer has not yet registered, fill in the name and phone number or email manually. 3. Find products by name, SKU or ID. Each product variant is @@ -84,9 +91,12 @@ An employee with the right `manage_orders` can create an order without a public inside this order and does not change the price of the product in the catalog. 5. Active promotions are previewed automatically. A matching bed and mattress, for example, can discount the mattress or add a configured gift. + A configured product bundle can be added to the order in one action. 6. If necessary, set a separate manager discount, delivery, payment, status and payment state. Promotions are applied first, then the manager discount, followed by delivery and payment surcharges. + The helper warns about incompatible delivery and payment methods and products + whose shipping packages are not configured. 7. After saving, the order card will open. It shows catalog and manual price, discount, author of creation and history of actions. @@ -100,6 +110,9 @@ The server recalculates promotions during save, so the browser preview cannot be used to submit a forged total. Applied promotion and gift snapshots are stored exactly as they are for orders created from the public cart. +The **Customer link** button in a saved order copies its public result URL. The +URL contains the random order token instead of the database record ID. + ## Changing the composition of an order In the saved order card, click **Change contents**. The editor allows you to: diff --git a/help/en/modules/crossposting.md b/help/en/modules/crossposting.md new file mode 100644 index 0000000..23c309f --- /dev/null +++ b/help/en/modules/crossposting.md @@ -0,0 +1,63 @@ +# Cross-posting + +The `crossposting` module publishes AVE.cms documents to Telegram, VK +communities, Zen through a bridge, and custom HTTPS webhooks. Network requests +run in the background and never block document editing. + +## Setup + +1. Install and enable **Scheduler** and **Cross-posting**. +2. Add a channel and test its connection. +3. Create a publication template. +4. Create a rule that selects a rubric, template, channels, and delivery time. +5. Publish a test document and inspect the **Queue** and **Attempts** tabs. + +The **New publication** action queues one document manually. An empty date +means now; a date in the future creates a scheduled job. + +## Templates + +Available native tags include: + +- `[tag:document:title]`; +- `[tag:document:excerpt]`; +- `[tag:document:url]`; +- `[tag:document:published]`; +- `[tag:document:tags]`; +- `[tag:document:meta_description]`; +- `[tag:field:alias]` for a rubric field. + +An image field is selected by alias. Preview renders the current form against +a real document before the template is saved. + +**Plain text** safely escapes markup characters. **Safe HTML** preserves the +formatting supported by the gateway; Telegram accepts emphasis, code, and links. + +## Rules and statuses + +A rule can run manually, immediately after publication, or at the document's +publication date. After an already delivered document changes, the rule can do +nothing, update the remote post, or create a new one. + +A template marked **For all gateways** can be reused. Create separate templates +and rules when Telegram, VK, and Zen need different copy or images. + +Every document revision, rule, channel, and scheduled time has a stable +idempotency key. Repeated saves and parallel scheduler runs therefore do not +create duplicate posts. + +**Published** means the platform accepted a post and returned its identifier. +**Verified** means a gateway additionally confirmed that the remote post still +exists. Subscriber reads are not reported as delivery. + +The Scheduler runs `crossposting.publish` every minute. Temporary failures use +exponential retry delays and remain available for manual retry. +Permanently failed publications also appear in the control-panel notifications. + +## Custom gateways + +Another module can register a factory from its `services.php` through +`GatewayRegistry::register()`. The gateway implements `GatewayInterface`, uses +`OutboundHttpClient`, and receives public settings separately from protected +secrets. The built-in webhook also supplies a stable `Idempotency-Key` and an +optional HMAC SHA-256 signature. diff --git a/help/en/modules/environment-comparison.md b/help/en/modules/environment-comparison.md new file mode 100644 index 0000000..fb25fe5 --- /dev/null +++ b/help/en/modules/environment-comparison.md @@ -0,0 +1,16 @@ +# Comparing local and production systems + +The module compares two AVE.cms installations before deployment or an update. + +1. Install the module on both installations. +2. Download a JSON snapshot from the production control panel. +3. Open the module locally, upload that snapshot and run the comparison. +4. Review core files, modules, migrations, database schema, settings, themes and + templates. + +For core files, modules, and themes the report identifies the exact changed or +missing path. The snapshot stores only the path and SHA-256, never file content. + +The snapshot contains no documents, personal data, passwords, tokens, keys or +database connection details. Non-secret setting values are represented by hashes, +so the report can detect a change without exposing the value. diff --git a/help/en/modules/experiments.md b/help/en/modules/experiments.md index 561eb66..73ab40d 100644 --- a/help/en/modules/experiments.md +++ b/help/en/modules/experiments.md @@ -32,6 +32,17 @@ coverage - the percentage of visitors participating in the test. Payload option You can leave the experiment as a draft, run it, pause it, or end it. The start and end period are optional. +## When the result is reliable + +**Minimum impressions per variant** is a fixed sample threshold. Before every +variant reaches it, AVE.cms reports **Not enough data** and does not name a +winner. The default is 500 impressions per variant. + +After the threshold, control A is compared with the best alternative using a +two-proportion z-test and a correction for multiple alternatives. The result is +not enough data, difference within sampling error, or a winner at 95% +confidence. A mature result also appears in panel notifications. + ## Inserting into a template ```text @@ -70,9 +81,8 @@ POST /api/v1/experiments/event ``` The last endpoint accepts `code` and `event`. The variant ID from the client is not -accepted: the server recalculates the destination and writes the event only to -this option. For one visitor, the uniqueness of an event is considered once a day, -however, the total number of events is also maintained. +accepted: the server recalculates the assignment and requires its signed proof. +One event is counted at most once per visitor per day. ##Hooks @@ -102,7 +112,8 @@ IP and original seed are not saved in statistics. ## Permissions and deletion - `view_experiments` - viewing experiments and results; -- `manage_experiments` - creation, launch, statistics reset and deletion. +- `manage_experiments` - statistics reset and operational control; +- `manage_experiment_code` - public HTML variants and automatic injection, with password re-authentication. Uninstallation removes experiments, variants, statistics, deduplication visitors and module settings. diff --git a/help/en/modules/personal-tools.md b/help/en/modules/personal-tools.md index 5a28db3..926ef01 100644 --- a/help/en/modules/personal-tools.md +++ b/help/en/modules/personal-tools.md @@ -1,25 +1,24 @@ -# Personal toolbar +# Panel work tools ← [Back to the “Modules” section](README.md) -Todo, Kanban, Notes and Reminders store personal work data of the current -panel user. Posts by one administrator do not become a common board for -other users. +Todo and Kanban support collaborative work. Notes and Reminders remain private +to their owner. ##Todo -Module `todo` - short checklist. A task can be created from an action in the header, -Mark completed or delete. The full list is in **Modules → Todo**, and -The summary is available via the home widget. +Module `todo` is a checklist with an assignee, due date, visibility, and an +optional document link. The author can edit or delete a task; the author and +assignee can complete it. Overdue tasks appear in notifications. - `view_todos` - list, action in the header and widget; - `manage_todos` - creation, execution and deletion. ## Kanban -Module `kanban` - personal board with customizable columns and cards. Columns and -The cards are colored and the order can be changed by dragging and dropping. Widget shows the board -on the main panel. +Module `kanban` has configurable columns and cards. Cards may have an assignee, +due date, team visibility, and document link. Columns remain on the author's +board; recipients see assigned/shared cards in a separate list. - `view_kanban` — view the board and widget; - `manage_kanban` - columns, cards and movement. diff --git a/help/en/modules/popups.md b/help/en/modules/popups.md index e7a99e5..d9861fe 100644 --- a/help/en/modules/popups.md +++ b/help/en/modules/popups.md @@ -16,6 +16,12 @@ percent; one visitor consistently receives the same option. Available in HTML: The form inside the option may contain `name`, `email`, `phone` and additional fields. For the response status, add an element with `data-popup-status`. Show, conversion and application are counted separately. The frequency is stored in the visitor's browser. +Impressions and conversions require a signed variant assignment and are counted +only once per visitor per day. + +`manage_popups` covers lead processing. Editing public HTML or automatic +injection requires `manage_popup_code` and password re-authentication because +that markup executes on public pages. Automatic insertion before `` can be turned off in the module settings. Then the module does not change the public HTML. diff --git a/help/en/modules/public-widgets.md b/help/en/modules/public-widgets.md new file mode 100644 index 0000000..7e8969e --- /dev/null +++ b/help/en/modules/public-widgets.md @@ -0,0 +1,15 @@ +# Public widgets + +← [Back to Modules](README.md) + +The private `widgets` module contains small public blocks for exchange rates +and email subscription. It is not included in the regular core distribution +and is installed separately. + +After installation open **Modules → Widgets**. Saving creates an override under +the active theme's `views/widgets_public/` namespace; returning to fallback +restores the module view. + +The exchange template receives `usd` and `eur`. The subscription template +receives `csrf`, `message`, and `success`. Delivery, rate limiting, and email +validation remain enforced by the module runtime. diff --git a/help/en/modules/quiz.md b/help/en/modules/quiz.md new file mode 100644 index 0000000..4e3c070 --- /dev/null +++ b/help/en/modules/quiz.md @@ -0,0 +1,18 @@ +# Product quiz + +← [Back to Modules](README.md) + +The `quiz` module builds a step-by-step product selector on top of native +catalog filters. Answers narrow the catalog and may produce matching products, +a manager lead, or both. + +## Public page template + +Open **Modules → Product quiz → Page template** to edit `page.twig`: the title, +progress, question area, and step navigation. Without a theme override the +module fallback is used. The first save creates +`views/quiz_public/page.twig` in the active theme. + +The editor documents `quiz`, `api`, `csrf`, and `steps_count`, validates Twig, +records theme revisions, and clears the public cache. Questions, answers, +filter bindings, and result behavior remain in the quiz constructor. diff --git a/help/en/modules/ratings.md b/help/en/modules/ratings.md index 3f431d0..708a174 100644 --- a/help/en/modules/ratings.md +++ b/help/en/modules/ratings.md @@ -56,3 +56,10 @@ it, and the API will reject the entry according to the channel rules. Filter `ratings.rendering` receives `target_type`, `target_key`, `average`, `count`, `own`, `scale` and the finished `html`. The handler can return the modified HTML without interfering with the calculation. + +## Reviewing and removing ratings + +The action in **Most rated** opens individual votes for a target. A user with +`manage_ratings` may remove one invalid vote or reset the whole target. The +aggregate is rebuilt in the same operation and moderation is written to the +audit log. The document, poll, or gallery itself is not deleted. diff --git a/help/en/modules/search.md b/help/en/modules/search.md index 1d34c56..aa799c9 100644 --- a/help/en/modules/search.md +++ b/help/en/modules/search.md @@ -98,9 +98,15 @@ modules/search/app/view/ modules/search/app/assets/ ``` -The files contain the functional shell of the page, the form and regular assets. -The markup of the results themselves is stored in the module settings. For different cards -It is more convenient for sections to use queries. +The files contain the functional page shell, form, and bundled assets. Use the +**Page shell** button in the module header to edit the form, complete page, +AJAX fragment, and pagination. Saving creates an active-theme override, checks +Twig syntax, records a theme revision, and invalidates the public cache. + +Result-card markup remains on the **Output templates** tab. The page-shell +editor controls where the form and result fragment live; output templates +control the contents of each result. Existing requests remain the best option +when different sections require different cards. In **Modules → Site Search → Output Templates** the built-in markup can be changed without editing files. Four templates are available: @@ -199,8 +205,43 @@ The area defines its own categories, algorithm, output and API fields. For examp | Articles | `articles` | Blog, Instructions | Request with article card | | Products | `products` | Product headings | Request with product card | -An empty rubric set means all rubrics. An area can inherit the main one -request and a set of API fields or override them. +An empty rubric set means all rubrics. An area can inherit the main request and +API field set or override them. + +### Scope display conditions + +A scope can have additional restrictions without SQL and without fixed field +IDs. Each condition stores a field alias, so it survives a site transfer as +long as the rubric keeps the same system field names. + +To add a restriction: + +1. Open **Scopes** and the required scope. +2. Under **Display conditions**, click **Add condition**. +3. Select a field. Its alias is shown next to its title. +4. Select a check and enter a value when required. +5. Save the search settings. + +All rows must match. For example, a product scope may use: + +| Field | Check | Value | Meaning | +| --- | --- | --- | --- | +| Price (`price`) | Number is greater than | `0` | Include only items with a positive price. | +| Hide (`noshow`) | Checkbox is off | — | Exclude manually hidden items. | +| Not in price list (`noprice`) | Checkbox is off | — | Exclude items absent from a price list. | + +The aliases above are examples only. Another site selects its own fields. The +module does not require product-specific aliases and does not add these rules to +a clean installation. + +Available checks cover empty values, checkbox state, text equality, and numeric +comparisons. For **Checkbox is off**, **Does not equal**, and **Is empty**, a +missing field also matches. This is useful when one scope combines rubrics with +slightly different field sets. + +Document state is checked independently. Disabled, deleted, and non-searchable +documents stay out even when the search index is stale. Conditions are applied +before counting, so totals, suggestions, and pagination match the actual output. The public page shows areas as radio buttons. Direct links: diff --git a/help/en/modules/smsc-auth.md b/help/en/modules/smsc-auth.md index 81884cd..e352674 100644 --- a/help/en/modules/smsc-auth.md +++ b/help/en/modules/smsc-auth.md @@ -40,7 +40,8 @@ An API key that can be revoked without changing the SMSC owner's password. 8. Enable public login only after a successful test. The general ability to create users and their group is configured in -`System -> Site Users -> Registration`. Module switch +`System -> Site Users -> Registration`. Select `By phone` or `By email or phone` +there. Module switch `Create accounts` may additionally prohibit registration by phone, leaving existing users logged in. diff --git a/help/fields/settings.md b/help/fields/settings.md index 1971eaf..84e461c 100644 --- a/help/fields/settings.md +++ b/help/fields/settings.md @@ -53,6 +53,22 @@ $unit = (string) $ctx->setting('unit', ''); $all = $ctx->settings(); ``` +## Подсказка редактору + +У каждого поля есть общая настройка **«Подсказка редактору (HTML)»**. Её текст +показывается под полем при создании и редактировании документа. Подсказка не +является значением поля и не выводится в публичной части сайта. + +Поддерживается безопасная HTML-разметка, например: + +```html +Важно: укажите ширину в сантиметрах.
+Допустимое значение: от 40 до 120. +``` + +Также можно использовать ссылки и списки. Скрипты, обработчики событий и +опасные адреса автоматически удаляются перед выводом. + ## validationSchema() `AbstractFieldType` уже добавляет подходящие базовые правила в зависимости от diff --git a/help/media/README.md b/help/media/README.md index eae1e75..3639e13 100644 --- a/help/media/README.md +++ b/help/media/README.md @@ -5,6 +5,37 @@ миниатюрами. Для просмотра требуется право `Медиа: просмотр`, для изменений — `Медиа: управление файлами`. +Для проверки неиспользуемых и одинаковых файлов, отсутствующих оригиналов, +превью и неверных путей используйте [Анализатор медиа](analyzer.md). + +## Поиск, связи и корзина + +Глобальный поиск панели (`Ctrl+K`) находит медиа по имени файла и пути. Индекс +обновляется при загрузке, переименовании и восстановлении файла. После переноса +старой медиатеки запустите **Медиа → Анализатор**: проверка один раз соберёт +полный индекс уже существующих файлов. + +В карточке файла блок **Где используется файл** показывает найденные документы, +поля, шаблоны, блоки и другие сущности. Эти сведения относятся к последнему +запуску анализатора. Динамически собранный в PHP путь может не распознаться, +поэтому нулевое число связей не заменяет просмотр публичного сайта. + +Удаление из файлового браузера всегда означает перенос в **Корзину медиа**: + +1. Система заново проверяет явные ссылки именно на выбранный файл или папку. +2. Предварительно запускать полный анализатор не требуется. +3. Если найдены связи, подтверждение прямо сообщает их количество. +4. Перед переносом проверка повторяется, чтобы учесть только что добавленные ссылки. +5. Файл или папка перемещается из `/uploads` в закрытое хранилище. +6. В корзине объект можно вернуть на исходный путь или удалить окончательно. + +При восстановлении исходный путь должен быть свободен. Соседняя WebP-копия, +перемещённая вместе с оригиналом, также возвращается. Окончательное удаление из +корзины необратимо. + +Текущую папку, строку поиска, тип файла и режим сетки можно сохранить как личное +представление. Оно доступно только текущему сотруднику и не меняет файлы. + SVG и другие активные веб-форматы в пользовательское хранилище не принимаются: при прямом открытии с домена сайта они способны выполнять встроенный код. Векторные иконки темы храните в её собственных assets, а для медиатеки diff --git a/help/media/analyzer.md b/help/media/analyzer.md new file mode 100644 index 0000000..1626e0b --- /dev/null +++ b/help/media/analyzer.md @@ -0,0 +1,45 @@ +# Анализатор медиа + +Раздел **Медиа → Анализатор** проверяет содержимое `/uploads` и сопоставляет +файлы с явными ссылками в документах, полях, рубриках, шаблонах, блоках, +навигации, запросах, настройках, теме и установленных модулях. + +Проверка работает только на чтение. Она не удаляет, не переименовывает и не +пережимает файлы. + +После проверки AVE.cms обновляет компактную карту использования для карточек, +а также индекс имён и путей для глобального поиска. При удалении система не +доверяет возрасту этой карты и отдельно проверяет выбранный путь по живым данным. +Запускайте анализатор после массовой загрузки по FTP или восстановления копии +`/uploads`. Обычная загрузка и переименование через панель обновляют поисковый +индекс сразу. + +## Что показывает отчёт + +| Группа | Что означает | +| --- | --- | +| Не используются | Явная ссылка на файл не найдена. Это кандидат на ручную проверку, а не команда на удаление. | +| Одинаковые | Несколько файлов имеют одинаковый размер и SHA-256 содержимого. | +| Нет оригиналов | В данных есть ссылка на отсутствующий файл либо служебное превью осталось без исходника. | +| Нет превью | Для изображения пока не найдено ни одной созданной миниатюры. При ленивой генерации это может быть нормой. | +| Большие | Файл не меньше 5 МБ либо сторона изображения не меньше 4000 px. | +| Неверные пути | Ссылка содержит недопустимый путь или ведёт за пределы `/uploads`. | +| Все файлы | Полная карта файлов, размеров, разрешений и найденных мест использования. | + +Для каждого используемого файла выводится карта связей: тип сущности, её ID, +поле или колонка и ссылка на подходящий раздел панели. Анализатор распознаёт +литеральные пути. Если модуль собирает URL динамически из нескольких частей, +файл может попасть в список «Не используются», поэтому удаление всегда +выполняется только после ручной проверки. + +## Как пользоваться + +1. Откройте **Медиа → Анализатор**. +2. Нажмите **Запустить проверку**. +3. Начните с групп **Нет оригиналов** и **Неверные пути**. +4. Проверьте дубли и крупные изображения. +5. Перед удалением файла из группы **Не используются** откройте карту связей и + проверьте публичный сайт. + +Отчёт сохраняется в `storage/reports/media-audit.json`. Повторная проверка +полностью заменяет предыдущий отчёт актуальным состоянием. diff --git a/help/modules/README.md b/help/modules/README.md index e618777..cb2f066 100644 --- a/help/modules/README.md +++ b/help/modules/README.md @@ -89,6 +89,8 @@ Manifest с вычисляемым runtime-содержимым объявляе | [Опросы](polls.md) | Одиночные и множественные голосования, архив, теги и API. | | [Галереи](galleries.md) | Медиаколлекции, сетка, слайдер, миниатюры и шаблонные теги. | | [Поиск по сайту](search.md) | Индекс, области, вывод через запросы, JSON/HTML API, живой поиск и хуки. | +| [Подбор товара](quiz.md) | Пошаговый подбор по фильтрам каталога и шаблон публичной страницы. | +| [Публичные виджеты](public-widgets.md) | Курсы валют, подписка и Twig-override активной темы. | | [Баннеры](banners.md) | Рекламные места, стратегии ротации, расписание, шаблоны и статистика. | | [Вопросы и ответы](faq.md) | Коллекции FAQ, rich-text ответы, шаблоны, JSON API и Schema.org. | | [Похожие материалы](related.md) | Релевантность, кольцевой вывод, ручные связи, шаблоны, запросы и API. | @@ -116,6 +118,8 @@ Manifest с вычисляемым runtime-содержимым объявляе | [A/B-тесты](experiments.md) | Варианты блоков и элементов, стабильное распределение и конверсии. | | [Надёжные исходящие события](reliable-events.md) | Webhook-очередь, подписи, повторы и журнал доставок. | | [Автоматический прогрев кеша](cache-warmup.md) | Пакетный прогрев опубликованных страниц после сброса кеша. | +| [Кросспостинг](crossposting.md) | Telegram, VK, Дзен и собственные шлюзы: шаблоны, расписание, очередь и защита от дублей. | +| [Сравнение систем](environment-comparison.md) | Ядро, модули, миграции, схема БД, настройки, темы и шаблоны без выгрузки секретов. | | [Пошагово: свой модуль](tutorial.md) | Мини-модуль с нуля до рабочего экрана. | ## Устанавливаемые модули AVE.cms @@ -154,13 +158,15 @@ Manifest с вычисляемым runtime-содержимым объявляе | `site_readiness` | Панель | `view_site_readiness`, `run_site_readiness`, `manage_site_readiness` | [Готовность сайта](site-readiness.md) | | `scheduler` | Паблик + панель | `view_scheduler`, `run_scheduler`, `manage_scheduler` | [Планировщик](scheduler.md) | | `system_health` | Панель | `view_system_health`, `run_system_health`, `manage_system_health` | [Мониторинг здоровья](system-health.md) | -| `popups` | Паблик + панель | `view_popups`, `manage_popups` | [Всплывающие окна](popups.md) | +| `popups` | Паблик + панель | `view_popups`, `manage_popups`, `manage_popup_code` | [Всплывающие окна](popups.md) | | `commerceml` | Паблик + панель | `view_commerceml`, `manage_commerceml` | [Обмен с 1С](commerceml.md) | | `traffic_analytics` | Паблик + панель | `view_traffic_analytics`, `manage_traffic_analytics` | [Источники трафика](traffic-analytics.md) | | `search_analytics` | Паблик + панель | `view_search_analytics`, `manage_search_analytics` | [Поисковые запросы](search-analytics.md) | -| `experiments` | Паблик + панель | `view_experiments`, `manage_experiments` | [A/B-тесты](experiments.md) | +| `experiments` | Паблик + панель | `view_experiments`, `manage_experiments`, `manage_experiment_code` | [A/B-тесты](experiments.md) | | `reliable_events` | Паблик + панель | `view_reliable_events`, `manage_reliable_events` | [Исходящие события](reliable-events.md) | | `cache_warmup` | Паблик + панель | `view_cache_warmup`, `manage_cache_warmup` | [Прогрев кеша](cache-warmup.md) | +| `crossposting` | Паблик + панель | `view_crossposting`, `manage_crossposting` | [Кросспостинг](crossposting.md) | +| `environment_compare` | Панель | `view_environment_compare`, `run_environment_compare` | [Сравнение систем](environment-comparison.md) | | `todo` | Панель | `view_todos`, `manage_todos` | [Личные инструменты](personal-tools.md#todo) | | `vk_oauth` | Паблик + панель | `view_vk_oauth`, `manage_vk_oauth` | [Вход через VK ID](oauth-providers.md#vk-id) | | `yandex_oauth` | Паблик + панель | `view_yandex_oauth`, `manage_yandex_oauth` | [Вход через Яндекс](oauth-providers.md#яндекс) | diff --git a/help/modules/cache-warmup.md b/help/modules/cache-warmup.md index e22feff..8b50ccd 100644 --- a/help/modules/cache-warmup.md +++ b/help/modules/cache-warmup.md @@ -5,14 +5,19 @@ Очередь обновляется после полного сброса кеша, сохранения опубликованного документа, ежедневного задания и ручного запуска. Полный план включает главную -страницу и опубликованные документы. +страницу, опубликованные документы и все активные товарные фиды. Примерно на +80% собственного TTL модуль напрямую и принудительно обновляет внутренний кеш +фида, поэтому робот площадки не оплачивает холодную генерацию. Это работает и +для закрытых фидов, но их токены не сохраняются и не показываются в очереди. +Фид с TTL `0` повторно прогревается только полным планом. Настройка **URL за один запуск** ограничивает нагрузку. Значение `10–15` подходит обычному сайту: планировщик обрабатывает следующую пачку раз в минуту, а не открывает весь сайт сразу. После трёх ошибок URL ждёт нового полного плана. В журнале остаются HTTP-статус, -время ответа и текст ошибки. +время ответа и текст ошибки. Окончательная ошибка после третьей попытки +появляется в колокольчике панели. Базовый URL можно оставить пустым, тогда используется домен запроса планировщика. На production рекомендуется задать канонический HTTPS URL. diff --git a/help/modules/comments.md b/help/modules/comments.md index de70511..0866135 100644 --- a/help/modules/comments.md +++ b/help/modules/comments.md @@ -80,12 +80,17 @@ POST /api/v1/comments/37/delete Создание принимает `body`, `parent_id`, а для гостя `author_name` и `author_email`; CSRF передаётся полем `_csrf` или заголовком `X-CSRF-Token`. -Ответ сообщает статус `published` или `pending`. +Ответ сообщает итоговый статус комментария. Посетителю подозрительная отправка +показывается как переданная на модерацию, без раскрытия правил защиты. Создать обсуждение для отсутствующего или удалённого объекта нельзя. Антиспам проверяет содержимое формы, но не заменяет ограничение частоты: для гостя всегда действуют отдельные лимиты по actor cookie и HMAC-префиксу сети/цели. Поэтому -смена cookie не создаёт неограниченный поток комментариев. +смена cookie не создаёт неограниченный поток комментариев. Если активный +профиль антиспама обнаружил автоматическую отправку, запись сохраняется со +статусом `spam` и причиной для модератора. Так ложное срабатывание можно +проверить и опубликовать. Техническая ошибка самого антиспама останавливает +отправку и не маскируется под спам. Настройка премодерации имеет режимы: без модерации, только гости, все. При удалении документа его обсуждение закрывается. Hooks `comments.creating`, diff --git a/help/modules/commerce.md b/help/modules/commerce.md index 6dc1519..e1a73f7 100644 --- a/help/modules/commerce.md +++ b/help/modules/commerce.md @@ -3,6 +3,13 @@ Модуль Commerce управляет корзиной, оформлением заказа, доставкой, оплатой, купонами, избранным, просмотренными товарами и историей заказов покупателя. +## Выгрузка заказов + +В списке заказов сначала задайте поиск, состояние и признак оплаты, затем +нажмите **CSV**. В файл попадёт именно текущая выборка. Экспорт идёт частями по +500 строк и не загружает всю историю заказов в память. В аудит записываются +сотрудник и применённые фильтры. + ## Акции корзины Раздел `Магазин -> Настройки -> Акции` создаёт автоматические правила без @@ -89,6 +96,7 @@ 1. Откройте `Заказы` и нажмите **Создать заказ** справа от заголовка. 2. Найдите существующего пользователя сайта по имени, телефону, email или ID. Его профиль будет связан с заказом, а контакты заполнятся автоматически. + После выбора форма покажет количество, сумму и последние заказы покупателя. Если покупатель ещё не зарегистрирован, заполните имя и телефон либо email вручную. 3. Найдите товары по названию, артикулу или ID. Каждый вариант товара является @@ -100,9 +108,12 @@ Например, после добавления кровати и матраса появится скидка на матрас или подарок. Расчёт использует выбранное количество и цену позиции, в том числе цену, которую менеджер изменил только для этого заказа. + Если для товара настроен комплект, его позиции можно добавить одной кнопкой. 6. При необходимости задайте отдельную скидку менеджера, доставку, оплату, статус и признак оплаты. Сначала применяется акция, затем скидка менеджера, после этого считаются доставка и платёжная наценка. + Помощник отдельно предупредит о несовместимых способах оплаты и доставки, а + также о товарах без заполненных грузовых мест. 7. После сохранения откроется карточка заказа. В ней видны каталожная и ручная цена, скидка, автор создания и история действий. @@ -118,6 +129,10 @@ скидку. Сработавшее правило и подарок записываются в карточку заказа так же, как при оформлении через публичную корзину. +В карточке сохранённого заказа кнопка **Ссылка покупателю** копирует адрес +готового заказа. Покупатель может открыть его на сайте и увидеть страницу +успешного оформления. Ссылка содержит случайный токен заказа, а не ID записи. + ## Изменение состава заказа В карточке сохранённого заказа нажмите **Изменить состав**. Редактор позволяет: diff --git a/help/modules/crossposting.md b/help/modules/crossposting.md new file mode 100644 index 0000000..5de4f05 --- /dev/null +++ b/help/modules/crossposting.md @@ -0,0 +1,139 @@ +# Кросспостинг + +Модуль `crossposting` отправляет документы AVE.cms в Telegram, сообщества VK, +Дзен через внешний шлюз и любые сервисы с HTTPS webhook. Публикация выполняется +в фоне, поэтому недоступная площадка не мешает редактору сохранить документ. + +## Быстрый запуск + +1. Установите и включите модули **Планировщик** и **Кросспостинг**. +2. Откройте **Модули → Кросспостинг → Каналы**. +3. Добавьте канал, сохраните его и нажмите кнопку проверки соединения. +4. На вкладке **Шаблоны** создайте текст публикации. +5. На вкладке **Правила** выберите рубрику, шаблон, каналы и момент отправки. +6. Опубликуйте тестовый документ и проверьте вкладки **Очередь** и **Попытки**. + +Кнопка **Новая публикация** позволяет отправить отдельный документ без +автоматического правила. Пустая дата означает отправку сейчас; заполненная дата +создаёт отложенное задание. + +## Каналы + +### Telegram + +Создайте бота через BotFather, добавьте его администратором канала и разрешите +публиковать сообщения. В настройках канала укажите токен и `@username` канала +или его числовой ID. Токен хранится в локальном хранилище секретов и после +сохранения больше не показывается. + +### VK + +Укажите ID сообщества и токен с правом публикации на стене. Положительное +значение ID автоматически преобразуется в формат владельца сообщества. + +### Дзен + +Способ прямой публикации Дзена может зависеть от типа канала и доступного +партнёрского интерфейса. Поэтому AVE.cms не привязывает очередь к закрытому или +недокументированному API. Канал **Дзен через шлюз** передаёт подготовленный +материал вашему HTTPS bridge. Замена bridge не требует менять правила, +шаблоны и документы. + +### Универсальный webhook + +Webhook получает JSON с событием `crossposting.publish`, `update`, `status` +или `test`. Заголовок `Idempotency-Key` стабилен для одной публикации. Если +задан секрет, тело подписывается заголовком: + +```text +X-AVE-Signature: sha256= +``` + +Ответ шлюза может содержать: + +```json +{ + "accepted": true, + "status": "published", + "id": "remote-123", + "url": "https://service.example/posts/remote-123" +} +``` + +## Шаблоны + +Базовые теги: + +| Тег | Значение | +|---|---| +| `[tag:document:title]` | название документа | +| `[tag:document:excerpt]` | тизер или анонс | +| `[tag:document:url]` | абсолютная публичная ссылка | +| `[tag:document:published]` | дата публикации | +| `[tag:document:tags]` | теги документа | +| `[tag:document:meta_description]` | meta description | +| `[tag:field:alias]` | значение поля рубрики по системному alias | + +Поле изображения задаётся отдельным alias. Поддерживается одиночное изображение +и первый кадр множественного media-поля. Кнопка **Предпросмотр** собирает +публикацию на реальном документе до сохранения шаблона. + +Формат **Обычный текст** безопасно экранирует символы разметки. Формат +**Безопасный HTML** сохраняет поддерживаемое площадкой оформление; Telegram +принимает жирный, курсив, подчёркивание, зачёркивание, код и ссылки. + +## Правила и обновления + +Правило применяется только к выбранной рубрике: + +- **Только вручную** — автоматические задания не создаются; +- **Сразу после публикации** — задание доступно немедленно; +- **По дате публикации документа** — будущая дата создаёт отложенное задание. + +После изменения уже отправленного документа можно ничего не делать, обновить +существующую запись или создать новую. Обновление выполняется только для шлюза, +который это поддерживает. + +Шаблон со значением **Для всех** можно использовать в разных каналах. Если для +Telegram, VK и Дзена нужны разные тексты или изображения, создайте отдельный +шаблон и правило для каждой площадки. Так оформление одного канала не влияет на +остальные. + +## Статусы и защита от дублей + +Одна строка очереди соответствует одному документу в одном канале. Для задания +строится уникальный ключ из документа, редакции, правила, канала и времени. +Повторное сохранение, двойной клик или параллельный cron не создают дубль. + +Статус **Опубликовано** означает, что площадка приняла публикацию и вернула её +ID. **Проверено** означает, что шлюз дополнительно нашёл удалённую запись. +Прочтение публикации подписчиками Telegram и VK не является статусом доставки +и модулем не выдумывается. + +Планировщик каждую минуту запускает задачу `crossposting.publish`. Ошибки +повторяются с увеличивающейся задержкой, а после исчерпания попыток остаются в +журнале для ручного повтора. Неисправимые публикации также появляются в +уведомлениях панели. + +## Новый шлюз в модуле + +Дополнительный модуль регистрирует шлюз из своего `services.php`: + +```php +use App\Modules\Crossposting\GatewayRegistry; + +GatewayRegistry::register('my_channel', function (array $settings, array $secrets) { + return new MyGateway($settings, $secrets); +}, array( + 'title' => 'Мой канал', + 'icon' => 'ti ti-send', + 'fields' => array( + array('name' => 'endpoint_url', 'label' => 'URL', 'type' => 'url', 'required' => true), + array('name' => 'token', 'label' => 'Токен', 'type' => 'secret', 'required' => true), + ), + 'capabilities' => array('publish', 'update', 'verify'), +)); +``` + +Класс реализует `GatewayInterface`. Сетевые запросы выполняются только через +`OutboundHttpClient`, секреты получает отдельно от обычных настроек. diff --git a/help/modules/environment-comparison.md b/help/modules/environment-comparison.md new file mode 100644 index 0000000..44cab94 --- /dev/null +++ b/help/modules/environment-comparison.md @@ -0,0 +1,25 @@ +# Сравнение локальной и рабочей системы + +Модуль помогает понять, чем две установки AVE.cms отличаются перед переносом +изменений или публикацией обновления. + +## Как пользоваться + +1. Установите модуль на рабочем и локальном сайте. +2. На рабочем сайте откройте «Модули -> Сравнение систем» и нажмите + «Скачать снимок». +3. Откройте тот же раздел на локальном сайте. +4. Загрузите полученный JSON и нажмите «Сравнить». +5. Раскройте раздел с отличиями: ядро, модули, миграции, схема БД, настройки, + темы или шаблоны. + +Для ядра, модулей и тем отчёт показывает конкретный путь изменённого или +отсутствующего файла. В снимке хранится только путь и SHA-256, содержимое файла +не передаётся. + +Снимок не содержит содержимое документов, персональные данные, пароли, токены, +ключи и параметры подключения к БД. Безопасные настройки представлены только +контрольными суммами: отчёт показывает факт изменения, но не раскрывает значение. + +Статусы «Только здесь» и «Только там» всегда читаются относительно системы, в +которой открыт отчёт. diff --git a/help/modules/experiments.md b/help/modules/experiments.md index 710e5c8..d5fb950 100644 --- a/help/modules/experiments.md +++ b/help/modules/experiments.md @@ -39,6 +39,22 @@ Эксперимент можно оставить черновиком, запустить, приостановить или завершить. Период начала и окончания необязателен. +## Когда результату можно доверять + +Поле **Минимум показов на вариант** задаёт фиксированную выборку. До её набора +модуль показывает состояние **Данных мало** и не называет победителя. По +умолчанию требуется 500 показов каждого варианта. + +После набора выборки контрольный вариант A сравнивается с лучшей альтернативой +двухпропорционным z-тестом. Для нескольких альтернатив учитывается число +сравнений. Возможны три вывода: данных мало, разница в пределах погрешности или +победитель с достоверностью не ниже 95%. Интерфейс также показывает разницу в +процентных пунктах и её 95-процентный интервал. Устойчивый результат появляется +в колокольчике панели. + +Не меняйте варианты и основное событие во время теста. Если условия изменились, +очистите статистику и начните набор заново. + ## Вставка в шаблон ```text @@ -78,8 +94,8 @@ POST /api/v1/experiments/event Последний endpoint принимает `code` и `event`. ID варианта от клиента не принимается: сервер повторно вычисляет назначение и записывает событие только в -этот вариант. Для одного посетителя уникальность события считается раз в день, -при этом общее число событий также сохраняется. +этот вариант. Запрос дополнительно содержит подписанное сервером назначение; +для одного посетителя одно событие учитывается не чаще раза в день. ## Hooks @@ -109,7 +125,8 @@ IP и исходный seed в статистике не сохраняются. ## Права и удаление - `view_experiments` - просмотр экспериментов и результатов; -- `manage_experiments` - создание, запуск, сброс статистики и удаление. +- `manage_experiments` - сброс статистики и операционное управление; +- `manage_experiment_code` - изменение HTML-вариантов и автоподключения с повторным подтверждением паролем. Деинсталляция удаляет эксперименты, варианты, статистику, дедупликацию посетителей и настройки модуля. diff --git a/help/modules/personal-tools.md b/help/modules/personal-tools.md index c0d1b3c..6b902d8 100644 --- a/help/modules/personal-tools.md +++ b/help/modules/personal-tools.md @@ -1,25 +1,26 @@ -# Личные инструменты панели +# Рабочие инструменты панели ← [Назад к разделу «Модули»](README.md) -Todo, канбан, заметки и напоминания хранят личные рабочие данные текущего -пользователя панели. Записи одного администратора не становятся общей доской для -других пользователей. +Todo и канбан поддерживают совместную работу. Заметки и напоминания остаются +личными и видны только своему владельцу. ## Todo -Модуль `todo` — короткий чек-лист. Задачу можно создать из действия в шапке, -отметить выполненной или удалить. Полный список находится в **Модули → Todo**, а -сводка доступна виджетом главной. +Модуль `todo` — короткий чек-лист. У задачи можно выбрать ответственного, срок, +доступ и связанный документ. В списке есть области **Мои**, **Назначены мне**, +**Команда** и **Все**. Автор может изменить и удалить задачу, автор и +ответственный - отметить её выполненной. Просроченное появляется в колокольчике. - `view_todos` — список, действие в шапке и виджет; - `manage_todos` — создание, выполнение и удаление. ## Канбан -Модуль `kanban` — личная доска с настраиваемыми колонками и карточками. Колонки и -карточки имеют цвет, порядок меняется перетаскиванием. Виджет показывает доску -на главной панели. +Модуль `kanban` — доска с настраиваемыми колонками и карточками. Карточка может +иметь ответственного, срок, командную видимость и связь с документом. Колонки и +порядок принадлежат автору доски. Получатель видит карточку в блоке **Назначено +мне и доступно команде**, поэтому этапы разных сотрудников не смешиваются. - `view_kanban` — просмотр доски и виджета; - `manage_kanban` — колонки, карточки и перемещение. diff --git a/help/modules/popups.md b/help/modules/popups.md index 8e50ecb..1ba029e 100644 --- a/help/modules/popups.md +++ b/help/modules/popups.md @@ -16,6 +16,12 @@ HTML вариантов A и B хранится отдельно. Доля ва Форма внутри варианта может содержать `name`, `email`, `phone` и дополнительные поля. Для статуса ответа добавьте элемент с `data-popup-status`. Показ, конверсия и заявка учитываются отдельно. Частота хранится в браузере посетителя. +Показы и конверсии принимаются только с подписанным назначением варианта и не +увеличиваются повторно для того же посетителя в течение суток. + +Право `manage_popups` предназначено для обработки заявок. Изменение HTML и +автоматического подключения требует отдельного права `manage_popup_code` и +повторного подтверждения паролем: эта разметка исполняется на публичной странице. Автоматическую вставку перед `` можно выключить в настройках модуля. Тогда модуль не меняет публичный HTML. diff --git a/help/modules/public-widgets.md b/help/modules/public-widgets.md new file mode 100644 index 0000000..b850c05 --- /dev/null +++ b/help/modules/public-widgets.md @@ -0,0 +1,15 @@ +# Публичные виджеты + +← [Назад к разделу «Модули»](README.md) + +Приватный модуль `widgets` содержит небольшие публичные блоки: курсы валют и +форму подписки. Он не входит в обычную core-сборку и устанавливается отдельно. + +После установки откройте **Модули → Виджеты**. Для каждого виджета показан +реальный Twig-файл и его источник. Сохранение создаёт override в +`views/widgets_public/` активной темы, а возврат к резерву снова включает файл +модуля. + +Шаблон курсов получает `usd` и `eur`. Форма подписки получает `csrf`, `message` +и `success`. Адрес отправки, rate limit и серверная проверка email остаются в +runtime модуля и не зависят от разметки. diff --git a/help/modules/quiz.md b/help/modules/quiz.md new file mode 100644 index 0000000..4508cea --- /dev/null +++ b/help/modules/quiz.md @@ -0,0 +1,21 @@ +# Подбор товара + +← [Назад к разделу «Модули»](README.md) + +Модуль `quiz` строит пошаговый подбор по нативным фильтрам товарного каталога. +Каждый ответ может добавить значение фильтра, а результатом становится выдача +товаров, заявка менеджеру или оба действия. + +## Шаблон публичной страницы + +Откройте **Модули → Подбор товара → Шаблон страницы**. Экран показывает +Twig-файл `page.twig`, который содержит заголовок, прогресс, область вопросов и +кнопки перехода между шагами. + +Если активная тема не переопределяет файл, используется резерв модуля. Первое +сохранение создаёт `views/quiz_public/page.twig` в теме. Под редактором доступны +переменные `quiz`, `api`, `csrf` и `steps_count`. Проверка Twig, ревизии и сброс +публичного кеша выполняются автоматически. + +Шаблон управляет разметкой, но не логикой шагов. Вопросы, варианты, фильтры и +режим результата настраиваются в конструкторе конкретного подбора. diff --git a/help/modules/ratings.md b/help/modules/ratings.md index a43ccf5..8b92836 100644 --- a/help/modules/ratings.md +++ b/help/modules/ratings.md @@ -56,3 +56,12 @@ POST /api/v1/interactions/document/145/rating Фильтр `ratings.rendering` получает `target_type`, `target_key`, `average`, `count`, `own`, `scale` и готовый `html`. Обработчик может вернуть изменённый HTML, не вмешиваясь в подсчёт. + +## Проверка и удаление оценок + +В таблице **Самые оцениваемые** кнопка списка открывает отдельные голоса +выбранного объекта. Пользователь с правом `manage_ratings` может удалить один +ошибочный голос или сбросить рейтинг объекта целиком. После удаления агрегат +пересчитывается в той же операции, поэтому среднее число и публичный виджет не +расходятся. Обе операции записываются в аудит и не удаляют сам документ, опрос +или галерею. diff --git a/help/modules/search.md b/help/modules/search.md index 88c0093..d5938ca 100644 --- a/help/modules/search.md +++ b/help/modules/search.md @@ -101,8 +101,15 @@ modules/search/app/assets/ ``` В файлах находятся функциональная оболочка страницы, форма и штатные assets. -Разметка самих результатов хранится в настройках модуля. Для разных карточек по -разделам удобнее использовать запросы. +Они доступны по кнопке **Оболочка страницы** в шапке модуля. Там отдельно +редактируются форма, вся страница, AJAX-фрагмент и пагинация. Сохранение создаёт +override в активной теме, проверяет Twig, записывает ревизию и очищает публичный +кеш. Кнопка возврата удаляет override и включает резерв модуля. + +Разметка самих карточек результатов хранится на вкладке **Шаблоны вывода**. +«Оболочка страницы» отвечает за расположение формы и выдачи, а «Шаблоны +вывода» — за содержимое найденных элементов. Для разных карточек по разделам +удобнее использовать запросы. В **Модули → Поиск по сайту → Шаблоны вывода** встроенную разметку можно менять без правки файлов. Доступны четыре шаблона: @@ -204,6 +211,43 @@ PHP, разрешённый в административно управляем Пустой набор рубрик означает все рубрики. Область может наследовать основной запрос и набор полей API либо переопределить их. +### Условия показа в области + +У области можно задать дополнительные ограничения без SQL и без привязки к ID +полей. Условие хранит системное имя поля (alias), поэтому продолжает работать +после переноса сайта, если в рубрике сохранены те же системные имена. + +Чтобы добавить ограничение: + +1. Откройте вкладку **Области** и нужную область. +2. В блоке **Условия показа** нажмите **Добавить условие**. +3. Выберите поле. В списке рядом с названием показан его alias. +4. Выберите проверку и, если требуется, укажите значение. +5. Сохраните настройки поиска. + +Все строки выполняются одновременно, то есть соединяются правилом «И». Например, +область товаров может содержать такие условия: + +| Поле | Проверка | Значение | Что означает | +| --- | --- | --- | --- | +| Цена (`price`) | Число больше | `0` | Показывать только позиции с положительной ценой. | +| Скрыть (`noshow`) | Флажок выключен | — | Не показывать вручную скрытые позиции. | +| Нет в прайсе (`noprice`) | Флажок выключен | — | Исключить позиции, которых нет в прайсе. | + +Названия `price`, `noshow` и `noprice` приведены только как пример. На другом +сайте выбираются его собственные поля. Модуль не содержит обязательных товарных +alias и не навязывает эти правила чистой установке. + +Доступны проверки заполненности, состояния флажка, равенства текста и сравнения +чисел. Для «Флажок выключен», «Не равно» и «Не заполнено» отсутствие самого поля +тоже считается подходящим результатом. Это удобно, когда одна область объединяет +несколько рубрик с немного разным набором полей. + +Статус документа проверяется независимо от этих строк. Выключенные, удалённые и +запрещённые для поиска документы не выводятся даже при устаревшем поисковом +индексе. Условия применяются до подсчёта результатов, поэтому итоговое количество, +подсказки и пагинация совпадают с фактической выдачей. + Публичная страница показывает области как переключатели. Прямые ссылки: ```text diff --git a/help/modules/smsc-auth.md b/help/modules/smsc-auth.md index f594cd8..7952f4d 100644 --- a/help/modules/smsc-auth.md +++ b/help/modules/smsc-auth.md @@ -40,7 +40,8 @@ API-ключ, который можно отозвать без смены па 8. Включите публичный вход только после успешного теста. Общая возможность создавать пользователей и их группа настраиваются в -`Система -> Пользователи сайта -> Регистрация`. Переключатель модуля +`Система -> Пользователи сайта -> Регистрация`. Там выберите регистрацию +`По телефону` или `По email или телефону`. Переключатель модуля `Создавать аккаунты` может дополнительно запретить регистрацию по телефону, оставив вход существующих пользователей. diff --git a/setup/Installer.php b/setup/Installer.php index 0cdabb4..05e7f6c 100644 --- a/setup/Installer.php +++ b/setup/Installer.php @@ -252,6 +252,7 @@ 'admin_language' => isset($site['admin_language']) && in_array($site['admin_language'], array('ru', 'en'), true) ? (string) $site['admin_language'] : $this->locale, + 'site_url' => rtrim(trim(isset($site['site_url']) ? (string) $site['site_url'] : ''), '/'), ); if ($out['site_name'] === '' || $out['admin_name'] === '' || $out['admin_login'] === '') { throw new InvalidArgumentException('Укажите название сайта, имя и логин администратора.'); @@ -261,6 +262,12 @@ throw new InvalidArgumentException('Укажите корректный email администратора.'); } + $siteUrl = parse_url($out['site_url']); + if (!is_array($siteUrl) || !in_array(strtolower(isset($siteUrl['scheme']) ? $siteUrl['scheme'] : ''), array('http', 'https'), true) + || empty($siteUrl['host']) || isset($siteUrl['user']) || isset($siteUrl['pass']) || isset($siteUrl['query']) || isset($siteUrl['fragment'])) { + throw new InvalidArgumentException('Укажите полный публичный адрес сайта без параметров, например https://example.org.'); + } + if (!preg_match('/^[A-Za-z0-9_.@-]{3,150}$/', $out['admin_login'])) { throw new InvalidArgumentException('Логин содержит недопустимые символы.'); } @@ -349,7 +356,7 @@ $this->execute($db, 'INSERT INTO `' . $prefix . '_role_permissions` (role_id,permission_code) VALUES (1,\'all_permissions\')'); $this->seedSettings($db, $prefix, $site['site_name'], $site['admin_language'], $repositories); - $this->seedConstants($db, $prefix, $date); + $this->seedConstants($db, $prefix, $date, isset($site['site_url']) ? $site['site_url'] : ''); $this->seedCoreMigrationLedger($db, $prefix, $date); $this->seedPublicIdentity($db, $prefix, $site['site_name'], $site['admin_name'], $site['admin_login'], $site['admin_email'], $password, $date); $this->seedContent($db, $prefix, $site['site_name'], $now); @@ -428,7 +435,7 @@ } } - protected function seedConstants(mysqli $db, $prefix, $date) + protected function seedConstants(mysqli $db, $prefix, $date, $siteUrl = '') { if (!class_exists('App\\Common\\RuntimeConstantSchema', false)) { require_once $this->root . '/system/App/Common/RuntimeConstantSchema.php'; @@ -437,6 +444,10 @@ $position = 10; foreach (\App\Common\RuntimeConstantSchema::all(false) as $name => $definition) { $value = $definition['default']; + if ($name === 'PUBLIC_SITE_URL' && trim((string) $siteUrl) !== '') { + $value = rtrim(trim((string) $siteUrl), '/'); + } + if (is_bool($value)) { $value = $value ? '1' : '0'; $type = 'bool'; diff --git a/setup/core-migrations.php b/setup/core-migrations.php index b2a9263..cb3f429 100644 --- a/setup/core-migrations.php +++ b/setup/core-migrations.php @@ -30,7 +30,7 @@ 'customers' => array('directory' => 'Customers', 'ids' => array( '001_normalize_public_groups', '002_public_auth_schema', '003_normalize_last_visit', '004_materialize_public_auth_schema', '005_checkout_registration', - '006_phone_identity', + '006_phone_identity', '007_customer_center', ), 'backup' => array( '005_checkout_registration' => array( '{{public_user_prefix}}_auth_settings', @@ -42,6 +42,10 @@ '{{prefix}}_settings', ), '006_phone_identity' => array('{{public_user_prefix}}_users'), + '007_customer_center' => array( + '{{system_prefix}}_customer_notes', + '{{basket_prefix}}_module_basket_history', + ), )), 'database' => array('directory' => 'Database', 'ids' => array( '001_mysql_legacy_index_compatibility', '002_module_lifecycle_recovery', @@ -73,9 +77,16 @@ '006_normalize_control_panel_permission' => array('{{prefix}}_permissions'), )), 'media' => array('directory' => 'Media', 'ids' => array( - '001_image_presets', - ), 'backup' => array('001_image_presets' => array())), - 'navigation' => array('directory' => 'Navigation', 'ids' => array('001_create_navigation_tables')), + '001_image_presets', '002_media_search_index', + ), 'backup' => array( + '001_image_presets' => array(), + '002_media_search_index' => array(), + )), + 'navigation' => array('directory' => 'Navigation', 'ids' => array( + '001_create_navigation_tables', '002_navigation_revisions', + ), 'backup' => array( + '002_navigation_revisions' => array(), + )), 'notfound' => array('directory' => 'NotFound', 'optional' => true, 'ids' => array( '001_register_notfound_permissions', '002_create_not_found_log', )), @@ -94,9 +105,10 @@ '005_rebuild_native_condition_cache', '006_sargable_condition_cache', '007_result_contract', '008_preview_renderer', '009_native_executor', '010_native_audit_result', '011_order_tiebreaker', '012_condition_value_sources', - '013_sort_rules', + '013_sort_rules', '014_request_revisions', ), 'backup' => array( '013_sort_rules' => array('{{content_prefix}}_request'), + '014_request_revisions' => array(), )), 'rubrics' => array('directory' => 'Rubrics', 'ids' => array( '001_create_rubrics_tables', '002_rubric_field_settings', @@ -121,7 +133,11 @@ '001_create_settings_tables', '002_normalize_breadcrumb_separator', '003_remove_unused_constants', '004_seed_core_display_defaults', '005_order_system_navigation', '006_create_admin_saved_views', - ), 'backup' => array('006_create_admin_saved_views' => array())), + '007_public_site_url', + ), 'backup' => array( + '006_create_admin_saved_views' => array(), + '007_public_site_url' => array('{{prefix}}_constants'), + )), 'templates' => array('directory' => 'Templates', 'ids' => array( '001_create_templates_tables', '002_create_template_revisions', )), @@ -130,7 +146,10 @@ )), 'users' => array('directory' => 'Users', 'ids' => array( '001_normalize_public_session_activity', '002_harden_public_remember_tokens', + '003_system_user_security', '004_system_session_expiry', ), 'backup' => array( '002_harden_public_remember_tokens' => array('{{public_user_prefix}}_users_session'), + '003_system_user_security' => array(), + '004_system_session_expiry' => array('{{prefix}}_users_session'), )), ); diff --git a/setup/index.php b/setup/index.php index c47666a..65db032 100644 --- a/setup/index.php +++ b/setup/index.php @@ -160,6 +160,10 @@ $success = null; $base = rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'])), '/'); $rootUrl = preg_replace('#/setup$#', '', $base); + $requestHost = isset($_SERVER['HTTP_HOST']) ? strtolower(trim((string) $_SERVER['HTTP_HOST'])) : ''; + if (!preg_match('/^[a-z0-9.\-\[\]:]+$/', $requestHost)) { $requestHost = 'localhost'; } + $requestScheme = $setupCookieSecure ? 'https' : 'http'; + $defaultSiteUrl = $requestScheme . '://' . $requestHost . ($rootUrl !== '' ? $rootUrl : ''); $progressRequest = $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_SERVER['HTTP_X_SETUP_PROGRESS']) && $_SERVER['HTTP_X_SETUP_PROGRESS'] === '1'; @@ -193,6 +197,7 @@ 'admin_login' => 'admin', 'admin_email' => '', 'admin_directory' => 'adminx', + 'site_url' => $defaultSiteUrl, ); $resetPrefixRequested = $_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['reset_prefix']); $resetPrefixConfirmation = $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['reset_prefix_confirmation']) @@ -236,6 +241,7 @@ 'admin_password' => isset($_POST['admin_password']) ? $_POST['admin_password'] : '', 'admin_directory' => isset($_POST['admin_directory']) ? $_POST['admin_directory'] : 'adminx', 'admin_language' => $setupLanguage, + 'site_url' => isset($_POST['site_url']) ? $_POST['site_url'] : $defaultSiteUrl, ); $repositories = $installer->validateRepositorySelection($repositoryMode, $customRepositoryProfile); $success = $installer->install($database, $site, array( @@ -412,7 +418,8 @@
3

Сайт и администратор

Единственная учётная запись новой системы

- + + diff --git a/setup/schema.sql b/setup/schema.sql index 9cffa79..c814a24 100644 --- a/setup/schema.sql +++ b/setup/schema.sql @@ -40,6 +40,8 @@ CREATE TABLE IF NOT EXISTS `{{prefix}}_users` ( `last_name` VARCHAR(100) DEFAULT NULL, `phone` VARCHAR(35) DEFAULT NULL, `last_login_at` DATETIME DEFAULT NULL, + `password_changed_at` DATETIME DEFAULT NULL, + `must_change_password` TINYINT(1) NOT NULL DEFAULT 0, `legacy_id` INT UNSIGNED DEFAULT NULL, `legacy_password` VARCHAR(64) DEFAULT NULL, `legacy_salt` VARCHAR(32) DEFAULT NULL, @@ -57,9 +59,12 @@ CREATE TABLE IF NOT EXISTS `{{prefix}}_users_session` ( `ip` VARCHAR(45) DEFAULT NULL, `created_at` DATETIME NOT NULL, `last_active` DATETIME NOT NULL, + `expires_at` DATETIME NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uniq_token` (`token_hash`), - KEY `idx_user` (`user_id`) + KEY `idx_user` (`user_id`), + KEY `idx_user_active` (`user_id`,`last_active`), + KEY `idx_expiry` (`expires_at`) ) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `{{prefix}}_roles` ( @@ -121,7 +126,8 @@ CREATE TABLE IF NOT EXISTS `{{prefix}}_audit_log` ( PRIMARY KEY (`id`), KEY `idx_created` (`created_at`), KEY `idx_action` (`action`), - KEY `idx_actor` (`actor_id`) + KEY `idx_actor` (`actor_id`), + KEY `idx_target_action` (`target_id`,`action`,`created_at`) ) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `{{prefix}}_module_migrations` ( @@ -171,6 +177,29 @@ CREATE TABLE IF NOT EXISTS `{{prefix}}_media_image_presets` ( KEY `idx_sort` (`sort_order`) ) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS `{{prefix}}_media_search_index` ( + `path_hash` CHAR(40) CHARACTER SET ascii NOT NULL, + `path` TEXT NOT NULL, + `name` VARCHAR(190) NOT NULL DEFAULT '', + `extension` VARCHAR(20) CHARACTER SET ascii NOT NULL DEFAULT '', + `is_image` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0, + `size` BIGINT UNSIGNED NOT NULL DEFAULT 0, + `modified_at` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`path_hash`), + KEY `idx_modified` (`modified_at`), + KEY `idx_type` (`is_image`,`extension`) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `{{prefix}}_customer_notes` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT UNSIGNED NOT NULL, + `author_id` INT UNSIGNED NOT NULL DEFAULT 0, + `note` TEXT NOT NULL, + `created_at` INT UNSIGNED NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_customer_notes` (`user_id`,`created_at`) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; + CREATE TABLE IF NOT EXISTS `{{prefix}}_modules` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `code` VARCHAR(100) NOT NULL, @@ -1004,6 +1033,22 @@ CREATE TABLE IF NOT EXISTS `{{prefix}}_request_conditions` ( KEY `idx_request_group_position` (`request_id`, `condition_group_id`, `condition_position`) ) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS `{{prefix}}_request_revisions` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `request_id` SMALLINT UNSIGNED NOT NULL DEFAULT 0, + `action` VARCHAR(32) NOT NULL DEFAULT 'update', + `snapshot_hash` CHAR(40) NOT NULL DEFAULT '', + `snapshot_json` LONGTEXT NOT NULL, + `comment` VARCHAR(255) NOT NULL DEFAULT '', + `author_id` INT UNSIGNED NOT NULL DEFAULT 0, + `author_name` VARCHAR(255) NOT NULL DEFAULT '', + `source_revision_id` INT UNSIGNED NOT NULL DEFAULT 0, + `created_at` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_request_created` (`request_id`,`created_at`), + KEY `idx_snapshot_hash` (`snapshot_hash`) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; + CREATE TABLE IF NOT EXISTS `{{prefix}}_presentations` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `title` VARCHAR(190) NOT NULL, @@ -1107,6 +1152,22 @@ CREATE TABLE IF NOT EXISTS `{{prefix}}_navigation_items` ( KEY `idx_document` (`document_id`) ) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS `{{prefix}}_navigation_revisions` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `navigation_id` SMALLINT UNSIGNED NOT NULL DEFAULT 0, + `action` VARCHAR(32) NOT NULL DEFAULT 'update', + `snapshot_hash` CHAR(40) NOT NULL DEFAULT '', + `snapshot_json` LONGTEXT NOT NULL, + `comment` VARCHAR(255) NOT NULL DEFAULT '', + `author_id` INT UNSIGNED NOT NULL DEFAULT 0, + `author_name` VARCHAR(255) NOT NULL DEFAULT '', + `source_revision_id` INT UNSIGNED NOT NULL DEFAULT 0, + `created_at` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_navigation_created` (`navigation_id`,`created_at`), + KEY `idx_snapshot_hash` (`snapshot_hash`) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4; + CREATE TABLE IF NOT EXISTS `{{prefix}}_sysblocks_groups` ( `id` MEDIUMINT UNSIGNED NOT NULL AUTO_INCREMENT, `position` SMALLINT UNSIGNED NOT NULL DEFAULT 0, diff --git a/system/App/Common/ApiTokenRepository.php b/system/App/Common/ApiTokenRepository.php index dbcd249..c1ee22d 100644 --- a/system/App/Common/ApiTokenRepository.php +++ b/system/App/Common/ApiTokenRepository.php @@ -25,11 +25,23 @@ public static function table() { return SystemTables::table('api_tokens'); } public static function issue($userId, $name, array $scopes, $expiresAt = '') + { + return self::issueRestricted($userId, $name, $scopes, self::allowedScopes(), $expiresAt); + } + + /** Issue a token only when every requested scope belongs to the caller's boundary. */ + public static function issueRestricted($userId, $name, array $scopes, array $allowedScopes, $expiresAt = '') { $userId = (int) $userId; $name = trim((string) $name); if ($userId <= 0 || $name === '') { throw new \InvalidArgumentException('Укажите название API-токена'); } - $scopes = self::normalizeScopes($scopes); + $requested = array_values(array_unique(array_filter(array_map('trim', array_map('strval', $scopes))))); + $allowedScopes = self::normalizeScopes($allowedScopes); + if (array_diff($requested, $allowedScopes)) { + throw new \InvalidArgumentException('Запрошенное разрешение недоступно в этом разделе'); + } + + $scopes = self::normalizeScopes($requested); if (empty($scopes)) { throw new \InvalidArgumentException('Выберите хотя бы одно разрешение API'); } $raw = 'ave_' . bin2hex(random_bytes(32)); $expires = self::dateValue($expiresAt); @@ -63,6 +75,18 @@ return DB::Update(self::table(), array('revoked_at' => date('Y-m-d H:i:s')), 'id=%i AND revoked_at IS NULL', (int) $id); } + /** Revoke only a token that belongs to one of the caller's scope families. */ + public static function revokeRestricted($id, array $scopes) + { + $token = self::find((int) $id); + if (!$token) { return 0; } + foreach (self::normalizeScopes($scopes) as $scope) { + if (self::hasScope($token, $scope)) { return self::revoke((int) $id); } + } + + return 0; + } + public static function find($id) { $row = DB::query('SELECT * FROM %b WHERE id=%i LIMIT 1', self::table(), (int) $id)->getAssoc(); diff --git a/system/App/Common/AuditLog.php b/system/App/Common/AuditLog.php index 6264534..892df78 100644 --- a/system/App/Common/AuditLog.php +++ b/system/App/Common/AuditLog.php @@ -82,6 +82,12 @@ 'role.copied' => 'Роль скопирована', 'role.deleted' => 'Роль удалена', 'user.bulk_status_updated' => 'Состояние пользователей изменено', + 'user.created' => 'Пользователь создан', + 'user.updated' => 'Пользователь изменён', + 'user.deleted' => 'Пользователь удалён', + 'user.status_updated' => 'Доступ пользователя изменён', + 'user.session_revoked' => 'Сессия пользователя завершена', + 'user.sessions_revoked' => 'Сессии пользователя завершены', 'settings.updated' => 'Настройки изменены', 'settings.interface_updated' => 'Интерфейс изменен', 'settings.security_updated' => 'Настройки безопасности изменены', @@ -98,8 +104,25 @@ 'directory.item_created' => 'Значение справочника добавлено', 'directory.item_updated' => 'Значение справочника изменено', 'directory.item_deleted' => 'Значение справочника удалено', + 'media.trashed' => 'Медиа перемещено в корзину', + 'media.restored' => 'Медиа восстановлено из корзины', + 'media.purged' => 'Медиа удалено окончательно', + 'customers.exported' => 'Пользователи сайта выгружены в CSV', + 'commerce.orders_exported' => 'Заказы выгружены в CSV', + 'popups.leads_exported' => 'Заявки из поп-апов выгружены в CSV', + 'quiz.created' => 'Подбор товара создан', + 'quiz.updated' => 'Подбор товара изменён', + 'quiz.deleted' => 'Подбор товара удалён', + 'quiz.leads_exported' => 'Заявки подбора выгружены в CSV', + 'popups.campaign_created' => 'Кампания поп-апа создана', + 'popups.campaign_updated' => 'Кампания поп-апа изменена', + 'popups.campaign_deleted' => 'Кампания поп-апа удалена', + 'popups.lead_status_changed' => 'Статус заявки поп-апа изменён', + 'popups.settings_updated' => 'Настройки поп-апов изменены', + 'antispam.ip_blocked' => 'Антиспам заблокировал адрес', 'document.creation_preset_created' => 'Пресет создания документа добавлен', 'document.creation_preset_deleted' => 'Пресет создания документа удалён', + 'document.bulk_editor_completed' => 'Массовое изменение документов завершено', 'block.created' => 'Блок создан', 'block.updated' => 'Блок изменён', 'block.copied' => 'Блок скопирован', @@ -179,6 +202,22 @@ 'search.settings_updated' => 'Настройки поиска изменены', 'search.reindex_started' => 'Переиндексация поиска запущена', 'search.reindex_completed' => 'Переиндексация поиска завершена', + 'comments.updated' => 'Комментарий изменён', + 'comments.moderated' => 'Статус комментария изменён', + 'comments.bulk_moderated' => 'Комментарии обработаны массово', + 'comments.settings_updated' => 'Настройки комментариев изменены', + 'comments.thread_updated' => 'Состояние обсуждения изменено', + 'todo.created' => 'Задача Todo создана', + 'todo.updated' => 'Задача Todo изменена', + 'todo.completed' => 'Задача Todo выполнена', + 'todo.reopened' => 'Задача Todo возвращена в работу', + 'todo.deleted' => 'Задача Todo удалена', + 'kanban.created' => 'Карточка канбана создана', + 'kanban.updated' => 'Карточка канбана изменена', + 'kanban.deleted' => 'Карточка канбана удалена', + 'ratings.vote_deleted' => 'Оценка удалена модератором', + 'ratings.target_reset' => 'Рейтинг объекта сброшен', + 'reviews.bulk_moderated' => 'Отзывы обработаны массово', 'reviews.settings_updated' => 'Настройки отзывов изменены', 'theme.created' => 'Тема создана', 'theme.imported' => 'Тема импортирована', @@ -199,6 +238,9 @@ 'database.backup_restore_failed' => 'Ошибка восстановления базы данных', 'auth.reauth_confirmed' => 'Пароль для чувствительной операции подтверждён', 'auth.reauth_failed' => 'Неудачное подтверждение пароля', + 'auth.login_succeeded' => 'Вход в панель выполнен', + 'auth.login_failed' => 'Неудачная попытка входа', + 'auth.password_changed' => 'Временный пароль изменён', 'auth.phone_registered' => 'Пользователь зарегистрирован по телефону', 'auth.phone_authenticated' => 'Пользователь вошёл по телефону', 'mcp.connection_created' => 'MCP-подключение создано', diff --git a/system/App/Common/Auth.php b/system/App/Common/Auth.php index c45143a..26b3e8c 100644 --- a/system/App/Common/Auth.php +++ b/system/App/Common/Auth.php @@ -31,6 +31,7 @@ */ protected static $currentUsers = []; protected static $systemRolePermissions = []; + protected static $sessionExpiryColumns = []; protected function __construct() { @@ -192,11 +193,9 @@ $token = isset($_COOKIE[$options['cookie_name']]) ? (string) $_COOKIE[$options['cookie_name']] : ''; if (preg_match('/^[a-f0-9]{64}$/', $token)) { - $ownerId = (int) DB::query( - "SELECT user_id FROM " . $options['session_table'] . " WHERE token_hash = %s LIMIT 1", - hash('sha256', $token) - )->getOne(); - if ($ownerId === (int) $user['id']) { + $hash = hash('sha256', $token); + if (self::modernTokenBelongsTo($hash, (int) $user['id'], $options)) { + Session::set(self::modernTokenSessionKey($options), hash('sha256', $token)); return true; } } @@ -340,7 +339,7 @@ } self::modernForgetCookie($options); - Session::del($options['session_key']); + Session::del($options['session_key'], self::modernTokenSessionKey($options)); if (!empty($options['load_permissions'])) { Session::del('permissions'); } @@ -379,6 +378,17 @@ return null; } + // Browser-token is the revocable identity of this login. Without this + // check deleting a row from users_session would leave the PHP session + // authorised until its own expiry. + $tokenHash = self::currentBrowserTokenHash($options); + if ($tokenHash !== '' && !self::modernTokenBelongsTo($tokenHash, $id, $options)) { + self::modernForgetCookie($options); + Session::del($options['session_key'], self::modernTokenSessionKey($options)); + self::$currentUsers[$cacheKey] = false; + return null; + } + $sql = "SELECT * FROM " . $options['users_table'] . " WHERE id = %i"; $args = [$sql, $id]; if ($options['active_field'] !== '') { @@ -408,6 +418,26 @@ return $user ? (int) $user['id'] : 0; } + /** SHA-256 browser-token of the current modern-auth session, if present. */ + public static function currentBrowserTokenHash(array $options = array()) + { + $options = self::modernAuthOptions($options); + $sessionKey = self::modernTokenSessionKey($options); + $stored = (string) Session::get($sessionKey); + if (preg_match('/^[a-f0-9]{64}$/', $stored)) { + return $stored; + } + + $token = isset($_COOKIE[$options['cookie_name']]) ? (string) $_COOKIE[$options['cookie_name']] : ''; + if (!preg_match('/^[a-f0-9]{64}$/', $token)) { + return ''; + } + + $hash = hash('sha256', $token); + Session::set($sessionKey, $hash); + return $hash; + } + public static function role(array $options = []) { $options = self::modernAuthOptions($options); @@ -450,6 +480,31 @@ return $options['session_key'] . '|' . $options['cookie_name']; } + protected static function modernTokenSessionKey(array $options) + { + return '_auth_browser_token_' . substr(sha1($options['session_key'] . '|' . $options['cookie_name']), 0, 16); + } + + protected static function modernTokenBelongsTo($hash, $userId, array $options) + { + $expiryColumn = self::modernSessionHasExpiry($options); + $row = DB::query( + 'SELECT user_id,last_active,created_at' . ($expiryColumn ? ',expires_at' : '') + . ' FROM ' . $options['session_table'] . ' WHERE token_hash=%s LIMIT 1', + (string) $hash + )->getAssoc(); + if (!$row || (int) $row['user_id'] !== (int) $userId) { + return false; + } + + if (self::modernTokenExpired($row, $options)) { + DB::Delete($options['session_table'], 'token_hash=%s', (string) $hash); + return false; + } + + return true; + } + protected static function modernIssueRememberToken($userId, array $options, $persistent = true) { if (headers_sent()) { @@ -459,14 +514,21 @@ $token = bin2hex(random_bytes(32)); $now = date('Y-m-d H:i:s'); - DB::Insert($options['session_table'], [ + $tokenHash = hash('sha256', $token); + $values = [ 'user_id' => (int) $userId, - 'token_hash' => hash('sha256', $token), + 'token_hash' => $tokenHash, 'agent' => substr(Request::userAgent(), 0, 255), 'ip' => self::modernClientIp(), 'created_at' => $now, 'last_active' => $now, - ]); + ]; + if (self::modernSessionHasExpiry($options)) { + $values['expires_at'] = date('Y-m-d H:i:s', time() + (int) $options['token_ttl']); + } + + DB::Insert($options['session_table'], $values); + Session::set(self::modernTokenSessionKey($options), $tokenHash); self::modernSendCookie($token, $persistent ? time() + (int) $options['token_ttl'] : 0, $options); $_COOKIE[$options['cookie_name']] = $token; @@ -492,12 +554,19 @@ } $hash = hash('sha256', $token); - $userId = (int) DB::query( - "SELECT user_id FROM " . $options['session_table'] . " WHERE token_hash = %s LIMIT 1", + $expiryColumn = self::modernSessionHasExpiry($options); + $tokenRow = DB::query( + "SELECT user_id,last_active,created_at" . ($expiryColumn ? ',expires_at' : '') + . " FROM " . $options['session_table'] . " WHERE token_hash = %s LIMIT 1", $hash - )->getOne(); + )->getAssoc(); + $userId = $tokenRow ? (int) $tokenRow['user_id'] : 0; + + if ($userId <= 0 || self::modernTokenExpired($tokenRow ?: array(), $options)) { + if ($userId > 0) { + DB::Delete($options['session_table'], "token_hash = %s", $hash); + } - if ($userId <= 0) { self::modernForgetCookie($options); return 0; } @@ -520,7 +589,21 @@ } if ($persistSession) { + $newToken = bin2hex(random_bytes(32)); + $newHash = hash('sha256', $newToken); + DB::Update( + $options['session_table'], + array('token_hash' => $newHash, 'last_active' => date('Y-m-d H:i:s'), 'ip' => self::modernClientIp()), + 'token_hash=%s', + $hash + ); + if ((int) DB::affectedRows() !== 1) { self::modernForgetCookie($options); return 0; } + $hash = $newHash; + $expiresAt = !empty($tokenRow['expires_at']) ? strtotime((string) $tokenRow['expires_at']) : 0; + self::modernSendCookie($newToken, $expiresAt > time() ? $expiresAt : 0, $options); + $_COOKIE[$options['cookie_name']] = $newToken; Session::set($options['session_key'], $userId); + Session::set(self::modernTokenSessionKey($options), $hash); } $touchKey = '_auth_token_touch_' . substr($hash, 0, 16); @@ -538,6 +621,29 @@ return $userId; } + protected static function modernTokenExpired(array $row, array $options) + { + $ttl = max(1, (int) $options['token_ttl']); + $lastActive = !empty($row['last_active']) ? strtotime((string) $row['last_active']) : 0; + $absolute = !empty($row['expires_at']) + ? strtotime((string) $row['expires_at']) + : (!empty($row['created_at']) ? strtotime((string) $row['created_at']) + $ttl : 0); + return $lastActive <= 0 || $lastActive < time() - $ttl || $absolute <= 0 || $absolute < time(); + } + + protected static function modernSessionHasExpiry(array $options) + { + $table = (string) $options['session_table']; + if (!array_key_exists($table, self::$sessionExpiryColumns)) { + self::$sessionExpiryColumns[$table] = (bool) DB::query( + 'SHOW COLUMNS FROM ' . $table . ' LIKE %s', + 'expires_at' + )->getAssoc(); + } + + return self::$sessionExpiryColumns[$table]; + } + protected static function modernClientIp() { return substr((string) Request::ip(), 0, 45); diff --git a/system/App/Common/Auth/PublicUserProvider.php b/system/App/Common/Auth/PublicUserProvider.php index 4328ea4..b3d515d 100644 --- a/system/App/Common/Auth/PublicUserProvider.php +++ b/system/App/Common/Auth/PublicUserProvider.php @@ -153,7 +153,8 @@ . ' FROM `' . PublicUserTables::table('users') . '` usr' . ' LEFT JOIN `' . PublicUserTables::table('user_groups') . '` grp ON grp.user_group = usr.user_group' . ' WHERE usr.deleted != %s' - . ' AND (usr.email = %s1 OR usr.user_name = %s1 OR usr.phone_normalized = %s2) LIMIT 1', + . ' AND ((usr.email = %s1 AND (usr.email_verified_at>0 OR usr.user_name=%s1 OR usr.phone_verified_at=0))' + . ' OR usr.user_name = %s1 OR usr.phone_normalized = %s2) LIMIT 1', '1', (string) $identifier, $phone diff --git a/system/App/Common/CoreUpdate/UpdateRepository.php b/system/App/Common/CoreUpdate/UpdateRepository.php index 8c09b29..1dac1b8 100644 --- a/system/App/Common/CoreUpdate/UpdateRepository.php +++ b/system/App/Common/CoreUpdate/UpdateRepository.php @@ -87,6 +87,29 @@ } } + /** + * Каталог только из кеша, без обращения к сети. + * + * Нужен там, где нельзя блокировать отрисовку страницы ожиданием + * внешнего сервера: колокольчик, виджеты. Кеш прогревает регулярная + * проверка, поэтому пустой ответ означает «пока не проверяли». + */ + public static function cachedCatalog() + { + $settings = self::settings(); + if (!$settings['enabled'] || $settings['url'] === '' || $settings['public_key'] === '') { return null; } + $key = 'core-update-repository:' . hash('sha256', $settings['url'] . "\0" . $settings['public_key']); + try { + $catalog = Cache::get($key); + } catch (\Throwable $e) { + return null; + } + + if (!is_array($catalog) || !isset($catalog['items'])) { return null; } + $catalog['enabled'] = true; + return self::withState($catalog); + } + public static function download($id, $actorId = null) { $catalog = self::catalog(false); diff --git a/system/App/Common/OutboundHttpClient.php b/system/App/Common/OutboundHttpClient.php index 854a1bd..bd96c75 100644 --- a/system/App/Common/OutboundHttpClient.php +++ b/system/App/Common/OutboundHttpClient.php @@ -32,6 +32,34 @@ return self::request('POST', $url, $body, $options); } + public static function head($url, array $options = array()) + { + return self::request('HEAD', $url, null, $options); + } + + /** Download a checked response without keeping it in PHP memory. */ + public static function download($url, $destination, array $options = array()) + { + $destination = (string) $destination; + if ($destination === '' || !is_dir(dirname($destination))) { + throw new \InvalidArgumentException('Некорректный путь для сохранения удалённого файла'); + } + + $options['download_path'] = $destination; + @unlink($destination); + try { + $response = self::request('GET', $url, null, $options); + if (!is_file($destination) || (int) filesize($destination) !== (int) $response['bytes']) { + throw new \RuntimeException('Удалённый файл сохранён не полностью'); + } + + return $response; + } catch (\Throwable $e) { + @unlink($destination); + throw $e; + } + } + public static function inspectUrl($url, array $options = array()) { $options = self::options($options); @@ -55,7 +83,7 @@ $options = self::options($options); $method = strtoupper((string) $method); - if (!in_array($method, array('GET', 'POST'), true)) { + if (!in_array($method, array('GET', 'POST', 'HEAD'), true)) { throw new \InvalidArgumentException('Неподдерживаемый метод исходящего HTTP-запроса'); } @@ -76,7 +104,7 @@ $target = self::resolve($currentUrl, $options); $response = self::execute($currentMethod, $target, $currentBody, $currentHeaders, $options); if ($response['status'] < 300 || $response['status'] >= 400) { - if ($response['status'] < 200 || $response['status'] >= 300) { + if (!$options['allow_http_errors'] && ($response['status'] < 200 || $response['status'] >= 300)) { throw new \RuntimeException('Удалённый сервер вернул HTTP ' . $response['status']); } @@ -129,6 +157,12 @@ $overflow = false; $responseHeaders = array(); $headerOverflow = false; + $file = null; + if ($options['download_path'] !== '' && $method !== 'HEAD') { + $file = @fopen($options['download_path'], 'wb'); + if (!$file) { throw new \RuntimeException('Не удалось создать временный файл'); } + } + $handle = curl_init($target['url']); $resolveIp = strpos($target['ip'], ':') !== false ? '[' . $target['ip'] . ']' : $target['ip']; $curlOptions = array( @@ -166,8 +200,15 @@ $responseHeaders[$name] = $value; return $length; }, - CURLOPT_WRITEFUNCTION => function ($curl, $chunk) use (&$buffer, &$bytes, &$overflow, $options) { - return self::appendChunk($buffer, $bytes, $overflow, $chunk, $options['max_bytes']); + CURLOPT_WRITEFUNCTION => function ($curl, $chunk) use (&$buffer, &$bytes, &$overflow, $options, $file) { + if (!is_resource($file)) { + return self::appendChunk($buffer, $bytes, $overflow, $chunk, $options['max_bytes']); + } + + $length = strlen($chunk); + $bytes += $length; + if ($bytes > $options['max_bytes']) { $overflow = true; return 0; } + return fwrite($file, $chunk); }, ); if (defined('CURLOPT_PROTOCOLS')) { @@ -182,12 +223,15 @@ $curlOptions[CURLOPT_POSTFIELDS] = (string) $body; } + if ($method === 'HEAD') { $curlOptions[CURLOPT_NOBODY] = true; } + curl_setopt_array($handle, $curlOptions); $result = curl_exec($handle); $status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE); $primaryIp = (string) curl_getinfo($handle, CURLINFO_PRIMARY_IP); $error = curl_error($handle); curl_close($handle); + if (is_resource($file)) { fclose($file); } if ($overflow || $headerOverflow) { throw new \RuntimeException('Удалённый ответ превышает допустимый размер'); } @@ -374,13 +418,17 @@ 'headers' => array(), 'user_agent' => 'AVE.cms/' . (defined('APP_VERSION') ? APP_VERSION : '3.3') . ' OutboundHttpClient', 'source' => 'runtime', + 'download_path' => '', + 'allow_http_errors' => false, ), $options); $options['allowed_schemes'] = array_values(array_intersect(array('http', 'https'), array_map('strtolower', (array) $options['allowed_schemes']))); $options['allowed_ports'] = array_values(array_unique(array_map('intval', (array) $options['allowed_ports']))); $options['connect_timeout'] = max(1, min(30, (int) $options['connect_timeout'])); $options['timeout'] = max($options['connect_timeout'], min(120, (int) $options['timeout'])); $options['max_redirects'] = max(0, min(5, (int) $options['max_redirects'])); - $options['max_bytes'] = max(1, min(52428800, (int) $options['max_bytes'])); + $options['max_bytes'] = max(1, min(104857600, (int) $options['max_bytes'])); + $options['download_path'] = isset($options['download_path']) ? (string) $options['download_path'] : ''; + $options['allow_http_errors'] = !empty($options['allow_http_errors']); $options['headers'] = self::headers((array) $options['headers']); $options['source'] = substr( preg_replace('/[^a-z0-9_.-]+/', '_', strtolower(trim((string) $options['source']))), diff --git a/system/App/Common/PublicAuthSettings.php b/system/App/Common/PublicAuthSettings.php index 7e29279..bf11ad3 100644 --- a/system/App/Common/PublicAuthSettings.php +++ b/system/App/Common/PublicAuthSettings.php @@ -63,6 +63,21 @@ return self::all(); } + /** Returns whether the selected public-registration method is enabled. */ + public static function allowsRegistrationMethod($method, array $settings = null) + { + $settings = $settings === null ? self::all() : $settings; + if (empty($settings['registration_enabled'])) { return false; } + + $method = strtolower(trim((string) $method)); + $gate = isset($settings['registration_gate']) + ? strtolower(trim((string) $settings['registration_gate'])) + : 'email'; + if ($gate === 'both') { $gate = 'email_phone'; } + + return $gate === $method || $gate === 'email_phone'; + } + protected static function defaults(array $fallback) { return array( @@ -107,7 +122,9 @@ foreach ($bools as $key) { $data[$key] = !empty($data[$key]) ? 1 : 0; } $mode = isset($data['registration_mode']) ? (string) $data['registration_mode'] : 'email'; $data['registration_mode'] = in_array($mode, array('now','email','byadmin'), true) ? $mode : 'email'; - $data['registration_gate'] = 'email'; + $gate = isset($data['registration_gate']) ? strtolower(trim((string) $data['registration_gate'])) : 'email'; + if ($gate === 'both') { $gate = 'email_phone'; } + $data['registration_gate'] = in_array($gate, array('email','phone','email_phone'), true) ? $gate : 'email'; if (empty($data['show_lastname'])) { $data['require_lastname'] = 0; } if (empty($data['show_phone'])) { $data['require_phone'] = 0; } if (empty($data['show_company'])) { $data['require_company'] = 0; } diff --git a/system/App/Common/PublicBootstrap.php b/system/App/Common/PublicBootstrap.php index 778329d..99e2909 100644 --- a/system/App/Common/PublicBootstrap.php +++ b/system/App/Common/PublicBootstrap.php @@ -35,18 +35,7 @@ protected static function defineHost() { - $hostHeader = isset($_SERVER['HTTP_HOST']) ? strtolower((string) $_SERVER['HTTP_HOST']) : ''; - if ($hostHeader !== '' && !preg_match('/^\[?(?:[a-z0-9-:\]_]+\.?)+$/', $hostHeader)) { - http_response_code(400); - exit; - } - - $_SERVER['HTTP_HOST'] = $hostHeader; - $serverPort = isset($_SERVER['SERVER_PORT']) ? (string) $_SERVER['SERVER_PORT'] : '80'; - $ssl = self::isSsl(); - $host = str_replace(':' . $serverPort, '', $hostHeader); - $port = in_array($serverPort, array('80', '443'), true) || $ssl ? '' : ':' . $serverPort; - defined('HOST') || define('HOST', ($ssl ? 'https://' : 'http://') . $host . $port); + SiteOrigin::initializeHost(); $phpSelf = isset($_SERVER['PHP_SELF']) ? (string) $_SERVER['PHP_SELF'] : '/index.php'; $scriptName = isset($_SERVER['SCRIPT_NAME']) ? (string) $_SERVER['SCRIPT_NAME'] : '/index.php'; diff --git a/system/App/Common/RuntimeConstantSchema.php b/system/App/Common/RuntimeConstantSchema.php index b602f3e..34a2cbe 100644 --- a/system/App/Common/RuntimeConstantSchema.php +++ b/system/App/Common/RuntimeConstantSchema.php @@ -35,6 +35,7 @@ : array('dracula'); self::$schemas[$cacheKey] = array( + 'PUBLIC_SITE_URL' => self::item('_CONST_URL', '', 'string', 'Канонический публичный URL сайта для проверки Host и защищённых ссылок.'), 'REWRITE_MODE' => self::item('_CONST_URL', true, 'bool', 'Использовать человекопонятные URL.'), 'URL_SUFF' => self::item('_CONST_URL', '', 'string', 'Суффикс публичных URL, например .html.'), 'TRANSLIT_URL' => self::item('_CONST_URL', true, 'bool', 'Транслитерировать кириллицу в публичных URL.'), diff --git a/system/App/Common/RuntimeDirectoryGuard.php b/system/App/Common/RuntimeDirectoryGuard.php index c6e8a53..c2b3604 100644 --- a/system/App/Common/RuntimeDirectoryGuard.php +++ b/system/App/Common/RuntimeDirectoryGuard.php @@ -19,6 +19,29 @@ /** Creates runtime directories and blocks direct HTTP access to their contents. */ class RuntimeDirectoryGuard { + /** Прежняя защита состояла из одной строки синтаксиса Apache 2.2. */ + const LEGACY_CONTENTS = "Deny from all\n"; + + /** + * Содержимое сторожевого файла. + * + * Apache 2.4 понимает `Deny from all`, только пока загружен + * mod_access_compat, поэтому основной директивой идёт `Require all + * denied`, а старый синтаксис остаётся запасным для Apache 2.2. + * Оба варианта нужны: сервер клиента может быть любым. + */ + public static function contents() + { + return "Options -Indexes\n\n" + . "\n" + . " Require all denied\n" + . "\n" + . "\n" + . " Order allow,deny\n" + . " Deny from all\n" + . "\n"; + } + public static function protect($directory) { $directory = rtrim((string) $directory, '/\\'); @@ -31,10 +54,20 @@ } $file = $directory . '/.htaccess'; - if (is_file($file)) { + if (is_file($file) && !self::upgradable($file)) { return true; } - return @file_put_contents($file, "Deny from all\n", LOCK_EX) !== false; + return @file_put_contents($file, self::contents(), LOCK_EX) !== false; + } + + /** + * Сторож переписывается, только если это ровно прежний однострочник. + * Файл, изменённый администратором, остаётся нетронутым. + */ + protected static function upgradable($file) + { + $current = @file_get_contents($file); + return $current !== false && trim((string) $current) === trim(self::LEGACY_CONTENTS); } } diff --git a/system/App/Common/SignedEventProof.php b/system/App/Common/SignedEventProof.php new file mode 100644 index 0000000..7938f81 --- /dev/null +++ b/system/App/Common/SignedEventProof.php @@ -0,0 +1,77 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Common; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + /** Short-lived HMAC proof binding a public event to its server-side assignment. */ + class SignedEventProof + { + protected static $secrets = array(); + + public static function issue($module, array $claims, $visitorHash, $ttl = 3600) + { + $claims['exp'] = time() + max(60, min(86400, (int) $ttl)); + $claims['visitor'] = hash('sha256', (string) $visitorHash); + $payload = self::encode(json_encode($claims, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + return $payload . '.' . self::encode(hash_hmac('sha256', $payload, self::secret($module), true)); + } + + public static function verify($module, $proof, $visitorHash, array $expected = array()) + { + $parts = explode('.', trim((string) $proof)); + if (count($parts) !== 2 || !hash_equals(self::encode(hash_hmac('sha256', $parts[0], self::secret($module), true)), $parts[1])) { + return null; + } + + $data = json_decode(self::decode($parts[0]), true); + if (!is_array($data) || empty($data['exp']) || (int) $data['exp'] < time() + || empty($data['visitor']) || !hash_equals((string) $data['visitor'], hash('sha256', (string) $visitorHash))) { + return null; + } + + foreach ($expected as $key => $value) { + if (!array_key_exists($key, $data) || (string) $data[$key] !== (string) $value) { return null; } + } + + return $data; + } + + protected static function secret($module) + { + if (isset(self::$secrets[$module])) { return self::$secrets[$module]; } + self::$secrets[$module] = Lock::run('event-proof:' . $module, function () use ($module) { + $data = ModuleSecretStore::read($module); + if (empty($data['event_proof_key']) || !preg_match('/^[a-f0-9]{64}$/', (string) $data['event_proof_key'])) { + $data['event_proof_key'] = bin2hex(random_bytes(32)); + ModuleSecretStore::write($module, $data); + } + + return (string) $data['event_proof_key']; + }); + return self::$secrets[$module]; + } + + protected static function encode($value) + { + return rtrim(strtr(base64_encode((string) $value), '+/', '-_'), '='); + } + + protected static function decode($value) + { + $value = strtr((string) $value, '-_', '+/'); + return (string) base64_decode($value . str_repeat('=', (4 - strlen($value) % 4) % 4), true); + } + } diff --git a/system/App/Common/SiteOrigin.php b/system/App/Common/SiteOrigin.php new file mode 100644 index 0000000..1cefb50 --- /dev/null +++ b/system/App/Common/SiteOrigin.php @@ -0,0 +1,187 @@ + + | @copyright 2007-2026 (c) AVE.cms + | @link https://ave-cms.ru + | @version 3.3 + */ + + namespace App\Common; + + defined('BASEPATH') || die('Direct access to this location is not allowed.'); + + use App\Helpers\Request; + + /** Canonical public origin and Host-header boundary. */ + class SiteOrigin + { + public static function configuredUrl() + { + $value = getenv('AVE_PUBLIC_URL'); + if ($value === false || trim((string) $value) === '') { + $value = defined('PUBLIC_SITE_URL') ? (string) PUBLIC_SITE_URL : ''; + } + + if (trim((string) $value) === '') { + $value = (string) PublicConfiguration::value('canonical_url', ''); + } + + return self::normalizeUrl($value, false); + } + + public static function requireConfiguredUrl() + { + $url = self::configuredUrl(); + if ($url === '') { + throw new \RuntimeException( + 'Укажите PUBLIC_SITE_URL в разделе «Настройки → Константы», чтобы отправлять защищённые ссылки.' + ); + } + + return $url; + } + + public static function absolute($path, $required = false) + { + $base = $required ? self::requireConfiguredUrl() : self::configuredUrl(); + if ($base === '') { + $base = defined('HOST') ? rtrim((string) HOST, '/') : ''; + } + + if ($base === '') { return ''; } + $path = (string) $path; + if ($path === '') { return $base; } + return rtrim($base, '/') . '/' . ltrim($path, '/'); + } + + public static function initializeHost() + { + $configured = self::configuredUrl(); + $header = isset($_SERVER['HTTP_HOST']) ? strtolower(trim((string) $_SERVER['HTTP_HOST'])) : ''; + if ($header === '' && PHP_SAPI === 'cli') { + $origin = $configured !== '' ? self::origin($configured) : 'http://localhost'; + defined('HOST') || define('HOST', $origin); + return $origin; + } + + if (!self::validAuthority($header)) { self::reject(); } + + if ($configured !== '' && !self::hostAllowed($header, $configured, self::configuredAliases())) { + self::reject(); + } + + $_SERVER['HTTP_HOST'] = $header; + $origin = $configured !== '' ? self::origin($configured) : self::requestOrigin($header); + defined('HOST') || define('HOST', $origin); + return $origin; + } + + public static function hostAllowed($authority, $configuredUrl, array $aliases = array()) + { + $authority = self::normalizeAuthority($authority); + $configured = self::normalizeUrl($configuredUrl, false); + if ($authority === '' || $configured === '') { return false; } + + $allowed = array(self::authority($configured)); + foreach ($aliases as $alias) { + $alias = self::normalizeAuthority($alias); + if ($alias !== '') { $allowed[] = $alias; } + } + + return in_array($authority, array_values(array_unique($allowed)), true); + } + + public static function normalizeUrl($url, $required = true) + { + $url = trim((string) $url); + if ($url === '') { + if ($required) { throw new \InvalidArgumentException('Укажите полный публичный URL сайта'); } + return ''; + } + + $parts = parse_url($url); + if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host']) + || isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment'])) { + throw new \InvalidArgumentException('Публичный URL должен содержать только протокол, домен и путь установки'); + } + + $scheme = strtolower((string) $parts['scheme']); + if (!in_array($scheme, array('http', 'https'), true)) { + throw new \InvalidArgumentException('Публичный URL должен использовать HTTP или HTTPS'); + } + + $host = strtolower(rtrim((string) $parts['host'], '.')); + if (!self::validAuthority($host)) { throw new \InvalidArgumentException('Некорректный домен публичного URL'); } + $hostForUrl = strpos($host, ':') !== false ? '[' . trim($host, '[]') . ']' : $host; + $port = isset($parts['port']) ? (int) $parts['port'] : 0; + if ($port < 0 || $port > 65535) { throw new \InvalidArgumentException('Некорректный порт публичного URL'); } + $portPart = $port > 0 && !(($scheme === 'http' && $port === 80) || ($scheme === 'https' && $port === 443)) + ? ':' . $port + : ''; + $path = isset($parts['path']) ? '/' . trim((string) $parts['path'], '/') : ''; + if ($path === '/') { $path = ''; } + + return $scheme . '://' . $hostForUrl . $portPart . $path; + } + + protected static function configuredAliases() + { + $aliases = PublicConfiguration::value('allowed_hosts', array()); + return is_array($aliases) ? $aliases : array(); + } + + protected static function requestOrigin($authority) + { + $scheme = Request::isHttps() ? 'https' : 'http'; + return $scheme . '://' . self::normalizeAuthority($authority, $scheme); + } + + protected static function origin($url) + { + $parts = parse_url((string) $url); + return strtolower((string) $parts['scheme']) . '://' . self::authority((string) $url); + } + + protected static function authority($url) + { + $parts = parse_url((string) $url); + $scheme = strtolower((string) $parts['scheme']); + $host = strtolower((string) $parts['host']); + $host = strpos($host, ':') !== false ? '[' . trim($host, '[]') . ']' : $host; + $port = isset($parts['port']) ? (int) $parts['port'] : 0; + return self::normalizeAuthority($host . ($port > 0 ? ':' . $port : ''), $scheme); + } + + protected static function normalizeAuthority($authority, $scheme = '') + { + $authority = strtolower(rtrim(trim((string) $authority), '.')); + if (!self::validAuthority($authority)) { return ''; } + if (($scheme === 'https' && substr($authority, -4) === ':443') + || ($scheme === 'http' && substr($authority, -3) === ':80')) { + $authority = substr($authority, 0, strrpos($authority, ':')); + } + + return $authority; + } + + protected static function validAuthority($authority) + { + return $authority !== '' + && strlen((string) $authority) <= 255 + && preg_match('/^\[?(?:[a-z0-9-:\]_]+\.?)+$/', (string) $authority); + } + + protected static function reject() + { + http_response_code(400); + header('Content-Type: text/plain; charset=UTF-8'); + echo 'Некорректный адрес сайта.'; + exit; + } + } diff --git a/system/App/Content/ContentPlacementMap.php b/system/App/Content/ContentPlacementMap.php index 75c4f96..11f6461 100644 --- a/system/App/Content/ContentPlacementMap.php +++ b/system/App/Content/ContentPlacementMap.php @@ -31,7 +31,7 @@ class ContentPlacementMap { const CACHE_TTL = 900; - const CACHE_VERSION = 2; + const CACHE_VERSION = 3; public static function all($refresh = false) { @@ -67,6 +67,35 @@ return self::build(); } + /** List saved components and templates which have no active placement. */ + public static function inventory(array $placements = null) + { + $placements = $placements === null ? self::all() : $placements; + $usage = array(); + foreach ($placements as $row) { + if ((int) $row['component_id'] <= 0) { continue; } + $key = $row['component_type'] . ':' . (int) $row['component_id']; + $usage[$key] = isset($usage[$key]) ? $usage[$key] + max(1, (int) $row['occurrences']) : max(1, (int) $row['occurrences']); + } + + $items = array(); + self::appendInventory($items, 'template', ContentTables::table('templates'), + 'SELECT t.Id id,t.template_title title,\'\' alias,COUNT(r.Id) usage_count FROM %s t LEFT JOIN ' . ContentTables::table('rubrics') . ' r ON r.rubric_template_id=t.Id GROUP BY t.Id,t.template_title', $usage); + self::appendInventory($items, 'rubric_template', ContentTables::table('rubric_templates'), + 'SELECT t.id,t.title,\'\' alias,t.rubric_id,COUNT(d.Id) usage_count FROM %s t LEFT JOIN ' . ContentTables::table('documents') . " d ON d.rubric_tmpl_id=t.id AND d.document_deleted!='1' GROUP BY t.id,t.title,t.rubric_id", $usage); + self::appendInventory($items, 'block', ContentTables::table('sysblocks'), + 'SELECT id,sysblock_name title,sysblock_alias alias,0 usage_count FROM %s', $usage); + self::appendInventory($items, 'request', ContentTables::table('request'), + 'SELECT Id id,request_title title,request_alias alias,0 usage_count FROM %s', $usage); + self::appendInventory($items, 'navigation', ContentTables::table('navigation'), + 'SELECT navigation_id id,title,alias,0 usage_count FROM %s', $usage); + $items = array_values(array_filter($items, function ($item) { return (int) $item['usage_count'] === 0; })); + usort($items, function ($left, $right) { + return array($left['type_title'], $left['title'], $left['id']) <=> array($right['type_title'], $right['title'], $right['id']); + }); + return $items; + } + /** * Extract native component references from one saved fragment. * @@ -80,7 +109,7 @@ } $pattern = '#' - . '\[tag:(sysblock|block|request|navigation):([^\]:\]]+)(?:[^\]]*)\]' + . '\[tag:(sysblock|block|request|navigation|fld|rfld):([^\]:\]]+)(?:[^\]]*)\]' . '|' . '\[mod_([a-z][a-z0-9_]*)(?::[^\]]*)?\]' . '#i'; @@ -100,7 +129,7 @@ $key = strtolower(trim((string) $match[3][0])); } else { $tagType = strtolower(trim(isset($match[1][0]) ? (string) $match[1][0] : '')); - $type = $tagType === 'sysblock' ? 'block' : $tagType; + $type = $tagType === 'sysblock' ? 'block' : (in_array($tagType, array('fld', 'rfld'), true) ? 'field' : $tagType); $key = trim(isset($match[2][0]) ? (string) $match[2][0] : ''); } @@ -183,7 +212,8 @@ $reference['component_type'], $reference['component_key'], $components, - $reference['tag'] + $reference['tag'], + $source ); $placements[] = array_merge($reference, $component, array( 'source_type' => $source['type'], @@ -258,6 +288,7 @@ $sources[] = array( 'type' => 'rubric', 'id' => (int) $row['Id'], + 'rubric_id' => (int) $row['Id'], 'title' => $row['rubric_title'], 'fragments' => array( self::fragment('header', 'Шаблон до документа', 'before_content', $row['rubric_header_template']), @@ -306,13 +337,14 @@ } $rows = DB::query( - 'SELECT Id,request_title,request_template_main,request_template_item FROM ' . $table . ' ORDER BY Id' + 'SELECT Id,rubric_id,request_title,request_template_main,request_template_item FROM ' . $table . ' ORDER BY Id' )->getAll() ?: array(); $sources = array(); foreach ($rows as $row) { $sources[] = array( 'type' => 'request', 'id' => (int) $row['Id'], + 'rubric_id' => (int) $row['rubric_id'], 'title' => $row['request_title'], 'fragments' => array( self::fragment('wrapper', 'Общий шаблон результата', 'main', $row['request_template_main']), @@ -409,9 +441,27 @@ ContentTables::table('navigation'), 'SELECT navigation_id id,alias,title,1 active FROM %s ORDER BY navigation_id' ), + 'field' => self::fieldIndex(), ); } + protected static function fieldIndex() + { + $table = ContentTables::table('rubric_fields'); + $index = array('rubrics' => array()); + if (!self::available($table)) { return $index; } + $rows = DB::query('SELECT Id id,rubric_id,rubric_field_alias alias,rubric_field_title title FROM ' . $table . ' ORDER BY rubric_id,rubric_field_position,Id')->getAll() ?: array(); + foreach ($rows as $row) { + $rubricId = (int) $row['rubric_id']; + $item = array('id' => (int) $row['id'], 'rubric_id' => $rubricId, 'alias' => trim((string) $row['alias']), 'title' => self::decode($row['title']), 'active' => true); + if (!isset($index['rubrics'][$rubricId])) { $index['rubrics'][$rubricId] = array(); } + $index['rubrics'][$rubricId][(string) $item['id']] = $item; + if ($item['alias'] !== '') { $index['rubrics'][$rubricId][strtolower($item['alias'])] = $item; } + } + + return $index; + } + protected static function indexedComponents($table, $sql) { $index = array(); @@ -435,7 +485,7 @@ return $index; } - protected static function resolveComponent($type, $key, array $components, $tag = '') + protected static function resolveComponent($type, $key, array $components, $tag = '', array $source = array()) { $type = (string) $type; $key = trim((string) $key); @@ -443,6 +493,10 @@ return self::resolveModuleComponent($key, $tag); } + if ($type === 'field') { + return self::resolveFieldComponent($key, isset($source['rubric_id']) ? (int) $source['rubric_id'] : 0, $components); + } + $lookup = ctype_digit($key) ? (string) (int) $key : strtolower($key); $item = isset($components[$type][$lookup]) ? $components[$type][$lookup] : null; if (!$item) { @@ -466,6 +520,34 @@ ); } + protected static function resolveFieldComponent($key, $rubricId, array $components) + { + $lookup = ctype_digit((string) $key) ? (string) (int) $key : strtolower((string) $key); + if ($rubricId <= 0) { + return array( + 'component_id' => 0, 'component_alias' => ctype_digit((string) $key) ? '' : (string) $key, + 'component_title' => 'Поле текущего документа «' . $key . '»', 'component_status' => 'contextual', + 'component_status_title' => 'проверяется в контексте документа', 'component_url' => '', + ); + } + + $item = isset($components['field']['rubrics'][$rubricId][$lookup]) ? $components['field']['rubrics'][$rubricId][$lookup] : null; + if (!$item) { + return array( + 'component_id' => 0, 'component_alias' => ctype_digit((string) $key) ? '' : (string) $key, + 'component_title' => 'Поле «' . $key . '»', 'component_status' => 'broken', + 'component_status_title' => 'поля нет в типе контента', 'component_url' => '/rubrics/' . $rubricId . '/fields', + ); + } + + return array( + 'component_id' => $item['id'], 'component_alias' => $item['alias'], + 'component_title' => $item['title'] !== '' ? $item['title'] : 'Поле #' . $item['id'], + 'component_status' => 'active', 'component_status_title' => 'поле существует', + 'component_url' => '/rubrics/' . $rubricId . '/fields', + ); + } + protected static function resolveModuleComponent($key, $tag) { $key = strtolower(trim((string) $key)); @@ -582,6 +664,24 @@ ); } + protected static function appendInventory(array &$items, $type, $table, $sql, array $usage) + { + if (!self::available($table)) { return; } + foreach (DB::query(sprintf($sql, $table))->getAll() ?: array() as $row) { + $id = (int) $row['id']; + $count = isset($row['usage_count']) ? (int) $row['usage_count'] : 0; + $key = $type . ':' . $id; + if (isset($usage[$key])) { $count += (int) $usage[$key]; } + $source = array('type' => $type, 'id' => $id, 'title' => $row['title']); + if ($type === 'rubric_template') { $source['rubric_id'] = (int) $row['rubric_id']; } + $items[] = array( + 'type' => $type, 'type_title' => self::sourceTypeTitle($type), 'id' => $id, + 'title' => self::decode($row['title']), 'alias' => isset($row['alias']) ? (string) $row['alias'] : '', + 'usage_count' => $count, 'url' => self::sourceUrl($source), + ); + } + } + protected static function sourceUrl(array $source) { switch ($source['type']) { @@ -636,6 +736,7 @@ 'request' => 'Подборка', 'navigation' => 'Навигация', 'module' => 'Модуль', + 'field' => 'Поле', ); return isset($titles[$type]) ? $titles[$type] : (string) $type; } diff --git a/system/App/Content/Documents/DocumentMutationService.php b/system/App/Content/Documents/DocumentMutationService.php index 333c50d..9c615c3 100644 --- a/system/App/Content/Documents/DocumentMutationService.php +++ b/system/App/Content/Documents/DocumentMutationService.php @@ -129,6 +129,116 @@ ); } + /** + * Move an existing document to another rubric while preserving fields + * with the same non-empty alias and compatible type. + */ + public function move($documentId, $targetRubricId, $actorId, $source = 'bulk_editor') + { + $documentId = (int) $documentId; + $targetRubricId = (int) $targetRubricId; + $existing = $this->document($documentId); + $targetRubric = $this->rubric($targetRubricId); + if (!$existing) { throw new DocumentSaveRejected('Документ не найден'); } + if (!$targetRubric) { throw new DocumentSaveRejected('Целевая рубрика не найдена'); } + if ((int) $existing['rubric_id'] === $targetRubricId) { + return array('id' => $documentId, 'operation' => 'move', 'moved' => false, 'mapped_fields' => 0, 'skipped_fields' => array()); + } + + $sourceDefinitions = $this->fieldDefinitions((int) $existing['rubric_id']); + $sourceValues = $this->fieldValues($documentId, $sourceDefinitions, false); + $sourceByAlias = array(); + foreach ($sourceDefinitions as $fieldId => $field) { + $alias = trim((string) $field['rubric_field_alias']); + if ($alias === '') { continue; } + $sourceByAlias[$alias] = array( + 'type' => (string) $field['rubric_field_type'], + 'value' => isset($sourceValues[$fieldId]) ? $sourceValues[$fieldId] : '', + 'title' => (string) $field['rubric_field_title'], + ); + } + + $targetDefinitions = $this->fieldDefinitions($targetRubricId); + $targetFields = $this->fieldValues(0, $targetDefinitions, true); + $mapped = 0; + $skipped = array(); + foreach ($targetDefinitions as $fieldId => $field) { + $alias = trim((string) $field['rubric_field_alias']); + if ($alias === '' || !isset($sourceByAlias[$alias])) { continue; } + if ((string) $field['rubric_field_type'] !== $sourceByAlias[$alias]['type']) { + $skipped[] = $alias; + continue; + } + + $targetFields[$fieldId] = $sourceByAlias[$alias]['value']; + $mapped++; + } + + $targetTemplateId = (int) DB::query( + 'SELECT id FROM ' . ContentTables::table('rubric_templates') + . ' WHERE id=%i AND rubric_id=%i LIMIT 1', + isset($existing['rubric_tmpl_id']) ? (int) $existing['rubric_tmpl_id'] : 0, + $targetRubricId + )->getValue(); + $moveInput = $existing; + $moveInput['rubric_tmpl_id'] = $targetTemplateId > 0 ? $targetTemplateId : 0; + $data = $this->normalizeData($moveInput, $existing, $targetRubric, true); + $data['rubric_id'] = $targetRubricId; + $targetFields = ComputedFieldEvaluator::apply($targetDefinitions, $targetFields, $data); + DocumentSaveEvents::before('update', $source, $documentId, $targetRubricId, $actorId, $data, $targetFields, $existing); + DocumentRubricCodeRunner::before($targetRubricId, $data, $targetFields, $documentId, $actorId, false, $source); + $data = $this->normalizeData($data, $existing, $targetRubric, true); + $data['rubric_id'] = $targetRubricId; + $targetFields = ComputedFieldEvaluator::apply($targetDefinitions, $targetFields, $data); + $errors = array_merge( + $this->validate($data, $documentId, $targetRubricId), + FieldValidator::validateValues(array_values($targetDefinitions), $targetFields, !empty($targetRubric['rubric_form_conditions'])) + ); + if ($errors) { throw new DocumentSaveRejected('Проверьте данные документа', $errors); } + + $previousDatabaseExceptionMode = DB::$throw_exception_on_error; + DB::$throw_exception_on_error = true; + $externalTransaction = DB::$transaction_in_progress; + $ownsTransaction = !$externalTransaction; + if ($ownsTransaction) { DB::startTransaction(); } + try { + $this->captureRevision($documentId, $actorId); + $this->writeDocument($documentId, $data, $targetRubric, $actorId, $existing); + DB::Delete(ContentTables::table('document_fields_text'), 'document_id=%i', $documentId); + DB::Delete(ContentTables::table('document_fields'), 'document_id=%i', $documentId); + \App\Content\Fields\DocumentRelationIndex::removeSource($documentId); + DocumentRubricCodeRunner::after($targetRubricId, $data, $targetFields, $documentId, $actorId, false, $source); + $data = $this->normalizeData($data, $this->document($documentId), $targetRubric, true); + $data['rubric_id'] = $targetRubricId; + $this->writeDocument($documentId, $data, $targetRubric, $actorId, $this->document($documentId), false); + $this->writeFields($documentId, $data, $targetDefinitions, $targetFields); + DocumentTerms::sync($documentId, $targetRubricId, $data['document_meta_keywords'], $data['document_tags']); + DocumentSaveEvents::persisted('update', $source, $documentId, $targetRubricId, $actorId, $data, $targetFields, $existing); + if ($ownsTransaction) { DB::commit(); } + } catch (\Throwable $e) { + if ($ownsTransaction) { DB::rollback(); } + throw $e; + } finally { + DB::$throw_exception_on_error = $previousDatabaseExceptionMode; + } + + $dispatch = function () use ($source, $documentId, $targetRubricId, $actorId, $data, $targetFields, $existing) { + ContentCacheInvalidator::document($documentId, true); + $snapshot = (new DocumentSnapshotRepository())->find($documentId); + DocumentSaveEvents::after('update', $source, $documentId, $targetRubricId, $actorId, $data, $targetFields, $existing, is_array($snapshot) ? $snapshot : array()); + }; + if ($externalTransaction) { DB::afterCommit($dispatch); } + else { $dispatch(); } + + return array( + 'id' => $documentId, + 'operation' => 'move', + 'moved' => true, + 'mapped_fields' => $mapped, + 'skipped_fields' => $skipped, + ); + } + /** Permanently remove a package-owned document and all core-owned derivatives. */ public function hardDelete($documentId, $source = 'content_package') { @@ -385,7 +495,12 @@ $value = $saving->result(); $first = mb_substr((string) $value, 0, 500, 'UTF-8'); $more = mb_substr((string) $value, 500, null, 'UTF-8'); - $numeric = $type && $type->isNumeric() ? $this->numeric($value) : 0; + $isNumeric = !empty($field['rubric_field_numeric']) || ($type && $type->isNumeric()); + $numeric = $isNumeric + ? ((string) $field['rubric_field_type'] === 'period' + ? \App\Content\Fields\Types\PeriodValue::indexValue($value) + : FieldValueCodec::numericIndexValue($value)) + : 0; $rowId = (int) DB::query('SELECT Id FROM ' . ContentTables::table('document_fields') . ' WHERE document_id=%i AND rubric_field_id=%i LIMIT 1', (int) $documentId, (int) $id)->getValue(); $row = array('field_value'=>$first,'field_number_value'=>$numeric,'document_in_search'=>(int)$document['document_in_search'] && (int)$field['rubric_field_search'] ? '1' : '0'); if ($rowId > 0) { DB::Update(ContentTables::table('document_fields'), $row, 'Id=%i', $rowId); } @@ -410,5 +525,4 @@ protected function boolean($value) { return in_array(strtolower(trim((string)$value)), array('1','true','yes','on','published','active'), true) ? 1 : 0; } protected function timestamp($value, $default) { if (is_int($value) || ctype_digit((string)$value)) { return max(0,(int)$value); } $time=strtotime((string)$value); return $time ? $time : (int)$default; } protected function robots($value) { return in_array((string)$value,array('index,follow','index,nofollow','noindex,nofollow'),true)?(string)$value:'index,follow'; } - protected function numeric($value) { $value=str_replace(',','.',(string)$value); $value=preg_replace('/[^0-9.\-]/','',$value); return is_numeric($value)?(float)$value:0; } } diff --git a/system/App/Content/Fields/FieldValueCodec.php b/system/App/Content/Fields/FieldValueCodec.php index d396798..4e9f8d8 100644 --- a/system/App/Content/Fields/FieldValueCodec.php +++ b/system/App/Content/Fields/FieldValueCodec.php @@ -27,6 +27,36 @@ */ class FieldValueCodec { + /** + * Построить безопасное значение для DECIMAL(18,4) в числовом индексе поля. + * Для составного текста берётся первое число: размеры не склеиваются в одно + * заведомо ложное и потенциально слишком большое значение. + */ + public static function numericIndexValue($value) + { + $raw = str_replace(array(',', "\xC2\xA0", "\xE2\x80\xAF"), array('.', ' ', ' '), trim((string) $value)); + if (!preg_match('/[-+]?(?:(?:\d+(?:\.\d*)?)|(?:\.\d+))/', $raw, $match)) { + return '0'; + } + + $number = $match[0]; + $negative = substr($number, 0, 1) === '-'; + $number = ltrim($number, '+-'); + $parts = explode('.', $number, 2); + $integer = ltrim($parts[0], '0'); + $integer = $integer === '' ? '0' : $integer; + if (strlen($integer) > 14) { + return '0'; + } + + $fraction = isset($parts[1]) ? substr($parts[1], 0, 4) : ''; + if ($integer === '0' && trim($fraction, '0') === '') { + return '0'; + } + + return ($negative ? '-' : '') . $integer . ($fraction === '' ? '' : '.' . $fraction); + } + public static function normalizeForStorage($value) { if (is_array($value)) { diff --git a/system/App/Content/Fields/HtmlSanitizer.php b/system/App/Content/Fields/HtmlSanitizer.php index 0fb76af..cedac4e 100644 --- a/system/App/Content/Fields/HtmlSanitizer.php +++ b/system/App/Content/Fields/HtmlSanitizer.php @@ -21,7 +21,7 @@ { protected static $tags = array( 'p', 'br', 'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', - 'strong', 'b', 'em', 'i', 'u', 's', 'del', 'blockquote', 'ul', 'ol', 'li', + 'strong', 'b', 'em', 'i', 'u', 's', 'del', 'small', 'blockquote', 'ul', 'ol', 'li', 'a', 'img', 'figure', 'figcaption', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'colgroup', 'col', 'pre', 'code', 'hr', 'sup', 'sub' ); @@ -151,7 +151,7 @@ protected static function fallback($html) { $html = preg_replace('#<(script|style|iframe|object|embed|form|svg|math|template)\b[^>]*>.*?#is', '', (string) $html); - $html = strip_tags($html, '