Compare commits
1981 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da86e919d9 | |||
| 45d6a491cb | |||
| 3dc90f89f6 | |||
| e2bea57146 | |||
| 43a32071f1 | |||
| e3a9872d28 | |||
| 75f86cd669 | |||
| 73f72cbbb0 | |||
| 2033f25386 | |||
| 8d0a6af2aa | |||
| a698ba3082 | |||
| b57d286b15 | |||
| 3cd5e86527 | |||
| bccba46332 | |||
| 70357ada3d | |||
| 043ad713e7 | |||
| bb178b0a5c | |||
| 288bded7d9 | |||
| d12ad3d538 | |||
| 905d498ecd | |||
| 802067f398 | |||
| b40e227ad3 | |||
| b89504e1c3 | |||
| c8ae09cd43 | |||
| 8ebddd95ff | |||
| c14880dfca | |||
| a27c551f64 | |||
| e71453cb5d | |||
| bb19add3b6 | |||
| 9bd6396a15 | |||
| b060327219 | |||
| 8b383df13f | |||
| 6a0ec69451 | |||
| 66bf6275b8 | |||
| 6fc2ff7252 | |||
| 0f6367bb17 | |||
| e3190c3922 | |||
| 53628d6ae3 | |||
| d03f68ae34 | |||
| 87bc834885 | |||
| 9f89dffaae | |||
| ab1a5c0241 | |||
| 758c1cabc5 | |||
| 3dd5358e73 | |||
| 8cc4ad27c9 | |||
| cd1f6b8e73 | |||
| 07e70cf7a9 | |||
| a9d1069705 | |||
| 10703263a8 | |||
| b0aa21bee7 | |||
| 82fa1d7a26 | |||
| be446e97d7 | |||
| c44f3319e3 | |||
| 678d1c1428 | |||
| 535d7c0ff6 | |||
| e430e4e6e2 | |||
| df92076e15 | |||
| e2ba35ee80 | |||
| f4cac96358 | |||
| 5257c2ce84 | |||
| b378cf31f4 | |||
| 0f184840df | |||
| 3df21df85b | |||
| 0d870d540d | |||
| 144772cfbe | |||
| 80c8aa41dc | |||
| 5658cd6dd4 | |||
| 374f426f0c | |||
| 2af0c237a9 | |||
| dafd72af59 | |||
| cbc6dc94a5 | |||
| f74e7510c5 | |||
| d87cd7cbb9 | |||
| 9a8cbd6e00 | |||
| abc4363e83 | |||
| df0ee6020a | |||
| 53599544af | |||
| b5ec9e080d | |||
| 8f98c8a862 | |||
| 0959d87534 | |||
| 1252681d55 | |||
| 9bc4efa5ff | |||
| 5656e4f5b7 | |||
| a966198a75 | |||
| 4ff214ac47 | |||
| 5169d174ad | |||
| 9c849c337f | |||
| d0685464f6 | |||
| 10b5a8ef21 | |||
| f0a9a49753 | |||
| 1afde946d2 | |||
| e8ba1feddc | |||
| 18e9b5c5bf | |||
| f186dc20f6 | |||
| 80a722d465 | |||
| 765487f62e | |||
| 1d186fffaa | |||
| 6295b7726e | |||
| e7c45644b9 | |||
| 356a0d4c12 | |||
| 00d4d6c7a8 | |||
| 371d44b2a7 | |||
| 79732a9151 | |||
| a6e55fb462 | |||
| d032a51a3d | |||
| 9c2495af29 | |||
| d7bc6c45f6 | |||
| 4382e01f57 | |||
| bab5294399 | |||
| a161fa8519 | |||
| 5e5bd7a17d | |||
| 285717ab12 | |||
| 81d91da0b8 | |||
| b017e9382f | |||
| eb5334e865 | |||
| 01b1c3923d | |||
| 780fb76af8 | |||
| ab90fc16e0 | |||
| 990c50c5b9 | |||
| 2e91b3dc9a | |||
| 211bd02786 | |||
| e8d000a17a | |||
| 8fc373abfc | |||
| a473ca737e | |||
| ff6fc68981 | |||
| f133a67550 | |||
| b0a6cdc29f | |||
| edcb429366 | |||
| ba5a674526 | |||
| e84496f8b1 | |||
| 5f9212383a | |||
| a5493f11bc | |||
| ce434b3d04 | |||
| ade07b411b | |||
| 3868e469c0 | |||
| ea939acbd3 | |||
| 522544c131 | |||
| 445fb6f253 | |||
| 7bf8fd5eeb | |||
| c758fb4c83 | |||
| 4145f64399 | |||
| 4120ab6fe6 | |||
| 0170fb7711 | |||
| 42df2f6c31 | |||
| 9b522b69ff | |||
| 135db70b0f | |||
| 048e97f9a9 | |||
| 18d8f257ee | |||
| ec67195014 | |||
| 0d745ad10f | |||
| 89ce71b350 | |||
| 63c1f7922f | |||
| 5809ac7997 | |||
| 92b6e46249 | |||
| 46c11d8599 | |||
| 7651365ff6 | |||
| 35caa0e68d | |||
| e0a7fe443d | |||
| c31190a128 | |||
| de50ec30b7 | |||
| c0fe308d7d | |||
| 0a20141b7c | |||
| 4d0282ca0a | |||
| e61143f746 | |||
| 69dc91d225 | |||
| 6f1c49e14d | |||
| 78acc3685d | |||
| bf5013e527 | |||
| cbd961e922 | |||
| fa26e23383 | |||
| 7abe1bed50 | |||
| 155df0a94d | |||
| 4d06e81768 | |||
| 9bf1e2401d | |||
| 4edf40acaf | |||
| a54ed750a3 | |||
| c02a6c105a | |||
| 4d2416ab96 | |||
| 61ae30528a | |||
| e883eb70b9 | |||
| a5b1379cdb | |||
| ef64210ed2 | |||
| f29846ec20 | |||
| d2b4d84374 | |||
| dfe3f5fb9f | |||
| 8dbb19eb82 | |||
| 45cdff6920 | |||
| c25d56ea85 | |||
| f92a9a6cc6 | |||
| 988729fbeb | |||
| e00f7b5b67 | |||
| 39fbe98313 | |||
| 46d5234fd7 | |||
| dd4117bd5b | |||
| 4dcd5190df | |||
| 48728e83b2 | |||
| 087b895bba | |||
| 2ed28f7f7a | |||
| 9f50328da2 | |||
| 3737b34913 | |||
| aa0eb24e80 | |||
| 9d012dd06d | |||
| df28c80553 | |||
| 2a3a3f7818 | |||
| 15cb7993f6 | |||
| 15529a0c9c | |||
| d2c30dd08c | |||
| 972b27140a | |||
| cac13dd949 | |||
| 112bf498e6 | |||
| 02488a62c1 | |||
| f5313f6ec0 | |||
| 3206549170 | |||
| 59b621500f | |||
| cd5716d66d | |||
| 6a68a38d71 | |||
| f23ea5ce8f | |||
| c893b69b5f | |||
| 269e6c4ef6 | |||
| a0ab9d3a80 | |||
| cdd72cf372 | |||
| e38b8cdd68 | |||
| c44cb23dea | |||
| 84bdfa98d1 | |||
| f3055e7442 | |||
| 9c36ade1e2 | |||
| 4127c6a0c0 | |||
| c133c869ae | |||
| d74197aacc | |||
| c870dd0dae | |||
| 6d1d89105d | |||
| f3a4f5edaa | |||
| 8f61d1e729 | |||
| 4782734ed4 | |||
| a9d65f7e81 | |||
| e59f5d92a4 | |||
| 93576fc435 | |||
| 221ae337f2 | |||
| 1b1d1f77d5 | |||
| d7ef85235c | |||
| 3a714c3ef6 | |||
| 2a69bf903e | |||
| 23c93473c8 | |||
| 266f04b04c | |||
| 9f64a90a45 | |||
| baacf171f4 | |||
| 109e7fff68 | |||
| 816868cfc8 | |||
| c21b44aded | |||
| 0565ec22cb | |||
| 17fc52a237 | |||
| f535b8ffd2 | |||
| 221e495974 | |||
| 8f06902230 | |||
| ff95416a90 | |||
| b1491b524d | |||
| 703c5ca4ed | |||
| ce6c7146ea | |||
| e1e614ebc8 | |||
| 7918653413 | |||
| a23bc89607 | |||
| 6f25f80260 | |||
| 6da5f2e19b | |||
| 518351eba1 | |||
| ce0ce8688b | |||
| 43be1e8364 | |||
| 6e749d34a4 | |||
| 6e55d78c19 | |||
| 884dc926fe | |||
| a383033ffa | |||
| 67fa473281 | |||
| 28b3e34a84 | |||
| 72383fdbd7 | |||
| 44f9101d93 | |||
| 9cab197651 | |||
| db4fcff1f3 | |||
| ea820ce99a | |||
| d21ff001bf | |||
| 69ddde697a | |||
| 3f72d0afd8 | |||
| 1d209155f2 | |||
| 20b2d22991 | |||
| e12ac03dd8 | |||
| 9c73b26cd1 | |||
| cabc842f52 | |||
| d099cbd8e5 | |||
| cfa8069953 | |||
| 45df8ea55e | |||
| 33846b0d61 | |||
| b7df1dcefb | |||
| 3b0278bd3a | |||
| b3be2baf40 | |||
| 3cff19f9ca | |||
| 4380a46d1c | |||
| f4b9138a3f | |||
| f5dbf27592 | |||
| 069912d051 | |||
| 6a1c3e29d0 | |||
| 9fc37cf6b9 | |||
| b37adb8c49 | |||
| 86245ad4ae | |||
| e3afe3b74d | |||
| c8bafdad79 | |||
| ee3ebe32e2 | |||
| 3060fd305b | |||
| 72666cdd47 | |||
| 4fbd6b2f15 | |||
| 10ee84cb26 | |||
| c94fce2367 | |||
| 432e625186 | |||
| 653b1327cb | |||
| d011ad3dde | |||
| 54d01409dc | |||
| d5ce5a82de | |||
| 849b217300 | |||
| b224cc636c | |||
| 371f096e54 | |||
| 5efb21eb0b | |||
| ec24da12a1 | |||
| 6aa8d8e772 | |||
| 424ed48d06 | |||
| 3c44ce8682 | |||
| 948dadc333 | |||
| 0ff2fb5cff | |||
| c0773772f4 | |||
| 3c1b18919a | |||
| 72a11113e7 | |||
| 978c8f81a5 | |||
| ac2162113d | |||
| b0635f24db | |||
| 34b4cf12e2 | |||
| fec0a1b2b5 | |||
| 96088c416e | |||
| c8f3e833e5 | |||
| 4f943d4a7a | |||
| 37361ef52f | |||
| 5307a44fab | |||
| d97f579761 | |||
| afc287b607 | |||
| ded6515cbc | |||
| 1af9b42d82 | |||
| 403f9c848b | |||
| 480d252173 | |||
| d329e5f862 | |||
| d9bc110868 | |||
| e7c80b89eb | |||
| 50ba979840 | |||
| 6fd834e4d2 | |||
| 2d6eb5d80a | |||
| f74fedb226 | |||
| 90e2c105cd | |||
| 5976e93de2 | |||
| 34101c148f | |||
| 048a46b317 | |||
| 6ae09e15fb | |||
| f03b27ec88 | |||
| cc1e0d82dd | |||
| f233bd2d01 | |||
| 7a8b22df26 | |||
| 17df4a08a7 | |||
| c377b41198 | |||
| 0c59ca70cf | |||
| e9e9dfeeab | |||
| f8c084cde7 | |||
| 8f7fa6c0f5 | |||
| f381362130 | |||
| bef4a50720 | |||
| 2a93de675f | |||
| e5f41f8f17 | |||
| b9da8ee55c | |||
| bf525f7213 | |||
| c9ef163142 | |||
| feb3bd58cf | |||
| f9288e450b | |||
| 541128dd7a | |||
| 23b9c881ad | |||
| cacd6f7e9b | |||
| 4db4314f18 | |||
| 51aa66a77d | |||
| aa0b491080 | |||
| c01c9201ee | |||
| 0ad1a5b6ba | |||
| 95909d552a | |||
| a159c3b84e | |||
| 875b0bbdec | |||
| be5b74af90 | |||
| be1f1bd1c5 | |||
| 5dcc8efcca | |||
| c9be696c84 | |||
| 8748ddffd8 | |||
| e19a9b23e5 | |||
| 5752fe68f0 | |||
| 187f160b21 | |||
| a6bbf0edf0 | |||
| 8908b67b3d | |||
| 4a0797d59f | |||
| 07f1f247de | |||
| 4373f761c7 | |||
| 559491d31a | |||
| 1f51155c92 | |||
| 58f7370935 | |||
| 043292ff15 | |||
| a04bf04900 | |||
| 9408f4005c | |||
| f9567af55a | |||
| 62a0c3764e | |||
| 66cab56c47 | |||
| dfa8590a65 | |||
| ee10cc970f | |||
| 96a42d0f33 | |||
| 57b257057a | |||
| c45040818d | |||
| fe2d599099 | |||
| 1d2ba0a8c1 | |||
| 63454f8c63 | |||
| 8e9bd5dbb1 | |||
| 87d6328fb8 | |||
| 5020aec71a | |||
| 39ff553b3e | |||
| aea3877718 | |||
| dbaa900444 | |||
| 751541a54d | |||
| 26382eb0a1 | |||
| 3972799e56 | |||
| 50baed175f | |||
| d870b3625b | |||
| ae2e51c66c | |||
| db2afd0dc7 | |||
| 826bbe37c9 | |||
| 27a637a7a4 | |||
| 460daf71b6 | |||
| 56d5f17dde | |||
| 3074bae47c | |||
| b9c7bcf035 | |||
| 071c46a91e | |||
| 49138f2cb1 | |||
| fd46794350 | |||
| 9305c3e845 | |||
| 058da6bfef | |||
| a65ae59810 | |||
| 1967b3b7a7 | |||
| 30dbf1698b | |||
| 8e1ad53a31 | |||
| a5272968de | |||
| 94e14e5ee9 | |||
| db46e16530 | |||
| 0630ef9f89 | |||
| 0f80950a91 | |||
| aae07bd3a7 | |||
| 2620b60048 | |||
| 1cff2d67aa | |||
| 80418d0b16 | |||
| 14c5cff429 | |||
| 0ea6eb13c2 | |||
| 86cc20034f | |||
| 57f17e80a2 | |||
| 81b1cdc6e9 | |||
| 12c3629c89 | |||
| 0304933c53 | |||
| 0a02c0b81a | |||
| f0d84f5350 | |||
| fdcc3f1968 | |||
| 663bab1f9d | |||
| 8b1e312292 | |||
| 9004211a59 | |||
| 053eb91457 | |||
| 3810513224 | |||
| e50e0f0e34 | |||
| cdf73f9c89 | |||
| bc808cbe46 | |||
| fdc65fb1b2 | |||
| 1ad562f8b9 | |||
| 3db9a15dd3 | |||
| 42bf43d68d | |||
| 4548ed8a45 | |||
| a5cea247f1 | |||
| 407e2d0246 | |||
| 2af7367480 | |||
| 29b9a78f54 | |||
| 571bc39495 | |||
| 382a164b9d | |||
| 19f70656ee | |||
| 9216a7550f | |||
| 4f9ba7c6cc | |||
| afb7c69ac3 | |||
| 8ea78fae21 | |||
| 5f232c0584 | |||
| ed931d497a | |||
| 59278c3f70 | |||
| 179d031bb2 | |||
| dc1410aa70 | |||
| 0f595a8854 | |||
| 70e1dcf1b4 | |||
| 780e3e1cd9 | |||
| 339c93ebbf | |||
| b4bb1556be | |||
| ba96aa5a61 | |||
| 2171556ec4 | |||
| 2d33368063 | |||
| 93a2f74f9e | |||
| ee61084ac8 | |||
| 8d8a1889cd | |||
| f275cb6928 | |||
| db8de1f794 | |||
| d901e821cc | |||
| 34a533b2d6 | |||
| ed6b3c04ab | |||
| d3d37c70ab | |||
| 475e674fc6 | |||
| 01436d0532 | |||
| 96bf7d0c2b | |||
| 529973aa77 | |||
| a4ca0a592f | |||
| f4cd090ac6 | |||
| 6d5e68274d | |||
| 3e002cb940 | |||
| b7ea9a959c | |||
| dc3a16c437 | |||
| 608af84253 | |||
| 90c8689596 | |||
| 161d7e1c2b | |||
| 8627032c4f | |||
| 5bead4fbcc | |||
| ef44ba5f97 | |||
| 2dc0ec9e7e | |||
| afd435e895 | |||
| 80d1bf6a7a | |||
| 737f3ef3db | |||
| d179f47274 | |||
| 1832d95371 | |||
| a614f986f0 | |||
| f398a59d26 | |||
| c8bd104268 | |||
| 7f01bd4c56 | |||
| 6c3c7fdf49 | |||
| bdc8fc8d4a | |||
| 41be127489 | |||
| fdfae9593d | |||
| 6a21eb53c9 | |||
| efde2b4672 | |||
| daaa26cbf4 | |||
| 3e7441562c | |||
| 7b53fa5245 | |||
| d35d46f5b4 | |||
| f4772a9cad | |||
| 4c1bb7e0ac | |||
| 762ea9b4db | |||
| b16970a61e | |||
| dadb9bd81e | |||
| 13dc7de660 | |||
| 003ea36e18 | |||
| f4bd2a68c9 | |||
| be4e75d4f7 | |||
| 538c21ce1e | |||
| 626cd6cb2e | |||
| 2a56f6573d | |||
| 6ee2dc1cd6 | |||
| 3fcde8bd16 | |||
| e2ff7a7bc7 | |||
| c7efd16517 | |||
| f2907f04d9 | |||
| 7d98c267d5 | |||
| 5bc6330c13 | |||
| 1706ed597d | |||
| 6e9ba28ef7 | |||
| 554d1a44de | |||
| c0a8f4c1a4 | |||
| 08be9aac6d | |||
| a51b17fb53 | |||
| 66d5618d60 | |||
| e16c2384fd | |||
| b3323f08a0 | |||
| 7e63c2ef92 | |||
| 7f65b6d598 | |||
| 8fb8f0a4d2 | |||
| 637dbc8d2a | |||
| 978990fdff | |||
| 52a058e511 | |||
| 64bea202c5 | |||
| 37f60993ca | |||
| 32717c67c7 | |||
| 3681e3f025 | |||
| 1d0f055349 | |||
| fb3024ca9c | |||
| 005c0ea9f6 | |||
| 7c3f1f3a84 | |||
| 900e5209d9 | |||
| 4fbf416d16 | |||
| 7b7d2c87fb | |||
| 6debb3a65d | |||
| 315ba49a1d | |||
| ff57855038 | |||
| da6e837578 | |||
| a2d8f89162 | |||
| e36d65e695 | |||
| 34abf14cbe | |||
| dda7a4f22f | |||
| 283a885196 | |||
| d44aa3f16e | |||
| 575e825579 | |||
| dc8cbf4786 | |||
| 5f81a48d8b | |||
| c22e4c00a5 | |||
| afb37981bf | |||
| 9b5ead39d3 | |||
| 158e66f9c6 | |||
| bd8e944e2f | |||
| 2b6518427a | |||
| 06d95b679b | |||
| ff75b9eed8 | |||
| 17a88fcb80 | |||
| 185e0073b3 | |||
| eca34de593 | |||
| d0794ba71c | |||
| 40e89756bf | |||
| 55e46b2d15 | |||
| 02383aad7b | |||
| e75f54cc1c | |||
| 1b42e2e138 | |||
| 3668c24d02 | |||
| a84533b4f4 | |||
| b4efabe82e | |||
| cbe750cc9e | |||
| a77dedf3d7 | |||
| b6ce823cc2 | |||
| f7e8ce2ade | |||
| 62e5b71dc1 | |||
| 3d04324595 | |||
| 468cf73b97 | |||
| 5b90f9fb87 | |||
| 9131dbf09b | |||
| 9b37e95b58 | |||
| a425234365 | |||
| a92d8eeaab | |||
| cd4e268c72 | |||
| b94945a461 | |||
| 5b0a779c07 | |||
| e8dbb12ccc | |||
| d099bf2983 | |||
| f7add0e4dd | |||
| 1e1cc897ad | |||
| 04e2c59aa9 | |||
| 03bd3517be | |||
| eeba5bc8fd | |||
| 1f54180c9c | |||
| 8497a27c81 | |||
| 80afa470ee | |||
| 10c750e1a2 | |||
| 3aa175b36d | |||
| 8a2cd19ea6 | |||
| e76036965b | |||
| 2bb86a2ec1 | |||
| a89c8c6e5b | |||
| 1bdf205ca6 | |||
| afdf86ad0d | |||
| ccf801137a | |||
| ef746a173e | |||
| a5dae3f222 | |||
| e3552f4e36 | |||
| 75d9357488 | |||
| 26c028cf37 | |||
| 10c483967f | |||
| 07b33e8189 | |||
| 97765c08b1 | |||
| fc3ea78005 | |||
| 6ad92556a1 | |||
| bd4150af5a | |||
| e2465ca2a7 | |||
| 1c6c93da35 | |||
| 0daec32ddd | |||
| e466ed9e06 | |||
| 4445b0317f | |||
| beaea6c3bf | |||
| f5644928a8 | |||
| a279c44aa5 | |||
| f1f96e574c | |||
| 1879001ef3 | |||
| 5014b1c459 | |||
| 903459cf7e | |||
| 7c04661cfa | |||
| 76d3194c96 | |||
| b63aee2851 | |||
| f57d2608c5 | |||
| 34331525b1 | |||
| 8d1f4427ae | |||
| 7f89f8284f | |||
| 3b2ac2bc3c | |||
| 73e88be8f3 | |||
| f5d092f497 | |||
| 8edbad92cb | |||
| b0e13a1352 | |||
| 5c75648cd7 | |||
| 1872c6eed9 | |||
| 53199b9737 | |||
| 73861c6a04 | |||
| e2969dd3e2 | |||
| 0b1b99697e | |||
| 07202a8061 | |||
| 189454096b | |||
| 55ee5df852 | |||
| f6466b9154 | |||
| 8e5a64dca9 | |||
| b894147514 | |||
| d55c2c269f | |||
| c7afcf0bef | |||
| c79f5b8b74 | |||
| c5296fd76d | |||
| 3cb3284b26 | |||
| d5d0d00ecc | |||
| dc6b45cbcb | |||
| 5db9d67e65 | |||
| f64dfa7f92 | |||
| 06584d17a6 | |||
| 73bbe5062d | |||
| 75cb1041ec | |||
| b61ed66d9d | |||
| 48ebd7faf5 | |||
| d6de3baa6e | |||
| 1be44a4c05 | |||
| f17f34f730 | |||
| da5bb6126a | |||
| a5d04d2e65 | |||
| 22d07214fe | |||
| 8d4523d250 | |||
| 37a3d694d4 | |||
| d21ccdfcbf | |||
| 11eaf7ce7b | |||
| 4eba97d388 | |||
| 590e97a99f | |||
| 613137551a | |||
| 23f941c810 | |||
| 4c09f3a229 | |||
| e7312801ac | |||
| cd69a7ea53 | |||
| 13ffdda12e | |||
| 372e74aad3 | |||
| 2a32f7d372 | |||
| a2d34cca76 | |||
| c513ed5fc3 | |||
| 34cd5dcf7c | |||
| 260ca085bb | |||
| 7b00074b9e | |||
| 16e981d99d | |||
| 16eb899ba7 | |||
| 3367f8e5c7 | |||
| ad635ab95c | |||
| b94e7fd8a0 | |||
| 683fbd7953 | |||
| 246ec9e20b | |||
| 81d669d62a | |||
| 9ff951d379 | |||
| e327303b3c | |||
| 21d030db26 | |||
| 444b58504c | |||
| c1e2f4ad75 | |||
| ec6778e770 | |||
| 1c5d81cb04 | |||
| 10e6c93a95 | |||
| d37f43daba | |||
| dbabd1bab3 | |||
| 6b5398139a | |||
| 0ec45a4fd0 | |||
| b99fd237f3 | |||
| 5d7123eb05 | |||
| 7eb6ebb60d | |||
| 0060207816 | |||
| 5bc273686e | |||
| 2f6420e05f | |||
| c01699b6e4 | |||
| 6c6199add8 | |||
| 42cd5e0017 | |||
| baee6a37ea | |||
| 90b3685808 | |||
| 37a37318aa | |||
| 74e831c4f0 | |||
| be36390b0f | |||
| e9a628066f | |||
| 8f46b5254e | |||
| cf44119bc6 | |||
| a15e9d737c | |||
| 08f6f5cf71 | |||
| faa2adbde2 | |||
| 7fae60d5c3 | |||
| 4f9ce07304 | |||
| 3cad34821e | |||
| 1e4353f0db | |||
| 7520a1b2a3 | |||
| cdd91e498a | |||
| 2daf0458a7 | |||
| 6299fc09bf | |||
| 2327cc6866 | |||
| b235df0bbf | |||
| 6d56ab9b63 | |||
| ce5de8fe06 | |||
| ce3f80246e | |||
| 046ef82c65 | |||
| 0f347e8453 | |||
| 1cb0ca84ab | |||
| 7625646c11 | |||
| 743c598b83 | |||
| 324530fb8c | |||
| 68acf7b90a | |||
| 9c610f51af | |||
| 40ec0627c4 | |||
| 645e66b30c | |||
| 2311d56836 | |||
| 1f3481c54b | |||
| 07fa51aa4c | |||
| 0866469cc0 | |||
| 3d9bb29b1b | |||
| 5a67bcaf17 | |||
| 01b18513f1 | |||
| d92ec582fa | |||
| 205eb7fd37 | |||
| 0798e62417 | |||
| 83adcc61bc | |||
| 788e07947f | |||
| f7717571ea | |||
| 83fec75bc8 | |||
| 53c240f13f | |||
| f142eb7a44 | |||
| fe84d35ce4 | |||
| 5c5414c960 | |||
| 2eeb1f588a | |||
| 9f69eacf71 | |||
| 495382c42f | |||
| 029634707b | |||
| fd5736fac4 | |||
| f3ed2d9dd8 | |||
| 676cd66e4b | |||
| 17c73c4017 | |||
| 5983a4530f | |||
| 1cd8395b23 | |||
| ea4374a855 | |||
| 061b913413 | |||
| b2fda13ac3 | |||
| e79af0163a | |||
| 91e41049bd | |||
| 18f67bcce5 | |||
| 264347e323 | |||
| 18d9f7dbf1 | |||
| 702af91c84 | |||
| e9db3b3861 | |||
| 896922fde5 | |||
| 7c2bb69bc9 | |||
| d2921346a2 | |||
| cc06b2f0eb | |||
| d98c7bddba | |||
| fe308ef2e4 | |||
| 9b43835e2d | |||
| 88d34a5b92 | |||
| 019f0af282 | |||
| c6619a621c | |||
| 565e3de183 | |||
| b91dd15f96 | |||
| 1a852cdacf | |||
| 4ea527d980 | |||
| 392af4f127 | |||
| 6e8e72f281 | |||
| 1c198500c6 | |||
| 8620f25c0e | |||
| 1311ce48d3 | |||
| 8a59809937 | |||
| bac8299ea6 | |||
| 500dcdd582 | |||
| cbae494c54 | |||
| 09bec66406 | |||
| 2b2291dc7e | |||
| 1357b45e24 | |||
| 4a0dbba3ec | |||
| fcd0360135 | |||
| 6935cf1dde | |||
| cf384373df | |||
| 48c4f34af3 | |||
| 7b800152ee | |||
| f289691e22 | |||
| 50421494c5 | |||
| 33b8861ae3 | |||
| 31f90d20f8 | |||
| a94ba474f3 | |||
| a81ab0ea0f | |||
| 87e65893d3 | |||
| 405540aea2 | |||
| ccfebee5f1 | |||
| 1d9469a3df | |||
| 5417bf3445 | |||
| 51ea1327cf | |||
| 8113ddb2d5 | |||
| a88ad35b68 | |||
| 6e60f59265 | |||
| a866bfafcd | |||
| 97d1677568 | |||
| f4562db0c0 | |||
| a616da3e5c | |||
| a895566b02 | |||
| 5d75765aae | |||
| 3bc34fcd5e | |||
| 99c3ac56e9 | |||
| 95c7d5eeff | |||
| 371f0b82f6 | |||
| 114b5d3db0 | |||
| 9ab0f60b41 | |||
| 33b8226ebe | |||
| e062062cb3 | |||
| 5f713862fb | |||
| fe013b5ea0 | |||
| 57df2dc2cf | |||
| c86fa4c521 | |||
| c90acf53d5 | |||
| fece4d2fdc | |||
| f49837e5fe | |||
| e6ead7c6fa | |||
| 3e84af83d8 | |||
| aff375c799 | |||
| 6ada1a646f | |||
| 42bfd24a8f | |||
| c7c6b41dab | |||
| 449e6b5f5c | |||
| bbe0c7409f | |||
| 41bb9c378b | |||
| 9a82b890d4 | |||
| 2d2180c9e8 | |||
| 798a590c2a | |||
| 8a08878062 | |||
| fde6ff1571 | |||
| 8c9a48b38a | |||
| fff89ee94c | |||
| 2745552915 | |||
| 2f400a2b17 | |||
| de5256b8f5 | |||
| 344ae053cf | |||
| 45fffd74b7 | |||
| a0cf0751de | |||
| 7485cb81aa | |||
| 7faa9a6fdf | |||
| f6f7063419 | |||
| 1300fff94c | |||
| 5ef9798c68 | |||
| db48c18766 | |||
| 880261500b | |||
| e02c257df6 | |||
| d6b48a2818 | |||
| f8c7eee17b | |||
| 5898205480 | |||
| c8d2118c74 | |||
| 50676288a1 | |||
| db2269092a | |||
| ddaa75a6dd | |||
| b3f56900e5 | |||
| f1820b739f | |||
| 56957e28f9 | |||
| 3c32721791 | |||
| 2632433cc6 | |||
| 16ea577099 | |||
| 182e06173d | |||
| f6b4600f8a | |||
| 7456c9dce5 | |||
| f9e16e16d1 | |||
| b42094a1be | |||
| 4c343afec7 | |||
| 40b3007676 | |||
| 48395d162a | |||
| 50aaa54c27 | |||
| 47737b082b | |||
| c4a3c71448 | |||
| 9939849e40 | |||
| d690989b58 | |||
| d9deb0f30c | |||
| 53ce14dddf | |||
| 1d0be6261b | |||
| 108c6eda1d | |||
| 6e33bfaf8f | |||
| a7bc9f0ae9 | |||
| 927e0a4e7b | |||
| 75b2ac9d33 | |||
| b0d7ae6f04 | |||
| c764605d07 | |||
| 205cf3cf28 | |||
| ea274f0df0 | |||
| 31541c4a56 | |||
| 2a601ae483 | |||
| 3fe8600a70 | |||
| dbd7df2b85 | |||
| 717deb544e | |||
| 51446a5fe0 | |||
| 4c4ec3eacc | |||
| 71b72eae10 | |||
| 01eb585e59 | |||
| 2343841aa1 | |||
| b2790d98d0 | |||
| b14e925158 | |||
| 18ef770a85 | |||
| ee831c9361 | |||
| e446dc1cba | |||
| af283c7e01 | |||
| 4703a8b021 | |||
| eb3a608e80 | |||
| 6fbd189553 | |||
| 753e2790ac | |||
| 2c2de8719b | |||
| 1e884bf627 | |||
| 7a001c81ea | |||
| 9e82f3ffd9 | |||
| e1dc605657 | |||
| bca93b57ec | |||
| d929c87bbd | |||
| 72eb4d6d4d | |||
| 0ccdeed318 | |||
| 5d6fc9f516 | |||
| 7f0435e3d6 | |||
| 85d7ba73aa | |||
| c6a3afa555 | |||
| 6cc8ec63be | |||
| cf7cb8069b | |||
| e70519c9c2 | |||
| 6617cc3e7e | |||
| 3426a427d8 | |||
| 2e67787d75 | |||
| 70a30a96fa | |||
| 8832596aa0 | |||
| 0cd013191a | |||
| 8f70b299cf | |||
| 2c87a469e3 | |||
| 5ca2ec5534 | |||
| 46caf9d9ec | |||
| 7ac0842bc2 | |||
| de1674d001 | |||
| 37c30a3079 | |||
| ce825d1df2 | |||
| b5c01ab820 | |||
| 8de674c837 | |||
| d289ac7f05 | |||
| 7ec60bc6f2 | |||
| eb78474d1e | |||
| 6dc5a4a27e | |||
| 10c8045351 | |||
| 03b6a54fe8 | |||
| 1140795ab8 | |||
| 0441c07266 | |||
| 64f346a5f0 | |||
| 0ac63a8ac6 | |||
| f5a3d751da | |||
| 985e7d0c7c | |||
| 97a024b3ec | |||
| c501999676 | |||
| 80e7cf0b46 | |||
| b7d2bea3ea | |||
| 2acb38a6a5 | |||
| 8ea7242c38 | |||
| 616a93cb52 | |||
| 7170ea0303 | |||
| 10eb14776b | |||
| a7b43d1879 | |||
| 439f3c9c91 | |||
| e2b8368f40 | |||
| 1243f690c4 | |||
| e2c8d41a58 | |||
| 591bba71d5 | |||
| 7aef0f78b0 | |||
| b6dc0e2a08 | |||
| 1ed7dd0e1e | |||
| 5ecfa0b8d8 | |||
| 3578580956 | |||
| 67457d324c | |||
| 8b4e4aff27 | |||
| eb11c4640b | |||
| 5806fced78 | |||
| ee0e036354 | |||
| af0ec10e78 | |||
| ccbd73259b | |||
| 1a44a11b62 | |||
| 8502a2291b | |||
| 67ccb5e6d9 | |||
| 520a70d2ea | |||
| 3dee30c48e | |||
| f314e12685 | |||
| e3b53c8fa2 | |||
| 4bb5020e0a | |||
| 4f1fa95cf9 | |||
| baeeb8e609 | |||
| f109ca6f1f | |||
| 1714d62762 | |||
| f19ac4d5bb | |||
| c6dbccb463 | |||
| 80ca2a6d21 | |||
| 90c1c8cddd | |||
| 0b6593bdc8 | |||
| 6e8c0e5a14 | |||
| 78c300ea1b | |||
| a3fb492e37 | |||
| 667f50497c | |||
| cb93eda4e2 | |||
| 613b536f97 | |||
| 1ffaa077e6 | |||
| dbc850550f | |||
| 1efe65e6ba | |||
| 6a39db7e47 | |||
| 43841b8b3c | |||
| c33ab9c924 | |||
| 135118de65 | |||
| b42b9e354f | |||
| b59b51b2aa | |||
| 7677b3916d | |||
| b4debacd1a | |||
| d91c26e718 | |||
| 8e64083f06 | |||
| 56580f117a | |||
| 29e994dfd0 | |||
| b833daf943 | |||
| 537e09a0a6 | |||
| f53f55b283 | |||
| 943903d8d6 | |||
| 523920d6d6 | |||
| e39a242a76 | |||
| e3b57b0c2f | |||
| 125a9e4031 | |||
| 9576871ff9 | |||
| 968724f369 | |||
| 4444a63b92 | |||
| 9924112d08 | |||
| 368796c40e | |||
| b9f6b2bbb8 | |||
| 86afa9d201 | |||
| 294d320aa0 | |||
| bdd44061f3 | |||
| 8545d2d703 | |||
| 61f3180d74 | |||
| 9efcb09836 | |||
| 80b7ebd508 | |||
| 4545cf8989 | |||
| 4dc5e8bbdb | |||
| 0261776778 | |||
| 1dfce30a32 | |||
| d7f44fdda4 | |||
| 8facdcd55c | |||
| 582b8858bc | |||
| 6d4264bc58 | |||
| 340433f418 | |||
| 107576eb01 | |||
| ede406c904 | |||
| 3b875ce6ec | |||
| c89e14ae52 | |||
| cff2fc0f16 | |||
| e8b637b900 | |||
| 84bb484761 | |||
| 25c8fdd5d6 | |||
| 6beaea8be9 | |||
| 7952bdefa8 | |||
| 280d16637a | |||
| cc397f6846 | |||
| bec443ce97 | |||
| 8417007eb8 | |||
| 3db77f05e9 | |||
| 3c1eb27ce1 | |||
| 614a2cd5de | |||
| 616d0f00f9 | |||
| ef22fb256b | |||
| 6e0dbc94d7 | |||
| 328a724920 | |||
| f9e620a77f | |||
| 334f27424e | |||
| 45b7df15c3 | |||
| 316f1be3d0 | |||
| a500dd4e9e | |||
| 4fc35e30c4 | |||
| 920676fbd7 | |||
| c2c90dd614 | |||
| c69b83da3f | |||
| 3d43de0763 | |||
| 413b571ce8 | |||
| e777d3a54c | |||
| 1981c7daef | |||
| 6a802f9c3c | |||
| f64912e461 | |||
| 6e3567f0bf | |||
| 9406b600f9 | |||
| 1398b4cbd6 | |||
| bde097a827 | |||
| a4ad7a0baf | |||
| a3927f25ce | |||
| d5d8084f95 | |||
| b48fe19617 | |||
| f802ea4d38 | |||
| 8107588576 | |||
| 531dce4305 | |||
| 44e81dfb8a | |||
| b4753e369c | |||
| 7a5842712b | |||
| b479cdf358 | |||
| 222277de9a | |||
| e285ee2931 | |||
| c4b20a16ce | |||
| ae7d967227 | |||
| 2135efe8a9 | |||
| 00c617b2b8 | |||
| 8dd105a31a | |||
| 66aeaaefdb | |||
| eb68789327 | |||
| d73ab0daa0 | |||
| bce9a91408 | |||
| 43808b00ad | |||
| 17e8ef8e30 | |||
| d02278930d | |||
| c2597a788b | |||
| 227afd3965 | |||
| fc97b68503 | |||
| d83ec4ea05 | |||
| 420bf9162d | |||
| 8b1ec3d54b | |||
| ea3d970743 | |||
| a0c905de33 | |||
| 3517e040c4 | |||
| cf1fb87b63 | |||
| b2389fb67c | |||
| f9f57fb161 | |||
| a9f7d42d77 | |||
| 537861c232 | |||
| 6eaea0b73f | |||
| ee7dddf836 | |||
| 4c493efb24 | |||
| 323d308c73 | |||
| 07ddc0e574 | |||
| d159f6a3db | |||
| 1bcfe94818 | |||
| f7db8ef03d | |||
| 1b2a46d7a0 | |||
| 0ff2a971a4 | |||
| dce076157b | |||
| 7cc0aa336b | |||
| 5b193f7a7a | |||
| e290c70732 | |||
| a73e68fa1a | |||
| b0578757d2 | |||
| bf8082f0b9 | |||
| 04bebca323 | |||
| 0fe753b7da | |||
| 24c3c01851 | |||
| a6a211f386 | |||
| 603aa39e3f | |||
| 66607069fe | |||
| 54badc5545 | |||
| 64c07aa7b6 | |||
| 9d40df179d | |||
| af5e87970e | |||
| e9d7189e16 | |||
| 1cc15c3931 | |||
| dfb4cae5ed | |||
| c4c8750b26 | |||
| aa25a68af8 | |||
| ad5c5e27bd | |||
| 141e28d627 | |||
| d0b28b6e65 | |||
| 068e6c0e92 | |||
| 95a342fc25 | |||
| b21efb91b5 | |||
| d363793c56 | |||
| 7e687e91c2 | |||
| 255c7e323f | |||
| d3fd535605 | |||
| 5992525d8e | |||
| 03b0e24289 | |||
| f7f58ba12d | |||
| 595b5c865f | |||
| 2b2a015f51 | |||
| 8160ebf854 | |||
| dc27169129 | |||
| 8d7cf50089 | |||
| 8498b9b6bd | |||
| aead8f6c56 | |||
| d58fda626e | |||
| 0f753ae5b5 | |||
| 3371ac9552 | |||
| 1351df90f9 | |||
| 02b6a928b4 | |||
| a1c72d8972 | |||
| 62596c875e | |||
| 282388fb33 | |||
| afbd31ca01 | |||
| f1d32b8cae | |||
| fa7033f4e3 | |||
| 46bcc7ede8 | |||
| 2adc10ed6e | |||
| ab8a88350f | |||
| e1809f1c19 | |||
| b8cc9ac788 | |||
| 40c2a49e98 | |||
| 1894f55474 | |||
| 18a4c88ead | |||
| 210fd6399a | |||
| b91882f5dd | |||
| 6af827f595 | |||
| 200d92186c | |||
| cc971dba4c | |||
| 35c0b3b8b1 | |||
| cf637f8f65 | |||
| c86226c13b | |||
| eaed40966a | |||
| 5083207477 | |||
| 5f57402903 | |||
| 4c9ffe4eb9 | |||
| f705a2e24d | |||
| 61a8567cc3 | |||
| 8e33a644cf | |||
| e2e4743994 | |||
| 7b1a5aea19 | |||
| 602e13dab7 | |||
| 64117b92b0 | |||
| 17c89a3f2b | |||
| 1482abc8b9 | |||
| 9d33a2c524 | |||
| af31dfd19c | |||
| 53c090fd6c | |||
| 1aef412b13 | |||
| f9956cf617 | |||
| bc7473d863 | |||
| a470ba76df | |||
| 84c42999e4 | |||
| 5f1566d6ab | |||
| 87c5e3e01e | |||
| 27a554bebd | |||
| 3ce017fa68 | |||
| d446da2243 | |||
| cdb4416421 | |||
| a1de8aa20c | |||
| adfad90f7c | |||
| 22703806cd | |||
| 22a63fc2ee | |||
| eec2773c88 | |||
| c1c3c6724f | |||
| 0223304ec7 | |||
| 08f9879ada | |||
| 29559485e2 | |||
| 6e100177da | |||
| d36e17eaae | |||
| 51e60d2d6b | |||
| 995276643c | |||
| a3c792619d | |||
| 946161a514 | |||
| 075be02f96 | |||
| 3664bbf423 | |||
| 820b41927d | |||
| 7ff5927409 | |||
| f93b40f402 | |||
| 7d226a6dca | |||
| 7b2edd0762 | |||
| 5dc0401f23 | |||
| b5164e4c92 | |||
| e1a06fa188 | |||
| b2ec69ce7f | |||
| a9706f6d5e | |||
| cee2b77fbc | |||
| 3425599461 | |||
| 104123495d | |||
| c2735fce90 | |||
| d4ff9dce24 | |||
| 5c8a62d9b7 | |||
| 87fa71c599 | |||
| 0b08b8007b | |||
| 3e93684451 | |||
| 8747ff32dd | |||
| 218190d989 | |||
| 0a4ec84875 | |||
| 33402f5e0c | |||
| d0e57cfab6 | |||
| 0ebd103e21 | |||
| 95ba562021 | |||
| 67f5fb72c3 | |||
| 62cadfdc8c | |||
| 27e8995cce | |||
| e2406e33d8 | |||
| da2dd79765 | |||
| 4568180e85 | |||
| cc8f59d9e0 | |||
| 446f5f3cef | |||
| d84eb43278 | |||
| bb72dab877 | |||
| b06762ea4f | |||
| 33d75cdd2d | |||
| 3372b7a647 | |||
| 4e0d34b826 | |||
| 324c937cc4 | |||
| 908d04e98a | |||
| 79fd541498 | |||
| 8c360a26e5 | |||
| 93ae07cc89 | |||
| 713a726bde | |||
| 100f1683be | |||
| ed201b24d6 | |||
| 0b7ebcaeb0 | |||
| 52a9993b0d | |||
| 4e733fab02 | |||
| 3257c6e709 | |||
| ece4ed4caf | |||
| fa04891ddb | |||
| 6145c6cc5a | |||
| dffff07436 | |||
| 97854ad02d | |||
| 500d6e1f2d | |||
| f69e2c671b | |||
| 84fdb5d6c1 | |||
| dc58ddce59 | |||
| b5a46a370f | |||
| 199bdb219f | |||
| c1a93e3ac8 | |||
| f334b8caa3 | |||
| 8b658a19b9 | |||
| 59dd9970d0 | |||
| 55d46cbefe | |||
| e37ce27773 | |||
| b2c0a21230 | |||
| 00704aea73 | |||
| 31043d1f5c | |||
| 33b68c11db | |||
| de607e7d83 | |||
| ab7ff30a73 | |||
| 53f2ef2ca1 | |||
| 25f2e560f1 | |||
| 317b1a462e | |||
| bf97734533 | |||
| 3e831bf9b3 | |||
| b4400b38a9 | |||
| a613380811 | |||
| 7f30fe1a95 | |||
| 93168326da | |||
| 70c672eb52 | |||
| ec6caf9b59 | |||
| 1f1d41ecfd | |||
| 9bc92f57c8 | |||
| bbe6475eb2 | |||
| 1e5d426e70 | |||
| ccf6856143 | |||
| 175d8306b8 | |||
| 2e7046a810 | |||
| c03c913ce7 | |||
| f7b82ad1ff | |||
| 118ddfce94 | |||
| 8bce38b918 | |||
| d31ba20dd5 | |||
| a3c7410c35 | |||
| 624e6839c3 | |||
| 9623fa4d87 | |||
| c196637922 | |||
| 67284e2e6d | |||
| f6ee500e6c | |||
| b76d909619 | |||
| faf9cecc10 | |||
| 1246de2644 | |||
| 0c72109ad8 | |||
| 5aad15256c | |||
| 69f7778067 | |||
| 5d6b9890ca | |||
| 20382ea5bf | |||
| 0d257d956f | |||
| f853d25d4f | |||
| 4c05f26940 | |||
| ce18ff669c | |||
| a3b5346773 | |||
| 3c96491295 | |||
| 53abf8cdcc | |||
| e376492128 | |||
| 2658b9b064 | |||
| 46e7e12cb2 | |||
| f412b56caa | |||
| 9c63b40a5a | |||
| 40843f93dc | |||
| 9165f59dcc | |||
| 92ff333778 | |||
| 63e62cde1b | |||
| c5081ce3e5 | |||
| 67d2a5d094 | |||
| a170da5c01 | |||
| 9652cb312a | |||
| e4d7e08902 | |||
| ce3a7bb687 | |||
| 428095b71b | |||
| 411ffb12ca | |||
| 3b93193da1 | |||
| 17584e4799 | |||
| 224a813f25 | |||
| 7d079f74a1 | |||
| 4ca53e6f70 | |||
| 70d1ffe294 | |||
| 335e1a7e18 | |||
| 287481a44e | |||
| 2dd09f9702 | |||
| 1503f90394 | |||
| 9317d6551d | |||
| d2834fcdb9 | |||
| ed3d30e343 | |||
| ca5a25f703 | |||
| 4ddd2f1cf8 | |||
| 11c8fd4d4c | |||
| ab04f3de93 | |||
| 692a9ebebf | |||
| bf314a0f84 | |||
| c8c2bb6709 | |||
| 4c16796256 | |||
| 3fc8b976fc | |||
| 516771d948 | |||
| 6b9dc97fa1 | |||
| 69d7d6aae2 | |||
| 4f3a30261e | |||
| e7c478318c | |||
| e75860c6ee | |||
| 982766dd77 | |||
| d0dbd1e561 | |||
| 81cbad52f7 | |||
| e7eb4f0e80 | |||
| f1ef1bc38a | |||
| 676a995889 | |||
| b696642993 | |||
| a44fe14de1 | |||
| e7bb7d3656 | |||
| 0015dbcd1d | |||
| fc5e7cccbc | |||
| 8987f3f951 | |||
| ba671a8f1f | |||
| fbe871f8d1 | |||
| 116b2d1229 | |||
| 20fd870b59 | |||
| 5c46990195 | |||
| fab57020f2 | |||
| b37074f473 | |||
| 24e2e81a28 | |||
| ccabc1fbcc | |||
| f847f83cb8 | |||
| e0771827aa | |||
| fa6adaa155 | |||
| f3504ce6fc | |||
| a075ca904b | |||
| e3fb6fabf8 | |||
| 6f89af790e | |||
| 28f493d84d | |||
| a87e862148 | |||
| 6e264bfee0 | |||
| 1ecf862f2d | |||
| 338fc88095 | |||
| 0a724cc49a | |||
| a2efcf1ca9 | |||
| ffc9e882d7 | |||
| c9d73e85e1 | |||
| a956676745 | |||
| 5800d08202 | |||
| b8958bad72 | |||
| 0508d7558e | |||
| 98c3192a13 | |||
| 6e7ff15e78 | |||
| b886e11670 | |||
| b0c45c7179 | |||
| 92bb1df82e | |||
| 1c4549dd8e | |||
| 83e61ec8cd | |||
| 7ea549df4a | |||
| 0fabc5d88d | |||
| af1c55cd7e | |||
| 7822c0bc3e | |||
| 2b7ed1f7fa | |||
| 33ae9f1d5b | |||
| 45ddaf1b72 | |||
| f27aae5e31 | |||
| 7595c922fa | |||
| ff6a6407f5 | |||
| 4d4513d936 | |||
| e378c99923 | |||
| 32a6c8edbe | |||
| 078c870970 | |||
| 4a91d37423 | |||
| 96befb2aef | |||
| 925452e106 | |||
| 1678330f0c | |||
| 58cf3fc2ea | |||
| 13ce17ffc6 | |||
| 70e020ee0c | |||
| c6a167ef40 | |||
| f0d1697108 | |||
| 420ba5f261 | |||
| 90cb53566c | |||
| 7aa1f3ae1d | |||
| 556d638136 | |||
| 64982d01cf | |||
| c4aa10baf8 | |||
| b6cad58917 | |||
| 86f647e714 | |||
| 4f9c952dbe | |||
| 64f5d40fd9 | |||
| b9c3c8954f | |||
| 1a0869c2ca | |||
| 82b6159475 | |||
| 1fb32ee461 | |||
| 68a863e63e | |||
| a1e62ccd46 | |||
| dae66c0fa4 | |||
| 1d6b21e1c7 | |||
| 455be12058 | |||
| 2ebb3ecb96 | |||
| b29056dddf | |||
| 3ed09f304c | |||
| 4875283ed1 | |||
| 261231c09e | |||
| 180dad6ee6 | |||
| d7121ad82f | |||
| f121c61524 | |||
| e25ea465c5 | |||
| 30ac3d1a26 | |||
| e47c772230 | |||
| 706b623d95 | |||
| a908a76f53 | |||
| 93bf91869b | |||
| a026ca92ff | |||
| a2ec707f79 | |||
| 1b7fe4f728 | |||
| 2bf2d55c6e | |||
| dbe998d9cf | |||
| 2ed32612b9 | |||
| 314bcbe208 | |||
| 6290e34623 | |||
| 5e5521a128 | |||
| 1e8bed86d3 | |||
| 7cbc0fa671 | |||
| 79907a2770 | |||
| 8bc7d50e35 | |||
| aa420e5006 | |||
| 4babaa85fc | |||
| 6f60ef9ec2 | |||
| 378d9e008e | |||
| 581867eefc | |||
| 340fabe4f6 | |||
| 29331b8b06 | |||
| 234855f225 | |||
| 6fbcaa0959 | |||
| 52e10205c5 | |||
| 0b8176a730 | |||
| f419ae3f4a | |||
| 84a6b8e012 | |||
| d1be571d4d | |||
| f234aee691 | |||
| 623d516595 | |||
| 156eaf53d5 | |||
| 678a7c1437 | |||
| ce2a760093 | |||
| 34b02d8534 | |||
| 42e0e691dd | |||
| d392439f82 | |||
| ccfda99060 | |||
| 57f02bdc57 | |||
| 7056656eab | |||
| 2d899fc772 | |||
| 80ee0835aa | |||
| f423b88b16 | |||
| 7b7e6c7971 | |||
| 853aed5954 | |||
| 025fb30335 | |||
| 947a149d08 | |||
| fcace4a192 | |||
| 619e1c70ea | |||
| 7a95bdfba6 | |||
| 89962de161 | |||
| d9328bd0ce | |||
| 81306df06a | |||
| 75abe9eb3e | |||
| 1f26bf1125 | |||
| 3aec52eab0 | |||
| 14b9c06d77 | |||
| 4f3d23d1ef | |||
| cada43f34d | |||
| 8d0fda88b7 | |||
| 9188f89a2a | |||
| 1cc7ef0543 | |||
| 91a95dbc66 | |||
| a86ffc29d1 | |||
| a15adc806b | |||
| 9ed5540c49 | |||
| 3a7e00ccc3 | |||
| f328da37bc | |||
| f82cdabccd | |||
| 15346eec22 | |||
| b9c9cc0046 | |||
| 27ece84d52 | |||
| 3adc8f279b | |||
| 1299186fb8 | |||
| d837e4845b | |||
| 39be0c5590 | |||
| 41c75022a9 | |||
| d2bb10e96d | |||
| f139a616c7 | |||
| 84924a68b7 | |||
| d5099d973b | |||
| 82b18a3207 | |||
| 5a3a63e0a4 | |||
| 10aee6bb5f | |||
| 980cc5704f | |||
| 28054a9112 | |||
| c1b11ab9bf | |||
| c226a061f5 | |||
| c0be738aef | |||
| 7a312f5868 | |||
| e6987ec148 | |||
| 973fa7a58b | |||
| 5ce493180d | |||
| b6195ba3ae | |||
| d3bd213f29 | |||
| bbdc78a13c | |||
| 9502dd8bd7 | |||
| e81ccf4280 | |||
| 9730f2c0f3 | |||
| 1de34c0656 | |||
| bdce6b1387 | |||
| ce0c2ecd8f | |||
| 40f07e3d72 | |||
| 43971b9625 | |||
| 3c3ccae7c9 | |||
| be211f6f86 | |||
| f27a3a2c61 | |||
| b96d0d55c9 | |||
| 9789ca42f8 | |||
| da26bc5165 | |||
| 0b67626961 | |||
| 546c8ce7d5 | |||
| c48e0c7377 | |||
| c76a888d11 | |||
| 38c495a4ac | |||
| 95fdfa6396 | |||
| f699935f5f | |||
| d0ce5e0c57 | |||
| 7a0e5b57db | |||
| 8336cf5baa | |||
| c31f1d2cce | |||
| d3d90abba7 | |||
| ea98ee07e5 | |||
| a38bfada57 | |||
| bdaf13da4c | |||
| e5ff19aec4 | |||
| 504f63066f | |||
| 41b327529b | |||
| e84279c402 | |||
| 65e6519f97 | |||
| dbbb3beacc | |||
| e8a73a8de6 | |||
| 411cbc0962 | |||
| b487c0e3e9 | |||
| fa0e8f6e01 | |||
| e25e405f74 | |||
| 302c4a6414 | |||
| d1e7f6e55e | |||
| d12f0974df | |||
| e92e550e9c | |||
| b751fe7903 | |||
| a6e34522eb | |||
| 735e6f3471 | |||
| c9f41c950a | |||
| 091c710940 | |||
| 276f412a3c | |||
| 6f6e5c847a | |||
| 6ef0274bb3 | |||
| 7ef6a72ec4 | |||
| e2b8c69cf6 | |||
| 1254d12d83 | |||
| b68642a827 | |||
| c5214b976b | |||
| e611121244 | |||
| 3aa7f0b7fe | |||
| 3d38eee71d | |||
| 90e6e309f9 | |||
| 885314b87a | |||
| ef013a7026 | |||
| 0353ade90e | |||
| d90c9282ac | |||
| 5055aafe1d | |||
| e68b9ea267 | |||
| 1d8ba2ec61 | |||
| d56970eaa3 | |||
| 8b3f18ec59 | |||
| 170d20ddb5 | |||
| 70407dac85 | |||
| c2334d87de | |||
| 6b90b3743e | |||
| 4d9bc04d58 | |||
| 3527b86b6d | |||
| 30f4e1eb08 | |||
| 8142ab64f6 | |||
| f8ecbf8f0b | |||
| 7eb536f80e | |||
| d91ce88e9a | |||
| c5ffbf6ed9 | |||
| 7ada7fb327 | |||
| 9d632e39bf | |||
| 5d085469ec | |||
| 7c54429c13 | |||
| 8dfd04573e | |||
| 2115de9926 | |||
| 1aee8679d2 | |||
| 8829ea9b29 | |||
| 6ba99e8199 | |||
| b4eb603f22 | |||
| 62f9ce979d | |||
| f5928fc707 | |||
| f7040e3616 | |||
| a70f1cc1ef | |||
| a86b738231 | |||
| 8a9ddba208 | |||
| 4c727966e7 | |||
| c2ac53029a | |||
| 5d300d85bd | |||
| 1dc181797b | |||
| 30d932e2e0 | |||
| 9fb32d33af | |||
| 70baa60521 | |||
| 8ea2784d44 | |||
| 2b56d82577 | |||
| 53149666ad | |||
| 5d55c5021b | |||
| 778da511a5 | |||
| 84940f12c5 | |||
| 0f45ecc00f | |||
| 5d91e0fa0a | |||
| eb41247f2d | |||
| d53eafe87a | |||
| 065c17002e | |||
| 0e46a54013 | |||
| 0e35fb941b | |||
| 71c9b927c6 | |||
| e42960ea15 | |||
| 636d42a52e | |||
| 1451843f84 | |||
| f8af21306a | |||
| 4e874cdb1b | |||
| dbd9a844dc | |||
| c5284ed195 | |||
| 5cd92dd794 | |||
| bd98ee62e1 | |||
| 33f7a8d356 | |||
| 64c3fe9099 | |||
| 193fba71f3 | |||
| 8a14800ef2 | |||
| a46d73f562 | |||
| 865392d1f7 | |||
| b880ed2371 | |||
| def04017e0 | |||
| 9eeb916796 | |||
| b06a0c5d83 | |||
| 90541ba349 | |||
| 0cc346259b | |||
| 98c343b438 | |||
| 7ee0cdc6c7 | |||
| 6a770832ba | |||
| ef0ff65162 | |||
| 8c0e7e1bb3 | |||
| 8cb2ef7cac | |||
| 046b38e5c2 | |||
| d50d7fd631 | |||
| a34ea0804d | |||
| 0fbf4ce443 | |||
| d062cc45df | |||
| da790136ff | |||
| 134f374ada | |||
| df304a894f | |||
| 2d1d90e38c | |||
| dcbdc6fcb8 | |||
| 5a4ef15de5 | |||
| affc4c8bd9 | |||
| bc5d6e89ba | |||
| c17e6811d2 | |||
| 7f097c029a | |||
| 60f5affe43 | |||
| e769239213 | |||
| 45923a74f6 | |||
| 7bf7a87f8a | |||
| 8396e27a2c | |||
| 5153c68b8b | |||
| 391495dd86 | |||
| 8c89eb6650 | |||
| 4167c6ea70 | |||
| ca3151ce29 | |||
| cc20844eff | |||
| d876e710e4 | |||
| 5c1290425b | |||
| 2043488c67 | |||
| e7e48c8f03 | |||
| dad650b804 | |||
| d0e73714c6 | |||
| d8b95d3a20 | |||
| 559d8cc0db | |||
| 7a804aa576 | |||
| 0bca66b671 | |||
| 24e5cf8121 | |||
| ee7c4ce0f3 | |||
| 428b511687 | |||
| 49497996d5 | |||
| bccd65e2fc | |||
| f2158843ce | |||
| 87fc4a4f22 | |||
| 27291f9ee9 | |||
| cba963110e | |||
| aa014e3706 | |||
| 58d577f67a | |||
| cd3678841b | |||
| 425e0c33df | |||
| 2018407782 | |||
| a4729a7de8 | |||
| 54f065f42c | |||
| 333ebb88b9 | |||
| 53ff367473 | |||
| 33a7de9448 | |||
| c797472bcc | |||
| 6faf171007 | |||
| 9348204987 | |||
| 776c7caaa5 | |||
| 2216b83ca7 | |||
| 777872d41f | |||
| b85d1f184a | |||
| 02129eeddb | |||
| 2612e0bbc8 | |||
| 3670efacb4 | |||
| 476611b70f | |||
| 60df2a17f8 | |||
| f64f4795c1 | |||
| e036f756d5 | |||
| 92fd121cae | |||
| 6307337892 | |||
| fc2e35cd32 | |||
| 1811e061aa | |||
| 59037f0d83 | |||
| 67edb7d396 | |||
| 0da393f950 | |||
| abd30e551e | |||
| 6fb2889a92 | |||
| 54125d27e0 | |||
| 41efda5f82 | |||
| 2aee14a800 | |||
| be69da0a0d | |||
| 31a247b55b | |||
| 33c156be16 | |||
| fd66a083d6 | |||
| d276f50fdf | |||
| 4cb748e124 | |||
| 503e6898c3 | |||
| f34056fe2e | |||
| 8ff3575442 | |||
| b5e3358bbd | |||
| 047a1197be | |||
| 0e87843446 | |||
| 4f1ff328ad | |||
| ad5bbb9b37 | |||
| c6e2fd2cab | |||
| d3a7e25b86 | |||
| 062445a48e | |||
| 3c32be6181 | |||
| e2f4a9bf9f | |||
| 2db4c1b2e4 | |||
| 6ed93f4a4f | |||
| 9dcee71baf | |||
| 9bdd0d1d1e | |||
| 13b51d8608 | |||
| 19969fee39 | |||
| 28dc4bf52e | |||
| 9a380ac3d4 | |||
| 17a26b43f0 | |||
| 3c42acebf0 | |||
| 3327b2ce3c | |||
| 8c56aa9575 | |||
| d528126f15 | |||
| 64d0e3928c | |||
| fc4ac029b1 | |||
| 73f4afa05e | |||
| 7017a0cae1 | |||
| a40e4d7d04 | |||
| 9e3b56f4bc | |||
| ef1a42fff2 | |||
| 760d089073 | |||
| 92fbf83bdb | |||
| 9525bbf502 | |||
| 61df3bc462 | |||
| 3492ed54de | |||
| 1455281957 | |||
| e1de57384e | |||
| c95462328a | |||
| 84fc89250a | |||
| a80f52cbf4 | |||
| 7ce324a3a5 | |||
| 7624082b29 | |||
| 6d3bba696a |
@@ -4262,6 +4262,24 @@
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Husky-Devel",
|
||||
"name": "Peter Gallwas",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/75509373?v=4",
|
||||
"profile": "https://www.husky.nz",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "CybotTM",
|
||||
"name": "Sebastian Mendel",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/326348?v=4",
|
||||
"profile": "https://github.com/CybotTM",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
@@ -113,7 +113,7 @@ ENABLE_HSTS=false
|
||||
# --------------------------------------------
|
||||
CACHE_DRIVER=file
|
||||
SESSION_DRIVER=file
|
||||
QUEUE_DRIVER=sync
|
||||
QUEUE_CONNECTION=sync
|
||||
CACHE_PREFIX=snipeit
|
||||
|
||||
# --------------------------------------------
|
||||
|
||||
+2
-1
@@ -37,6 +37,7 @@ MYSQL_ROOT_PASSWORD=changeme1234
|
||||
DB_PREFIX=null
|
||||
DB_DUMP_PATH='/usr/bin'
|
||||
DB_DUMP_SKIP_SSL=true
|
||||
DB_DUMP_SINGLE_TRANSACTION=false
|
||||
DB_CHARSET=utf8mb4
|
||||
DB_COLLATION=utf8mb4_unicode_ci
|
||||
|
||||
@@ -120,7 +121,7 @@ ENABLE_HSTS=false
|
||||
# --------------------------------------------
|
||||
CACHE_DRIVER=file
|
||||
SESSION_DRIVER=file
|
||||
QUEUE_DRIVER=sync
|
||||
QUEUE_CONNECTION=sync
|
||||
CACHE_PREFIX=snipeit
|
||||
|
||||
# --------------------------------------------
|
||||
|
||||
+1
-1
@@ -72,7 +72,7 @@ CORS_ALLOWED_ORIGINS="*"
|
||||
# --------------------------------------------
|
||||
CACHE_DRIVER=file
|
||||
SESSION_DRIVER=file
|
||||
QUEUE_DRIVER=sync
|
||||
QUEUE_CONNECTION=sync
|
||||
|
||||
# --------------------------------------------
|
||||
# OPTIONAL: LOGIN THROTTLING
|
||||
|
||||
+22
-3
@@ -32,6 +32,7 @@ DB_PASSWORD=null
|
||||
DB_PREFIX=null
|
||||
DB_DUMP_PATH='/usr/bin'
|
||||
DB_DUMP_SKIP_SSL=false
|
||||
DB_DUMP_SINGLE_TRANSACTION=false
|
||||
DB_CHARSET=utf8mb4
|
||||
DB_COLLATION=utf8mb4_unicode_ci
|
||||
DB_SANITIZE_BY_DEFAULT=false
|
||||
@@ -90,7 +91,16 @@ IMAGE_LIB=gd
|
||||
|
||||
# --------------------------------------------
|
||||
# OPTIONAL: BACKUP SETTINGS
|
||||
# --------------------------------------------
|
||||
# Backup filesystem configuration
|
||||
# - BACKUP_FILESYSTEM_DRIVER: Driver to use (local, s3, etc.)
|
||||
# Default: local (backward compatible)
|
||||
# Set to s3 to use S3 for backups (requires PRIVATE_AWS_* credentials)
|
||||
# - BACKUP_FILESYSTEM_ROOT: Root path/prefix
|
||||
# For local driver: leave commented for default to storage_path("app")
|
||||
# For S3 driver: empty string = bucket root, or specify prefix like "backups/"
|
||||
#--------------------------------------------
|
||||
BACKUP_FILESYSTEM_DRIVER=local
|
||||
#BACKUP_FILESYSTEM_ROOT=
|
||||
MAIL_BACKUP_NOTIFICATION_DRIVER=null
|
||||
MAIL_BACKUP_NOTIFICATION_ADDRESS=null
|
||||
BACKUP_ENV=true
|
||||
@@ -124,7 +134,7 @@ BS_TABLE_DEEPLINK=true
|
||||
APP_TRUSTED_PROXIES=192.168.1.1,10.0.0.1
|
||||
ALLOW_IFRAMING=false
|
||||
REFERRER_POLICY=same-origin
|
||||
ENABLE_CSP=false
|
||||
ENABLE_CSP=true
|
||||
ADDITIONAL_CSP_URLS=null
|
||||
CORS_ALLOWED_ORIGINS=null
|
||||
ENABLE_HSTS=false
|
||||
@@ -133,7 +143,7 @@ ENABLE_HSTS=false
|
||||
# OPTIONAL: CACHE SETTINGS
|
||||
# --------------------------------------------
|
||||
CACHE_DRIVER=file
|
||||
QUEUE_DRIVER=sync
|
||||
QUEUE_CONNECTION=sync
|
||||
CACHE_PREFIX=snipeit
|
||||
|
||||
# --------------------------------------------
|
||||
@@ -149,6 +159,14 @@ REDIS_PORT=null
|
||||
MEMCACHED_HOST=null
|
||||
MEMCACHED_PORT=null
|
||||
|
||||
# --------------------------------------------
|
||||
# OPTIONAL: S3 PROXY
|
||||
# When enabled, public uploads (images, logos, avatars) are served through
|
||||
# the application instead of directly from S3. This allows using a single
|
||||
# fully private S3 bucket for all storage.
|
||||
# --------------------------------------------
|
||||
PUBLIC_S3_PROXY=false
|
||||
|
||||
# --------------------------------------------
|
||||
# OPTIONAL: PUBLIC S3 Settings
|
||||
# --------------------------------------------
|
||||
@@ -193,6 +211,7 @@ LOGIN_AUTOCOMPLETE=false
|
||||
RESET_PASSWORD_LINK_EXPIRES=15
|
||||
PASSWORD_CONFIRM_TIMEOUT=10800
|
||||
PASSWORD_RESET_MAX_ATTEMPTS_PER_MIN=50
|
||||
TWO_FACTOR_MAX_ATTEMPTS_PER_MIN=5
|
||||
INVITE_PASSWORD_LINK_EXPIRES=1500
|
||||
|
||||
# --------------------------------------------
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Pint: Models
|
||||
9623fa4d87e7fb38307028338c6991afb7d4e099
|
||||
|
||||
# Pint: Actions
|
||||
a3c7410c35388af08997b1c52adebda1056488a6
|
||||
|
||||
# Pint: Console
|
||||
8bce38b9187d23089a28a4f3a4ab960ac7471e90
|
||||
|
||||
# Pint: Enums and Events
|
||||
f7b82ad1ff513a25d775c20b58e9a8ce23461ec2
|
||||
|
||||
# Pint: Exceptions
|
||||
2e7046a810ce1f7562dec9d3ee4fee0cbc7262db
|
||||
|
||||
# Pint: Non-api controllers
|
||||
9bc92f57c8a29ac0e89c2d3f72f23c6c64567dd8
|
||||
|
||||
# Pint: Api controllers
|
||||
1e5d426e70dcd72fd7e87c2b11ff42fe3cc7a1a4
|
||||
|
||||
# Pint: Middleware
|
||||
ec6caf9b5959c6c57bd7be047e91bbb70fc303a7
|
||||
|
||||
# Pint: Requests
|
||||
93168326da54fa87880570c82df3ccbf3ff152e1
|
||||
|
||||
# Pint: Traits and Transformers
|
||||
a613380811f63f51e2951d2f4b8454d5274d5cdf
|
||||
|
||||
# Pint: Importer
|
||||
3e831bf9b3cc060f11c88ec69a9313131de8ee1f
|
||||
|
||||
# Pint: Jobs and Listeners
|
||||
317b1a462e079bf96d492dd3782de38b7144be9f
|
||||
|
||||
# Pint: Livewire
|
||||
53f2ef2ca11b0571de758b101f08f259de7830cf
|
||||
|
||||
# Pint: Mail
|
||||
de607e7d83704b30f809238c44d3d759196a77db
|
||||
|
||||
# Pint: Notifications
|
||||
31043d1f5cb5d287c0ab2ca2ba1ae08665bc6ad5
|
||||
|
||||
# Pint: Observers and policies
|
||||
b2c0a21230977443536655e43e524773e2ad9e27
|
||||
|
||||
# Pint: Presenters
|
||||
55d46cbefec5fe0bb7e28b859d540977d2cfee46
|
||||
|
||||
# Pint: Providers
|
||||
8b658a19b9182bf9a19e34bc9101ee11a13ed85b
|
||||
|
||||
# Pint: Config
|
||||
c1a93e3ac890ed1fc1c27ba6c431f6b58ff661d6
|
||||
|
||||
# Pint: Lang and resources
|
||||
84fdb5d6c19bf7882cb91d42fe8768fc0db0ce67
|
||||
|
||||
# Pint: Database
|
||||
b5a46a370f85c6e87c8a9fa4a4593424bb027712
|
||||
|
||||
# Pint: Tests
|
||||
d84eb43278177a9bcdfffe04c94d933eb49f2c48
|
||||
446f5f3cefdc1837a65fd4bc983741b29f821a78
|
||||
@@ -0,0 +1,117 @@
|
||||
# GitHub Copilot Custom Instructions for Snipe-IT
|
||||
|
||||
These instructions guide Copilot to generate code that aligns with modern Laravel 11 standards, PHP 8.2/8.4 features, software engineering principles, and industry best practices to improve software quality, maintainability, and security.
|
||||
|
||||
## ✅ General Coding Standards
|
||||
|
||||
- Prefer short, expressive, and readable code.
|
||||
- Use **meaningful, descriptive variable, function, class, and file names**.
|
||||
- Apply proper PHPDoc blocks for classes, methods, and complex logic.
|
||||
- Organize code into small, reusable functions or classes with single responsibility.
|
||||
- Avoid magic numbers or hard-coded strings; use constants or config files.
|
||||
|
||||
## ✅ PHP 8.2/8.4 Best Practices
|
||||
|
||||
- Use **readonly properties** to enforce immutability where applicable.
|
||||
- Use **Enums** instead of string or integer constants.
|
||||
- Utilize **First-class callable syntax** for callbacks.
|
||||
- Leverage **Constructor Property Promotion**.
|
||||
- Use **Union Types**, **Intersection Types**, and **true/false return types** for strict typing.
|
||||
- Apply **Static Return Type** where needed.
|
||||
- Use the **Nullsafe Operator (?->)** for optional chaining.
|
||||
- Adopt **final classes** where extension is not intended.
|
||||
- Use **Named Arguments** for improved clarity when calling functions with multiple parameters.
|
||||
|
||||
## ✅ Laravel 11 Project Structure & Conventions
|
||||
|
||||
- Follow the official Laravel project structure:
|
||||
- `app/Http/Controllers` - Controllers
|
||||
- `app/Models` - Eloquent models
|
||||
- `app/Http/Requests` - Form request validation
|
||||
- `app/Http/Resources` - API resource responses
|
||||
- `app/Enums` - Enums
|
||||
- `app/Actions` - Single-responsibility action classes
|
||||
- `app/Policies` - Authorization logic
|
||||
|
||||
- Controllers must:
|
||||
- Use dependency injection.
|
||||
- Use Form Requests for validation. The request class should utilize the rules set on the model.
|
||||
- Return typed responses (e.g., `JsonResponse`).
|
||||
- Use Transformers for API responses.
|
||||
|
||||
## ✅ Eloquent ORM & Database
|
||||
|
||||
- Use **Eloquent Models** with proper `$fillable` or `$guarded` attributes for mass assignment protection.
|
||||
- Utilize **casts** for date, boolean, JSON, and custom data types.
|
||||
- Apply **accessors & mutators** for attribute transformation.
|
||||
- Avoid direct raw SQL unless absolutely necessary; prefer Eloquent or Query Builder.
|
||||
- Migrations:
|
||||
- Always use migrations for schema changes.
|
||||
- Include proper constraints (foreign keys, unique indexes, etc.).
|
||||
- Prefer UUIDs or ULIDs as primary keys where applicable.
|
||||
|
||||
## ✅ API Development
|
||||
|
||||
- Use **Transformer classes** for consistent and structured JSON responses.
|
||||
- Apply **route model binding** where possible.
|
||||
- Use Form Requests for input validation.
|
||||
|
||||
## ✅ Blade & Frontend (if applicable)
|
||||
|
||||
- Keep Blade templates clean and logic-free; use View Composers or dedicated View Models for complex data.
|
||||
- Use `@props`, `@aware`, `@once` Blade features appropriately.
|
||||
- Utilize Alpine.js or Livewire for interactive frontend logic (optional).
|
||||
|
||||
## ✅ Security Best Practices
|
||||
|
||||
- Never trust user input; always validate and sanitize inputs.
|
||||
- Use prepared statements via Eloquent or Query Builder to prevent SQL injection.
|
||||
- Use Laravel's built-in CSRF, XSS, and validation mechanisms.
|
||||
- Store sensitive information in `.env`, never hard-code secrets.
|
||||
- Apply proper authorization checks using Policies or Gates.
|
||||
- Follow principle of least privilege for users, roles, and permissions.
|
||||
|
||||
## ✅ Testing Standards
|
||||
|
||||
- Use **factories** for test data setup.
|
||||
- Include feature tests for user-facing functionality.
|
||||
- Include unit tests for business logic, services, and helper classes.
|
||||
- Mock external services using Laravel's `Http::fake()` or equivalent.
|
||||
- Maintain high code coverage but focus on meaningful tests over 100% coverage obsession.
|
||||
|
||||
## ✅ Software Quality & Maintainability
|
||||
|
||||
- Follow **SOLID Principles**:
|
||||
- Single Responsibility Principle (SRP)
|
||||
- Open/Closed Principle (OCP)
|
||||
- Liskov Substitution Principle (LSP)
|
||||
- Interface Segregation Principle (ISP)
|
||||
- Dependency Inversion Principle (DIP)
|
||||
|
||||
- Follow **DRY** (Don't Repeat Yourself) and **KISS** (Keep It Simple, Stupid) principles.
|
||||
- Apply **YAGNI** (You Aren't Gonna Need It) to avoid overengineering.
|
||||
- Document complex logic with PHPDoc and inline comments.
|
||||
|
||||
## ✅ Performance & Optimization
|
||||
|
||||
- Eager load relationships to avoid N+1 queries.
|
||||
- Use caching with Laravel's Cache system for frequently accessed data.
|
||||
- Paginate large datasets using `paginate()` instead of `get()`.
|
||||
- Queue long-running tasks using Laravel Queues.
|
||||
- Optimize database indexes for common queries.
|
||||
|
||||
## ✅ Modern Laravel Features to Use
|
||||
|
||||
- Use **Event Broadcasting** if real-time updates are needed.
|
||||
- Use **Full-text search** if search functionality is required.
|
||||
- Use **Rate Limiting** for API routes.
|
||||
|
||||
## ✅ Additional Copilot Behavior Preferences
|
||||
|
||||
- Generate **strictly typed**, modern PHP code using latest language features.
|
||||
- Prioritize **readable, clean, maintainable** code over cleverness.
|
||||
- Avoid legacy or deprecated Laravel patterns (facade overuse, logic-heavy views, etc.).
|
||||
- Suggest proper class placement based on Laravel directory structure.
|
||||
- Suggest tests alongside new features where applicable.
|
||||
- Default to **immutability**, **dependency injection**, and **encapsulation** best practices.
|
||||
No newline at end of file
|
||||
@@ -1,57 +0,0 @@
|
||||
# This workflow checks out code, performs a Codacy security scan
|
||||
# and integrates the results with the
|
||||
# GitHub Advanced Security code scanning feature. For more information on
|
||||
# the Codacy security scan action usage and parameters, see
|
||||
# https://github.com/codacy/codacy-analysis-cli-action.
|
||||
# For more information on Codacy Analysis CLI in general, see
|
||||
# https://github.com/codacy/codacy-analysis-cli.
|
||||
|
||||
name: Codacy Security Scan
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ develop ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ develop ]
|
||||
schedule:
|
||||
- cron: '36 23 * * 3'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
codacy-security-scan:
|
||||
# Ensure schedule job never runs on forked repos. It's only executed for 'grokability/snipe-it'
|
||||
permissions:
|
||||
contents: read # for actions/checkout to fetch code
|
||||
security-events: write # for github/codeql-action/upload-sarif to upload SARIF results
|
||||
if: (github.repository == 'grokability/snipe-it') || ((github.repository != 'grokability/snipe-it') && (github.event_name != 'schedule'))
|
||||
name: Codacy Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Checkout the repository to the GitHub Actions runner
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis
|
||||
- name: Run Codacy Analysis CLI
|
||||
uses: codacy/codacy-analysis-cli-action@v4.4.7
|
||||
with:
|
||||
# Check https://github.com/codacy/codacy-analysis-cli#project-token to get your project token from your Codacy repository
|
||||
# You can also omit the token and run the tools that support default configurations
|
||||
project-token: ${{ secrets.CODACY_PROJECT_TOKEN }}
|
||||
verbose: true
|
||||
output: results.sarif
|
||||
format: sarif
|
||||
# Adjust severity of non-security issues
|
||||
gh-code-scanning-compat: true
|
||||
# Force 0 exit code to allow SARIF file generation
|
||||
# This will handover control about PR rejection to the GitHub side
|
||||
max-allowed-issues: 2147483647
|
||||
|
||||
# Upload the SARIF file generated in the previous step
|
||||
- name: Upload SARIF results file
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
@@ -46,13 +46,13 @@ jobs:
|
||||
|
||||
# https://github.com/docker/setup-buildx-action
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
# https://github.com/docker/login-action
|
||||
- name: Login to DockerHub
|
||||
# Only login if not a PR, as PRs only trigger a Docker build and not a push
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
# Get Metadata for docker_build step below
|
||||
- name: Sync metadata (tags, labels) from GitHub to Docker for 'snipe-it' image
|
||||
id: meta_build
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: snipe/snipe-it
|
||||
tags: ${{ env.IMAGE_TAGS }}
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push 'snipe-it' image
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.alpine
|
||||
|
||||
@@ -46,13 +46,13 @@ jobs:
|
||||
|
||||
# https://github.com/docker/setup-buildx-action
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
# https://github.com/docker/login-action
|
||||
- name: Login to DockerHub
|
||||
# Only login if not a PR, as PRs only trigger a Docker build and not a push
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
# Get Metadata for docker_build step below
|
||||
- name: Sync metadata (tags, labels) from GitHub to Docker for 'snipe-it' image
|
||||
id: meta_build
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: snipe/snipe-it
|
||||
tags: ${{ env.IMAGE_TAGS }}
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push 'snipe-it' image
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# This workflow uses actions that are not certified by GitHub.
|
||||
# They are provided by a third-party and are governed by
|
||||
# separate terms of service, privacy policy, and support
|
||||
# documentation.
|
||||
|
||||
# EthicalCheck addresses the critical need to continuously security test APIs in development and in production.
|
||||
|
||||
# EthicalCheck provides the industry’s only free & automated API security testing service that uncovers security vulnerabilities using OWASP API list.
|
||||
# Developers relies on EthicalCheck to evaluate every update and release, ensuring that no APIs go to production with exploitable vulnerabilities.
|
||||
|
||||
# You develop the application and API, we bring complete and continuous security testing to you, accelerating development.
|
||||
|
||||
# Know your API and Applications are secure with EthicalCheck – our free & automated API security testing service.
|
||||
|
||||
# How EthicalCheck works?
|
||||
# EthicalCheck functions in the following simple steps.
|
||||
# 1. Security Testing.
|
||||
# Provide your OpenAPI specification or start with a public Postman collection URL.
|
||||
# EthicalCheck instantly instrospects your API and creates a map of API endpoints for security testing.
|
||||
# It then automatically creates hundreds of security tests that are non-intrusive to comprehensively and completely test for authentication, authorizations, and OWASP bugs your API. The tests addresses the OWASP API Security categories including OAuth 2.0, JWT, Rate Limit etc.
|
||||
|
||||
# 2. Reporting.
|
||||
# EthicalCheck generates security test report that includes all the tested endpoints, coverage graph, exceptions, and vulnerabilities.
|
||||
# Vulnerabilities are fully triaged, it contains CVSS score, severity, endpoint information, and OWASP tagging.
|
||||
|
||||
|
||||
# This is a starter workflow to help you get started with EthicalCheck Actions
|
||||
|
||||
name: EthicalCheck-Workflow
|
||||
|
||||
# Controls when the workflow will run
|
||||
on:
|
||||
# Triggers the workflow on push or pull request events but only for the "master" branch
|
||||
# Customize trigger events based on your DevSecOps processes.
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
pull_request:
|
||||
branches: [ "master" ]
|
||||
schedule:
|
||||
- cron: '35 17 * * 6'
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
Trigger_EthicalCheck:
|
||||
permissions:
|
||||
security-events: write # for github/codeql-action/upload-sarif to upload SARIF results
|
||||
actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: EthicalCheck Free & Automated API Security Testing Service
|
||||
uses: apisec-inc/ethicalcheck-action@005fac321dd843682b1af6b72f30caaf9952c641
|
||||
with:
|
||||
# The OpenAPI Specification URL or Swagger Path or Public Postman collection URL.
|
||||
oas-url: "http://netbanking.apisec.ai:8080/v2/api-docs"
|
||||
# The email address to which the penetration test report will be sent.
|
||||
email: "snipe@snipe.net"
|
||||
sarif-result-file: "ethicalcheck-results.sarif"
|
||||
|
||||
- name: Upload sarif file to repository
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: ./ethicalcheck-results.sarif
|
||||
|
||||
@@ -28,6 +28,7 @@ jobs:
|
||||
- "8.2"
|
||||
- "8.3"
|
||||
- "8.4"
|
||||
- "8.5"
|
||||
|
||||
name: PHP ${{ matrix.php-version }}
|
||||
|
||||
@@ -82,7 +83,7 @@ jobs:
|
||||
|
||||
- name: Upload Laravel logs as artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: laravel-logs-php-${{ matrix.php-version }}-run-${{ github.run_attempt }}
|
||||
path: |
|
||||
|
||||
@@ -24,6 +24,7 @@ jobs:
|
||||
- "8.2"
|
||||
- "8.3"
|
||||
- "8.4"
|
||||
- "8.5"
|
||||
|
||||
|
||||
name: PHP ${{ matrix.php-version }}
|
||||
@@ -81,7 +82,7 @@ jobs:
|
||||
|
||||
- name: Upload Laravel logs as artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: laravel-logs-php-${{ matrix.php-version }}-run-${{ github.run_attempt }}
|
||||
path: |
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php-version:
|
||||
- "8.3"
|
||||
- "8.5"
|
||||
|
||||
name: PHP ${{ matrix.php-version }}
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
|
||||
- name: Upload Laravel logs as artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: laravel-logs-php-${{ matrix.php-version }}-run-${{ github.run_attempt }}
|
||||
path: |
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"DOC1": "This file is meant to be pulled from the current HEAD of the desired branch, NOT referenced locally",
|
||||
"DOC2": "In other words, what you see locally are the requirements for your _current_ install",
|
||||
"DOC3": "Please don't rely on these versions for planning upgrades unless you've fetched the most recent version",
|
||||
"DOC4": "You should really just ignore it and run upgrade.php. Really",
|
||||
"php_min_version": "8.2.0",
|
||||
"php_max_major_minor": "8.4",
|
||||
"php_max_wontwork": "8.5.0",
|
||||
"current_snipeit_version": "8.0"
|
||||
"DOC1": "This file is meant to be pulled from the current HEAD of the desired branch, NOT referenced locally",
|
||||
"DOC2": "In other words, what you see locally are the requirements for your _current_ install",
|
||||
"DOC3": "Please don't rely on these versions for planning upgrades unless you've fetched the most recent version",
|
||||
"DOC4": "You should really just ignore it and run upgrade.php. Really",
|
||||
"php_min_version": "8.2.0",
|
||||
"php_max_major_minor": "8.5",
|
||||
"php_max_wontwork": "8.6.0",
|
||||
"current_snipeit_version": "8.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Stack
|
||||
|
||||
- **PHP 8.2+** / **Laravel 12** (framework), **Laravel Mix** (webpack) for frontend assets
|
||||
- **AdminLTE 2** / **Bootstrap 3** UI — Blade views, no Livewire/Inertia
|
||||
- **Chart.js v2.9.4** — bundled at `public/js/dist/Chart.min.js`; use `horizontalBar` type (v2 API, not v3)
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
php artisan test
|
||||
# or
|
||||
vendor/bin/phpunit
|
||||
|
||||
# Run a single test file
|
||||
php artisan test tests/Feature/Assets/AssetsTest.php
|
||||
|
||||
# Run a specific test method
|
||||
php artisan test --filter testSomeMethod
|
||||
|
||||
# Build frontend assets (dev)
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run prod
|
||||
|
||||
# Laravel Mix watch
|
||||
npm run watch
|
||||
|
||||
# Tinker / REPL
|
||||
php artisan tinker
|
||||
|
||||
# Clear caches after config/route changes
|
||||
php artisan optimize:clear
|
||||
```
|
||||
|
||||
Dev server is served via **Laravel Herd** (`herd coverage` for coverage reports).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Controllers
|
||||
|
||||
Two parallel controller trees:
|
||||
- `app/Http/Controllers/` — web/UI controllers (Blade views)
|
||||
- `app/Http/Controllers/Api/` — REST API controllers (JSON, used by datatables + select2)
|
||||
|
||||
Subdirectory groupings: `Assets/`, `Licenses/`, `Users/`, `Accessories/`, `Consumables/`, `Components/`, `Kits/`, `Account/`, `Auth/`
|
||||
|
||||
### API Pattern
|
||||
|
||||
Every API controller returns data via a **Transformer** (`app/Http/Transformers/`). Never return raw model attributes from API controllers — always pass through the transformer. `DatatablesTransformer` wraps paginated results.
|
||||
|
||||
```php
|
||||
return (new AssetsTransformer)->transformAssets($assets, $assets->count());
|
||||
```
|
||||
|
||||
### Authorization
|
||||
|
||||
All authorization goes through **Policies** (`app/Policies/`). `CheckoutablePermissionsPolicy` is the base for assets/licenses/accessories/consumables — its `checkout()` / `checkin()` methods accept `$item = null` so you can use `@can('checkout', \App\Models\Asset::class)` without an instance.
|
||||
|
||||
### FMCS (Full Multiple Company Support)
|
||||
|
||||
`Setting::getSettings()->full_multiple_companies_support == '1'` gates company-scoped filtering. The select2 API endpoints (`selectlist()` methods) accept a `companyId` query param — apply it like this:
|
||||
|
||||
```php
|
||||
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($request->filled('companyId'))) {
|
||||
$query->where('table.company_id', $request->input('companyId'));
|
||||
}
|
||||
```
|
||||
|
||||
Pass `data-company-id="{{ $user->company_id }}"` in Blade to wire it to select2.
|
||||
|
||||
### Select2 AJAX Dropdowns
|
||||
|
||||
Use `class="js-data-ajax"` with `data-endpoint="hardware|licenses|consumables|..."`. `snipeit.js` auto-initializes these, forwarding `data-company-id` as `companyId` and `data-asset-status-type` as `statusType` to the API.
|
||||
|
||||
### Routes
|
||||
|
||||
All routes are in `routes/web.php` (UI) and `routes/api.php` (API). Breadcrumbs are defined inline using `->breadcrumbs(fn (Trail $trail) => ...)` from `tabuna/breadcrumbs`. Every UI route should have a breadcrumb.
|
||||
|
||||
Note: the `reports/unaccepted_assets` route is named with slashes, not dots — use `route('reports/unaccepted_assets')`.
|
||||
|
||||
### Translations
|
||||
|
||||
String keys live in `resources/lang/en-US/general.php` (and other files in that directory). Always add new UI strings as translation keys rather than hard-coding English.
|
||||
|
||||
### Checkout Redirect Flow
|
||||
|
||||
After checkout, `Helper::getRedirectOption()` reads `$request->redirect_option`. For redirecting back to the assigned user after checkout:
|
||||
- Set `redirect_option=target` in the form
|
||||
- Set `checkout_to_type=user` in the form
|
||||
- Set `assigned_user={{ $user->id }}` in the form
|
||||
|
||||
### Key Helper Methods (`app/Helpers/Helper.php`)
|
||||
|
||||
- `Helper::deployableStatusLabelList()` — status labels for checkout forms
|
||||
- `Helper::defaultChartColors()` — 10-color palette used in charts
|
||||
- `Helper::getRedirectOption($request, $id, $table)` — post-checkout redirect logic
|
||||
|
||||
### Global View Variables
|
||||
|
||||
`$snipeSettings` is injected into all views via a service provider — no need to pass `Setting::getSettings()` from every controller. Use it directly in Blade.
|
||||
|
||||
## Testing
|
||||
|
||||
Tests live in `tests/Feature/` (organized by entity) and `tests/Unit/`. Feature tests hit the database; the test environment uses `array` cache/session/mail drivers. Tests use factories for data setup.
|
||||
+1
-1
@@ -69,7 +69,7 @@ Thanks goes to all of these wonderful people ([emoji key](https://github.com/ken
|
||||
| [<img src="https://avatars.githubusercontent.com/u/10965027?v=4" width="110px;"/><br /><sub>Ellie</sub>](https://leafedfox.xyz/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=LeafedFox "Code") | [<img src="https://avatars.githubusercontent.com/u/20960555?v=4" width="110px;"/><br /><sub>GA Stamper</sub>](https://github.com/gastamper)<br />[💻](https://github.com/snipe/snipe-it/commits?author=gastamper "Code") | [<img src="https://avatars.githubusercontent.com/u/206553556?v=4" width="110px;"/><br /><sub>Guillaume Lefranc</sub>](https://github.com/gl-pup)<br />[💻](https://github.com/snipe/snipe-it/commits?author=gl-pup "Code") | [<img src="https://avatars.githubusercontent.com/u/733892?v=4" width="110px;"/><br /><sub>Hajo Möller</sub>](https://github.com/dasjoe)<br />[💻](https://github.com/snipe/snipe-it/commits?author=dasjoe "Code") | [<img src="https://avatars.githubusercontent.com/u/3420063?v=4" width="110px;"/><br /><sub>Istvan Basa</sub>](https://github.com/pottom)<br />[💻](https://github.com/snipe/snipe-it/commits?author=pottom "Code") | [<img src="https://avatars.githubusercontent.com/u/810824?v=4" width="110px;"/><br /><sub>JJ Asghar</sub>](https://jjasghar.github.io/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jjasghar "Code") | [<img src="https://avatars.githubusercontent.com/u/40404495?v=4" width="110px;"/><br /><sub>James E. Msenga</sub>](https://github.com/JemCdo)<br />[💻](https://github.com/snipe/snipe-it/commits?author=JemCdo "Code") |
|
||||
| [<img src="https://avatars.githubusercontent.com/u/6865786?v=4" width="110px;"/><br /><sub>Jan Felix Wiebe</sub>](https://github.com/jfwiebe)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jfwiebe "Code") | [<img src="https://avatars.githubusercontent.com/u/43412008?v=4" width="110px;"/><br /><sub>Jo Drexl</sub>](https://www.nfon.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=drexljo "Code") | [<img src="https://avatars.githubusercontent.com/u/4807843?v=4" width="110px;"/><br /><sub>Austin Sasko</sub>](https://github.com/austinsasko)<br />[💻](https://github.com/snipe/snipe-it/commits?author=austinsasko "Code") | [<img src="https://avatars.githubusercontent.com/u/4875039?v=4" width="110px;"/><br /><sub>Jasson</sub>](http://jassoncordones.github.io)<br />[💻](https://github.com/snipe/snipe-it/commits?author=JassonCordones "Code") | [<img src="https://avatars.githubusercontent.com/u/76069640?v=4" width="110px;"/><br /><sub>Okean</sub>](https://github.com/Tinyblargon)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Tinyblargon "Code") | [<img src="https://avatars.githubusercontent.com/u/6515064?v=4" width="110px;"/><br /><sub>Alejandro Medrano</sub>](https://www.lst.tfo.upm.es/alejandro-medrano/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=amedranogil "Code") | [<img src="https://avatars.githubusercontent.com/u/58696401?v=4" width="110px;"/><br /><sub>Lukas Kraic</sub>](https://github.com/lukaskraic)<br />[💻](https://github.com/snipe/snipe-it/commits?author=lukaskraic "Code") |
|
||||
| [<img src="https://avatars.githubusercontent.com/u/1571724?v=4" width="110px;"/><br /><sub>Герхард PICCORO Lenz McKAY </sub>](https://github-readme-stats.vercel.app/api?username=mckaygerhard)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mckaygerhard "Code") | [<img src="https://avatars.githubusercontent.com/u/15015119?v=4" width="110px;"/><br /><sub>Johannes Pollitt</sub>](https://github.com/FlorestanII)<br />[💻](https://github.com/snipe/snipe-it/commits?author=FlorestanII "Code") | [<img src="https://avatars.githubusercontent.com/u/14185442?v=4" width="110px;"/><br /><sub>Michael Strobel</sub>](https://strobelm.de)<br />[💻](https://github.com/snipe/snipe-it/commits?author=strobelm "Code") | [<img src="https://avatars.githubusercontent.com/u/634790?v=4" width="110px;"/><br /><sub>Nicky West</sub>](http://nickwest.me)<br />[💻](https://github.com/snipe/snipe-it/commits?author=nickwest "Code") | [<img src="https://avatars.githubusercontent.com/u/1347327?v=4" width="110px;"/><br /><sub>akaspeh1</sub>](https://github.com/akaspeh1)<br />[💻](https://github.com/snipe/snipe-it/commits?author=akaspeh1 "Code") | [<img src="https://avatars.githubusercontent.com/u/2880129?v=4" width="110px;"/><br /><sub>Sebastian Marsching</sub>](http://sebastian.marsching.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=smarsching "Code") | [<img src="https://avatars.githubusercontent.com/u/40658372?v=4" width="110px;"/><br /><sub>Mo</sub>](https://github.com/mohammad-ahmadi1)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mohammad-ahmadi1 "Code") |
|
||||
| [<img src="https://avatars.githubusercontent.com/u/20994684?v=4" width="110px;"/><br /><sub>Owen V. Hayes</sub>](https://github.com/MarvelousAnything)<br />[💻](https://github.com/snipe/snipe-it/commits?author=MarvelousAnything "Code") |
|
||||
| [<img src="https://avatars.githubusercontent.com/u/20994684?v=4" width="110px;"/><br /><sub>Owen V. Hayes</sub>](https://github.com/MarvelousAnything)<br />[💻](https://github.com/snipe/snipe-it/commits?author=MarvelousAnything "Code") | [<img src="https://avatars.githubusercontent.com/u/75509373?v=4" width="110px;"/><br /><sub>Peter Gallwas</sub>](https://www.husky.nz)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Husky-Devel "Code") | [<img src="https://avatars.githubusercontent.com/u/326348?v=4" width="110px;"/><br /><sub>Sebastian Mendel</sub>](https://github.com/CybotTM)<br />[💻](https://github.com/snipe/snipe-it/commits?author=CybotTM "Code") |
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
|
||||
This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome!
|
||||
|
||||
@@ -56,6 +56,7 @@ COPY --from=mlocati/php-extension-installer:2.1.15 /usr/bin/install-php-extensio
|
||||
RUN set -eux; \
|
||||
install-php-extensions \
|
||||
bcmath \
|
||||
exif \
|
||||
gd \
|
||||
ldap \
|
||||
mysqli \
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||

|
||||
|
||||
[](https://crowdin.com/project/snipe-it) [](https://hub.docker.com/r/snipe/snipe-it/) [](https://app.codacy.com/gh/grokability/snipe-it/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [](https://github.com/grokability/snipe-it/actions/workflows/tests.yml)
|
||||
[](https://crowdin.com/project/snipe-it) [](https://hub.docker.com/r/snipe/snipe-it/) [](https://github.com/grokability/snipe-it/actions/workflows/tests-mysql.yml)
|
||||
[](#contributing) [](https://discord.gg/yZFtShAcKk)
|
||||
|
||||
## Snipe-IT - Open Source Asset Management System
|
||||
|
||||
This is a FOSS project for asset management in IT Operations. Knowing who has which laptop, when it was purchased in order to depreciate it correctly, handling software licenses, etc.
|
||||
|
||||
It is built on [Laravel 11](http://laravel.com).
|
||||
It is built on [Laravel 12](http://laravel.com).
|
||||
|
||||
Snipe-IT is actively developed and we [release quite frequently](https://github.com/grokability/snipe-it/releases). ([Check out the live demo here](https://snipeitapp.com/demo/).)
|
||||
|
||||
@@ -98,6 +98,7 @@ Since the release of the JSON REST API, several third-party developers have been
|
||||
- [InQRy (archived)](https://github.com/Microsoft/InQRy) by [@Microsoft](https://github.com/Microsoft)
|
||||
- [Marksman (archived)](https://github.com/Scope-IT/marksman) - A Windows agent for Snipe-IT
|
||||
- [Python Module (archived)](https://github.com/jbloomer/SnipeIT-PythonAPI) by [@jbloomer](https://github.com/jbloomer)
|
||||
[IT-Tools](https://github.com/chrisnox/Snipeit-it-tools) by @chrisnox - Browser bookmarklets for PDF handover/return protocols, digital signatures, label printing (Zebra ZD410), AirWatch MDM sync and Lansweeper CSV import.
|
||||
|
||||
We also have a handful of [Google Apps scripts](https://github.com/grokability/google-apps-scripts-for-snipe-it) to help with various tasks.
|
||||
|
||||
@@ -124,7 +125,7 @@ We're currently working on our own mobile app, but in the meantime, check out th
|
||||
|
||||
### Contributing
|
||||
|
||||
**Please refrain from submitting issues or pull requests generated by fully-automated tools. Maintainers reserve the right, at their sole discretion, to close such submissions and to block any account responsible for them.**
|
||||
**Please refrain from submitting issues or pull requests generated by fully-automated tools. Maintainers reserve the right, at their sole discretion, to close such submissions and to block any account responsible for them.** Please see our [AI Contribution Policy](https://snipe-it.readme.io/docs/contributing-overview#ai-usage-policy) for more information.
|
||||
|
||||
Contributions should follow from a human-to-human discussion in the form of an issue for the best chances of being merged into the core project. (Sometimes we might already be working on that feature, sometimes we've decided against )
|
||||
|
||||
|
||||
+15
-4
@@ -10,9 +10,9 @@ however there are times when library dependencies and/or PHP/MySQL dependencies
|
||||
make it impossible to backport security fixes on older versions.
|
||||
|
||||
| Version | Supported |
|
||||
|---------| ------------------ |
|
||||
|---------|--------------------|
|
||||
| 8.x | :white_check_mark: |
|
||||
| 7.x | :white_check_mark: |
|
||||
| 7.x | :x: |
|
||||
| 6.x | :x: |
|
||||
| 5.1.x | :x: |
|
||||
| 5.0.x | :x: |
|
||||
@@ -24,7 +24,18 @@ make it impossible to backport security fixes on older versions.
|
||||
Security vulnerabilities should be sent to security@snipeitapp.com. You can typically expect a
|
||||
response within two business days, and we typically have fixes out in under a week from the initial disclosure.
|
||||
|
||||
This obviously varies based on the severity of the security issue and the difficulty in remediation,
|
||||
but those have historically been the timelines we worm around.
|
||||
This obviously varies based on the severity of the security issue and the difficulty in remediation, but those have
|
||||
historically been the timelines we work around.
|
||||
|
||||
We do ask that you do not disclose the vulnerability publicly until we have had a chance to address it and tag a release
|
||||
so that we can protect our users, and we will work
|
||||
with you to coordinate a public disclosure once we have a fix out. We will also work with you to ensure that you receive
|
||||
appropriate credit for the discovery of the vulnerability, if you would like to be credited. (Please provide a GitHub
|
||||
username or other information if you would like to be credited, and please let us know if you would like to remain
|
||||
anonymous.)
|
||||
|
||||
For responsible disclosure, we ask that you give us at least __90 days__ to address the issue before disclosing it
|
||||
publicly,
|
||||
but we will work with you if you need to disclose it sooner than that.
|
||||
|
||||
For a full breakdown of our security policies, please see https://snipeitapp.com/security.
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Breadcrumbs;
|
||||
|
||||
use App\Models\Accessory;
|
||||
use App\Models\Asset;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\Consumable;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\User;
|
||||
use Tabuna\Breadcrumbs\Trail;
|
||||
|
||||
final class BuildAcceptanceBreadcrumbs
|
||||
{
|
||||
public static function forAcceptance(Trail $trail, CheckoutAcceptance|int|string $acceptance): void
|
||||
{
|
||||
$acceptance = self::resolveAcceptance($acceptance);
|
||||
$trail->parent('home');
|
||||
|
||||
if (! $acceptance instanceof CheckoutAcceptance) {
|
||||
self::appendProfileContext($trail);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! self::isSignInPlaceFlow($acceptance)) {
|
||||
self::appendProfileContext($trail);
|
||||
$trail->push(trans('general.accept_item'), route('account.accept.item', $acceptance));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
self::appendCheckoutFlowContext($trail, $acceptance);
|
||||
$trail->push(self::buildSignInPlaceLabel($acceptance));
|
||||
}
|
||||
|
||||
private static function resolveAcceptance(CheckoutAcceptance|int|string $acceptance): ?CheckoutAcceptance
|
||||
{
|
||||
if ($acceptance instanceof CheckoutAcceptance) {
|
||||
return $acceptance;
|
||||
}
|
||||
|
||||
if (is_numeric($acceptance)) {
|
||||
return CheckoutAcceptance::find((int) $acceptance);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function isSignInPlaceFlow(CheckoutAcceptance $acceptance): bool
|
||||
{
|
||||
return (int) session('sign_in_place_acceptance_id') === (int) $acceptance->id;
|
||||
}
|
||||
|
||||
private static function appendProfileContext(Trail $trail): void
|
||||
{
|
||||
$trail->push(trans('general.profile'), route('account'));
|
||||
$trail->push(trans('general.accept_items'), route('account.accept'));
|
||||
}
|
||||
|
||||
private static function appendCheckoutFlowContext(Trail $trail, CheckoutAcceptance $acceptance): void
|
||||
{
|
||||
$checkoutable = $acceptance->checkoutable;
|
||||
|
||||
if ($checkoutable instanceof Asset) {
|
||||
$trail->push(trans('general.assets'), route('hardware.index'));
|
||||
$trail->push($checkoutable->display_name ?? trans('general.asset'), route('hardware.show', $checkoutable));
|
||||
$trail->push(trans('general.checkout'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($checkoutable instanceof LicenseSeat) {
|
||||
$license = $checkoutable->license;
|
||||
|
||||
if ($license instanceof License) {
|
||||
$trail->push(trans('general.licenses'), route('licenses.index'));
|
||||
$trail->push($license->display_name ?? trans('general.license'), route('licenses.show', $license));
|
||||
$trail->push(trans('general.checkout'));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($checkoutable instanceof Consumable) {
|
||||
$trail->push(trans('general.consumables'), route('consumables.index'));
|
||||
$trail->push($checkoutable->display_name ?? trans('general.consumable'), route('consumables.show', $checkoutable));
|
||||
$trail->push(trans('general.checkout'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($checkoutable instanceof Accessory) {
|
||||
$trail->push(trans('general.accessories'), route('accessories.index'));
|
||||
$trail->push($checkoutable->display_name ?? trans('general.accessory'), route('accessories.show', $checkoutable));
|
||||
$trail->push(trans('general.checkout'));
|
||||
}
|
||||
}
|
||||
|
||||
private static function buildSignInPlaceLabel(CheckoutAcceptance $acceptance): string
|
||||
{
|
||||
if ($acceptance->assignedTo instanceof User) {
|
||||
return sprintf('%s for %s', trans('general.sign_in_place'), $acceptance->assignedTo->display_name);
|
||||
}
|
||||
|
||||
return trans('general.sign_in_place');
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ class DestroyCategoryAction
|
||||
* @throws ItemStillHasLicenses
|
||||
* @throws ItemStillHasConsumables
|
||||
*/
|
||||
static function run(Category $category): bool
|
||||
public static function run(Category $category): bool
|
||||
{
|
||||
$category->loadCount([
|
||||
'assets as assets_count',
|
||||
@@ -29,7 +29,7 @@ class DestroyCategoryAction
|
||||
'consumables as consumables_count',
|
||||
'components as components_count',
|
||||
'licenses as licenses_count',
|
||||
'models as models_count'
|
||||
'models as models_count',
|
||||
]);
|
||||
|
||||
if ($category->assets_count > 0) {
|
||||
@@ -56,4 +56,4 @@ class DestroyCategoryAction
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ class CancelCheckoutRequestAction
|
||||
{
|
||||
public static function run(Asset $asset, User $user)
|
||||
{
|
||||
if (!Company::isCurrentUserHasAccess($asset)) {
|
||||
throw new AuthorizationException();
|
||||
if (! Company::isCurrentUserHasAccess($asset)) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
$asset->cancelRequest();
|
||||
@@ -27,7 +27,7 @@ class CancelCheckoutRequestAction
|
||||
$data['item_quantity'] = 1;
|
||||
$settings = Setting::getSettings();
|
||||
|
||||
$logaction = new Actionlog();
|
||||
$logaction = new Actionlog;
|
||||
$logaction->item_id = $data['asset_id'] = $asset->id;
|
||||
$logaction->item_type = $data['item_type'] = Asset::class;
|
||||
$logaction->created_at = $data['requested_date'] = date('Y-m-d H:i:s');
|
||||
@@ -44,5 +44,4 @@ class CancelCheckoutRequestAction
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ class CreateCheckoutRequestAction
|
||||
if (is_null(Asset::RequestableAssets()->find($asset->id))) {
|
||||
throw new AssetNotRequestable($asset);
|
||||
}
|
||||
if (!Company::isCurrentUserHasAccess($asset)) {
|
||||
throw new AuthorizationException();
|
||||
if (! Company::isCurrentUserHasAccess($asset)) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
$data['item'] = $asset;
|
||||
@@ -32,7 +32,7 @@ class CreateCheckoutRequestAction
|
||||
$data['item_quantity'] = 1;
|
||||
$settings = Setting::getSettings();
|
||||
|
||||
$logaction = new Actionlog();
|
||||
$logaction = new Actionlog;
|
||||
$logaction->item_id = $data['asset_id'] = $asset->id;
|
||||
$logaction->item_type = $data['item_type'] = Asset::class;
|
||||
$logaction->created_at = $data['requested_date'] = date('Y-m-d H:i:s');
|
||||
@@ -44,11 +44,11 @@ class CreateCheckoutRequestAction
|
||||
$asset->request();
|
||||
$asset->increment('requests_counter', 1);
|
||||
try {
|
||||
$settings->notify(new RequestAssetNotification($data));
|
||||
$settings->notify((new RequestAssetNotification($data))->locale($settings->locale));
|
||||
} catch (\Exception $e) {
|
||||
Log::warning($e);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ class DeleteManufacturerAction
|
||||
* @throws ItemStillHasLicenses
|
||||
* @throws ItemStillHasConsumables
|
||||
*/
|
||||
static function run(Manufacturer $manufacturer): bool
|
||||
public static function run(Manufacturer $manufacturer): bool
|
||||
{
|
||||
$manufacturer->loadCount([
|
||||
'assets as assets_count',
|
||||
@@ -55,9 +55,8 @@ class DeleteManufacturerAction
|
||||
}
|
||||
|
||||
$manufacturer->delete();
|
||||
//dd($manufacturer);
|
||||
// dd($manufacturer);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Permissions;
|
||||
|
||||
final class NormalizePermissionsPayloadAction
|
||||
{
|
||||
/**
|
||||
* Normalize permissions payloads from request/model to a consistent associative array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function run(mixed $permissions): array
|
||||
{
|
||||
if (is_string($permissions)) {
|
||||
$decoded = json_decode($permissions, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
if (is_array($permissions)) {
|
||||
return $permissions;
|
||||
}
|
||||
|
||||
if ($permissions instanceof \stdClass) {
|
||||
return (array) $permissions;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Permissions;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
final class PreserveUnauthorizedPrivilegedPermissionsAction
|
||||
{
|
||||
/**
|
||||
* Preserve privileged permission keys unless the authenticated user may manage them.
|
||||
*
|
||||
* @param array<string, mixed> $requestedPermissions
|
||||
* @param array<string, mixed> $originalPermissions
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function run(array $requestedPermissions, User $authenticatedUser, array $originalPermissions = [], ?User $targetUser = null): array
|
||||
{
|
||||
// Disallow non-admin/superuser users from modifying their own permissions, but allow them to modify other users' permissions (except for admin/superuser keys).
|
||||
if ($targetUser && ! $authenticatedUser->isSuperUser() && $authenticatedUser->id === $targetUser->id) {
|
||||
return $originalPermissions;
|
||||
}
|
||||
|
||||
if (! $authenticatedUser->isSuperUser()) {
|
||||
if (array_key_exists('superuser', $originalPermissions)) {
|
||||
$requestedPermissions['superuser'] = $originalPermissions['superuser'];
|
||||
} else {
|
||||
unset($requestedPermissions['superuser']);
|
||||
}
|
||||
}
|
||||
|
||||
if ((! $authenticatedUser->isAdmin()) && (! $authenticatedUser->isSuperUser())) {
|
||||
if (array_key_exists('admin', $originalPermissions)) {
|
||||
$requestedPermissions['admin'] = $originalPermissions['admin'];
|
||||
} else {
|
||||
unset($requestedPermissions['admin']);
|
||||
}
|
||||
}
|
||||
|
||||
return $requestedPermissions;
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,18 @@
|
||||
namespace App\Actions\Suppliers;
|
||||
|
||||
use App\Exceptions\ItemStillHasAccessories;
|
||||
use App\Exceptions\ItemStillHasAssets;
|
||||
use App\Exceptions\ItemStillHasComponents;
|
||||
use App\Exceptions\ItemStillHasConsumables;
|
||||
use App\Models\Supplier;
|
||||
use App\Exceptions\ItemStillHasAssets;
|
||||
use App\Exceptions\ItemStillHasMaintenances;
|
||||
use App\Exceptions\ItemStillHasLicenses;
|
||||
use App\Exceptions\ItemStillHasMaintenances;
|
||||
use App\Models\Supplier;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class DestroySupplierAction
|
||||
{
|
||||
/**
|
||||
*
|
||||
* @throws ItemStillHasLicenses
|
||||
* @throws ItemStillHasAssets
|
||||
* @throws ItemStillHasMaintenances
|
||||
@@ -23,7 +22,7 @@ class DestroySupplierAction
|
||||
* @throws ItemStillHasConsumables
|
||||
* @throws ItemStillHasComponents
|
||||
*/
|
||||
static function run(Supplier $supplier): bool
|
||||
public static function run(Supplier $supplier): bool
|
||||
{
|
||||
$supplier->loadCount([
|
||||
'maintenances as maintenances_count',
|
||||
|
||||
@@ -0,0 +1,989 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Events\CheckoutableCheckedIn;
|
||||
use App\Mail\BulkDeleteReportMail;
|
||||
use App\Models\Accessory;
|
||||
use App\Models\AccessoryCheckout;
|
||||
use App\Models\Actionlog;
|
||||
use App\Models\Asset;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\Company;
|
||||
use App\Models\Component;
|
||||
use App\Models\Consumable;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
|
||||
use function Laravel\Prompts\confirm;
|
||||
use function Laravel\Prompts\error;
|
||||
use function Laravel\Prompts\info;
|
||||
use function Laravel\Prompts\multisearch;
|
||||
use function Laravel\Prompts\multiselect;
|
||||
use function Laravel\Prompts\search;
|
||||
use function Laravel\Prompts\select;
|
||||
use function Laravel\Prompts\warning;
|
||||
|
||||
class BulkDelete extends Command
|
||||
{
|
||||
protected $signature = 'snipeit:checkin-delete-items';
|
||||
|
||||
protected $description = 'Interactively check in and/or delete items by company and type';
|
||||
|
||||
private const CHECKIN_NOTE = 'Checked in via bulk CLI operation';
|
||||
|
||||
private array $reportLines = [];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
// Step 1: Dry run?
|
||||
$dryRun = confirm(
|
||||
label: 'Is this a dry run?',
|
||||
default: true,
|
||||
yes: 'Yes — preview only, no changes will be made',
|
||||
no: 'No — LIVE RUN, changes WILL be made',
|
||||
);
|
||||
|
||||
// Step 2: Who are you?
|
||||
$adminId = search(
|
||||
label: 'Who are you? Search by username, first or last name.',
|
||||
placeholder: 'Type to search users...',
|
||||
options: function (string $value): array {
|
||||
if (strlen($value) < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return User::where('activated', 1)
|
||||
->whereNull('deleted_at')
|
||||
->onlySuperAdmins()
|
||||
->where(function ($query) use ($value) {
|
||||
$query->where('username', 'like', "%{$value}%")
|
||||
->orWhere('first_name', 'like', "%{$value}%")
|
||||
->orWhere('last_name', 'like', "%{$value}%")
|
||||
->orWhereRaw("CONCAT(first_name, ' ', last_name) LIKE ?", ["%{$value}%"]);
|
||||
})
|
||||
->get()
|
||||
->mapWithKeys(fn (User $u) => [$u->id => "{$u->first_name} {$u->last_name} ({$u->username})"])
|
||||
->toArray();
|
||||
},
|
||||
validate: fn (mixed $value) => ! $value ? 'A valid active user is required.' : null,
|
||||
);
|
||||
|
||||
/** @var User $admin */
|
||||
$admin = User::findOrFail((int) $adminId);
|
||||
|
||||
// Step 3: Which companies?
|
||||
if (! Company::exists()) {
|
||||
error('No companies found. Please create at least one company before using this command.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$selectedCompanyKeys = multisearch(
|
||||
label: 'Which companies would you like to check in and delete items for?',
|
||||
placeholder: 'Type to search companies...',
|
||||
options: function (string $value): array {
|
||||
$results = [];
|
||||
|
||||
if ($value === '' || str_contains('(no company / unassigned)', strtolower($value))) {
|
||||
$results['__null__'] = '(No Company / Unassigned)';
|
||||
}
|
||||
|
||||
$query = Company::orderBy('name');
|
||||
if ($value !== '') {
|
||||
$query->where('name', 'like', "%{$value}%");
|
||||
}
|
||||
|
||||
$query->get()->each(function (Company $c) use (&$results) {
|
||||
$results[$c->id] = "{$c->name} (ID: {$c->id})";
|
||||
});
|
||||
|
||||
return $results;
|
||||
},
|
||||
scroll: 10,
|
||||
required: 'Please select at least one company.',
|
||||
hint: 'If you\'re searching on several differently named companies, use the up-arrow to go back to the search box to search again. ',
|
||||
);
|
||||
|
||||
$includeNullCompany = in_array('__null__', $selectedCompanyKeys);
|
||||
$selectedCompanyIds = array_values(array_filter(
|
||||
$selectedCompanyKeys,
|
||||
fn ($k) => $k !== '__null__'
|
||||
));
|
||||
|
||||
$companyNamesById = Company::whereIn('id', $selectedCompanyIds)->pluck('name', 'id')->toArray();
|
||||
$selectedCompanyNames = array_map(
|
||||
fn ($id) => $id === '__null__' ? '(No Company)' : ($companyNamesById[$id] ?? "(ID: {$id})"),
|
||||
$selectedCompanyKeys
|
||||
);
|
||||
|
||||
// Step 4: Which item types?
|
||||
$rawTypeSelection = multiselect(
|
||||
label: 'What item types would you like to check in and delete?',
|
||||
options: [
|
||||
'all' => 'All Items (assets, licenses, accessories, components, consumables, users)',
|
||||
'assets' => 'Assets',
|
||||
'licenses' => 'Licenses',
|
||||
'accessories' => 'Accessories',
|
||||
'components' => 'Components',
|
||||
'consumables' => 'Consumables',
|
||||
'users' => 'Users',
|
||||
],
|
||||
required: 'Please select at least one item type.',
|
||||
hint: 'Select "All Items" to process every supported type.',
|
||||
);
|
||||
|
||||
$allSubTypes = ['assets', 'licenses', 'accessories', 'components', 'consumables', 'users'];
|
||||
$selectedTypes = in_array('all', $rawTypeSelection)
|
||||
? $allSubTypes
|
||||
: array_values(array_intersect($allSubTypes, $rawTypeSelection));
|
||||
|
||||
// Compute and display counts now so the user can see what will be affected
|
||||
$counts = $this->getCounts($selectedTypes, $selectedCompanyIds, $includeNullCompany);
|
||||
|
||||
$skipAdminUser = false;
|
||||
|
||||
$this->line('');
|
||||
$this->line(' Items that would be affected:');
|
||||
foreach ($counts as $type => $count) {
|
||||
$this->line(sprintf(' %-14s %d', ucfirst($type).':', $count));
|
||||
}
|
||||
|
||||
if (in_array('users', $selectedTypes)) {
|
||||
$userInScope = $this->buildUserQuery($selectedCompanyIds, $includeNullCompany)
|
||||
->where('users.id', $admin->id)
|
||||
->exists();
|
||||
|
||||
if ($userInScope) {
|
||||
$skipAdminUser = true;
|
||||
$counts['users'] = max(0, ($counts['users'] ?? 0) - 1);
|
||||
warning(" Your user ({$admin->username}) is within the selected scope and will be skipped during user deletion.");
|
||||
}
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
|
||||
// Step 5: Hard delete, soft delete, or no delete?
|
||||
$deleteType = select(
|
||||
label: 'How should items be deleted?',
|
||||
options: [
|
||||
'soft' => 'Soft delete — items moved to trash (recoverable)',
|
||||
'hard' => 'Hard delete — permanently removed (cannot be recovered)',
|
||||
'none' => 'No delete — check in only, items remain in inventory',
|
||||
],
|
||||
default: 'soft',
|
||||
);
|
||||
|
||||
// Step 6: Send checkin notifications? (not applicable to users or consumables)
|
||||
$notifiableTypes = array_intersect($selectedTypes, ['assets', 'licenses', 'accessories', 'components']);
|
||||
$sendNotifications = false;
|
||||
|
||||
if (! empty($notifiableTypes)) {
|
||||
$sendNotifications = confirm(
|
||||
label: 'Should we send checkin notifications?',
|
||||
default: true,
|
||||
hint: 'Applies to: '.implode(', ', $notifiableTypes).'. Users and consumables are excluded.',
|
||||
);
|
||||
}
|
||||
|
||||
// Step 7: Clear related action_logs?
|
||||
$clearLogs = confirm(
|
||||
label: 'Should we clear related action logs?',
|
||||
default: false,
|
||||
hint: 'This removes all history for affected items, as if the data never existed.',
|
||||
);
|
||||
|
||||
// Step 8: Delete associated files?
|
||||
$deleteFiles = false;
|
||||
if ($deleteType !== 'none') {
|
||||
$deleteFiles = confirm(
|
||||
label: 'Should we also delete associated image and upload files?',
|
||||
default: $deleteType === 'hard',
|
||||
hint: 'Permanently removes images, avatars, signatures, EULAs, and action log uploads from disk.',
|
||||
);
|
||||
}
|
||||
|
||||
// Step 9: Delete the companies themselves?
|
||||
$deleteCompanyType = 'keep';
|
||||
if (! empty($selectedCompanyIds)) {
|
||||
$deleteCompanyType = select(
|
||||
label: 'Should the selected companies also be deleted?',
|
||||
options: [
|
||||
'keep' => 'Keep — do not delete the companies',
|
||||
'soft' => 'Soft delete — companies moved to trash (recoverable)',
|
||||
'hard' => 'Hard delete — permanently removed (cannot be recovered)',
|
||||
],
|
||||
default: 'keep',
|
||||
);
|
||||
}
|
||||
|
||||
// Step 10: Backup first?
|
||||
$doBackup = confirm(
|
||||
label: 'Should we run a backup before proceeding?',
|
||||
default: true,
|
||||
hint: 'Strongly recommended. Saved as backup-before-bulk-delete-cli-[datetime].zip',
|
||||
);
|
||||
|
||||
// Step 11: Summary + final confirmation
|
||||
$this->line('');
|
||||
$this->line(' ════════════════════════════════════════════════════');
|
||||
$this->line(' SUMMARY OF ACTIONS');
|
||||
$this->line(' ════════════════════════════════════════════════════');
|
||||
$this->line(" Admin user: {$admin->first_name} {$admin->last_name} ({$admin->username})");
|
||||
$this->line(' Companies: '.implode(', ', $selectedCompanyNames));
|
||||
$this->line(' Item types: '.implode(', ', $selectedTypes));
|
||||
$this->line(" Delete mode: {$deleteType}");
|
||||
$this->line(' Notifications: '.($sendNotifications ? 'Yes' : 'No'));
|
||||
$this->line(' Clear logs: '.($clearLogs ? 'Yes' : 'No'));
|
||||
$this->line(' Delete files: '.($deleteFiles ? 'Yes' : 'No'));
|
||||
$this->line(' Delete companies: '.($deleteCompanyType === 'keep' ? 'No' : ucfirst($deleteCompanyType).' delete'));
|
||||
$this->line(' Backup first: '.($doBackup ? 'Yes' : 'No'));
|
||||
$this->line(' Dry run: '.($dryRun ? 'Yes' : 'No'));
|
||||
$this->line('');
|
||||
$this->line(' Items to be processed:');
|
||||
foreach ($counts as $type => $count) {
|
||||
$this->line(sprintf(' %-14s %d', ucfirst($type).':', $count));
|
||||
}
|
||||
if ($skipAdminUser) {
|
||||
$this->line(' * Your user account will be skipped during user deletion.');
|
||||
}
|
||||
$this->line(' ════════════════════════════════════════════════════');
|
||||
$this->line('');
|
||||
|
||||
// Step 10.5: Email report?
|
||||
$sendEmailReport = false;
|
||||
if ($admin->email) {
|
||||
$sendEmailReport = confirm(
|
||||
label: "Send an email report to {$admin->email}?",
|
||||
default: false,
|
||||
hint: 'A summary of all '.($dryRun ? 'would-be ' : '').'actions will be emailed to you.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$confirmed = confirm(
|
||||
label: 'Are you sure you want to proceed? This cannot be undone.',
|
||||
default: false,
|
||||
);
|
||||
|
||||
if (! $confirmed) {
|
||||
info('Aborted. No changes were made.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Run backup if requested
|
||||
if ($doBackup && ! $dryRun) {
|
||||
$backupFilename = 'backup-before-bulk-delete-cli-'.now()->format('Y-m-d-H-i-s');
|
||||
info("Running backup ({$backupFilename}.zip)...");
|
||||
$result = $this->callSilently('snipeit:backup', ['--filename' => $backupFilename]);
|
||||
if ($result === 0) {
|
||||
info("Backup completed: {$backupFilename}.zip");
|
||||
} else {
|
||||
warning("Backup may have failed (exit code {$result}). Proceeding anyway.");
|
||||
}
|
||||
}
|
||||
|
||||
// Step 11: Execute with progress bar
|
||||
$totalItems = array_sum($counts);
|
||||
$bar = $this->output->createProgressBar($totalItems > 0 ? $totalItems : 1);
|
||||
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %message%');
|
||||
$bar->setMessage('Starting...');
|
||||
$bar->start();
|
||||
|
||||
foreach ($selectedTypes as $type) {
|
||||
match ($type) {
|
||||
'assets' => $this->processAssets($selectedCompanyIds, $includeNullCompany, $sendNotifications, $admin, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
|
||||
'licenses' => $this->processLicenses($selectedCompanyIds, $includeNullCompany, $sendNotifications, $admin, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
|
||||
'accessories' => $this->processAccessories($selectedCompanyIds, $includeNullCompany, $sendNotifications, $admin, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
|
||||
'components' => $this->processComponents($selectedCompanyIds, $includeNullCompany, $sendNotifications, $admin, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
|
||||
'consumables' => $this->processConsumables($selectedCompanyIds, $includeNullCompany, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
|
||||
'users' => $this->processUsers($selectedCompanyIds, $includeNullCompany, $admin, $skipAdminUser, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
|
||||
};
|
||||
}
|
||||
|
||||
$bar->setMessage('Done.');
|
||||
$bar->finish();
|
||||
$this->line('');
|
||||
$this->line('');
|
||||
|
||||
// Delete companies if requested
|
||||
if ($deleteCompanyType !== 'keep' && ! empty($selectedCompanyIds)) {
|
||||
$companies = Company::whereIn('id', $selectedCompanyIds)->get();
|
||||
foreach ($companies as $company) {
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would {$deleteCompanyType}-delete company {$company->name}");
|
||||
$this->reportLines[] = "Would {$deleteCompanyType}-delete company {$company->name}";
|
||||
} else {
|
||||
if ($deleteCompanyType === 'soft') {
|
||||
$company->delete();
|
||||
} else {
|
||||
$company->forceDelete();
|
||||
}
|
||||
// Remove any remaining pivot associations (e.g. the admin user who was
|
||||
// skipped during user processing but is still a member of this company)
|
||||
DB::table('company_user')->where('company_id', $company->id)->delete();
|
||||
$this->reportLines[] = ucfirst($deleteCompanyType)."-deleted company {$company->name}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
warning('Dry run complete — no changes were made.');
|
||||
} else {
|
||||
info('All actions completed successfully.');
|
||||
}
|
||||
|
||||
if ($sendEmailReport && $admin->email) {
|
||||
Mail::to($admin->email)->send(new BulkDeleteReportMail(
|
||||
admin: $admin,
|
||||
dryRun: $dryRun,
|
||||
companyNames: $selectedCompanyNames,
|
||||
selectedTypes: $selectedTypes,
|
||||
deleteType: $deleteType,
|
||||
reportLines: $this->reportLines,
|
||||
runAt: now(),
|
||||
));
|
||||
info("Report sent to {$admin->email}.");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function getCounts(array $types, array $companyIds, bool $includeNull): array
|
||||
{
|
||||
$counts = [];
|
||||
|
||||
if (in_array('assets', $types)) {
|
||||
$counts['assets'] = $this->buildCompanyQuery(Asset::query(), $companyIds, $includeNull)->count();
|
||||
}
|
||||
if (in_array('licenses', $types)) {
|
||||
$counts['licenses'] = $this->buildCompanyQuery(License::query(), $companyIds, $includeNull)->count();
|
||||
}
|
||||
if (in_array('accessories', $types)) {
|
||||
$counts['accessories'] = $this->buildCompanyQuery(Accessory::query(), $companyIds, $includeNull)->count();
|
||||
}
|
||||
if (in_array('components', $types)) {
|
||||
$counts['components'] = $this->buildCompanyQuery(Component::query(), $companyIds, $includeNull)->count();
|
||||
}
|
||||
if (in_array('consumables', $types)) {
|
||||
$counts['consumables'] = $this->buildCompanyQuery(Consumable::query(), $companyIds, $includeNull)->count();
|
||||
}
|
||||
if (in_array('users', $types)) {
|
||||
$counts['users'] = $this->buildUserQuery($companyIds, $includeNull)->count();
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
private function buildCompanyQuery(Builder $query, array $companyIds, bool $includeNull): Builder
|
||||
{
|
||||
return $query->where(function (Builder $q) use ($companyIds, $includeNull) {
|
||||
if (! empty($companyIds)) {
|
||||
$q->whereIn('company_id', $companyIds);
|
||||
}
|
||||
if ($includeNull) {
|
||||
$method = ! empty($companyIds) ? 'orWhereNull' : 'whereNull';
|
||||
$q->{$method}('company_id');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function buildUserQuery(array $companyIds, bool $includeNull): Builder
|
||||
{
|
||||
return User::query()
|
||||
->where('activated', 1)
|
||||
->where(function (Builder $q) use ($companyIds, $includeNull) {
|
||||
if (! empty($companyIds)) {
|
||||
$q->whereIn('company_id', $companyIds);
|
||||
}
|
||||
if ($includeNull) {
|
||||
$method = ! empty($companyIds) ? 'orWhereNull' : 'whereNull';
|
||||
$q->{$method}('company_id');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function processAssets(
|
||||
array $companyIds,
|
||||
bool $includeNull,
|
||||
bool $sendNotifications,
|
||||
User $admin,
|
||||
bool $dryRun,
|
||||
string $deleteType,
|
||||
bool $clearLogs,
|
||||
bool $deleteFiles,
|
||||
ProgressBar $bar,
|
||||
): void {
|
||||
$assets = $this->buildCompanyQuery(Asset::query(), $companyIds, $includeNull)->get();
|
||||
|
||||
foreach ($assets as $asset) {
|
||||
$bar->setMessage("Assets: {$asset->asset_tag}");
|
||||
|
||||
if ($asset->assignedTo) {
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would check in asset {$asset->asset_tag} from {$asset->assignedTo->name}");
|
||||
$this->reportLines[] = "Would check in asset {$asset->asset_tag} (assigned to {$asset->assignedTo->name})";
|
||||
} else {
|
||||
$target = $asset->assignedTo;
|
||||
$checkinAt = now()->format('Y-m-d H:i:s');
|
||||
$originalValues = $asset->getRawOriginal();
|
||||
|
||||
if ($sendNotifications) {
|
||||
event(new CheckoutableCheckedIn($asset, $target, $admin, self::CHECKIN_NOTE, $checkinAt, $originalValues));
|
||||
DB::table('assets')->where('id', $asset->id)->update(['assigned_to' => null, 'assigned_type' => null]);
|
||||
} else {
|
||||
DB::table('assets')->where('id', $asset->id)->update(['assigned_to' => null, 'assigned_type' => null]);
|
||||
$asset->logCheckin($target, self::CHECKIN_NOTE, $checkinAt, $originalValues);
|
||||
}
|
||||
|
||||
$this->reportLines[] = "Checked in asset {$asset->asset_tag} from {$target->name}";
|
||||
$asset->licenseseats()->update(['assigned_to' => null]);
|
||||
|
||||
CheckoutAcceptance::where('checkoutable_type', Asset::class)
|
||||
->where('checkoutable_id', $asset->id)
|
||||
->whereNull('accepted_at')
|
||||
->whereNull('declined_at')
|
||||
->forceDelete();
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
// Collect action log file paths before logs may be cleared
|
||||
$actionLogPaths = $deleteFiles
|
||||
? $asset->assetlog()->whereNotNull('filename')->get()
|
||||
->map(fn (Actionlog $log) => $log->uploads_file_path())
|
||||
->filter()
|
||||
->values()
|
||||
->toArray()
|
||||
: [];
|
||||
|
||||
// Delete checkout acceptance files, then hard-remove all acceptances
|
||||
if ($deleteFiles) {
|
||||
CheckoutAcceptance::where('checkoutable_type', Asset::class)
|
||||
->where('checkoutable_id', $asset->id)
|
||||
->get()
|
||||
->each(fn (CheckoutAcceptance $ca) => $this->deleteAcceptanceFiles($ca));
|
||||
}
|
||||
CheckoutAcceptance::where('checkoutable_type', Asset::class)
|
||||
->where('checkoutable_id', $asset->id)
|
||||
->forceDelete();
|
||||
|
||||
// Hard-delete-only cleanup: maintenance records, accessory checkouts to this
|
||||
// asset, and any other assets that were assigned to this one
|
||||
$maintenanceImages = [];
|
||||
if ($deleteType === 'hard') {
|
||||
if ($deleteFiles) {
|
||||
$maintenanceImages = $asset->maintenances()
|
||||
->whereNotNull('image')
|
||||
->pluck('image')
|
||||
->toArray();
|
||||
}
|
||||
$asset->maintenances()->forceDelete();
|
||||
AccessoryCheckout::where('assigned_to', $asset->id)
|
||||
->where('assigned_type', Asset::class)
|
||||
->delete();
|
||||
DB::table('assets')
|
||||
->where('assigned_to', $asset->id)
|
||||
->where('assigned_type', Asset::class)
|
||||
->update(['assigned_to' => null, 'assigned_type' => null]);
|
||||
}
|
||||
|
||||
match ($deleteType) {
|
||||
'soft' => $asset->delete(),
|
||||
'hard' => $asset->forceDelete(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($deleteType !== 'none') {
|
||||
$this->reportLines[] = ucfirst($deleteType)."-deleted asset {$asset->asset_tag}";
|
||||
}
|
||||
|
||||
if ($clearLogs) {
|
||||
$asset->assetlog()->forceDelete();
|
||||
}
|
||||
|
||||
if ($deleteFiles) {
|
||||
if ($asset->image) {
|
||||
$this->deleteStorageFile('public', app('assets_upload_path').$asset->image);
|
||||
}
|
||||
foreach ($maintenanceImages as $img) {
|
||||
$this->deleteStorageFile('public', app('maintenances_upload_path').$img);
|
||||
}
|
||||
foreach ($actionLogPaths as $path) {
|
||||
$this->deleteStorageFile('local', $path);
|
||||
}
|
||||
}
|
||||
} elseif ($deleteType !== 'none') {
|
||||
$this->line(" [dry-run] Would {$deleteType}-delete asset {$asset->asset_tag}");
|
||||
$this->reportLines[] = "Would {$deleteType}-delete asset {$asset->asset_tag}";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
}
|
||||
|
||||
private function processLicenses(
|
||||
array $companyIds,
|
||||
bool $includeNull,
|
||||
bool $sendNotifications,
|
||||
User $admin,
|
||||
bool $dryRun,
|
||||
string $deleteType,
|
||||
bool $clearLogs,
|
||||
bool $deleteFiles,
|
||||
ProgressBar $bar,
|
||||
): void {
|
||||
$licenses = $this->buildCompanyQuery(License::query(), $companyIds, $includeNull)->get();
|
||||
|
||||
foreach ($licenses as $license) {
|
||||
$bar->setMessage("Licenses: {$license->name}");
|
||||
|
||||
$seats = LicenseSeat::where('license_id', $license->id)
|
||||
->where(fn ($q) => $q->whereNotNull('assigned_to')->orWhereNotNull('asset_id'))
|
||||
->get();
|
||||
|
||||
foreach ($seats as $seat) {
|
||||
$target = $seat->assigned_to ? $seat->user : $seat->asset;
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would check in license seat for {$license->name} from ".($target?->name ?? $target?->asset_tag ?? 'unknown'));
|
||||
$this->reportLines[] = "Would check in license seat for {$license->name} from ".($target?->name ?? $target?->asset_tag ?? 'unknown');
|
||||
} else {
|
||||
$seat->assigned_to = null;
|
||||
$seat->asset_id = null;
|
||||
$seat->save();
|
||||
|
||||
$this->reportLines[] = "Checked in license seat for {$license->name} from ".($target?->name ?? $target?->asset_tag ?? 'unknown');
|
||||
|
||||
if ($target) {
|
||||
if ($sendNotifications) {
|
||||
event(new CheckoutableCheckedIn($seat, $target, $admin, self::CHECKIN_NOTE));
|
||||
} else {
|
||||
$seat->logCheckin($target, self::CHECKIN_NOTE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
// Collect action log file paths before logs may be cleared
|
||||
$actionLogPaths = $deleteFiles
|
||||
? $license->assetlog()->whereNotNull('filename')->get()
|
||||
->map(fn (Actionlog $log) => $log->uploads_file_path())
|
||||
->filter()
|
||||
->values()
|
||||
->toArray()
|
||||
: [];
|
||||
|
||||
if ($deleteType === 'soft') {
|
||||
$license->licenseseats()->delete();
|
||||
$license->delete();
|
||||
$this->reportLines[] = "Soft-deleted license {$license->name}";
|
||||
} elseif ($deleteType === 'hard') {
|
||||
$seatIds = $license->licenseseats()->pluck('id');
|
||||
if ($deleteFiles) {
|
||||
CheckoutAcceptance::where('checkoutable_type', LicenseSeat::class)
|
||||
->whereIn('checkoutable_id', $seatIds)
|
||||
->get()
|
||||
->each(fn (CheckoutAcceptance $ca) => $this->deleteAcceptanceFiles($ca));
|
||||
}
|
||||
CheckoutAcceptance::where('checkoutable_type', LicenseSeat::class)
|
||||
->whereIn('checkoutable_id', $seatIds)
|
||||
->forceDelete();
|
||||
$license->licenseseats()->forceDelete();
|
||||
DB::table('kits_licenses')->where('license_id', $license->id)->delete();
|
||||
$license->forceDelete();
|
||||
$this->reportLines[] = "Hard-deleted license {$license->name}";
|
||||
}
|
||||
|
||||
if ($clearLogs) {
|
||||
$license->assetlog()->forceDelete();
|
||||
}
|
||||
|
||||
if ($deleteFiles) {
|
||||
foreach ($actionLogPaths as $path) {
|
||||
$this->deleteStorageFile('local', $path);
|
||||
}
|
||||
}
|
||||
} elseif ($deleteType !== 'none') {
|
||||
$this->line(" [dry-run] Would {$deleteType}-delete license {$license->name}");
|
||||
$this->reportLines[] = "Would {$deleteType}-delete license {$license->name}";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
}
|
||||
|
||||
private function processAccessories(
|
||||
array $companyIds,
|
||||
bool $includeNull,
|
||||
bool $sendNotifications,
|
||||
User $admin,
|
||||
bool $dryRun,
|
||||
string $deleteType,
|
||||
bool $clearLogs,
|
||||
bool $deleteFiles,
|
||||
ProgressBar $bar,
|
||||
): void {
|
||||
$accessories = $this->buildCompanyQuery(Accessory::query(), $companyIds, $includeNull)->get();
|
||||
|
||||
foreach ($accessories as $accessory) {
|
||||
$bar->setMessage("Accessories: {$accessory->name}");
|
||||
|
||||
$checkouts = AccessoryCheckout::where('accessory_id', $accessory->id)->get();
|
||||
|
||||
foreach ($checkouts as $checkout) {
|
||||
$target = $checkout->assignedTo;
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would check in accessory {$accessory->name} from ".($target?->name ?? 'unknown'));
|
||||
$this->reportLines[] = "Would check in accessory {$accessory->name} from ".($target?->name ?? 'unknown');
|
||||
} else {
|
||||
$checkinAt = now()->format('Y-m-d H:i:s');
|
||||
$checkout->delete();
|
||||
|
||||
$this->reportLines[] = "Checked in accessory {$accessory->name} from ".($target?->name ?? 'unknown');
|
||||
|
||||
if ($target) {
|
||||
if ($sendNotifications) {
|
||||
event(new CheckoutableCheckedIn($accessory, $target, $admin, self::CHECKIN_NOTE, $checkinAt));
|
||||
} else {
|
||||
$accessory->logCheckin($target, self::CHECKIN_NOTE, $checkinAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
// Collect action log file paths before logs may be cleared
|
||||
$actionLogPaths = $deleteFiles
|
||||
? $accessory->assetlog()->whereNotNull('filename')->get()
|
||||
->map(fn (Actionlog $log) => $log->uploads_file_path())
|
||||
->filter()
|
||||
->values()
|
||||
->toArray()
|
||||
: [];
|
||||
|
||||
if ($clearLogs) {
|
||||
$accessory->assetlog()->forceDelete();
|
||||
}
|
||||
|
||||
if ($deleteType === 'hard') {
|
||||
DB::table('kits_accessories')->where('accessory_id', $accessory->id)->delete();
|
||||
}
|
||||
|
||||
match ($deleteType) {
|
||||
'soft' => $accessory->delete(),
|
||||
'hard' => $accessory->forceDelete(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($deleteType !== 'none') {
|
||||
$this->reportLines[] = ucfirst($deleteType)."-deleted accessory {$accessory->name}";
|
||||
}
|
||||
|
||||
if ($deleteFiles) {
|
||||
if ($accessory->image) {
|
||||
$this->deleteStorageFile('public', app('accessories_upload_path').$accessory->image);
|
||||
}
|
||||
foreach ($actionLogPaths as $path) {
|
||||
$this->deleteStorageFile('local', $path);
|
||||
}
|
||||
}
|
||||
} elseif ($deleteType !== 'none') {
|
||||
$this->line(" [dry-run] Would {$deleteType}-delete accessory {$accessory->name}");
|
||||
$this->reportLines[] = "Would {$deleteType}-delete accessory {$accessory->name}";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
}
|
||||
|
||||
private function processComponents(
|
||||
array $companyIds,
|
||||
bool $includeNull,
|
||||
bool $sendNotifications,
|
||||
User $admin,
|
||||
bool $dryRun,
|
||||
string $deleteType,
|
||||
bool $clearLogs,
|
||||
bool $deleteFiles,
|
||||
ProgressBar $bar,
|
||||
): void {
|
||||
$components = $this->buildCompanyQuery(Component::query(), $companyIds, $includeNull)->get();
|
||||
|
||||
foreach ($components as $component) {
|
||||
$bar->setMessage("Components: {$component->name}");
|
||||
|
||||
$assignments = DB::table('components_assets')
|
||||
->where('component_id', $component->id)
|
||||
->get();
|
||||
|
||||
foreach ($assignments as $assignment) {
|
||||
$asset = Asset::find($assignment->asset_id);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would check in component {$component->name} from asset ".($asset?->asset_tag ?? 'unknown'));
|
||||
$this->reportLines[] = "Would check in component {$component->name} from asset ".($asset?->asset_tag ?? 'unknown');
|
||||
} else {
|
||||
$checkinAt = now()->format('Y-m-d H:i:s');
|
||||
DB::table('components_assets')->where('id', $assignment->id)->delete();
|
||||
|
||||
$this->reportLines[] = "Checked in component {$component->name} from asset ".($asset?->asset_tag ?? 'unknown');
|
||||
|
||||
if ($asset) {
|
||||
if ($sendNotifications) {
|
||||
event(new CheckoutableCheckedIn($component, $asset, $admin, self::CHECKIN_NOTE, $checkinAt));
|
||||
} else {
|
||||
$component->logCheckin($asset, self::CHECKIN_NOTE, $checkinAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
// Collect action log file paths before logs may be cleared
|
||||
$actionLogPaths = $deleteFiles
|
||||
? $component->assetlog()->whereNotNull('filename')->get()
|
||||
->map(fn (Actionlog $log) => $log->uploads_file_path())
|
||||
->filter()
|
||||
->values()
|
||||
->toArray()
|
||||
: [];
|
||||
|
||||
if ($clearLogs) {
|
||||
$component->assetlog()->forceDelete();
|
||||
}
|
||||
|
||||
match ($deleteType) {
|
||||
'soft' => $component->delete(),
|
||||
'hard' => $component->forceDelete(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($deleteType !== 'none') {
|
||||
$this->reportLines[] = ucfirst($deleteType)."-deleted component {$component->name}";
|
||||
}
|
||||
|
||||
if ($deleteFiles) {
|
||||
if ($component->image) {
|
||||
$this->deleteStorageFile('public', app('components_upload_path').$component->image);
|
||||
}
|
||||
foreach ($actionLogPaths as $path) {
|
||||
$this->deleteStorageFile('local', $path);
|
||||
}
|
||||
}
|
||||
} elseif ($deleteType !== 'none') {
|
||||
$this->line(" [dry-run] Would {$deleteType}-delete component {$component->name}");
|
||||
$this->reportLines[] = "Would {$deleteType}-delete component {$component->name}";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
}
|
||||
|
||||
private function processConsumables(
|
||||
array $companyIds,
|
||||
bool $includeNull,
|
||||
bool $dryRun,
|
||||
string $deleteType,
|
||||
bool $clearLogs,
|
||||
bool $deleteFiles,
|
||||
ProgressBar $bar,
|
||||
): void {
|
||||
$consumables = $this->buildCompanyQuery(Consumable::query(), $companyIds, $includeNull)->get();
|
||||
|
||||
foreach ($consumables as $consumable) {
|
||||
$bar->setMessage("Consumables: {$consumable->name}");
|
||||
|
||||
if (! $dryRun) {
|
||||
// Collect action log file paths before logs may be cleared
|
||||
$actionLogPaths = $deleteFiles
|
||||
? $consumable->assetlog()->whereNotNull('filename')->get()
|
||||
->map(fn (Actionlog $log) => $log->uploads_file_path())
|
||||
->filter()
|
||||
->values()
|
||||
->toArray()
|
||||
: [];
|
||||
|
||||
if ($clearLogs) {
|
||||
$consumable->assetlog()->forceDelete();
|
||||
}
|
||||
|
||||
if ($deleteType === 'hard') {
|
||||
DB::table('kits_consumables')->where('consumable_id', $consumable->id)->delete();
|
||||
}
|
||||
|
||||
match ($deleteType) {
|
||||
'soft' => $consumable->delete(),
|
||||
'hard' => $consumable->forceDelete(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($deleteType !== 'none') {
|
||||
$this->reportLines[] = ucfirst($deleteType)."-deleted consumable {$consumable->name}";
|
||||
}
|
||||
|
||||
if ($deleteFiles) {
|
||||
if ($consumable->image) {
|
||||
$this->deleteStorageFile('public', app('consumables_upload_path').$consumable->image);
|
||||
}
|
||||
foreach ($actionLogPaths as $path) {
|
||||
$this->deleteStorageFile('local', $path);
|
||||
}
|
||||
}
|
||||
} elseif ($deleteType !== 'none') {
|
||||
$this->line(" [dry-run] Would {$deleteType}-delete consumable {$consumable->name}");
|
||||
$this->reportLines[] = "Would {$deleteType}-delete consumable {$consumable->name}";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
}
|
||||
|
||||
private function processUsers(
|
||||
array $companyIds,
|
||||
bool $includeNull,
|
||||
User $admin,
|
||||
bool $skipAdminUser,
|
||||
bool $dryRun,
|
||||
string $deleteType,
|
||||
bool $clearLogs,
|
||||
bool $deleteFiles,
|
||||
ProgressBar $bar,
|
||||
): void {
|
||||
$users = $this->buildUserQuery($companyIds, $includeNull)->get();
|
||||
|
||||
foreach ($users as $user) {
|
||||
if ($skipAdminUser && $user->id === $admin->id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$bar->setMessage("Users: {$user->username}");
|
||||
|
||||
// If real companies were selected, check whether this user also belongs to
|
||||
// companies outside the selected scope. If so, only remove the selected-company
|
||||
// associations and skip full deletion to avoid orphaning them from their other companies.
|
||||
if (! empty($companyIds)) {
|
||||
$allUserCompanyIds = array_unique(array_filter(array_merge(
|
||||
$user->companies()->pluck('companies.id')->toArray(),
|
||||
$user->company_id ? [$user->company_id] : [],
|
||||
)));
|
||||
$outsideCompanyIds = array_values(array_diff($allUserCompanyIds, $companyIds));
|
||||
|
||||
if (! empty($outsideCompanyIds)) {
|
||||
$outsideNames = Company::whereIn('id', $outsideCompanyIds)->pluck('name')->implode(', ');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would partially disassociate user {$user->username} (also belongs to: {$outsideNames})");
|
||||
$this->reportLines[] = "Would partially disassociate user {$user->username} — also belongs to: {$outsideNames}";
|
||||
} else {
|
||||
$user->companies()->detach($companyIds);
|
||||
warning(" Skipped full deletion of {$user->username}: they also belong to {$outsideNames}. Removed selected company associations only.");
|
||||
$this->reportLines[] = "Partially disassociated user {$user->username} — also belongs to: {$outsideNames}. Full deletion skipped.";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
// Collect file paths and acceptance records before deleting pivot data
|
||||
$acceptancesToDelete = $deleteFiles
|
||||
? CheckoutAcceptance::where('assigned_to_id', $user->id)->get()
|
||||
: collect();
|
||||
|
||||
$actionLogPaths = $deleteFiles
|
||||
? Actionlog::where('item_type', User::class)
|
||||
->where('item_id', $user->id)
|
||||
->where('action_type', 'uploaded')
|
||||
->whereNotNull('filename')
|
||||
->get()
|
||||
->map(fn (Actionlog $log) => $log->uploads_file_path())
|
||||
->filter()
|
||||
->values()
|
||||
->toArray()
|
||||
: [];
|
||||
|
||||
// Clear pivot/assignment data that will orphan on deletion
|
||||
LicenseSeat::where('assigned_to', $user->id)->update(['assigned_to' => null]);
|
||||
AccessoryCheckout::where('assigned_to', $user->id)
|
||||
->where('assigned_type', User::class)
|
||||
->delete();
|
||||
DB::table('consumables_users')->where('assigned_to', $user->id)->delete();
|
||||
CheckoutAcceptance::where('assigned_to_id', $user->id)->forceDelete();
|
||||
if ($deleteType === 'hard') {
|
||||
DB::table('company_user')->where('user_id', $user->id)->delete();
|
||||
}
|
||||
|
||||
if ($clearLogs) {
|
||||
$user->userlog()->forceDelete();
|
||||
}
|
||||
|
||||
match ($deleteType) {
|
||||
'soft' => $user->delete(),
|
||||
'hard' => $user->forceDelete(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($deleteType !== 'none') {
|
||||
$this->reportLines[] = ucfirst($deleteType)."-deleted user {$user->username}";
|
||||
}
|
||||
|
||||
if ($deleteFiles) {
|
||||
if ($user->avatar) {
|
||||
$this->deleteStorageFile('public', app('users_upload_path').$user->avatar);
|
||||
}
|
||||
$acceptancesToDelete->each(fn (CheckoutAcceptance $ca) => $this->deleteAcceptanceFiles($ca));
|
||||
foreach ($actionLogPaths as $path) {
|
||||
$this->deleteStorageFile('local', $path);
|
||||
}
|
||||
}
|
||||
} elseif ($deleteType !== 'none') {
|
||||
$this->line(" [dry-run] Would {$deleteType}-delete user {$user->username}");
|
||||
$this->reportLines[] = "Would {$deleteType}-delete user {$user->username}";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteStorageFile(string $disk, ?string $path): void
|
||||
{
|
||||
if (! $path) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$storage = $disk === 'public'
|
||||
? Storage::disk('public')
|
||||
: Storage::disk(config('filesystems.default'));
|
||||
if ($storage->exists($path)) {
|
||||
$storage->delete($path);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::warning("Could not delete file {$path}: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteAcceptanceFiles(CheckoutAcceptance $acceptance): void
|
||||
{
|
||||
if ($acceptance->signature_filename) {
|
||||
$this->deleteStorageFile('local', 'private_uploads/signatures/'.$acceptance->signature_filename);
|
||||
}
|
||||
if ($acceptance->stored_eula_file) {
|
||||
$this->deleteStorageFile('local', 'private_uploads/eula-pdfs/'.$acceptance->stored_eula_file);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Events\CheckoutableCheckedIn;
|
||||
use App\Models\Accessory;
|
||||
use App\Models\AccessoryCheckout;
|
||||
use App\Models\Asset;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\Component;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CheckinAndDeleteItems extends Command
|
||||
{
|
||||
protected $signature = 'snipeit:checkin-delete-all
|
||||
{--company-id= : Only process items belonging to this company ID}
|
||||
{--admin-id= : ID of the user credited for the checkins (defaults to first superadmin)}
|
||||
{--no-notifications : Suppress email and webhook notifications}
|
||||
{--type=all : Comma-separated types to process: assets, licenses, accessories, components, or all}
|
||||
{--note= : Note recorded on each checkin action log entry}
|
||||
{--dry-run : Preview what would be processed without making any changes}
|
||||
{--force : Skip the confirmation prompt}';
|
||||
|
||||
protected $description = 'Check in all assigned items and soft-delete them, optionally scoped to a company';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$companyId = $this->option('company-id');
|
||||
$noNotifications = $this->option('no-notifications');
|
||||
$dryRun = $this->option('dry-run');
|
||||
$typeOption = $this->option('type') ?? 'all';
|
||||
$note = $this->option('note') ?: 'Checked in and deleted via CLI';
|
||||
|
||||
$allTypes = ['assets', 'licenses', 'accessories', 'components'];
|
||||
$typesToProcess = $typeOption === 'all'
|
||||
? $allTypes
|
||||
: array_intersect(array_map('trim', explode(',', $typeOption)), $allTypes);
|
||||
|
||||
if (empty($typesToProcess)) {
|
||||
$this->error('Invalid --type value. Use: assets, licenses, accessories, components, or all.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$admin = null;
|
||||
if (! $dryRun && ! $noNotifications) {
|
||||
if ($this->option('admin-id')) {
|
||||
$admin = User::find($this->option('admin-id'));
|
||||
if (! $admin) {
|
||||
$this->error('No user found with admin-id '.$this->option('admin-id').'.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
$admin = User::onlySuperAdmins()->first();
|
||||
}
|
||||
|
||||
if (! $admin) {
|
||||
$this->warn('No admin user found — notifications will be suppressed.');
|
||||
$noNotifications = true;
|
||||
}
|
||||
}
|
||||
|
||||
$scopeMsg = $companyId ? "company ID {$companyId}" : 'all companies';
|
||||
$typesMsg = implode(', ', $typesToProcess);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY RUN — no changes will be made.');
|
||||
} elseif (! $this->option('force')) {
|
||||
if (! $this->confirm("This will check in and soft-delete all [{$typesMsg}] for [{$scopeMsg}]. Continue?")) {
|
||||
$this->info('Aborted.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array('assets', $typesToProcess)) {
|
||||
$this->processAssets($companyId, $noNotifications, $note, $admin, $dryRun);
|
||||
}
|
||||
|
||||
if (in_array('licenses', $typesToProcess)) {
|
||||
$this->processLicenses($companyId, $noNotifications, $note, $admin, $dryRun);
|
||||
}
|
||||
|
||||
if (in_array('accessories', $typesToProcess)) {
|
||||
$this->processAccessories($companyId, $noNotifications, $note, $admin, $dryRun);
|
||||
}
|
||||
|
||||
if (in_array('components', $typesToProcess)) {
|
||||
$this->processComponents($companyId, $noNotifications, $note, $admin, $dryRun);
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('Dry run complete — no changes were made.');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function processAssets(?string $companyId, bool $noNotifications, string $note, ?User $admin, bool $dryRun): void
|
||||
{
|
||||
$query = Asset::query();
|
||||
if ($companyId) {
|
||||
$query->where('company_id', $companyId);
|
||||
}
|
||||
|
||||
$assets = $query->get();
|
||||
$checkedIn = 0;
|
||||
$deleted = 0;
|
||||
|
||||
foreach ($assets as $asset) {
|
||||
if ($asset->assignedTo) {
|
||||
if ($dryRun) {
|
||||
$this->line(' Would check in asset: '.$asset->asset_tag.' (assigned to '.$asset->assignedTo->name.')');
|
||||
} else {
|
||||
$target = $asset->assignedTo;
|
||||
$checkin_at = now()->format('Y-m-d H:i:s');
|
||||
$originalValues = $asset->getRawOriginal();
|
||||
|
||||
if ($noNotifications) {
|
||||
DB::table('assets')->where('id', $asset->id)
|
||||
->update(['assigned_to' => null, 'assigned_type' => null]);
|
||||
$asset->logCheckin($target, $note, $checkin_at, $originalValues);
|
||||
} else {
|
||||
// Fire event before clearing so the log captures the original state
|
||||
event(new CheckoutableCheckedIn($asset, $target, $admin, $note, $checkin_at, $originalValues));
|
||||
DB::table('assets')->where('id', $asset->id)
|
||||
->update(['assigned_to' => null, 'assigned_type' => null]);
|
||||
}
|
||||
|
||||
$asset->licenseseats()->update(['assigned_to' => null]);
|
||||
|
||||
CheckoutAcceptance::pending()
|
||||
->whereHasMorph('checkoutable', [Asset::class], fn (Builder $q) => $q->where('id', $asset->id))
|
||||
->delete();
|
||||
}
|
||||
|
||||
$checkedIn++;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(' Would delete asset: '.$asset->asset_tag);
|
||||
} else {
|
||||
$asset->delete();
|
||||
}
|
||||
|
||||
$deleted++;
|
||||
}
|
||||
|
||||
$action = $dryRun ? 'would be' : 'were';
|
||||
$this->info("Assets: {$checkedIn} {$action} checked in, {$deleted} {$action} deleted.");
|
||||
}
|
||||
|
||||
private function processLicenses(?string $companyId, bool $noNotifications, string $note, ?User $admin, bool $dryRun): void
|
||||
{
|
||||
$query = License::query();
|
||||
if ($companyId) {
|
||||
$query->where('company_id', $companyId);
|
||||
}
|
||||
|
||||
$licenses = $query->get();
|
||||
$seatsCheckedIn = 0;
|
||||
$deleted = 0;
|
||||
|
||||
foreach ($licenses as $license) {
|
||||
$seats = LicenseSeat::where('license_id', $license->id)
|
||||
->where(fn ($q) => $q->whereNotNull('assigned_to')->orWhereNotNull('asset_id'))
|
||||
->get();
|
||||
|
||||
foreach ($seats as $seat) {
|
||||
$target = $seat->assigned_to ? $seat->user : $seat->asset;
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(' Would check in license seat for: '.$license->name.' (assigned to '.($target?->name ?? $target?->asset_tag ?? 'unknown').')');
|
||||
} else {
|
||||
$seat->assigned_to = null;
|
||||
$seat->asset_id = null;
|
||||
$seat->save();
|
||||
|
||||
if ($target) {
|
||||
if ($noNotifications) {
|
||||
$seat->logCheckin($target, $note);
|
||||
} else {
|
||||
event(new CheckoutableCheckedIn($seat, $target, $admin, $note));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$seatsCheckedIn++;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(' Would delete license: '.$license->name);
|
||||
} else {
|
||||
$license->licenseseats()->delete();
|
||||
$license->delete();
|
||||
}
|
||||
|
||||
$deleted++;
|
||||
}
|
||||
|
||||
$action = $dryRun ? 'would be' : 'were';
|
||||
$this->info("Licenses: {$seatsCheckedIn} seats {$action} checked in, {$deleted} licenses {$action} deleted.");
|
||||
}
|
||||
|
||||
private function processAccessories(?string $companyId, bool $noNotifications, string $note, ?User $admin, bool $dryRun): void
|
||||
{
|
||||
$query = Accessory::query();
|
||||
if ($companyId) {
|
||||
$query->where('company_id', $companyId);
|
||||
}
|
||||
|
||||
$accessories = $query->get();
|
||||
$checkedIn = 0;
|
||||
$deleted = 0;
|
||||
|
||||
foreach ($accessories as $accessory) {
|
||||
$checkouts = AccessoryCheckout::where('accessory_id', $accessory->id)->get();
|
||||
|
||||
foreach ($checkouts as $checkout) {
|
||||
$target = $checkout->assignedTo;
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(' Would check in accessory: '.$accessory->name.' (assigned to '.($target?->name ?? $target?->asset_tag ?? 'unknown').')');
|
||||
} else {
|
||||
$checkin_at = now()->format('Y-m-d H:i:s');
|
||||
$checkout->delete();
|
||||
|
||||
if ($target) {
|
||||
if ($noNotifications) {
|
||||
$accessory->logCheckin($target, $note, $checkin_at);
|
||||
} else {
|
||||
event(new CheckoutableCheckedIn($accessory, $target, $admin, $note, $checkin_at));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$checkedIn++;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(' Would delete accessory: '.$accessory->name);
|
||||
} else {
|
||||
$accessory->delete();
|
||||
}
|
||||
|
||||
$deleted++;
|
||||
}
|
||||
|
||||
$action = $dryRun ? 'would be' : 'were';
|
||||
$this->info("Accessories: {$checkedIn} {$action} checked in, {$deleted} {$action} deleted.");
|
||||
}
|
||||
|
||||
private function processComponents(?string $companyId, bool $noNotifications, string $note, ?User $admin, bool $dryRun): void
|
||||
{
|
||||
$query = Component::query();
|
||||
if ($companyId) {
|
||||
$query->where('company_id', $companyId);
|
||||
}
|
||||
|
||||
$components = $query->get();
|
||||
$checkedIn = 0;
|
||||
$deleted = 0;
|
||||
|
||||
foreach ($components as $component) {
|
||||
$assignments = DB::table('components_assets')
|
||||
->where('component_id', $component->id)
|
||||
->get();
|
||||
|
||||
foreach ($assignments as $assignment) {
|
||||
$asset = Asset::find($assignment->asset_id);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(' Would check in component: '.$component->name.' (assigned to '.($asset?->asset_tag ?? 'unknown').')');
|
||||
} else {
|
||||
$checkin_at = now()->format('Y-m-d H:i:s');
|
||||
DB::table('components_assets')->where('id', $assignment->id)->delete();
|
||||
|
||||
if ($asset) {
|
||||
if ($noNotifications) {
|
||||
$component->logCheckin($asset, $note, $checkin_at);
|
||||
} else {
|
||||
event(new CheckoutableCheckedIn($component, $asset, $admin, $note, $checkin_at));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$checkedIn++;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(' Would delete component: '.$component->name);
|
||||
} else {
|
||||
$component->delete();
|
||||
}
|
||||
|
||||
$deleted++;
|
||||
}
|
||||
|
||||
$action = $dryRun ? 'would be' : 'were';
|
||||
$this->info("Components: {$checkedIn} {$action} checked in, {$deleted} {$action} deleted.");
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,7 @@ namespace App\Console\Commands;
|
||||
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CheckinLicensesFromAllUsers extends Command
|
||||
{
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CheckoutLicenseToAllUsers extends Command
|
||||
{
|
||||
@@ -75,6 +73,7 @@ class CheckoutLicenseToAllUsers extends Command
|
||||
|
||||
if ($user->licenses->where('id', '=', $license_id)->count()) {
|
||||
$this->info($user->username.' already has this license checked out to them. Skipping... ');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ class CleanIncorrectCheckoutAcceptances extends Command
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = "Delete checkout acceptances for checkouts to non-users";
|
||||
protected $description = 'Delete checkout acceptances for checkouts to non-users';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
@@ -30,39 +30,77 @@ class CleanIncorrectCheckoutAcceptances extends Command
|
||||
{
|
||||
$deletions = 0;
|
||||
$skips = 0;
|
||||
$total = CheckoutAcceptance::count();
|
||||
|
||||
// This walks *every* checkoutacceptance. That's gnarly. But necessary
|
||||
$this->withProgressBar(CheckoutAcceptance::all(), function ($checkoutAcceptance) use (&$deletions, &$skips) {
|
||||
$item = $checkoutAcceptance->checkoutable;
|
||||
$checkout_to_id = $checkoutAcceptance->assigned_to_id;
|
||||
if(is_null($item)) {
|
||||
$this->info("'Checkoutable' Item is null, going to next record");
|
||||
return; //'false' allegedly breaks execution entirely, so 'true' maybe doesn't? hrm. just straight return maybe?
|
||||
}
|
||||
if(get_class($item) == LicenseSeat::class) {
|
||||
$item = $item->license;
|
||||
}
|
||||
foreach($item->assetlog()->where('action_type','checkout')->get() as $assetlog) {
|
||||
if ($assetlog->target_id == $checkout_to_id && $assetlog->target_type != User::class) {
|
||||
//We have a checkout-to an ID for a non-User, which matches to an ID in the checkout_acceptances table
|
||||
$this->info("Processing {$total} checkout acceptances...");
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
$bar->start();
|
||||
|
||||
//now, let's compare the _times_ - are they close?
|
||||
//I'm picking `created_at` over `action_date` because I'm more interested in when the actionlogs
|
||||
//were _created_, not when they were alleged to have happened - those created_at times need to be within 'X' seconds of
|
||||
//each other (currently 5)
|
||||
if ($assetlog->created_at->diffInSeconds($checkoutAcceptance->created_at, true) <= 5) { //we're allowing for five _ish_ seconds of slop
|
||||
$deletions++;
|
||||
$checkoutAcceptance->forceDelete(); // HARD delete this record; it should have never been
|
||||
return;
|
||||
} else {
|
||||
//$this->info("The two records are too far apart");
|
||||
// Chunk to avoid loading the whole table into memory; eager-load checkoutable
|
||||
// to eliminate the N+1 on that relationship.
|
||||
CheckoutAcceptance::with('checkoutable')
|
||||
->chunkById(500, function ($chunk) use (&$deletions, &$skips, $bar) {
|
||||
$idsToDelete = [];
|
||||
|
||||
foreach ($chunk as $checkoutAcceptance) {
|
||||
$item = $checkoutAcceptance->checkoutable;
|
||||
$checkout_to_id = $checkoutAcceptance->assigned_to_id;
|
||||
|
||||
if (is_null($item)) {
|
||||
$skips++;
|
||||
$bar->advance();
|
||||
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
//$this->info("No match! checkout to id: " . $checkout_to_id." target_id: ".$assetlog->target_id." target_type: ".$assetlog->target_type);
|
||||
|
||||
if (get_class($item) === LicenseSeat::class) {
|
||||
$item = $item->license;
|
||||
if (is_null($item)) {
|
||||
$skips++;
|
||||
$bar->advance();
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_null($checkoutAcceptance->created_at)) {
|
||||
$skips++;
|
||||
$bar->advance();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Push all filtering (including the ±5-second window) into the DB;
|
||||
// exists() returns as soon as one matching row is found rather than
|
||||
// fetching all checkout logs into PHP.
|
||||
$shouldDelete = $item->assetlog()
|
||||
->where('action_type', 'checkout')
|
||||
->where('target_id', $checkout_to_id)
|
||||
->where('target_type', '!=', User::class)
|
||||
->whereBetween('created_at', [
|
||||
$checkoutAcceptance->created_at->copy()->subSeconds(5),
|
||||
$checkoutAcceptance->created_at->copy()->addSeconds(5),
|
||||
])
|
||||
->exists();
|
||||
|
||||
if ($shouldDelete) {
|
||||
$idsToDelete[] = $checkoutAcceptance->id;
|
||||
$deletions++;
|
||||
} else {
|
||||
$skips++;
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
}
|
||||
$skips++;
|
||||
});
|
||||
$this->error("Final deletion count: $deletions, and skip count: $skips");
|
||||
|
||||
// Bulk-delete the bad records in one query per chunk instead of one per row.
|
||||
if (! empty($idsToDelete)) {
|
||||
CheckoutAcceptance::whereIn('id', $idsToDelete)->forceDelete();
|
||||
}
|
||||
});
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
$this->info("Final deletion count: {$deletions}, and skip count: {$skips}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use Illuminate\Console\Command;
|
||||
class CleanOldCheckoutRequests extends Command
|
||||
{
|
||||
private int $deletions = 0;
|
||||
|
||||
private int $skips = 0;
|
||||
|
||||
/**
|
||||
@@ -44,12 +45,14 @@ class CleanOldCheckoutRequests extends Command
|
||||
if ($this->shouldForceDelete($request)) {
|
||||
$request->forceDelete();
|
||||
$this->deletions++;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->shouldSoftDelete($request)) {
|
||||
$request->delete();
|
||||
$this->deletions++;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -64,7 +67,7 @@ class CleanOldCheckoutRequests extends Command
|
||||
private function shouldForceDelete(CheckoutRequest $request)
|
||||
{
|
||||
// check if the requestable or user relationship is null
|
||||
return !$request->requestable || !$request->user;
|
||||
return ! $request->requestable || ! $request->user;
|
||||
}
|
||||
|
||||
private function shouldSoftDelete(CheckoutRequest $request)
|
||||
|
||||
@@ -2,31 +2,28 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use \App\Models\User;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class CreateAdmin extends Command
|
||||
{
|
||||
|
||||
/** @mixin User **/
|
||||
/**
|
||||
* App\Console\CreateAdmin
|
||||
*
|
||||
* @property mixed $first_name
|
||||
* @property string $last_name
|
||||
* @property string $username
|
||||
* @property string $email
|
||||
* @property string $permissions
|
||||
* @property string $password
|
||||
* @property boolean $activated
|
||||
* @property boolean $show_in_list
|
||||
* @property boolean $autoassign_licenses
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property bool $activated
|
||||
* @property bool $show_in_list
|
||||
* @property bool $autoassign_licenses
|
||||
* @property Carbon|null $created_at
|
||||
* @property mixed $created_by
|
||||
*/
|
||||
|
||||
|
||||
|
||||
protected $signature = 'snipeit:create-admin {--first_name=} {--last_name=} {--email=} {--username=} {--password=} {show_in_list?} {autoassign_licenses?}';
|
||||
|
||||
/**
|
||||
@@ -46,7 +43,6 @@ class CreateAdmin extends Command
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$first_name = $this->option('first_name');
|
||||
@@ -57,8 +53,6 @@ class CreateAdmin extends Command
|
||||
$show_in_list = $this->argument('show_in_list');
|
||||
$autoassign_licenses = $this->argument('autoassign_licenses');
|
||||
|
||||
|
||||
|
||||
if (($first_name == '') || ($last_name == '') || ($username == '') || ($email == '') || ($password == '')) {
|
||||
$this->info('ERROR: All fields are required.');
|
||||
} else {
|
||||
|
||||
@@ -24,6 +24,7 @@ class FixBulkAccessoryCheckinActionLogEntries extends Command
|
||||
protected $description = 'This script attempts to fix timestamps and missing created_by values for bulk checkin entries in the log table';
|
||||
|
||||
private bool $dryrun = false;
|
||||
|
||||
private bool $skipBackup = false;
|
||||
|
||||
/**
|
||||
@@ -50,10 +51,11 @@ class FixBulkAccessoryCheckinActionLogEntries extends Command
|
||||
|
||||
if ($logs->isEmpty()) {
|
||||
$this->info('No logs found with incorrect timestamps.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info('Found ' . $logs->count() . ' logs with incorrect timestamps:');
|
||||
$this->info('Found '.$logs->count().' logs with incorrect timestamps:');
|
||||
|
||||
$this->table(
|
||||
['ID', 'Created By', 'Created At', 'Updated At'],
|
||||
@@ -67,11 +69,11 @@ class FixBulkAccessoryCheckinActionLogEntries extends Command
|
||||
})
|
||||
);
|
||||
|
||||
if (!$this->dryrun && !$this->confirm('Update these logs?')) {
|
||||
if (! $this->dryrun && ! $this->confirm('Update these logs?')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!$this->dryrun && !$this->skipBackup) {
|
||||
if (! $this->dryrun && ! $this->skipBackup) {
|
||||
$this->info('Backing up the database before making changes...');
|
||||
$this->call('snipeit:backup');
|
||||
}
|
||||
@@ -83,7 +85,7 @@ class FixBulkAccessoryCheckinActionLogEntries extends Command
|
||||
|
||||
foreach ($logs as $log) {
|
||||
$this->newLine();
|
||||
$this->info('Processing log id:' . $log->id);
|
||||
$this->info('Processing log id:'.$log->id);
|
||||
|
||||
// created_by was not being set for accessory bulk checkins
|
||||
// so let's see if there was another bulk checkin log
|
||||
@@ -106,7 +108,7 @@ class FixBulkAccessoryCheckinActionLogEntries extends Command
|
||||
$this->line(vsprintf('Updating log id:%s from %s to %s', [$log->id, $log->created_at, $log->updated_at]));
|
||||
$log->created_at = $log->updated_at;
|
||||
|
||||
if (!$this->dryrun) {
|
||||
if (! $this->dryrun) {
|
||||
Model::withoutTimestamps(function () use ($log) {
|
||||
$log->saveQuietly();
|
||||
});
|
||||
@@ -129,7 +131,7 @@ class FixBulkAccessoryCheckinActionLogEntries extends Command
|
||||
* This method attempts to find a bulk check in log that was
|
||||
* created at the same time as the log passed in.
|
||||
*/
|
||||
private function getCreatedByAttributeFromSimilarLog(Actionlog $log): null|int
|
||||
private function getCreatedByAttributeFromSimilarLog(Actionlog $log): ?int
|
||||
{
|
||||
$similarLog = Actionlog::query()
|
||||
->whereNotNull('created_by')
|
||||
|
||||
@@ -2,6 +2,21 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Accessory;
|
||||
use App\Models\Asset;
|
||||
use App\Models\AssetModel;
|
||||
use App\Models\Company;
|
||||
use App\Models\Component;
|
||||
use App\Models\Consumable;
|
||||
use App\Models\Department;
|
||||
use App\Models\Depreciation;
|
||||
use App\Models\Group;
|
||||
use App\Models\License;
|
||||
use App\Models\Location;
|
||||
use App\Models\Manufacturer;
|
||||
use App\Models\Statuslabel;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class FixDoubleEscape extends Command
|
||||
@@ -38,21 +53,21 @@ class FixDoubleEscape extends Command
|
||||
public function handle()
|
||||
{
|
||||
$tables = [
|
||||
\App\Models\Asset::class => ['name'],
|
||||
\App\Models\License::class => ['name'],
|
||||
\App\Models\Consumable::class => ['name'],
|
||||
\App\Models\Accessory::class => ['name'],
|
||||
\App\Models\Component::class => ['name'],
|
||||
\App\Models\Company::class => ['name'],
|
||||
\App\Models\Manufacturer::class => ['name'],
|
||||
\App\Models\Supplier::class => ['name'],
|
||||
\App\Models\Statuslabel::class => ['name'],
|
||||
\App\Models\Depreciation::class => ['name'],
|
||||
\App\Models\AssetModel::class => ['name'],
|
||||
\App\Models\Group::class => ['name'],
|
||||
\App\Models\Department::class => ['name'],
|
||||
\App\Models\Location::class => ['name'],
|
||||
\App\Models\User::class => ['first_name', 'last_name'],
|
||||
Asset::class => ['name'],
|
||||
License::class => ['name'],
|
||||
Consumable::class => ['name'],
|
||||
Accessory::class => ['name'],
|
||||
Component::class => ['name'],
|
||||
Company::class => ['name'],
|
||||
Manufacturer::class => ['name'],
|
||||
Supplier::class => ['name'],
|
||||
Statuslabel::class => ['name'],
|
||||
Depreciation::class => ['name'],
|
||||
AssetModel::class => ['name'],
|
||||
Group::class => ['name'],
|
||||
Department::class => ['name'],
|
||||
Location::class => ['name'],
|
||||
User::class => ['first_name', 'last_name'],
|
||||
];
|
||||
|
||||
$count = [];
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Actionlog;
|
||||
use App\Models\Asset;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class FixMismatchedAssetsAndLogs extends Command
|
||||
@@ -56,26 +57,26 @@ class FixMismatchedAssetsAndLogs extends Command
|
||||
|
||||
$mismatch_count = 0;
|
||||
$assets = Asset::whereNotNull('assigned_to')
|
||||
->where('assigned_type', '=', \App\Models\User::class)
|
||||
->where('assigned_type', '=', User::class)
|
||||
->orderBy('id', 'ASC')->get();
|
||||
foreach ($assets as $asset) {
|
||||
|
||||
// get the last checkout of the asset
|
||||
if ($checkout_log = Actionlog::where('target_type', '=', \App\Models\User::class)
|
||||
if ($checkout_log = Actionlog::where('target_type', '=', User::class)
|
||||
->where('action_type', '=', 'checkout')
|
||||
->where('item_id', '=', $asset->id)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->first()) {
|
||||
|
||||
// Now check for a subsequent checkin log - we want to ignore those
|
||||
if (! $checkin_log = Actionlog::where('target_type', '=', \App\Models\User::class)
|
||||
->where('action_type', '=', 'checkin from')
|
||||
->where('item_id', '=', $asset->id)
|
||||
->whereDate('created_at', '>', $checkout_log->created_at)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->first()) {
|
||||
// Now check for a subsequent checkin log - we want to ignore those
|
||||
if (! $checkin_log = Actionlog::where('target_type', '=', User::class)
|
||||
->where('action_type', '=', 'checkin from')
|
||||
->where('item_id', '=', $asset->id)
|
||||
->whereDate('created_at', '>', $checkout_log->created_at)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->first()) {
|
||||
|
||||
//print_r($asset);
|
||||
// print_r($asset);
|
||||
if ($checkout_log->target_id != $asset->assigned_to) {
|
||||
$this->error('Log ID: '.$checkout_log->id.' -- Asset ID '.$checkout_log->item_id.' SHOULD BE checked out to User '.$checkout_log->target_id.' but its assigned_to is '.$asset->assigned_to);
|
||||
|
||||
@@ -90,7 +91,7 @@ class FixMismatchedAssetsAndLogs extends Command
|
||||
$mismatch_count++;
|
||||
}
|
||||
} else {
|
||||
//$this->info('Asset ID '.$asset->id.': There is a checkin '.$checkin_log->created_at.' after this checkout '.$checkout_log->created_at);
|
||||
// $this->info('Asset ID '.$asset->id.': There is a checkin '.$checkin_log->created_at.' after this checkout '.$checkout_log->created_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,6 @@ class FixUpAssignedTypeWithoutAssignedTo extends Command
|
||||
public function handle()
|
||||
{
|
||||
DB::table('assets')->whereNotNull('assigned_type')->whereNull('assigned_to')->update(['assigned_type' => null]);
|
||||
$this->info("Assets with an assigned_type but no assigned_to are fixed");
|
||||
$this->info('Assets with an assigned_type but no assigned_to are fixed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,40 +27,42 @@ class FixupAssignedToWithoutAssignedType extends Command
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$assets = Asset::whereNull("assigned_type")->whereNotNull("assigned_to")->withTrashed();
|
||||
$assets = Asset::whereNull('assigned_type')->whereNotNull('assigned_to')->withTrashed();
|
||||
$this->withProgressBar($assets->get(), function (Asset $asset) {
|
||||
//now check each action log, from the most recent backwards, to find the last checkin or checkout
|
||||
foreach($asset->log()->orderBy("id","desc")->get() as $action_log) {
|
||||
if($this->option("debug")) {
|
||||
$this->info("Asset id: " . $asset->id . " action log, action type is: " . $action_log->action_type);
|
||||
// now check each action log, from the most recent backwards, to find the last checkin or checkout
|
||||
foreach ($asset->log()->orderBy('id', 'desc')->get() as $action_log) {
|
||||
if ($this->option('debug')) {
|
||||
$this->info('Asset id: '.$asset->id.' action log, action type is: '.$action_log->action_type);
|
||||
}
|
||||
switch($action_log->action_type) {
|
||||
switch ($action_log->action_type) {
|
||||
case 'checkin from':
|
||||
if($this->option("debug")) {
|
||||
$this->info("Doing a checkin for ".$asset->id);
|
||||
if ($this->option('debug')) {
|
||||
$this->info('Doing a checkin for '.$asset->id);
|
||||
}
|
||||
$asset->assigned_to = null;
|
||||
// if you have a required custom field, we still want to save, and we *don't* want an action_log
|
||||
$asset->saveQuietly();
|
||||
|
||||
return;
|
||||
|
||||
case 'checkout':
|
||||
if($this->option("debug")) {
|
||||
$this->info("Doing a checkout for " . $asset->id . " picking target type: " . $action_log->target_type);
|
||||
if ($this->option('debug')) {
|
||||
$this->info('Doing a checkout for '.$asset->id.' picking target type: '.$action_log->target_type);
|
||||
}
|
||||
if($asset->assigned_to != $action_log->target_id) {
|
||||
$this->error("Asset's assigned_to does *NOT* match Action Log's target_id. \$asset->assigned_to=".$asset->assigned_to." vs. \$action_log->target_id=".$action_log->target_id);
|
||||
//FIXME - do we abort here? Do we try to keep looking? I don't know, this means your data is *really* messed up...
|
||||
if ($asset->assigned_to != $action_log->target_id) {
|
||||
$this->error("Asset's assigned_to does *NOT* match Action Log's target_id. \$asset->assigned_to=".$asset->assigned_to.' vs. $action_log->target_id='.$action_log->target_id);
|
||||
// FIXME - do we abort here? Do we try to keep looking? I don't know, this means your data is *really* messed up...
|
||||
}
|
||||
$asset->assigned_type = $action_log->target_type;
|
||||
$asset->saveQuietly(); // see above
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
$asset->assigned_to = null; //asset was never checked in or out in its lifetime - it stays 'checked in'
|
||||
$asset->saveQuietly(); //see above
|
||||
$asset->assigned_to = null; // asset was never checked in or out in its lifetime - it stays 'checked in'
|
||||
$asset->saveQuietly(); // see above
|
||||
});
|
||||
$this->newLine();
|
||||
$this->info("Assets assigned_type are fixed");
|
||||
$this->info('Assets assigned_type are fixed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use App\Models\User;
|
||||
use Laravel\Passport\TokenRepository;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Laravel\Passport\TokenRepository;
|
||||
|
||||
class GeneratePersonalAccessToken extends Command
|
||||
{
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
@@ -27,15 +26,13 @@ class GeneratePersonalAccessToken extends Command
|
||||
*/
|
||||
protected $description = 'This console command allows you to generate Personal API tokens to be used with the Snipe-IT JSON REST API on behalf of a user.';
|
||||
|
||||
|
||||
/**
|
||||
* The token repository implementation.
|
||||
*
|
||||
* @var \Laravel\Passport\TokenRepository
|
||||
* @var TokenRepository
|
||||
*/
|
||||
protected $tokenRepository;
|
||||
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*
|
||||
@@ -56,11 +53,11 @@ class GeneratePersonalAccessToken extends Command
|
||||
{
|
||||
|
||||
$accessTokenName = $this->option('name');
|
||||
if ($accessTokenName=='') {
|
||||
if ($accessTokenName == '') {
|
||||
$accessTokenName = 'CLI Auth Token';
|
||||
}
|
||||
|
||||
if ($this->option('user_id')=='') {
|
||||
if ($this->option('user_id') == '') {
|
||||
return $this->error('ERROR: user_id cannot be blank.');
|
||||
}
|
||||
|
||||
@@ -75,7 +72,7 @@ class GeneratePersonalAccessToken extends Command
|
||||
|
||||
$this->warn('Your API Token has been created. Be sure to copy this token now, as it WILL NOT be accessible again.');
|
||||
|
||||
if ($token = DB::table('oauth_access_tokens')->where('user_id', '=', $user->id)->where('name','=',$accessTokenName)->orderBy('created_at', 'desc')->first()) {
|
||||
if ($token = DB::table('oauth_access_tokens')->where('user_id', '=', $user->id)->where('name', '=', $accessTokenName)->orderBy('created_at', 'desc')->first()) {
|
||||
$this->info('API Token ID: '.$token->id);
|
||||
}
|
||||
|
||||
@@ -84,11 +81,8 @@ class GeneratePersonalAccessToken extends Command
|
||||
$this->info('API Token: '.$createAccessToken);
|
||||
}
|
||||
} else {
|
||||
return $this->error('ERROR: Invalid user. API key was not created.');
|
||||
return $this->error('ERROR: Invalid user. API key was not created.');
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ class ImportLocations extends Command
|
||||
$filename = $this->argument('filename');
|
||||
$csv = Reader::createFromPath(storage_path('private_uploads/imports/').$filename, 'r');
|
||||
$this->info('Attempting to process: '.storage_path('private_uploads/imports/').$filename);
|
||||
$csv->setHeaderOffset(0); //because we don't want to insert the header
|
||||
$csv->setHeaderOffset(0); // because we don't want to insert the header
|
||||
$results = $csv->getRecords();
|
||||
|
||||
// Import parent location names first if they don't exist
|
||||
|
||||
@@ -38,22 +38,23 @@ class KillAllSessions extends Command
|
||||
public function handle()
|
||||
{
|
||||
|
||||
if (!$this->option('force') && !$this->confirm("****************************************************\nTHIS WILL FORCE A LOGIN FOR ALL LOGGED IN USERS.\n\nAre you SURE you wish to continue? ")) {
|
||||
return $this->error("Session loss not confirmed");
|
||||
if (! $this->option('force') && ! $this->confirm("****************************************************\nTHIS WILL FORCE A LOGIN FOR ALL LOGGED IN USERS.\n\nAre you SURE you wish to continue? ")) {
|
||||
return $this->error('Session loss not confirmed');
|
||||
}
|
||||
|
||||
$session_files = glob(storage_path("framework/sessions/*"));
|
||||
$session_files = glob(storage_path('framework/sessions/*'));
|
||||
|
||||
$count = 0;
|
||||
foreach ($session_files as $file) {
|
||||
|
||||
if (is_file($file))
|
||||
if (is_file($file)) {
|
||||
unlink($file);
|
||||
$count++;
|
||||
}
|
||||
$count++;
|
||||
}
|
||||
\DB::table('users')->update(['remember_token' => null]);
|
||||
|
||||
$this->info($count. ' sessions cleared!');
|
||||
$this->info($count.' sessions cleared!');
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+161
-104
@@ -5,11 +5,11 @@ namespace App\Console\Commands;
|
||||
use App\Models\Asset;
|
||||
use App\Models\Department;
|
||||
use App\Models\Group;
|
||||
use Illuminate\Console\Command;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Ldap;
|
||||
use App\Models\User;
|
||||
use App\Models\Location;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class LdapSync extends Command
|
||||
@@ -19,7 +19,7 @@ class LdapSync extends Command
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'snipeit:ldap-sync {--location=} {--location_id=*} {--base_dn=} {--filter=} {--summary} {--json_summary}';
|
||||
protected $signature = 'snipeit:ldap-sync {--location=} {--location_id=*} {--base_dn=} {--filter=} {--delete} {--summary} {--json_summary}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
@@ -47,35 +47,34 @@ class LdapSync extends Command
|
||||
{
|
||||
|
||||
// If LDAP enabled isn't set to 1 (ldap_enabled!=1) then we should cut this short immediately without going any further
|
||||
if (Setting::getSettings()->ldap_enabled!='1') {
|
||||
if (Setting::getSettings()->ldap_enabled != '1') {
|
||||
$this->error('LDAP is not enabled. Aborting. See Settings > LDAP to enable it.');
|
||||
exit();
|
||||
}
|
||||
|
||||
ini_set('max_execution_time', env('LDAP_TIME_LIM', 600)); //600 seconds = 10 minutes
|
||||
ini_set('max_execution_time', env('LDAP_TIME_LIM', 600)); // 600 seconds = 10 minutes
|
||||
ini_set('memory_limit', env('LDAP_MEM_LIM', '500M'));
|
||||
|
||||
|
||||
// Map the LDAP attributes to the Snipe-IT user fields.
|
||||
$ldap_map = [
|
||||
"username" => Setting::getSettings()->ldap_username_field,
|
||||
"last_name" => Setting::getSettings()->ldap_lname_field,
|
||||
"first_name" => Setting::getSettings()->ldap_fname_field,
|
||||
"active_flag" => Setting::getSettings()->ldap_active_flag,
|
||||
"emp_num" => Setting::getSettings()->ldap_emp_num,
|
||||
"email" => Setting::getSettings()->ldap_email,
|
||||
"phone" => Setting::getSettings()->ldap_phone_field,
|
||||
"mobile" => Setting::getSettings()->ldap_mobile,
|
||||
"jobtitle" => Setting::getSettings()->ldap_jobtitle,
|
||||
"address" => Setting::getSettings()->ldap_address,
|
||||
"city" => Setting::getSettings()->ldap_city,
|
||||
"state" => Setting::getSettings()->ldap_state,
|
||||
"zip" => Setting::getSettings()->ldap_zip,
|
||||
"country" => Setting::getSettings()->ldap_country,
|
||||
"location" => Setting::getSettings()->ldap_location,
|
||||
"dept" => Setting::getSettings()->ldap_dept,
|
||||
"manager" => Setting::getSettings()->ldap_manager,
|
||||
"display_name" => Setting::getSettings()->ldap_display_name,
|
||||
'username' => Setting::getSettings()->ldap_username_field,
|
||||
'last_name' => Setting::getSettings()->ldap_lname_field,
|
||||
'first_name' => Setting::getSettings()->ldap_fname_field,
|
||||
'active_flag' => Setting::getSettings()->ldap_active_flag,
|
||||
'emp_num' => Setting::getSettings()->ldap_emp_num,
|
||||
'email' => Setting::getSettings()->ldap_email,
|
||||
'phone' => Setting::getSettings()->ldap_phone_field,
|
||||
'mobile' => Setting::getSettings()->ldap_mobile,
|
||||
'jobtitle' => Setting::getSettings()->ldap_jobtitle,
|
||||
'address' => Setting::getSettings()->ldap_address,
|
||||
'city' => Setting::getSettings()->ldap_city,
|
||||
'state' => Setting::getSettings()->ldap_state,
|
||||
'zip' => Setting::getSettings()->ldap_zip,
|
||||
'country' => Setting::getSettings()->ldap_country,
|
||||
'location' => Setting::getSettings()->ldap_location,
|
||||
'dept' => Setting::getSettings()->ldap_dept,
|
||||
'manager' => Setting::getSettings()->ldap_manager,
|
||||
'display_name' => Setting::getSettings()->ldap_display_name,
|
||||
];
|
||||
|
||||
$ldap_default_group = Setting::getSettings()->ldap_default_group;
|
||||
@@ -95,19 +94,20 @@ class LdapSync extends Command
|
||||
}
|
||||
|
||||
$summary = [];
|
||||
$seen_ldap_usernames = [];
|
||||
|
||||
try {
|
||||
|
||||
/**
|
||||
* if a location ID has been specified, use that OU
|
||||
*/
|
||||
if ( $this->option('location_id') ) {
|
||||
if ($this->option('location_id')) {
|
||||
|
||||
foreach($this->option('location_id') as $location_id){
|
||||
foreach ($this->option('location_id') as $location_id) {
|
||||
$location_ou = Location::where('id', '=', $location_id)->value('ldap_ou');
|
||||
$search_base = $location_ou;
|
||||
Log::debug('Importing users from specified location OU: \"'.$search_base.'\".');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,21 +153,21 @@ class LdapSync extends Command
|
||||
$default_location = null;
|
||||
if ($this->option('location') != '') {
|
||||
if ($default_location = Location::where('name', '=', $this->option('location'))->first()) {
|
||||
Log::debug('Location name ' . $this->option('location') . ' passed');
|
||||
Log::debug('Location name '.$this->option('location').' passed');
|
||||
Log::debug('Importing to '.$default_location->name.' ('.$default_location->id.')');
|
||||
}
|
||||
|
||||
} elseif ($this->option('location_id')) {
|
||||
//TODO - figure out how or why this is an array?
|
||||
foreach($this->option('location_id') as $location_id) {
|
||||
// TODO - figure out how or why this is an array?
|
||||
foreach ($this->option('location_id') as $location_id) {
|
||||
if ($default_location = Location::where('id', '=', $location_id)->first()) {
|
||||
Log::debug('Location ID ' . $location_id . ' passed');
|
||||
Log::debug('Location ID '.$location_id.' passed');
|
||||
Log::debug('Importing to '.$default_location->name.' ('.$default_location->id.')');
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
if (!isset($default_location)) {
|
||||
if (! isset($default_location)) {
|
||||
Log::debug('That location is invalid or a location was not provided, so no location will be assigned by default.');
|
||||
}
|
||||
|
||||
@@ -208,17 +208,17 @@ class LdapSync extends Command
|
||||
}
|
||||
$usernames = [];
|
||||
for ($i = 0; $i < $location_users['count']; $i++) {
|
||||
if (array_key_exists($ldap_map["username"], $location_users[$i])) {
|
||||
if (array_key_exists($ldap_map['username'], $location_users[$i])) {
|
||||
$location_users[$i]['ldap_location_override'] = true;
|
||||
$location_users[$i]['location_id'] = $ldap_loc['id'];
|
||||
$usernames[] = $location_users[$i][$ldap_map["username"]][0];
|
||||
$usernames[] = $location_users[$i][$ldap_map['username']][0];
|
||||
}
|
||||
}
|
||||
|
||||
// Delete located users from the general group.
|
||||
foreach ($results as $key => $generic_entry) {
|
||||
if ((is_array($generic_entry)) && (array_key_exists($ldap_map["username"], $generic_entry))) {
|
||||
if (in_array($generic_entry[$ldap_map["username"]][0], $usernames)) {
|
||||
if ((is_array($generic_entry)) && (array_key_exists($ldap_map['username'], $generic_entry))) {
|
||||
if (in_array($generic_entry[$ldap_map['username']][0], $usernames)) {
|
||||
unset($results[$key]);
|
||||
}
|
||||
}
|
||||
@@ -232,42 +232,41 @@ class LdapSync extends Command
|
||||
|
||||
$manager_cache = [];
|
||||
|
||||
if($ldap_default_group != null) {
|
||||
if ($ldap_default_group != null) {
|
||||
|
||||
$default = Group::find($ldap_default_group);
|
||||
if (!$default) {
|
||||
if (! $default) {
|
||||
$ldap_default_group = null; // un-set the default group if that group doesn't exist
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// Assign the mapped LDAP attributes for each user to the Snipe-IT user fields
|
||||
for ($i = 0; $i < $results['count']; $i++) {
|
||||
$item = [];
|
||||
$item['username'] = $results[$i][$ldap_map["username"]][0] ?? null;
|
||||
$item['display_name'] = $results[$i][$ldap_map["display_name"]][0] ?? null;
|
||||
$item['employee_number'] = $results[$i][$ldap_map["emp_num"]][0] ?? null;
|
||||
$item['lastname'] = $results[$i][$ldap_map["last_name"]][0] ?? null;
|
||||
$item['firstname'] = $results[$i][$ldap_map["first_name"]][0] ?? null;
|
||||
$item['email'] = $results[$i][$ldap_map["email"]][0] ?? null;
|
||||
$item['username'] = $results[$i][$ldap_map['username']][0] ?? null;
|
||||
$item['display_name'] = $results[$i][$ldap_map['display_name']][0] ?? null;
|
||||
$item['employee_number'] = $results[$i][$ldap_map['emp_num']][0] ?? null;
|
||||
$item['lastname'] = $results[$i][$ldap_map['last_name']][0] ?? null;
|
||||
$item['firstname'] = $results[$i][$ldap_map['first_name']][0] ?? null;
|
||||
$item['email'] = $results[$i][$ldap_map['email']][0] ?? null;
|
||||
$item['ldap_location_override'] = $results[$i]['ldap_location_override'] ?? null;
|
||||
$item['location_id'] = $results[$i]['location_id'] ?? null;
|
||||
$item['telephone'] = $results[$i][$ldap_map["phone"]][0] ?? null;
|
||||
$item['mobile'] = $results[$i][$ldap_map["mobile"]][0] ?? null;
|
||||
$item['jobtitle'] = $results[$i][$ldap_map["jobtitle"]][0] ?? null;
|
||||
$item['address'] = $results[$i][$ldap_map["address"]][0] ?? null;
|
||||
$item['city'] = $results[$i][$ldap_map["city"]][0] ?? null;
|
||||
$item['state'] = $results[$i][$ldap_map["state"]][0] ?? null;
|
||||
$item['country'] = $results[$i][$ldap_map["country"]][0] ?? null;
|
||||
$item['zip'] = $results[$i][$ldap_map["zip"]][0] ?? null;
|
||||
$item['department'] = $results[$i][$ldap_map["dept"]][0] ?? null;
|
||||
$item['manager'] = $results[$i][$ldap_map["manager"]][0] ?? null;
|
||||
$item['location'] = $results[$i][$ldap_map["location"]][0] ?? null;
|
||||
$location = $default_location; //initially, set '$location' to the default_location (which may just be null)
|
||||
$item['telephone'] = $results[$i][$ldap_map['phone']][0] ?? null;
|
||||
$item['mobile'] = $results[$i][$ldap_map['mobile']][0] ?? null;
|
||||
$item['jobtitle'] = $results[$i][$ldap_map['jobtitle']][0] ?? null;
|
||||
$item['address'] = $results[$i][$ldap_map['address']][0] ?? null;
|
||||
$item['city'] = $results[$i][$ldap_map['city']][0] ?? null;
|
||||
$item['state'] = $results[$i][$ldap_map['state']][0] ?? null;
|
||||
$item['country'] = $results[$i][$ldap_map['country']][0] ?? null;
|
||||
$item['zip'] = $results[$i][$ldap_map['zip']][0] ?? null;
|
||||
$item['department'] = $results[$i][$ldap_map['dept']][0] ?? null;
|
||||
$item['manager'] = $results[$i][$ldap_map['manager']][0] ?? null;
|
||||
$item['location'] = $results[$i][$ldap_map['location']][0] ?? null;
|
||||
$location = $default_location; // initially, set '$location' to the default_location (which may just be null)
|
||||
|
||||
// ONLY if you are using the "ldap_location" option *AND* you have an actual result
|
||||
if ($ldap_map["location"] && $item['location']) {
|
||||
if ($ldap_map['location'] && $item['location']) {
|
||||
$location = Location::firstOrCreate([
|
||||
'name' => $item['location'],
|
||||
]);
|
||||
@@ -276,8 +275,14 @@ class LdapSync extends Command
|
||||
'name' => $item['department'],
|
||||
]);
|
||||
|
||||
$user = User::where('username', $item['username'])->first();
|
||||
$user = User::withTrashed()->where('username', $item['username'])->first();
|
||||
if (! empty($item['username'])) {
|
||||
$seen_ldap_usernames[] = $item['username'];
|
||||
}
|
||||
if ($user) {
|
||||
if ($user->trashed()) {
|
||||
$user->restore();
|
||||
}
|
||||
// Updating an existing user.
|
||||
$item['createorupdate'] = 'updated';
|
||||
} else {
|
||||
@@ -289,58 +294,58 @@ class LdapSync extends Command
|
||||
$item['createorupdate'] = 'created';
|
||||
}
|
||||
|
||||
//If a sync option is not filled in on the LDAP settings don't populate the user field
|
||||
if($ldap_map["username"] != null){
|
||||
// If a sync option is not filled in on the LDAP settings don't populate the user field
|
||||
if ($ldap_map['username'] != null) {
|
||||
$user->username = $item['username'];
|
||||
}
|
||||
if($ldap_map["display_name"] != null){
|
||||
if ($ldap_map['display_name'] != null) {
|
||||
$user->display_name = $item['display_name'];
|
||||
}
|
||||
if($ldap_map["last_name"] != null){
|
||||
if ($ldap_map['last_name'] != null) {
|
||||
$user->last_name = $item['lastname'];
|
||||
}
|
||||
if($ldap_map["first_name"] != null){
|
||||
if ($ldap_map['first_name'] != null) {
|
||||
$user->first_name = $item['firstname'];
|
||||
}
|
||||
if($ldap_map["emp_num"] != null){
|
||||
if ($ldap_map['emp_num'] != null) {
|
||||
$user->employee_num = e($item['employee_number']);
|
||||
}
|
||||
if($ldap_map["email"] != null){
|
||||
if ($ldap_map['email'] != null) {
|
||||
$user->email = $item['email'];
|
||||
}
|
||||
if($ldap_map["phone"] != null){
|
||||
if ($ldap_map['phone'] != null) {
|
||||
$user->phone = $item['telephone'];
|
||||
}
|
||||
if($ldap_map["mobile"] != null){
|
||||
if ($ldap_map['mobile'] != null) {
|
||||
$user->mobile = $item['mobile'];
|
||||
}
|
||||
if($ldap_map["jobtitle"] != null){
|
||||
if ($ldap_map['jobtitle'] != null) {
|
||||
$user->jobtitle = $item['jobtitle'];
|
||||
}
|
||||
if($ldap_map["address"] != null){
|
||||
if ($ldap_map['address'] != null) {
|
||||
$user->address = $item['address'];
|
||||
}
|
||||
if($ldap_map["city"] != null){
|
||||
if ($ldap_map['city'] != null) {
|
||||
$user->city = $item['city'];
|
||||
}
|
||||
if($ldap_map["state"] != null){
|
||||
if ($ldap_map['state'] != null) {
|
||||
$user->state = $item['state'];
|
||||
}
|
||||
if($ldap_map["country"] != null){
|
||||
if ($ldap_map['country'] != null) {
|
||||
$user->country = $item['country'];
|
||||
}
|
||||
if($ldap_map["zip"] != null){
|
||||
if ($ldap_map['zip'] != null) {
|
||||
$user->zip = $item['zip'];
|
||||
}
|
||||
if($ldap_map["dept"] != null){
|
||||
if ($ldap_map['dept'] != null) {
|
||||
$user->department_id = $department->id;
|
||||
}
|
||||
if($ldap_map["location"] != null){
|
||||
if ($ldap_map['location'] != null) {
|
||||
$user->location_id = $location?->id;
|
||||
}
|
||||
|
||||
if($ldap_map["manager"] != null){
|
||||
if($item['manager'] != null) {
|
||||
if ($ldap_map['manager'] != null) {
|
||||
if ($item['manager'] != null) {
|
||||
// Check Cache first
|
||||
if (isset($manager_cache[$item['manager']])) {
|
||||
// found in cache; use that and avoid extra lookups
|
||||
@@ -350,23 +355,23 @@ class LdapSync extends Command
|
||||
try {
|
||||
$ldap_manager = Ldap::findLdapUsers($item['manager'], -1, $this->option('filter'));
|
||||
} catch (\Exception $e) {
|
||||
Log::warning("Manager lookup caused an exception: " . $e->getMessage() . ". Falling back to direct username lookup");
|
||||
Log::warning('Manager lookup caused an exception: '.$e->getMessage().'. Falling back to direct username lookup');
|
||||
// Hail-mary for Okta manager 'shortnames' - will only work if
|
||||
// Okta configuration is using full email-address-style usernames
|
||||
$ldap_manager = [
|
||||
"count" => 1,
|
||||
'count' => 1,
|
||||
0 => [
|
||||
$ldap_map["username"] => [$item['manager']]
|
||||
]
|
||||
$ldap_map['username'] => [$item['manager']],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
$add_manager_to_cache = true;
|
||||
if ($ldap_manager["count"] > 0) {
|
||||
if ($ldap_manager['count'] > 0) {
|
||||
try {
|
||||
// Get the Manager's username
|
||||
// PHP LDAP returns every LDAP attribute as an array, and 90% of the time it's an array of just one item. But, hey, it's an array.
|
||||
$ldapManagerUsername = $ldap_manager[0][$ldap_map["username"]][0];
|
||||
$ldapManagerUsername = $ldap_manager[0][$ldap_map['username']][0];
|
||||
|
||||
// Get User from Manager username.
|
||||
$ldap_manager = User::where('username', $ldapManagerUsername)->first();
|
||||
@@ -377,11 +382,11 @@ class LdapSync extends Command
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$add_manager_to_cache = false;
|
||||
\Log::warning('Handling ldap manager ' . $item['manager'] . ' caused an exception: ' . $e->getMessage() . '. Continuing synchronization.');
|
||||
\Log::warning('Handling ldap manager '.$item['manager'].' caused an exception: '.$e->getMessage().'. Continuing synchronization.');
|
||||
}
|
||||
}
|
||||
if ($add_manager_to_cache) {
|
||||
$manager_cache[$item['manager']] = $ldap_manager && isset($ldap_manager->id) ? $ldap_manager->id : null; // Store results in cache, even if 'failed'
|
||||
$manager_cache[$item['manager']] = $ldap_manager && isset($ldap_manager->id) ? $ldap_manager->id : null; // Store results in cache, even if 'failed'
|
||||
}
|
||||
|
||||
}
|
||||
@@ -389,18 +394,18 @@ class LdapSync extends Command
|
||||
}
|
||||
|
||||
// Sync activated state for Active Directory.
|
||||
if (!empty($ldap_map["active_flag"])) { // IF we have an 'active' flag set....
|
||||
if (! empty($ldap_map['active_flag'])) { // IF we have an 'active' flag set....
|
||||
// ....then *most* things that are truthy will activate the user. Anything falsey will deactivate them.
|
||||
// (Specifically, we don't handle a value of '0.0' correctly)
|
||||
$raw_value = @$results[$i][$ldap_map["active_flag"]][0];
|
||||
$raw_value = @$results[$i][$ldap_map['active_flag']][0];
|
||||
$filter_var = filter_var($raw_value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
|
||||
|
||||
$boolean_cast = (bool) $raw_value;
|
||||
|
||||
|
||||
if (Setting::getSettings()->ldap_invert_active_flag === 1) {
|
||||
// Because ldap_active_flag is set, if filter_var is true or boolean_cast is true, then user is suspended
|
||||
$user->activated = !($filter_var ?? $boolean_cast);
|
||||
}else{
|
||||
$user->activated = ! ($filter_var ?? $boolean_cast);
|
||||
} else {
|
||||
$user->activated = $filter_var ?? $boolean_cast; // if filter_var() was true or false, use that. If it's null, use the $boolean_cast
|
||||
}
|
||||
|
||||
@@ -408,7 +413,6 @@ class LdapSync extends Command
|
||||
// ....otherwise, (ie if no 'active' LDAP flag is defined), IF the UAC setting exists,
|
||||
// ....then use the UAC setting on the account to determine can-log-in vs. cannot-log-in
|
||||
|
||||
|
||||
/* The following is _probably_ the correct logic, but we can't use it because
|
||||
some users may have been dependent upon the previous behavior, and this
|
||||
could cause additional access to be available to users they don't want
|
||||
@@ -434,7 +438,7 @@ class LdapSync extends Command
|
||||
'262688', // 0x40220 NORMAL_ACCOUNT, PASSWD_NOTREQD, SMARTCARD_REQUIRED
|
||||
'328192', // 0x50200 NORMAL_ACCOUNT, SMARTCARD_REQUIRED, DONT_EXPIRE_PASSWORD
|
||||
'328224', // 0x50220 NORMAL_ACCOUNT, PASSWD_NOT_REQD, SMARTCARD_REQUIRED, DONT_EXPIRE_PASSWORD
|
||||
'4194816',// 0x400200 NORMAL_ACCOUNT, DONT_REQ_PREAUTH
|
||||
'4194816', // 0x400200 NORMAL_ACCOUNT, DONT_REQ_PREAUTH
|
||||
'4260352', // 0x410200 NORMAL_ACCOUNT, DONT_EXPIRE_PASSWORD, DONT_REQ_PREAUTH
|
||||
'1049088', // 0x100200 NORMAL_ACCOUNT, NOT_DELEGATED
|
||||
'1114624', // 0x110200 NORMAL_ACCOUNT, DONT_EXPIRE_PASSWORD, NOT_DELEGATED,
|
||||
@@ -445,14 +449,13 @@ class LdapSync extends Command
|
||||
} /* implied 'else' here - leave the $user->activated flag alone. Newly-created accounts will be active.
|
||||
already-existing accounts will be however the administrator has set them */
|
||||
|
||||
|
||||
if ($item['ldap_location_override'] == true) {
|
||||
$user->location_id = $item['location_id'];
|
||||
} elseif ((isset($location)) && (!empty($location))) {
|
||||
} elseif ((isset($location)) && (! empty($location))) {
|
||||
if ((is_array($location)) && (array_key_exists('id', $location))) {
|
||||
$user->location_id = $location['id'];
|
||||
} elseif (is_object($location)) {
|
||||
$user->location_id = $location->id; //THIS is the magic line, this should do it.
|
||||
$user->location_id = $location->id; // THIS is the magic line, this should do it.
|
||||
}
|
||||
}
|
||||
// TODO - should we be NULLING locations if $location is really `null`, and that's what we came up with?
|
||||
@@ -468,13 +471,13 @@ class LdapSync extends Command
|
||||
$item['note'] = $item['createorupdate'];
|
||||
$item['status'] = 'success';
|
||||
if ($item['createorupdate'] === 'created' && $ldap_default_group) {
|
||||
// Check if the relationship already exists
|
||||
if (!$user->groups()->where('group_id', $ldap_default_group)->exists()) {
|
||||
$user->groups()->attach($ldap_default_group);
|
||||
// Check if the relationship already exists
|
||||
if (! $user->groups()->where('group_id', $ldap_default_group)->exists()) {
|
||||
$user->groups()->attach($ldap_default_group);
|
||||
}
|
||||
}
|
||||
|
||||
//updates assets location based on user's location
|
||||
|
||||
// updates assets location based on user's location
|
||||
if ($user->wasChanged('location_id')) {
|
||||
foreach ($user->assets as $asset) {
|
||||
$asset->location_id = $user->location_id;
|
||||
@@ -494,6 +497,41 @@ class LdapSync extends Command
|
||||
array_push($summary, $item);
|
||||
}
|
||||
|
||||
// Optionally soft-delete LDAP-imported users that are no longer present in LDAP.
|
||||
// users with assests etc. are not deletable and skipped
|
||||
if ($this->option('delete')) {
|
||||
$missing_ldap_users = User::where('ldap_import', 1);
|
||||
$missing_ldap_users = $missing_ldap_users->whereNotIn('username', $seen_ldap_usernames);
|
||||
$missing_ldap_users = $missing_ldap_users->get();
|
||||
|
||||
foreach ($missing_ldap_users as $missing_user) {
|
||||
$is_deletable = $this->isUserDeletable($missing_user);
|
||||
|
||||
$missing_item = [
|
||||
'id' => $missing_user->id,
|
||||
'username' => $missing_user->username,
|
||||
'firstname' => $missing_user->first_name,
|
||||
'lastname' => $missing_user->last_name,
|
||||
'email' => $missing_user->email,
|
||||
'createorupdate' => 'skipped',
|
||||
'status' => 'info',
|
||||
'deletable' => $is_deletable,
|
||||
'note' => $is_deletable ? 'missing from LDAP' : 'missing from LDAP, but not deletable',
|
||||
];
|
||||
|
||||
if ($is_deletable) {
|
||||
$missing_user->delete();
|
||||
$missing_item['createorupdate'] = 'deleted';
|
||||
$missing_item['status'] = 'success';
|
||||
$missing_item['note'] = 'deleted_missing_from_ldap';
|
||||
}
|
||||
|
||||
$summary[] = $missing_item;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
if ($this->option('summary')) {
|
||||
for ($x = 0; $x < count($summary); $x++) {
|
||||
if ($summary[$x]['status'] == 'error') {
|
||||
@@ -509,4 +547,23 @@ class LdapSync extends Command
|
||||
return $summary;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user is deletable without gate check
|
||||
*
|
||||
* A user is considered deletable if they have no associated assets, accessories, licenses, consumables, managed users, or managed locations.
|
||||
*
|
||||
* @param User $user The user to check
|
||||
*
|
||||
* @return bool True if the user is deletable, false otherwise
|
||||
*/
|
||||
private function isUserDeletable(User $user): bool
|
||||
{
|
||||
return (($user->assets_count ?? $user->assets()->count()) === 0)
|
||||
&& (($user->accessories_count ?? $user->accessories()->count()) === 0)
|
||||
&& (($user->licenses_count ?? $user->licenses()->count()) === 0)
|
||||
&& (($user->consumables_count ?? $user->consumables()->count()) === 0)
|
||||
&& (($user->manages_users_count ?? $user->managesUsers()->count()) === 0)
|
||||
&& (($user->manages_locations_count ?? $user->managedLocations()->count()) === 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,29 +2,32 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use App\Models\Ldap;
|
||||
use App\Models\Setting;
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use App\Models\Ldap;
|
||||
|
||||
/**
|
||||
* Check if a given ip is in a network
|
||||
* @param string $ip IP to check in IPV4 format eg. 127.0.0.1
|
||||
* @param string $range IP/CIDR netmask eg. 127.0.0.0/24, also 127.0.0.1 is accepted and /32 assumed
|
||||
* @return boolean true if the ip is in this range / false if not.
|
||||
*
|
||||
* @param string $ip IP to check in IPV4 format eg. 127.0.0.1
|
||||
* @param string $range IP/CIDR netmask eg. 127.0.0.0/24, also 127.0.0.1 is accepted and /32 assumed
|
||||
* @return bool true if the ip is in this range / false if not.
|
||||
*/
|
||||
function ip_in_range( $ip, $range ) {
|
||||
if ( strpos( $range, '/' ) == false ) {
|
||||
$range .= '/32';
|
||||
}
|
||||
// $range is in IP/CIDR format eg 127.0.0.1/24
|
||||
list( $range, $netmask ) = explode( '/', $range, 2 );
|
||||
$range_decimal = ip2long( $range );
|
||||
$ip_decimal = ip2long( $ip );
|
||||
$wildcard_decimal = pow( 2, ( 32 - $netmask ) ) - 1;
|
||||
$netmask_decimal = ~ $wildcard_decimal;
|
||||
return ( ( $ip_decimal & $netmask_decimal ) == ( $range_decimal & $netmask_decimal ) );
|
||||
function ip_in_range($ip, $range)
|
||||
{
|
||||
if (strpos($range, '/') == false) {
|
||||
$range .= '/32';
|
||||
}
|
||||
// $range is in IP/CIDR format eg 127.0.0.1/24
|
||||
[$range, $netmask] = explode('/', $range, 2);
|
||||
$range_decimal = ip2long($range);
|
||||
$ip_decimal = ip2long($ip);
|
||||
$wildcard_decimal = pow(2, (32 - $netmask)) - 1;
|
||||
$netmask_decimal = ~$wildcard_decimal;
|
||||
|
||||
return ($ip_decimal & $netmask_decimal) == ($range_decimal & $netmask_decimal);
|
||||
}
|
||||
// NOTE - this function was shamelessly stolen from this gist: https://gist.github.com/tott/7684443
|
||||
|
||||
@@ -33,10 +36,10 @@ function ip_in_range( $ip, $range ) {
|
||||
*/
|
||||
function parenthesized_filter($filter)
|
||||
{
|
||||
if(substr($filter,0,1) == "(" ) {
|
||||
if (substr($filter, 0, 1) == '(') {
|
||||
return $filter;
|
||||
} else {
|
||||
return "(".$filter.")";
|
||||
return '('.$filter.')';
|
||||
}
|
||||
|
||||
}
|
||||
@@ -74,41 +77,44 @@ class LdapTroubleshooter extends Command
|
||||
|
||||
/**
|
||||
* Output something *only* if debug is enabled
|
||||
*
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function debugout($string)
|
||||
{
|
||||
if($this->option('debug')) {
|
||||
if ($this->option('debug')) {
|
||||
$this->line($string);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean the results from ldap_get_entries into something useful
|
||||
* @param array $array
|
||||
*
|
||||
* @param array $array
|
||||
* @return array
|
||||
*/
|
||||
public function ldap_results_cleaner ($array) {
|
||||
public function ldap_results_cleaner($array)
|
||||
{
|
||||
$cleaned = [];
|
||||
for($i = 0; $i < $array['count']; $i++) {
|
||||
for ($i = 0; $i < $array['count']; $i++) {
|
||||
$row = $array[$i];
|
||||
$clean_row = [];
|
||||
foreach($row AS $key => $val ) {
|
||||
$this->debugout("Key is: ".$key);
|
||||
if($key == "count" || is_int($key) || $key == "dn") {
|
||||
foreach ($row as $key => $val) {
|
||||
$this->debugout('Key is: '.$key);
|
||||
if ($key == 'count' || is_int($key) || $key == 'dn') {
|
||||
$this->debugout(" and we're gonna skip it\n");
|
||||
|
||||
continue;
|
||||
}
|
||||
$this->debugout(" And that seems fine.\n");
|
||||
if(array_key_exists('count',$val)) {
|
||||
if($val['count'] == 1) {
|
||||
if (array_key_exists('count', $val)) {
|
||||
if ($val['count'] == 1) {
|
||||
$clean_row[$key] = $val[0];
|
||||
} else {
|
||||
unset($val['count']); //these counts are annoying
|
||||
unset($val['count']); // these counts are annoying
|
||||
$elements = [];
|
||||
foreach($val as $entry) {
|
||||
if(isset($ldap_constants[$entry])) {
|
||||
foreach ($val as $entry) {
|
||||
if (isset($ldap_constants[$entry])) {
|
||||
$elements[] = $ldap_constants[$entry];
|
||||
} else {
|
||||
$elements[] = $entry;
|
||||
@@ -122,6 +128,7 @@ class LdapTroubleshooter extends Command
|
||||
}
|
||||
$cleaned[$i] = $clean_row;
|
||||
}
|
||||
|
||||
return $cleaned;
|
||||
}
|
||||
|
||||
@@ -132,58 +139,58 @@ class LdapTroubleshooter extends Command
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
if($this->option('trace')) {
|
||||
ldap_set_option(NULL, LDAP_OPT_DEBUG_LEVEL, 7);
|
||||
if ($this->option('trace')) {
|
||||
ldap_set_option(null, LDAP_OPT_DEBUG_LEVEL, 7);
|
||||
}
|
||||
|
||||
$settings = Setting::getSettings();
|
||||
$this->settings = $settings;
|
||||
if($this->option('ldap-search')) {
|
||||
if(!$this->option('force')) {
|
||||
if ($this->option('ldap-search')) {
|
||||
if (! $this->option('force')) {
|
||||
$confirmation = $this->confirm('WARNING: This command will display your LDAP password on your terminal. Are you sure this is ok?');
|
||||
if(!$confirmation) {
|
||||
if (! $confirmation) {
|
||||
$this->error('ABORTING');
|
||||
exit(-1);
|
||||
}
|
||||
}
|
||||
$output = [];
|
||||
if($settings->ldap_server_cert_ignore) {
|
||||
$this->line("# Ignoring server certificate validity");
|
||||
$output[] = "LDAPTLS_REQCERT=never";
|
||||
if ($settings->ldap_server_cert_ignore) {
|
||||
$this->line('# Ignoring server certificate validity');
|
||||
$output[] = 'LDAPTLS_REQCERT=never';
|
||||
}
|
||||
if($settings->ldap_client_tls_cert && $settings->ldap_client_tls_key) {
|
||||
$this->line("# Adding LDAP Client Certificate and Key");
|
||||
$output[] = "LDAPTLS_CERT=storage/ldap_client_tls.cert";
|
||||
$output[] = "LDAPTLS_KEY=storage/ldap_client_tls.key";
|
||||
if ($settings->ldap_client_tls_cert && $settings->ldap_client_tls_key) {
|
||||
$this->line('# Adding LDAP Client Certificate and Key');
|
||||
$output[] = 'LDAPTLS_CERT=storage/ldap_client_tls.cert';
|
||||
$output[] = 'LDAPTLS_KEY=storage/ldap_client_tls.key';
|
||||
}
|
||||
$output[] = "ldapsearch";
|
||||
$output[] = "-H ".$settings->ldap_server;
|
||||
$output[] = "-x";
|
||||
$output[] = "-b ".escapeshellarg($settings->ldap_basedn);
|
||||
$output[] = "-D ".escapeshellarg($settings->ldap_uname);
|
||||
$output[] = 'ldapsearch';
|
||||
$output[] = '-H '.$settings->ldap_server;
|
||||
$output[] = '-x';
|
||||
$output[] = '-b '.escapeshellarg($settings->ldap_basedn);
|
||||
$output[] = '-D '.escapeshellarg($settings->ldap_uname);
|
||||
|
||||
try {
|
||||
$w = Crypt::Decrypt($settings->ldap_pword);
|
||||
} catch (\Exception $e) {
|
||||
$this->warn("Could not decrypt password. This usually means an LDAP password was not set or the APP_KEY was changed since the LDAP pasword was last saved. Aborting.");
|
||||
} catch (Exception $e) {
|
||||
$this->warn('Could not decrypt password. This usually means an LDAP password was not set or the APP_KEY was changed since the LDAP pasword was last saved. Aborting.');
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$output[] = "-w ". escapeshellarg($w);
|
||||
$output[] = '-w '.escapeshellarg($w);
|
||||
$output[] = escapeshellarg(parenthesized_filter($settings->ldap_filter));
|
||||
if($settings->ldap_tls) {
|
||||
$this->line("# adding STARTTLS option");
|
||||
$output[] = "-Z";
|
||||
if ($settings->ldap_tls) {
|
||||
$this->line('# adding STARTTLS option');
|
||||
$output[] = '-Z';
|
||||
}
|
||||
$output[] = "-v";
|
||||
$output[] = '-v';
|
||||
$this->line("\n");
|
||||
$this->line(implode(" \\\n",$output));
|
||||
$this->line(implode(" \\\n", $output));
|
||||
exit(0);
|
||||
}
|
||||
|
||||
//PHP Version check for warning
|
||||
// PHP Version check for warning
|
||||
$php_version = phpversion();
|
||||
list($major, $minor, $patch) = explode('.', $php_version);
|
||||
[$major, $minor, $patch] = explode('.', $php_version);
|
||||
if (
|
||||
$major < 8 ||
|
||||
($major == 8 && $minor < 3) ||
|
||||
@@ -191,22 +198,22 @@ class LdapTroubleshooter extends Command
|
||||
($major == 8 && $minor == 4 && $patch < 7)
|
||||
) {
|
||||
$this->warn("PHP Version: $php_version WARNING - Versions before 8.3.21 or 8.4.7 will return INCONSISTENT results!");
|
||||
if (!$this->confirm("Are you sure you wish to continue?")) {
|
||||
$this->warn("ABORTING");
|
||||
if (! $this->confirm('Are you sure you wish to continue?')) {
|
||||
$this->warn('ABORTING');
|
||||
exit(-1);
|
||||
}
|
||||
}
|
||||
|
||||
if(!$this->option('force')) {
|
||||
if (! $this->option('force')) {
|
||||
$confirmation = $this->confirm('WARNING: This command will make several attempts to connect to your LDAP server. Are you sure this is ok?');
|
||||
if(!$confirmation) {
|
||||
if (! $confirmation) {
|
||||
$this->error('ABORTING');
|
||||
exit(-1);
|
||||
}
|
||||
}
|
||||
//$this->line(print_r($settings,true));
|
||||
$this->line("STAGE 1: Checking settings");
|
||||
if(!$settings->ldap_enabled) {
|
||||
// $this->line(print_r($settings,true));
|
||||
$this->line('STAGE 1: Checking settings');
|
||||
if (! $settings->ldap_enabled) {
|
||||
$this->error("WARNING: Snipe-IT's LDAP setting is not turned on. (That may be OK if you're still trying to figure out settings)");
|
||||
}
|
||||
|
||||
@@ -214,123 +221,126 @@ class LdapTroubleshooter extends Command
|
||||
try {
|
||||
$ldap_conn = ldap_connect($settings->ldap_server);
|
||||
} catch (Exception $e) {
|
||||
$this->error("WARNING: Exception caught when executing 'ldap_connect()' - ".$e->getMessage().". We will try to guess.");
|
||||
$this->error("WARNING: Exception caught when executing 'ldap_connect()' - ".$e->getMessage().'. We will try to guess.');
|
||||
}
|
||||
|
||||
if(!$ldap_conn) {
|
||||
$this->error("WARNING: LDAP Server setting of: ".$settings->ldap_server." cannot be parsed. We will try to guess.");
|
||||
//exit(-1);
|
||||
if (! $ldap_conn) {
|
||||
$this->error('WARNING: LDAP Server setting of: '.$settings->ldap_server.' cannot be parsed. We will try to guess.');
|
||||
// exit(-1);
|
||||
}
|
||||
//since we never use $ldap_conn again, we don't have to ldap_unbind() it (it's not even connected, tbh - that only happens at bind-time)
|
||||
// since we never use $ldap_conn again, we don't have to ldap_unbind() it (it's not even connected, tbh - that only happens at bind-time)
|
||||
|
||||
$parsed = parse_url($settings->ldap_server);
|
||||
|
||||
if(@$parsed['scheme'] != 'ldap' && @$parsed['scheme'] != 'ldaps') {
|
||||
if (@$parsed['scheme'] != 'ldap' && @$parsed['scheme'] != 'ldaps') {
|
||||
$this->error("WARNING: LDAP URL Scheme of '".@$parsed['scheme']."' is probably incorrect; should usually be ldap or ldaps");
|
||||
}
|
||||
|
||||
if(!@$parsed['host']) {
|
||||
$this->error("ERROR: Cannot determine hostname or IP from ldap URL: ".$settings->ldap_server.". ABORTING.");
|
||||
if (! @$parsed['host']) {
|
||||
$this->error('ERROR: Cannot determine hostname or IP from ldap URL: '.$settings->ldap_server.'. ABORTING.');
|
||||
exit(-1);
|
||||
} else {
|
||||
$this->info("Determined LDAP hostname to be: ".$parsed['host']);
|
||||
$this->info('Determined LDAP hostname to be: '.$parsed['host']);
|
||||
}
|
||||
|
||||
$raw_ips = [];
|
||||
|
||||
if (inet_pton($parsed['host']) !== false) {
|
||||
$this->line($parsed['host'] . " already looks like an address; skipping DNS lookup");
|
||||
$this->line($parsed['host'].' already looks like an address; skipping DNS lookup');
|
||||
$raw_ips[] = $parsed['host'];
|
||||
} else {
|
||||
$this->line("Performing DNS lookup of: " . $parsed['host']);
|
||||
$this->line('Performing DNS lookup of: '.$parsed['host']);
|
||||
$ips = dns_get_record($parsed['host']);
|
||||
|
||||
//$this->info("Host IP is: ".print_r($ips,true));
|
||||
// $this->info("Host IP is: ".print_r($ips,true));
|
||||
|
||||
if (!$ips || count($ips) == 0) {
|
||||
$this->error("ERROR: DNS lookup of host: " . $parsed['host'] . " has failed. ABORTING.");
|
||||
if (! $ips || count($ips) == 0) {
|
||||
$this->error('ERROR: DNS lookup of host: '.$parsed['host'].' has failed. ABORTING.');
|
||||
exit(-1);
|
||||
}
|
||||
$this->debugout("IP's? " . print_r($ips, true));
|
||||
$this->debugout("IP's? ".print_r($ips, true));
|
||||
foreach ($ips as $ip) {
|
||||
if (!isset($ip['ip'])) {
|
||||
if (! isset($ip['ip'])) {
|
||||
continue;
|
||||
}
|
||||
$raw_ips[] = $ip['ip'];
|
||||
}
|
||||
}
|
||||
foreach ($raw_ips as $ip) {
|
||||
if ($ip == "127.0.0.1") {
|
||||
$this->error("WARNING: Using the localhost IP as the LDAP server. This is usually wrong");
|
||||
if ($ip == '127.0.0.1') {
|
||||
$this->error('WARNING: Using the localhost IP as the LDAP server. This is usually wrong');
|
||||
}
|
||||
if (ip_in_range($ip, '10.0.0.0/8') || ip_in_range($ip, '192.168.0.0/16') || ip_in_range($ip, '172.16.0.0/12')) {
|
||||
$this->error("WARNING: Using an RFC1918 Private address for LDAP server. This may be correct, but it can be a problem if your Snipe-IT instance is not hosted on your private network");
|
||||
$this->error('WARNING: Using an RFC1918 Private address for LDAP server. This may be correct, but it can be a problem if your Snipe-IT instance is not hosted on your private network');
|
||||
}
|
||||
}
|
||||
|
||||
$this->line("STAGE 2: Checking basic network connectivity");
|
||||
$this->line('STAGE 2: Checking basic network connectivity');
|
||||
$ports = [636, 389];
|
||||
if(@$parsed['port'] && !in_array($parsed['port'],$ports)) {
|
||||
if (@$parsed['port'] && ! in_array($parsed['port'], $ports)) {
|
||||
$ports[] = $parsed['port'];
|
||||
}
|
||||
|
||||
$open_ports=[];
|
||||
foreach($ports as $port ) {
|
||||
$open_ports = [];
|
||||
foreach ($ports as $port) {
|
||||
$errno = 0;
|
||||
$errstr = '';
|
||||
$timeout = 30.0;
|
||||
$result = '';
|
||||
$this->line("Attempting to connect to port: " . $port . " - may take up to $timeout seconds");
|
||||
$this->line('Attempting to connect to port: '.$port." - may take up to $timeout seconds");
|
||||
try {
|
||||
$result = fsockopen($parsed['host'], $port, $errno, $errstr, 30.0);
|
||||
} catch(Exception $e) {
|
||||
$this->error("Exception: ".$e->getMessage());
|
||||
} catch (Exception $e) {
|
||||
$this->error('Exception: '.$e->getMessage());
|
||||
}
|
||||
if($result) {
|
||||
$this->info("Success!");
|
||||
if ($result) {
|
||||
$this->info('Success!');
|
||||
$open_ports[] = $port;
|
||||
} else {
|
||||
$this->error("WARNING: Cannot connect to port: $port - $errstr ($errno)");
|
||||
}
|
||||
}
|
||||
|
||||
if(count($open_ports) == 0) {
|
||||
$this->error("ERROR - no open ports. ABORTING.");
|
||||
if (count($open_ports) == 0) {
|
||||
$this->error('ERROR - no open ports. ABORTING.');
|
||||
exit(-1);
|
||||
}
|
||||
|
||||
$this->line("STAGE 3: Determine encryption algorithm, if any");
|
||||
$this->line('STAGE 3: Determine encryption algorithm, if any');
|
||||
|
||||
$ldap_urls = []; // [url, cert-check?, start_tls?]
|
||||
$pretty_ldap_urls = [];
|
||||
foreach($open_ports as $port) {
|
||||
foreach ($open_ports as $port) {
|
||||
$this->line("Trying TLS first for port $port");
|
||||
$ldap_url = "ldaps://".$parsed['host'].":$port";
|
||||
if($this->test_anonymous_bind($ldap_url)) {
|
||||
$ldap_url = 'ldaps://'.$parsed['host'].":$port";
|
||||
if ($this->test_anonymous_bind($ldap_url)) {
|
||||
$this->info("Anonymous bind succesful to $ldap_url!");
|
||||
$ldap_urls[] = [ $ldap_url, true, false ];
|
||||
$pretty_ldap_urls[] = [$ldap_url, "enabled", "n/a (no)"];
|
||||
$ldap_urls[] = [$ldap_url, true, false];
|
||||
$pretty_ldap_urls[] = [$ldap_url, 'enabled', 'n/a (no)'];
|
||||
|
||||
continue; // TODO - lots of copypasta in these if(test_anonymous_bind()) routines...
|
||||
} else {
|
||||
$this->error("WARNING: Failed to bind to $ldap_url - trying without certificate checks.");
|
||||
}
|
||||
|
||||
if($this->test_anonymous_bind($ldap_url, false)) {
|
||||
if ($this->test_anonymous_bind($ldap_url, false)) {
|
||||
$this->info("Anonymous bind successful to $ldap_url with certificate-checks disabled");
|
||||
$ldap_urls[] = [$ldap_url, false, false];
|
||||
$pretty_ldap_urls[] = [$ldap_url, "DISABLED", "n/a (no)"];
|
||||
$pretty_ldap_urls[] = [$ldap_url, 'DISABLED', 'n/a (no)'];
|
||||
|
||||
continue;
|
||||
} else {
|
||||
$this->error("WARNING: Failed to bind to $ldap_url with certificate checks disabled. Trying unencrypted with STARTTLS");
|
||||
}
|
||||
|
||||
// now switching to ldap:// URL's from ldaps://
|
||||
$ldap_url = "ldap://".$parsed['host'].":$port";
|
||||
$ldap_url = 'ldap://'.$parsed['host'].":$port";
|
||||
|
||||
if($this->test_anonymous_bind($ldap_url, true, true)) {
|
||||
if ($this->test_anonymous_bind($ldap_url, true, true)) {
|
||||
$this->info("Plain connection to $ldap_url with STARTTLS succesful!");
|
||||
$ldap_urls[] = [ $ldap_url, true, true ];
|
||||
$pretty_ldap_urls[] = [$ldap_url, "enabled", "STARTTLS ENABLED"];
|
||||
$ldap_urls[] = [$ldap_url, true, true];
|
||||
$pretty_ldap_urls[] = [$ldap_url, 'enabled', 'STARTTLS ENABLED'];
|
||||
|
||||
continue;
|
||||
} else {
|
||||
$this->error("WARNING: Failed to bind to $ldap_url with STARTTLS enabled. Trying without certificate checks.");
|
||||
@@ -339,224 +349,235 @@ class LdapTroubleshooter extends Command
|
||||
if ($this->test_anonymous_bind($ldap_url, false, true)) {
|
||||
$this->info("Plain connection to $ldap_url with STARTTLS and cert checks *disabled* successful!");
|
||||
$ldap_urls[] = [$ldap_url, false, true];
|
||||
$pretty_ldap_urls[] = [$ldap_url, "DISABLED", "STARTTLS ENABLED"];
|
||||
$pretty_ldap_urls[] = [$ldap_url, 'DISABLED', 'STARTTLS ENABLED'];
|
||||
|
||||
continue;
|
||||
} else {
|
||||
$this->error("WARNING: Failed to bind to $ldap_url with STARTTLS enabled, and cert checks disabled. Trying without STARTTLS");
|
||||
}
|
||||
|
||||
if($this->test_anonymous_bind($ldap_url)) {
|
||||
if ($this->test_anonymous_bind($ldap_url)) {
|
||||
$this->info("Plain connection to $ldap_url succesful!");
|
||||
$ldap_urls[] = [ $ldap_url, true, false ];
|
||||
$pretty_ldap_urls[] = [$ldap_url, "n/a", "starttls disabled"];
|
||||
$ldap_urls[] = [$ldap_url, true, false];
|
||||
$pretty_ldap_urls[] = [$ldap_url, 'n/a', 'starttls disabled'];
|
||||
|
||||
continue;
|
||||
} else {
|
||||
$this->error("WARNING: Failed to bind to $ldap_url. Giving up on port $port");
|
||||
}
|
||||
}
|
||||
|
||||
$this->debugout(print_r($ldap_urls,true));
|
||||
$this->debugout(print_r($ldap_urls, true));
|
||||
|
||||
if(count($ldap_urls) > 0 ) {
|
||||
if (count($ldap_urls) > 0) {
|
||||
$this->debugout("Found working LDAP URL's: ");
|
||||
foreach($ldap_urls as $ldap_url) { // TODO maybe do this as a $this->table() instead?
|
||||
$this->debugout("LDAP URL: " . $ldap_url[0]);
|
||||
$this->debugout($ldap_url[0] . ($ldap_url[1] ? " certificate checks enabled" : " certificate checks disabled") . ($ldap_url[2] ? " STARTTLS Enabled " : " STARTTLS Disabled"));
|
||||
foreach ($ldap_urls as $ldap_url) { // TODO maybe do this as a $this->table() instead?
|
||||
$this->debugout('LDAP URL: '.$ldap_url[0]);
|
||||
$this->debugout($ldap_url[0].($ldap_url[1] ? ' certificate checks enabled' : ' certificate checks disabled').($ldap_url[2] ? ' STARTTLS Enabled ' : ' STARTTLS Disabled'));
|
||||
}
|
||||
$this->table(["URL", "Cert Checks?", "STARTTLS?"], $pretty_ldap_urls);
|
||||
$this->table(['URL', 'Cert Checks?', 'STARTTLS?'], $pretty_ldap_urls);
|
||||
} else {
|
||||
$this->error("ERROR - no valid LDAP URL's available - ABORTING");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$this->line("STAGE 4: Test Administrative Bind for LDAP Sync");
|
||||
foreach($ldap_urls AS $ldap_url) {
|
||||
$this->line('STAGE 4: Test Administrative Bind for LDAP Sync');
|
||||
foreach ($ldap_urls as $ldap_url) {
|
||||
try {
|
||||
$w = Crypt::Decrypt($settings->ldap_pword);
|
||||
} catch (\Exception $e) {
|
||||
$this->warn("Could not decrypt password. This usually means an LDAP password was not set or the APP_KEY was changed since the LDAP pasword was last saved. Aborting.");
|
||||
} catch (Exception $e) {
|
||||
$this->warn('Could not decrypt password. This usually means an LDAP password was not set or the APP_KEY was changed since the LDAP pasword was last saved. Aborting.');
|
||||
exit(0);
|
||||
}
|
||||
$this->test_authed_bind($ldap_url[0], $ldap_url[1], $ldap_url[2], $settings->ldap_uname, $w);
|
||||
}
|
||||
|
||||
$this->line("STAGE 5: Test BaseDN");
|
||||
//grab all LDAP_ constants and fill up a reversed array mapping from weird LDAP dotted-strings to (Constant Name)
|
||||
$this->line('STAGE 5: Test BaseDN');
|
||||
// grab all LDAP_ constants and fill up a reversed array mapping from weird LDAP dotted-strings to (Constant Name)
|
||||
$all_defined_constants = get_defined_constants();
|
||||
$ldap_constants = [];
|
||||
foreach($all_defined_constants AS $key => $val) {
|
||||
if(starts_with($key,"LDAP_") && is_string($val)) {
|
||||
foreach ($all_defined_constants as $key => $val) {
|
||||
if (starts_with($key, 'LDAP_') && is_string($val)) {
|
||||
$ldap_constants[$val] = $key; // INVERT the meaning here!
|
||||
}
|
||||
}
|
||||
$this->debugout("LDAP constants are: ".print_r($ldap_constants,true));
|
||||
$this->debugout('LDAP constants are: '.print_r($ldap_constants, true));
|
||||
|
||||
foreach($ldap_urls AS $ldap_url) {
|
||||
foreach ($ldap_urls as $ldap_url) {
|
||||
try {
|
||||
$w = Crypt::Decrypt($settings->ldap_pword);
|
||||
} catch (\Exception $e) {
|
||||
$this->warn("Could not decrypt password. This usually means an LDAP password was not set or the APP_KEY was changed since the LDAP pasword was last saved. Aborting.");
|
||||
} catch (Exception $e) {
|
||||
$this->warn('Could not decrypt password. This usually means an LDAP password was not set or the APP_KEY was changed since the LDAP pasword was last saved. Aborting.');
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if($this->test_informational_bind($ldap_url[0],$ldap_url[1],$ldap_url[2],$settings->ldap_uname,$w,$settings)) {
|
||||
$this->info("Success getting informational bind!");
|
||||
if ($this->test_informational_bind($ldap_url[0], $ldap_url[1], $ldap_url[2], $settings->ldap_uname, $w, $settings)) {
|
||||
$this->info('Success getting informational bind!');
|
||||
} else {
|
||||
$this->error("Unable to get information from bind.");
|
||||
$this->error('Unable to get information from bind.');
|
||||
}
|
||||
}
|
||||
|
||||
$this->line("STAGE 6: Test LDAP Login to Snipe-IT");
|
||||
foreach($ldap_urls AS $ldap_url) {
|
||||
$this->line("Starting auth to " . $ldap_url[0]);
|
||||
while(true) {
|
||||
$with_tls = $ldap_url[1] ? "with": "without";
|
||||
$with_startssl = $ldap_url[2] ? "using": "not using";
|
||||
if(!$this->confirm('Do you wish to try to authenticate to this directory: '.$ldap_url[0]." $with_tls TLS and $with_startssl STARTSSL?")) {
|
||||
$this->line('STAGE 6: Test LDAP Login to Snipe-IT');
|
||||
foreach ($ldap_urls as $ldap_url) {
|
||||
$this->line('Starting auth to '.$ldap_url[0]);
|
||||
while (true) {
|
||||
$with_tls = $ldap_url[1] ? 'with' : 'without';
|
||||
$with_startssl = $ldap_url[2] ? 'using' : 'not using';
|
||||
if (! $this->confirm('Do you wish to try to authenticate to this directory: '.$ldap_url[0]." $with_tls TLS and $with_startssl STARTSSL?")) {
|
||||
break;
|
||||
}
|
||||
$username = $this->ask("Username");
|
||||
$password = $this->secret("Password");
|
||||
$username = $this->ask('Username');
|
||||
$password = $this->secret('Password');
|
||||
$results = $this->test_authed_bind($ldap_url[0], $ldap_url[1], $ldap_url[2], $username, $password); // FIXME - should do some other stuff here, maybe with the concatenating or something? maybe? and/or should put up some results?
|
||||
if ($results) {
|
||||
$this->info("Success authenticating with " . $username);
|
||||
$this->info('Success authenticating with '.$username);
|
||||
} else {
|
||||
$this->error("Unable to authenticate with " . $username);
|
||||
$this->error('Unable to authenticate with '.$username);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("LDAP TROUBLESHOOTING COMPLETE!");
|
||||
$this->info('LDAP TROUBLESHOOTING COMPLETE!');
|
||||
}
|
||||
|
||||
public function connect_to_ldap($ldap_url, $check_cert, $start_tls)
|
||||
public function connect_to_ldap($ldap_url, $check_cert, $start_tls)
|
||||
{
|
||||
if ($check_cert) {
|
||||
$this->line("we *ARE* checking certs");
|
||||
$this->line('we *ARE* checking certs');
|
||||
Ldap::ignoreCertificates(false);
|
||||
|
||||
} else {
|
||||
$this->line("we are IGNORING certs");
|
||||
$this->line('we are IGNORING certs');
|
||||
Ldap::ignoreCertificates(true);
|
||||
}
|
||||
$lconn = ldap_connect($ldap_url);
|
||||
ldap_set_option($lconn, LDAP_OPT_PROTOCOL_VERSION, 3); // should we 'test' different protocol versions here? Does anyone even use anything other than LDAPv3?
|
||||
// no - it's formally deprecated: https://tools.ietf.org/html/rfc3494
|
||||
if($this->settings->ldap_client_tls_cert && $this->settings->ldap_client_tls_key) {
|
||||
// no - it's formally deprecated: https://tools.ietf.org/html/rfc3494
|
||||
if ($this->settings->ldap_client_tls_cert && $this->settings->ldap_client_tls_key) {
|
||||
// client-side TLS certificate support for LDAP (Google Secure LDAP)
|
||||
putenv('LDAPTLS_CERT=storage/ldap_client_tls.cert');
|
||||
putenv('LDAPTLS_KEY=storage/ldap_client_tls.key');
|
||||
}
|
||||
if($start_tls) {
|
||||
if(!ldap_start_tls($lconn)) {
|
||||
$this->error("WARNING: Unable to start TLS");
|
||||
if ($start_tls) {
|
||||
if (! ldap_start_tls($lconn)) {
|
||||
$this->error('WARNING: Unable to start TLS');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if(!$lconn) {
|
||||
$this->error("WARNING: Failed to generate connection string - using: ".$ldap_url);
|
||||
if (! $lconn) {
|
||||
$this->error('WARNING: Failed to generate connection string - using: '.$ldap_url);
|
||||
|
||||
return false;
|
||||
}
|
||||
$net = ldap_set_option($lconn, LDAP_OPT_NETWORK_TIMEOUT, $this->option('timeout'));
|
||||
$time = ldap_set_option($lconn, LDAP_OPT_TIMELIMIT, $this->option('timeout'));
|
||||
if(!$net || !$time) {
|
||||
$this->error("Unable to set timeouts!");
|
||||
if (! $net || ! $time) {
|
||||
$this->error('Unable to set timeouts!');
|
||||
}
|
||||
|
||||
return $lconn;
|
||||
}
|
||||
|
||||
public function test_anonymous_bind($ldap_url, $check_cert = true, $start_tls = false)
|
||||
{
|
||||
return $this->timed_boolean_execute(function () use ($ldap_url, $check_cert , $start_tls) {
|
||||
return $this->timed_boolean_execute(function () use ($ldap_url, $check_cert, $start_tls) {
|
||||
try {
|
||||
$lconn = $this->connect_to_ldap($ldap_url, $check_cert, $start_tls);
|
||||
$this->line("Attempting to bind now, this can take a while if we mess it up");
|
||||
$this->line('Attempting to bind now, this can take a while if we mess it up');
|
||||
$bind_results = ldap_bind($lconn);
|
||||
$this->line("Bind results are: " . $bind_results . " which translate into boolean: " . (bool)$bind_results);
|
||||
$this->line('Bind results are: '.$bind_results.' which translate into boolean: '.(bool) $bind_results);
|
||||
ldap_close($lconn);
|
||||
return (bool)$bind_results;
|
||||
|
||||
return (bool) $bind_results;
|
||||
} catch (Exception $e) {
|
||||
$this->error("WARNING: Exception caught during bind - ".$e->getMessage());
|
||||
$this->error('WARNING: Exception caught during bind - '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function test_authed_bind($ldap_url, $check_cert, $start_tls, $username, $password)
|
||||
public function test_authed_bind($ldap_url, $check_cert, $start_tls, $username, $password)
|
||||
{
|
||||
return $this->timed_boolean_execute(function () use ($ldap_url, $check_cert, $start_tls, $username, $password) {
|
||||
try {
|
||||
$lconn = $this->connect_to_ldap($ldap_url, $check_cert, $start_tls);
|
||||
$bind_results = ldap_bind($lconn, $username, $password);
|
||||
ldap_close($lconn);
|
||||
if(!$bind_results) {
|
||||
if (! $bind_results) {
|
||||
$this->error("WARNING: Failed to bind to $ldap_url as $username");
|
||||
|
||||
return false;
|
||||
} else {
|
||||
$this->info("SUCCESS - Able to bind to $ldap_url as $username");
|
||||
return (bool)$lconn;
|
||||
|
||||
return (bool) $lconn;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$this->error("WARNING: Exception caught during Authed bind to $username - ".$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function test_informational_bind($ldap_url, $check_cert, $start_tls, $username, $password,$settings)
|
||||
public function test_informational_bind($ldap_url, $check_cert, $start_tls, $username, $password, $settings)
|
||||
{
|
||||
return $this->timed_boolean_execute(function () use ($ldap_url, $check_cert, $start_tls, $username, $password, $settings) {
|
||||
try { // TODO - copypasta'ed from test_authed_bind
|
||||
$conn = $this->connect_to_ldap($ldap_url, $check_cert, $start_tls);
|
||||
$bind_results = ldap_bind($conn, $username, $password);
|
||||
if(!$bind_results) {
|
||||
if (! $bind_results) {
|
||||
$this->error("WARNING: Failed to bind to $ldap_url as $username");
|
||||
|
||||
return false;
|
||||
}
|
||||
$this->info("SUCCESS - Able to bind to $ldap_url as $username");
|
||||
$cleaned_results = [];
|
||||
try {
|
||||
// This _may_ only work for Active Directory?
|
||||
$result = ldap_read($conn, '', '(objectClass=*)'/* , ['supportedControl']*/);
|
||||
$result = ldap_read($conn, '', '(objectClass=*)'/* , ['supportedControl'] */);
|
||||
$results = ldap_get_entries($conn, $result);
|
||||
$cleaned_results = $this->ldap_results_cleaner($results);
|
||||
//$this->line(print_r($cleaned_results,true));
|
||||
// $this->line(print_r($cleaned_results,true));
|
||||
$default_naming_contexts = $cleaned_results[0]['namingcontexts'];
|
||||
$this->info("Default Naming Contexts:");
|
||||
$this->info(implode(", ", $default_naming_contexts));
|
||||
//okay, great - now how do we display those results? I have no idea.
|
||||
} catch (\Exception $e) {
|
||||
$this->info('Default Naming Contexts:');
|
||||
$this->info(implode(', ', $default_naming_contexts));
|
||||
// okay, great - now how do we display those results? I have no idea.
|
||||
} catch (Exception $e) {
|
||||
$this->error("Unable to get base naming contexts - here's what we *did* get:");
|
||||
$this->line(print_r($cleaned_results, true));
|
||||
}
|
||||
// I don't see why this throws an Exception for Google LDAP, but I guess we ought to try and catch it?
|
||||
$this->debugout("I guess we're trying to do the ldap search here, but sometimes it takes too long?");
|
||||
$this->debugout("Base DN is: ".$settings->ldap_basedn." and filter is: ".parenthesized_filter($settings->ldap_filter));
|
||||
$this->debugout('Base DN is: '.$settings->ldap_basedn.' and filter is: '.parenthesized_filter($settings->ldap_filter));
|
||||
$search_results = ldap_search($conn, $settings->ldap_basedn, parenthesized_filter($settings->ldap_filter));
|
||||
$entries = ldap_get_entries($conn, $search_results);
|
||||
$this->info("Printing first 10 results: ");
|
||||
$this->info('Printing first 10 results: ');
|
||||
$pretty_data = array_slice($this->ldap_results_cleaner($entries), 0, 10);
|
||||
//print_r($data);
|
||||
// print_r($data);
|
||||
$headers = [];
|
||||
foreach ($pretty_data as $row) {
|
||||
//populate headers
|
||||
// populate headers
|
||||
foreach ($row as $key => $value) {
|
||||
//skip objectsid and objectguid because it junks up output
|
||||
if ($key == "objectsid" || $key == "objectguid") {
|
||||
// skip objectsid and objectguid because it junks up output
|
||||
if ($key == 'objectsid' || $key == 'objectguid') {
|
||||
continue;
|
||||
}
|
||||
if (!in_array($key, $headers)) {
|
||||
if (! in_array($key, $headers)) {
|
||||
$headers[] = $key;
|
||||
}
|
||||
}
|
||||
}
|
||||
$table = [];
|
||||
//repeat again to populate table
|
||||
// repeat again to populate table
|
||||
foreach ($pretty_data as $row) {
|
||||
$newrow = [];
|
||||
foreach ($headers as $header) {
|
||||
if (is_array(@$row[$header])) {
|
||||
$newrow[] = "[" . implode(", ", $row[$header]) . "]";
|
||||
$newrow[] = '['.implode(', ', $row[$header]).']';
|
||||
} else {
|
||||
$newrow[] = @$row[$header];
|
||||
}
|
||||
@@ -565,8 +586,9 @@ class LdapTroubleshooter extends Command
|
||||
}
|
||||
|
||||
$this->table($headers, $table);
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
$this->error("WARNING: Exception caught during Authed bind to $username - ".$e->getMessage());
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
ldap_close($conn);
|
||||
@@ -575,53 +597,54 @@ class LdapTroubleshooter extends Command
|
||||
}
|
||||
|
||||
/***********************************************
|
||||
*
|
||||
* This function executes $function - which is expected to be some kind of executable function -
|
||||
*
|
||||
* This function executes $function - which is expected to be some kind of executable function -
|
||||
* with a timeout set. It respects the timeout by forking execution and setting a strict timer
|
||||
* for which to get back a SIGUSR1 or SIGUSR2 signal from the forked process.
|
||||
*
|
||||
*
|
||||
***********************************************/
|
||||
private function timed_boolean_execute($function)
|
||||
{
|
||||
if(!(function_exists('pcntl_sigtimedwait') && function_exists('posix_getpid') && function_exists('pcntl_fork') && function_exists('posix_kill') && function_exists('pcntl_wifsignaled'))) {
|
||||
if (! (function_exists('pcntl_sigtimedwait') && function_exists('posix_getpid') && function_exists('pcntl_fork') && function_exists('posix_kill') && function_exists('pcntl_wifsignaled'))) {
|
||||
// POSIX functions needed for forking aren't present, just run the function inline (ignoring timeout)
|
||||
$this->line('WARNING: Unable to execute POSIX fork() commands, timeout may not be respected');
|
||||
|
||||
return $function();
|
||||
} else {
|
||||
$parent_pid = posix_getpid();
|
||||
$pid = pcntl_fork();
|
||||
switch($pid) {
|
||||
switch ($pid) {
|
||||
case 0:
|
||||
//we're the 'child'
|
||||
if($function()) {
|
||||
//SUCCESS = SIGUSR1
|
||||
// we're the 'child'
|
||||
if ($function()) {
|
||||
// SUCCESS = SIGUSR1
|
||||
posix_kill($parent_pid, SIGUSR1);
|
||||
} else {
|
||||
//FAILURE = SIGUSR2
|
||||
// FAILURE = SIGUSR2
|
||||
posix_kill($parent_pid, SIGUSR2);
|
||||
}
|
||||
exit();
|
||||
break; //yes I know we don't need it.
|
||||
break; // yes I know we don't need it.
|
||||
case -1:
|
||||
//couldn't fork
|
||||
$this->error("COULD NOT FORK - assuming failure");
|
||||
// couldn't fork
|
||||
$this->error('COULD NOT FORK - assuming failure');
|
||||
|
||||
return false;
|
||||
break; //I still know that we don't need it
|
||||
break; // I still know that we don't need it
|
||||
default:
|
||||
//we remain the 'parent', $pid is the PID of the forked process.
|
||||
// we remain the 'parent', $pid is the PID of the forked process.
|
||||
$siginfo = [];
|
||||
$exit_status = pcntl_sigtimedwait ([SIGUSR1, SIGUSR2], $siginfo, $this->option('timeout'));
|
||||
$exit_status = pcntl_sigtimedwait([SIGUSR1, SIGUSR2], $siginfo, $this->option('timeout'));
|
||||
if ($exit_status == SIGUSR1) {
|
||||
return true;
|
||||
} else {
|
||||
posix_kill($pid, SIGKILL); //make sure we don't have processes hanging around that might try and send signals during later executions, confusing us
|
||||
posix_kill($pid, SIGKILL); // make sure we don't have processes hanging around that might try and send signals during later executions, confusing us
|
||||
|
||||
return false;
|
||||
}
|
||||
break; //Yeah I get it already, shush.
|
||||
break; // Yeah I get it already, shush.
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -44,19 +44,15 @@ class MergeUsersByUsername extends Command
|
||||
$users = User::where('username', 'LIKE', '%@%')->whereNull('deleted_at')->get();
|
||||
$this->info($users->count().' total non-deleted users whose usernames contain a @ symbol.');
|
||||
|
||||
|
||||
foreach ($users as $user) {
|
||||
$parts = explode('@', trim($user->username));
|
||||
$this->info('Checking against username '.trim($parts[0]).'.');
|
||||
|
||||
|
||||
$bad_users = User::where('username', '=', trim($parts[0]))
|
||||
->whereNull('deleted_at')
|
||||
->with('assets', 'manager', 'userlog', 'licenses', 'consumables', 'accessories', 'managedLocations','uploads', 'acceptances')
|
||||
->with('assets', 'manager', 'userlog', 'licenses', 'consumables', 'accessories', 'managedLocations', 'uploads', 'acceptances')
|
||||
->get();
|
||||
|
||||
|
||||
|
||||
foreach ($bad_users as $bad_user) {
|
||||
$this->info($bad_user->username.' ('.$bad_user->id.') will be merged into '.$user->username.' ('.$user->id.') ');
|
||||
|
||||
@@ -125,7 +121,6 @@ class MergeUsersByUsername extends Command
|
||||
|
||||
event(new UserMerged($bad_user, $user, null));
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace App\Console\Commands;
|
||||
use App\Enums\ActionType;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class MigrateLicenseSeatQuantitiesInActionLogs extends Command
|
||||
@@ -51,9 +50,9 @@ class MigrateLicenseSeatQuantitiesInActionLogs extends Command
|
||||
if ($this->option('no-interaction') || $this->confirm('Update quantities in the action log?')) {
|
||||
$query->chunk(50, function ($logs) {
|
||||
$logs->each(function ($log) {
|
||||
$quantityFromNote = Str::between($log->note, "ed ", " seats");
|
||||
$quantityFromNote = Str::between($log->note, 'ed ', ' seats');
|
||||
|
||||
if (!is_numeric($quantityFromNote)) {
|
||||
if (! is_numeric($quantityFromNote)) {
|
||||
$this->error('Could not parse quantity from ID: {id}', ['id' => $log->id]);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class MoveUploadsToNewDisk extends Command
|
||||
{
|
||||
@@ -47,30 +47,29 @@ class MoveUploadsToNewDisk extends Command
|
||||
}
|
||||
$delete_local = $this->argument('delete_local');
|
||||
|
||||
$public_uploads['accessories'] = glob('public/uploads/accessories'."/*.*");
|
||||
$public_uploads['assets'] = glob('public/uploads/assets'."/*.*");
|
||||
$public_uploads['avatars'] = glob('public/uploads/avatars'."/*.*");
|
||||
$public_uploads['categories'] = glob('public/uploads/categories'."/*.*");
|
||||
$public_uploads['companies'] = glob('public/uploads/companies'."/*.*");
|
||||
$public_uploads['components'] = glob('public/uploads/components'."/*.*");
|
||||
$public_uploads['consumables'] = glob('public/uploads/consumables'."/*.*");
|
||||
$public_uploads['departments'] = glob('public/uploads/departments'."/*.*");
|
||||
$public_uploads['locations'] = glob('public/uploads/locations'."/*.*");
|
||||
$public_uploads['manufacturers'] = glob('public/uploads/manufacturers'."/*.*");
|
||||
$public_uploads['suppliers'] = glob('public/uploads/suppliers'."/*.*");
|
||||
$public_uploads['assetmodels'] = glob('public/uploads/models'."/*.*");
|
||||
|
||||
$public_uploads['accessories'] = glob('public/uploads/accessories'.'/*.*');
|
||||
$public_uploads['assets'] = glob('public/uploads/assets'.'/*.*');
|
||||
$public_uploads['avatars'] = glob('public/uploads/avatars'.'/*.*');
|
||||
$public_uploads['categories'] = glob('public/uploads/categories'.'/*.*');
|
||||
$public_uploads['companies'] = glob('public/uploads/companies'.'/*.*');
|
||||
$public_uploads['components'] = glob('public/uploads/components'.'/*.*');
|
||||
$public_uploads['consumables'] = glob('public/uploads/consumables'.'/*.*');
|
||||
$public_uploads['departments'] = glob('public/uploads/departments'.'/*.*');
|
||||
$public_uploads['locations'] = glob('public/uploads/locations'.'/*.*');
|
||||
$public_uploads['manufacturers'] = glob('public/uploads/manufacturers'.'/*.*');
|
||||
$public_uploads['suppliers'] = glob('public/uploads/suppliers'.'/*.*');
|
||||
$public_uploads['assetmodels'] = glob('public/uploads/models'.'/*.*');
|
||||
|
||||
// iterate files
|
||||
foreach ($public_uploads as $public_type => $public_upload) {
|
||||
$type_count = 0;
|
||||
$this->info('- There are ' . count($public_upload) . ' PUBLIC ' . $public_type . ' files.');
|
||||
$this->info('- There are '.count($public_upload).' PUBLIC '.$public_type.' files.');
|
||||
|
||||
for ($i = 0; $i < count($public_upload); $i++) {
|
||||
$type_count++;
|
||||
$filename = basename($public_upload[$i]);
|
||||
|
||||
try {
|
||||
try {
|
||||
Storage::disk('public')->put('uploads/'.$public_type.'/'.$filename, file_get_contents($public_upload[$i]));
|
||||
$new_url = Storage::disk('public')->url('uploads/'.$public_type.'/'.$filename, $filename);
|
||||
$this->info($type_count.'. PUBLIC: '.$filename.' was copied to '.$new_url);
|
||||
@@ -81,49 +80,46 @@ class MoveUploadsToNewDisk extends Command
|
||||
}
|
||||
}
|
||||
|
||||
$logos = glob("public/uploads/setting*.*");
|
||||
$this->info("- There are ".count($logos).' files that might be logos.');
|
||||
$logos = glob('public/uploads/setting*.*');
|
||||
$this->info('- There are '.count($logos).' files that might be logos.');
|
||||
$type_count = 0;
|
||||
|
||||
foreach ($logos as $logo) {
|
||||
$this->info($logo);
|
||||
$type_count++;
|
||||
$filename = basename($logo);
|
||||
Storage::disk('public')->put('uploads/' . $filename, file_get_contents($logo));
|
||||
$this->info($type_count . '. LOGO: ' . $filename . ' was copied to ' . env('PUBLIC_AWS_URL') . '/uploads/' . $filename);
|
||||
Storage::disk('public')->put('uploads/'.$filename, file_get_contents($logo));
|
||||
$this->info($type_count.'. LOGO: '.$filename.' was copied to '.env('PUBLIC_AWS_URL').'/uploads/'.$filename);
|
||||
}
|
||||
|
||||
$private_uploads['assets'] = glob('storage/private_uploads/assets'."/*.*");
|
||||
$private_uploads['signatures'] = glob('storage/private_uploads/signatures'."/*.*");
|
||||
$private_uploads['audits'] = glob('storage/private_uploads/audits'."/*.*");
|
||||
$private_uploads['assetmodels'] = glob('storage/private_uploads/models'."/*.*");
|
||||
$private_uploads['imports'] = glob('storage/private_uploads/imports'."/*.*");
|
||||
$private_uploads['licenses'] = glob('storage/private_uploads/licenses'."/*.*");
|
||||
$private_uploads['users'] = glob('storage/private_uploads/users'."/*.*");
|
||||
$private_uploads['backups'] = glob('storage/private_uploads/backups'."/*.*");
|
||||
|
||||
$private_uploads['assets'] = glob('storage/private_uploads/assets'.'/*.*');
|
||||
$private_uploads['signatures'] = glob('storage/private_uploads/signatures'.'/*.*');
|
||||
$private_uploads['audits'] = glob('storage/private_uploads/audits'.'/*.*');
|
||||
$private_uploads['assetmodels'] = glob('storage/private_uploads/models'.'/*.*');
|
||||
$private_uploads['imports'] = glob('storage/private_uploads/imports'.'/*.*');
|
||||
$private_uploads['licenses'] = glob('storage/private_uploads/licenses'.'/*.*');
|
||||
$private_uploads['users'] = glob('storage/private_uploads/users'.'/*.*');
|
||||
$private_uploads['backups'] = glob('storage/private_uploads/backups'.'/*.*');
|
||||
|
||||
foreach ($private_uploads as $private_type => $private_upload) {
|
||||
{
|
||||
$this->info('- There are ' . count($private_upload) . ' PRIVATE ' . $private_type . ' files.');
|
||||
|
||||
$type_count = 0;
|
||||
for ($x = 0; $x < count($private_upload); $x++) {
|
||||
$type_count++;
|
||||
$filename = basename($private_upload[$x]);
|
||||
$this->info('- There are '.count($private_upload).' PRIVATE '.$private_type.' files.');
|
||||
|
||||
try {
|
||||
Storage::put($private_type . '/' . $filename, file_get_contents($private_upload[$x]));
|
||||
$new_url = Storage::url($private_type . '/' . $filename, $filename);
|
||||
$this->info($type_count . '. PRIVATE: ' . $filename . ' was copied to ' . $new_url);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
$this->error($e);
|
||||
}
|
||||
$type_count = 0;
|
||||
for ($x = 0; $x < count($private_upload); $x++) {
|
||||
$type_count++;
|
||||
$filename = basename($private_upload[$x]);
|
||||
|
||||
try {
|
||||
Storage::put($private_type.'/'.$filename, file_get_contents($private_upload[$x]));
|
||||
$new_url = Storage::url($private_type.'/'.$filename, $filename);
|
||||
$this->info($type_count.'. PRIVATE: '.$filename.' was copied to '.$new_url);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
$this->error($e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if ($delete_local == 'true') {
|
||||
$public_delete_count = 0;
|
||||
$private_delete_count = 0;
|
||||
@@ -160,7 +156,7 @@ class MoveUploadsToNewDisk extends Command
|
||||
}
|
||||
}
|
||||
|
||||
$this->info($public_delete_count . ' PUBLIC local files and ' . $private_delete_count . ' PRIVATE local files were deleted from your filesystem.');
|
||||
$this->info($public_delete_count.' PUBLIC local files and '.$private_delete_count.' PRIVATE local files were deleted from your filesystem.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class NormalizeUserNames extends Command
|
||||
{
|
||||
@@ -40,13 +40,13 @@ class NormalizeUserNames extends Command
|
||||
{
|
||||
|
||||
$users = User::get();
|
||||
$this->info($users->count() . ' users');
|
||||
$this->info($users->count().' users');
|
||||
|
||||
foreach ($users as $user) {
|
||||
$user->first_name = ucwords(strtolower($user->first_name));
|
||||
$user->last_name = ucwords(strtolower($user->last_name));
|
||||
$user->email = strtolower($user->email);
|
||||
$user->save();
|
||||
foreach ($users as $user) {
|
||||
$user->first_name = ucwords(strtolower($user->first_name));
|
||||
$user->last_name = ucwords(strtolower($user->last_name));
|
||||
$user->email = strtolower($user->email);
|
||||
$user->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\Console\Helper\ProgressIndicator;
|
||||
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
||||
/**
|
||||
* Class ObjectImportCommand
|
||||
@@ -55,7 +54,7 @@ class ObjectImportCommand extends Command
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
ini_set('max_execution_time', env('IMPORT_TIME_LIMIT', 600)); //600 seconds = 10 minutes
|
||||
ini_set('max_execution_time', env('IMPORT_TIME_LIMIT', 600)); // 600 seconds = 10 minutes
|
||||
ini_set('memory_limit', env('IMPORT_MEMORY_LIMIT', '500M'));
|
||||
|
||||
$this->progressIndicator = new ProgressIndicator($this->output);
|
||||
@@ -65,15 +64,15 @@ class ObjectImportCommand extends Command
|
||||
$classString = "App\\Importer\\{$class}Importer";
|
||||
$importer = new $classString($filename);
|
||||
$importer->setCallbacks([$this, 'log'], [$this, 'progress'], [$this, 'errorCallback'])
|
||||
->setCreatedBy($this->option('user_id'))
|
||||
->setUpdating($this->option('update'))
|
||||
->setShouldNotify($this->option('send-welcome'))
|
||||
->setUsernameFormat($this->option('username_format'));
|
||||
->setCreatedBy($this->option('user_id'))
|
||||
->setUpdating($this->option('update'))
|
||||
->setShouldNotify($this->option('send-welcome'))
|
||||
->setUsernameFormat($this->option('username_format'));
|
||||
|
||||
$this->logger = Log::build([
|
||||
'driver' => 'single',
|
||||
'path' => $this->option('logfile'),
|
||||
]);
|
||||
]);
|
||||
|
||||
$this->progressIndicator->start('======= Importing Items from '.$filename.' =========');
|
||||
|
||||
@@ -99,9 +98,11 @@ class ObjectImportCommand extends Command
|
||||
* If a warning message is passed, we'll spit it to the console as well.
|
||||
*
|
||||
* @author Daniel Melzter
|
||||
*
|
||||
* @since 3.0
|
||||
* @param string $string
|
||||
* @param string $level
|
||||
*
|
||||
* @param string $string
|
||||
* @param string $level
|
||||
*/
|
||||
public function log($string, $level = 'info')
|
||||
{
|
||||
@@ -120,7 +121,9 @@ class ObjectImportCommand extends Command
|
||||
* Get the console command arguments.
|
||||
*
|
||||
* @author Daniel Melzter
|
||||
*
|
||||
* @since 3.0
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function getArguments()
|
||||
@@ -134,20 +137,22 @@ class ObjectImportCommand extends Command
|
||||
* Get the console command options.
|
||||
*
|
||||
* @author Daniel Melzter
|
||||
*
|
||||
* @since 3.0
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function getOptions()
|
||||
{
|
||||
return [
|
||||
['email_format', null, InputOption::VALUE_REQUIRED, 'The format of the email addresses that should be generated. Options are firstname.lastname, firstname, filastname', null],
|
||||
['username_format', null, InputOption::VALUE_REQUIRED, 'The format of the username that should be generated. Options are firstname.lastname, firstname, filastname, email', null],
|
||||
['logfile', null, InputOption::VALUE_REQUIRED, 'The path to log output to. storage/logs/importer.log by default', storage_path('logs/importer.log')],
|
||||
['item-type', null, InputOption::VALUE_REQUIRED, 'Item Type To import. Valid Options are Asset, Consumable, Accessory, License, or User', 'Asset'],
|
||||
['web-importer', null, InputOption::VALUE_NONE, 'Internal: packages output for use with the web importer'],
|
||||
['user_id', null, InputOption::VALUE_REQUIRED, 'ID of user creating items', 1],
|
||||
['update', null, InputOption::VALUE_NONE, 'If a matching item is found, update item information'],
|
||||
['send-welcome', null, InputOption::VALUE_NONE, 'Whether to send a welcome email to any new users that are created.'],
|
||||
['email_format', null, InputOption::VALUE_REQUIRED, 'The format of the email addresses that should be generated. Options are firstname.lastname, firstname, filastname', null],
|
||||
['username_format', null, InputOption::VALUE_REQUIRED, 'The format of the username that should be generated. Options are firstname.lastname, firstname, filastname, email', null],
|
||||
['logfile', null, InputOption::VALUE_REQUIRED, 'The path to log output to. storage/logs/importer.log by default', storage_path('logs/importer.log')],
|
||||
['item-type', null, InputOption::VALUE_REQUIRED, 'Item Type To import. Valid Options are Asset, Consumable, Accessory, License, or User', 'Asset'],
|
||||
['web-importer', null, InputOption::VALUE_NONE, 'Internal: packages output for use with the web importer'],
|
||||
['user_id', null, InputOption::VALUE_REQUIRED, 'ID of user creating items', 1],
|
||||
['update', null, InputOption::VALUE_NONE, 'If a matching item is found, update item information'],
|
||||
['send-welcome', null, InputOption::VALUE_NONE, 'Whether to send a welcome email to any new users that are created.'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Asset;
|
||||
use App\Models\CustomField;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class PaveIt extends Command
|
||||
{
|
||||
@@ -42,9 +41,9 @@ class PaveIt extends Command
|
||||
public function handle()
|
||||
{
|
||||
|
||||
if (!$this->option('force')) {
|
||||
if (! $this->option('force')) {
|
||||
$confirmation = $this->confirm("\n****************************************************\nTHIS WILL DELETE ALL OF THE DATA IN YOUR DATABASE. \nThere is NO undo. This WILL destroy ALL of your data, \nINCLUDING ANY non-Snipe-IT tables you have in this database. \n****************************************************\n\nDo you wish to continue? No backsies! ");
|
||||
if (!$confirmation) {
|
||||
if (! $confirmation) {
|
||||
$this->error('ABORTING');
|
||||
exit(-1);
|
||||
}
|
||||
@@ -79,16 +78,16 @@ class PaveIt extends Command
|
||||
foreach ($tables as $table_obj) {
|
||||
$table = $table_obj['name'];
|
||||
if (in_array($table, $except_tables)) {
|
||||
$this->info($table. ' is SKIPPED.');
|
||||
$this->info($table.' is SKIPPED.');
|
||||
} else {
|
||||
\DB::statement('truncate '.$table);
|
||||
$this->info($table. ' is TRUNCATED.');
|
||||
$this->info($table.' is TRUNCATED.');
|
||||
}
|
||||
}
|
||||
|
||||
// Leave in the demo oauth keys so we don't have to reset them every day in the demos
|
||||
DB::statement('delete from oauth_clients WHERE id > 2');
|
||||
DB::statement('delete from oauth_access_tokens WHERE user_id > 2');
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,13 +149,13 @@ class Purge extends Command
|
||||
$filenames = Actionlog::where('action_type', 'uploaded')
|
||||
->where('item_id', $user->id)
|
||||
->pluck('filename');
|
||||
foreach($filenames as $filename) {
|
||||
foreach ($filenames as $filename) {
|
||||
try {
|
||||
if (Storage::exists($rel_path . '/' . $filename)) {
|
||||
Storage::delete($rel_path . '/' . $filename);
|
||||
if (Storage::exists($rel_path.'/'.$filename)) {
|
||||
Storage::delete($rel_path.'/'.$filename);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::info('An error occurred while deleting files: ' . $e->getMessage());
|
||||
Log::info('An error occurred while deleting files: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
$this->info('- User "'.$user->username.'" deleted.');
|
||||
|
||||
@@ -15,7 +15,9 @@ class PurgeEulaPDFs extends Command
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'snipeit:purge-eula-pdfs
|
||||
{--older-than-days= : The number of days we should delete before }
|
||||
{--older-than-days= : The number of days we should delete before }
|
||||
{--company-id= : Only purge acceptances for users in this company}
|
||||
{--only-deleted-users : Only purge acceptances for deleted users, including soft-deleted or missing users}
|
||||
{--force : Skip the interactive yes/no prompt for confirmation}
|
||||
{--dryrun : Show the records that would be deleted but don\'t update the database or delete files from disk}
|
||||
{--with-output : Display the results in a table in your console}';
|
||||
@@ -35,7 +37,7 @@ class PurgeEulaPDFs extends Command
|
||||
|
||||
$before = $this->option('older-than-days');
|
||||
|
||||
if (($before=='') || (!is_numeric($before))) {
|
||||
if (($before == '') || (! is_numeric($before))) {
|
||||
return $this->error('ERROR: You must pass a valid number for --older-than-days (example: snipeit:purge-eula-pdfs --older-than-days=365.)');
|
||||
}
|
||||
|
||||
@@ -43,35 +45,59 @@ class PurgeEulaPDFs extends Command
|
||||
$signature_path = 'private_uploads/signatures/';
|
||||
$eula_path = 'private_uploads/eula-pdfs/';
|
||||
|
||||
if (!Storage::exists($eula_path)) {
|
||||
if (! Storage::exists($eula_path)) {
|
||||
$this->fail('The storage directory "'.$eula_path.'" does not exist. No EULA files will be deleted.');
|
||||
}
|
||||
|
||||
if (!Storage::exists($signature_path)) {
|
||||
if (! Storage::exists($signature_path)) {
|
||||
$this->fail('The storage directory "'.$signature_path.'" does not exist. No signature files will be deleted.');
|
||||
}
|
||||
|
||||
|
||||
if ($this->option('dryrun')) {
|
||||
$this->info('This script is being run with the --dryrun option. No files or records will be deleted.');
|
||||
|
||||
}
|
||||
$acceptances = CheckoutAcceptance::HasFiles()->where('updated_at','<', $interval_date)->with('assignedTo')->get();
|
||||
$companyId = $this->option('company-id');
|
||||
$query = CheckoutAcceptance::HasFiles()->where('updated_at', '<', $interval_date)
|
||||
->with([
|
||||
'assignedTo' => function ($query) {
|
||||
$query->withTrashed();
|
||||
},
|
||||
]);
|
||||
|
||||
if (!$this->option('force')) {
|
||||
if ($this->option('only-deleted-users')) {
|
||||
$query->where(function ($query) use ($companyId) {
|
||||
$query->whereHas('assignedTo', function ($q) use ($companyId) {
|
||||
$q->withTrashed()->whereNotNull('deleted_at');
|
||||
|
||||
if ($companyId) {
|
||||
$q->where('company_id', $companyId);
|
||||
}
|
||||
});
|
||||
|
||||
$query->orWhereDoesntHave('assignedTo');
|
||||
});
|
||||
} else {
|
||||
if ($companyId) {
|
||||
$query->whereHas('assignedTo', function ($query) use ($companyId) {
|
||||
$query->withTrashed()->where('company_id', $companyId);
|
||||
});
|
||||
}
|
||||
}
|
||||
$acceptances = $query->get();
|
||||
|
||||
if (! $this->option('force')) {
|
||||
if ($this->confirm("\n****************************************************\nTHIS WILL DELETE ALL OF THE SIGNATURES AND EULA PDF FILES SINCE $interval_date. \nThere is NO undo! \n****************************************************\n\nDo you wish to continue? No backsies! [y|N]")) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
if ($acceptances->count() == 0) {
|
||||
return $this->warn('There are no item acceptances with signatures or EULA PDFs from before '.$interval_date);
|
||||
}
|
||||
|
||||
$this->info(number_format($acceptances->count()) . ' EULA PDFs from before '.$interval_date.' will be purged');
|
||||
$this->info(number_format($acceptances->count()).' EULA PDFs from before '.$interval_date.' will be purged');
|
||||
|
||||
if (!$this->option('with-output')) {
|
||||
if (! $this->option('with-output')) {
|
||||
$this->info('Run this command with the --with-output option to see the full list in the console.');
|
||||
} else {
|
||||
$this->table(
|
||||
@@ -86,7 +112,7 @@ class PurgeEulaPDFs extends Command
|
||||
trans('general.filename'),
|
||||
|
||||
],
|
||||
$acceptances->map(fn($acceptance) => [
|
||||
$acceptances->map(fn ($acceptance) => [
|
||||
trans('general.user') => $acceptance->assignedTo->display_name,
|
||||
trans('general.type') => $acceptance->display_checkoutable_type,
|
||||
trans('general.item') => $acceptance->checkoutable_type::find($acceptance->checkoutable_id)->display_name,
|
||||
@@ -99,31 +125,28 @@ class PurgeEulaPDFs extends Command
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
foreach ($acceptances as $acceptance) {
|
||||
|
||||
$signature_file = $signature_path.$acceptance->signature_filename;
|
||||
$eula_file = $eula_path.$acceptance->stored_eula_file;
|
||||
|
||||
if (Storage::exists($signature_file)) {
|
||||
if (!$this->option('dryrun')) {
|
||||
if (! $this->option('dryrun')) {
|
||||
Storage::delete($signature_file);
|
||||
}
|
||||
} else {
|
||||
$this->error('The file "'. $signature_file.'" does not exist.');
|
||||
$this->error('The file "'.$signature_file.'" does not exist.');
|
||||
}
|
||||
|
||||
|
||||
if (Storage::exists($eula_file)) {
|
||||
if (!$this->option('dryrun')) {
|
||||
if (! $this->option('dryrun')) {
|
||||
Storage::delete($eula_file);
|
||||
}
|
||||
} else {
|
||||
$this->error('The file "'.$eula_file.'" does not exist.');
|
||||
}
|
||||
|
||||
if (!$this->option('dryrun')) {
|
||||
if (! $this->option('dryrun')) {
|
||||
$acceptance->delete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,10 +82,10 @@ class ReEncodeCustomFieldNames extends Command
|
||||
if ($field->db_column == $field->convertUnicodeDbSlug() && \Schema::hasColumn('assets', $field->convertUnicodeDbSlug())) {
|
||||
$this->info('-- ✓ This field exists on the assets table and the value for db_column matches in the custom_fields table.');
|
||||
|
||||
/**
|
||||
* There is a mismatch between the fieldname on the assets table and
|
||||
* what $field->convertUnicodeDbSlug() is *now* expecting.
|
||||
*/
|
||||
/**
|
||||
* There is a mismatch between the fieldname on the assets table and
|
||||
* what $field->convertUnicodeDbSlug() is *now* expecting.
|
||||
*/
|
||||
} else {
|
||||
|
||||
if ($field->db_column != $field->convertUnicodeDbSlug()) {
|
||||
@@ -96,7 +96,6 @@ class ReEncodeCustomFieldNames extends Command
|
||||
|
||||
}
|
||||
|
||||
|
||||
/** Make sure the custom_field_columns array has the ID */
|
||||
if (array_key_exists($field->id, $custom_field_columns)) {
|
||||
|
||||
@@ -114,7 +113,6 @@ class ReEncodeCustomFieldNames extends Command
|
||||
$field->db_column = $field->convertUnicodeDbSlug();
|
||||
$field->save();
|
||||
|
||||
|
||||
} else {
|
||||
$this->warn('-- ✘ WARNING: There is no field on the assets table ending in '.$field->id.'. This may require more in-depth investigation and may mean the schema was altered manually.');
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Asset;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
class RegenerateAssetTags extends Command
|
||||
{
|
||||
|
||||
@@ -44,7 +44,7 @@ class RemoveExplicitEols extends Command
|
||||
}
|
||||
$endTime = microtime(true);
|
||||
$executionTime = ($endTime - $startTime);
|
||||
$this->info('Command executed in ' . round($executionTime, 2) . ' seconds.');
|
||||
$this->info('Command executed in '.round($executionTime, 2).' seconds.');
|
||||
}
|
||||
|
||||
private function updateAssets($assets)
|
||||
@@ -55,6 +55,6 @@ class RemoveExplicitEols extends Command
|
||||
$asset->save();
|
||||
}
|
||||
|
||||
$this->info($assets->count() . ' Assets updated successfully');
|
||||
$this->info($assets->count().' Assets updated successfully');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ class RemoveInvalidUploadDeleteActionLogItems extends Command
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->table(['ID', 'Action Type', 'Item Type', 'Item ID', 'Created At', 'Deleted At'], $invalidLogs->map(fn($log) => [
|
||||
$this->table(['ID', 'Action Type', 'Item Type', 'Item ID', 'Created At', 'Deleted At'], $invalidLogs->map(fn ($log) => [
|
||||
$log->id,
|
||||
$log->action_type,
|
||||
$log->item_type,
|
||||
@@ -48,7 +48,7 @@ class RemoveInvalidUploadDeleteActionLogItems extends Command
|
||||
])->toArray());
|
||||
|
||||
if ($this->confirm("Do you wish to remove {$invalidLogs->count()} log items?")) {
|
||||
$invalidLogs->each(fn($log) => $log->forceDelete());
|
||||
$invalidLogs->each(fn ($log) => $log->forceDelete());
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class ResetDemoSettings extends Command
|
||||
{
|
||||
@@ -48,10 +48,11 @@ class ResetDemoSettings extends Command
|
||||
$settings->auto_increment_assets = 1;
|
||||
$settings->logo = 'snipe-logo.png';
|
||||
$settings->alert_email = 'service@snipe-it.io';
|
||||
$settings->login_note = 'Use `admin` / `password` to login to the demo.';
|
||||
$settings->login_note = "Use any of the following credentials to login to the demo:\n\n- `admin` / `password`\n- `assets` / `password`\n- `testuser` / `password`";
|
||||
$settings->header_color = '#3c8dbc';
|
||||
$settings->link_dark_color = '#5fa4cc';
|
||||
$settings->link_light_color = '#296282;';
|
||||
$settings->nav_link_color = '#FFFFFF';
|
||||
$settings->label2_2d_type = 'QRCODE';
|
||||
$settings->default_currency = 'USD';
|
||||
$settings->brand = 2;
|
||||
@@ -76,7 +77,6 @@ class ResetDemoSettings extends Command
|
||||
$settings->saml_custom_settings = null;
|
||||
$settings->default_avatar = 'default.png';
|
||||
|
||||
|
||||
$settings->save();
|
||||
|
||||
if ($user = User::where('username', '=', 'admin')->first()) {
|
||||
@@ -86,9 +86,46 @@ class ResetDemoSettings extends Command
|
||||
$user->save();
|
||||
}
|
||||
|
||||
$assetsUser = User::updateOrCreate(
|
||||
['username' => 'assets'],
|
||||
[
|
||||
'first_name' => 'Assets',
|
||||
'last_name' => 'User',
|
||||
'password' => Hash::make('password'),
|
||||
'activated' => 1,
|
||||
]
|
||||
);
|
||||
$assetsUser->permissions = json_encode([
|
||||
'assets.view' => 1,
|
||||
'assets.create' => 1,
|
||||
'assets.edit' => 1,
|
||||
'assets.delete' => 1,
|
||||
'assets.checkout' => 1,
|
||||
'assets.checkin' => 1,
|
||||
'assets.audit' => 1,
|
||||
'assets.files' => 1,
|
||||
'assets.view.requestable' => 1,
|
||||
'assets.view.encrypted_custom_fields' => 1,
|
||||
]);
|
||||
$assetsUser->save();
|
||||
|
||||
$testUser = User::updateOrCreate(
|
||||
['username' => 'testuser'],
|
||||
[
|
||||
'first_name' => 'Test',
|
||||
'last_name' => 'User',
|
||||
'password' => Hash::make('password'),
|
||||
'activated' => 1,
|
||||
]
|
||||
);
|
||||
$testUser->permissions = json_encode([
|
||||
'self.checkout_assets' => 1,
|
||||
'assets.view.requestable' => 1,
|
||||
]);
|
||||
$testUser->save();
|
||||
|
||||
\Storage::disk('public')->put('snipe-logo.png', file_get_contents(public_path('img/demo/snipe-logo.png')));
|
||||
\Storage::disk('public')->put('snipe-logo-lg.png', file_get_contents(public_path('img/demo/snipe-logo-lg.png')));
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ use App\Models\Actionlog;
|
||||
use App\Models\Asset;
|
||||
use App\Models\License;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RestoreDeletedUsers extends Command
|
||||
{
|
||||
@@ -75,7 +75,7 @@ class RestoreDeletedUsers extends Command
|
||||
|
||||
DB::table('assets')
|
||||
->where('id', $user_log->item_id)
|
||||
->update(['assigned_to' => $user->id, 'assigned_type'=> User::class]);
|
||||
->update(['assigned_to' => $user->id, 'assigned_type' => User::class]);
|
||||
|
||||
$this->info(' ** Asset '.$user_log->item->id.' ('.$user_log->item->asset_tag.') restored to user '.$user->id.'');
|
||||
} elseif ($user_log->item_type == License::class) {
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use ZipArchive;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use enshrined\svgSanitize\Sanitizer;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use ZipArchive;
|
||||
|
||||
class SQLStreamer {
|
||||
class SQLStreamer
|
||||
{
|
||||
private $input;
|
||||
|
||||
private $output;
|
||||
|
||||
// embed the prefix here?
|
||||
public ?string $prefix;
|
||||
|
||||
@@ -18,106 +21,112 @@ class SQLStreamer {
|
||||
public static $buffer_size = 1024 * 1024; // use a 1MB buffer, ought to work fine for most cases?
|
||||
|
||||
public array $tablenames = [];
|
||||
|
||||
private bool $should_guess = false;
|
||||
|
||||
private bool $statement_is_permitted = false;
|
||||
|
||||
public function __construct($input, $output, string $prefix = null)
|
||||
public function __construct($input, $output, ?string $prefix = null)
|
||||
{
|
||||
$this->input = $input;
|
||||
$this->output = $output;
|
||||
$this->prefix = $prefix;
|
||||
}
|
||||
|
||||
public function parse_sql(string $line): string {
|
||||
public function parse_sql(string $line): string
|
||||
{
|
||||
// take into account the 'start of line or not' setting as an instance variable?
|
||||
// 'continuation' lines for a permitted statement are PERMITTED.
|
||||
// remove *only* line-feeds & carriage-returns; helpful for regexes against lines from
|
||||
// Windows dumps
|
||||
$line = trim($line, "\r\n");
|
||||
if($this->statement_is_permitted && $line[0] === ' ') {
|
||||
return $line . "\n"; //re-add the newline
|
||||
if ($this->statement_is_permitted && $line[0] === ' ') {
|
||||
return $line."\n"; // re-add the newline
|
||||
}
|
||||
|
||||
$table_regex = '`?([a-zA-Z0-9_]+)`?';
|
||||
$allowed_statements = [
|
||||
"/^(DROP TABLE (?:IF EXISTS )?)`$table_regex(.*)$/" => false,
|
||||
"/^(CREATE TABLE )$table_regex(.*)$/" => true, //sets up 'continuation'
|
||||
"/^(CREATE TABLE )$table_regex(.*)$/" => true, // sets up 'continuation'
|
||||
"/^(LOCK TABLES )$table_regex(.*)$/" => false,
|
||||
"/^(INSERT INTO )$table_regex(.*)$/" => false,
|
||||
"/^UNLOCK TABLES/" => false,
|
||||
'/^UNLOCK TABLES/' => false,
|
||||
// "/^\\) ENGINE=InnoDB AUTO_INCREMENT=16 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;/" => false, // FIXME not sure what to do here?
|
||||
"/^\\)[a-zA-Z0-9_= ]*;$/" => false,
|
||||
'/^\\)[a-zA-Z0-9_= ]*;$/' => false,
|
||||
// ^^^^^^ that bit should *exit* the 'permitted' block
|
||||
"/^\\(.*\\)[,;]$/" => false, //older MySQL dump style with one set of values per line
|
||||
'/^\\(.*\\)[,;]$/' => false, // older MySQL dump style with one set of values per line
|
||||
/* we *could* have made the ^INSERT INTO blah VALUES$ turn on the capturing state, and closed it with
|
||||
a ^(blahblah);$ but it's cleaner to not have to manage the state machine. We're just going to
|
||||
assume that (blahblah), or (blahblah); are values for INSERT and are always acceptable. */
|
||||
"<^/\*![0-9]{5} SET NAMES '?[a-zA-Z0-9_-]+'? \*/;$>" => false, //using weird delimiters (<,>) for readability. allow quoted or unquoted charsets
|
||||
"<^/\*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' \*/;$>" => false, //same, now handle zero-values
|
||||
"<^/\*![0-9]{5} SET NAMES '?[a-zA-Z0-9_-]+'? \*/;$>" => false, // using weird delimiters (<,>) for readability. allow quoted or unquoted charsets
|
||||
"<^/\*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' \*/;$>" => false, // same, now handle zero-values
|
||||
];
|
||||
|
||||
foreach($allowed_statements as $statement => $statechange) {
|
||||
// $this->info("Checking regex: $statement...\n");
|
||||
foreach ($allowed_statements as $statement => $statechange) {
|
||||
// $this->info("Checking regex: $statement...\n");
|
||||
$matches = [];
|
||||
if (preg_match($statement,$line,$matches)) {
|
||||
if (preg_match($statement, $line, $matches)) {
|
||||
$this->statement_is_permitted = $statechange;
|
||||
// matches are: 1 => first part of the statement, 2 => tablename, 3 => rest of statement
|
||||
// (with of course 0 being "the whole match")
|
||||
if (@$matches[2]) {
|
||||
// print "Found a tablename! It's: ".$matches[2]."\n";
|
||||
// print "Found a tablename! It's: ".$matches[2]."\n";
|
||||
if ($this->should_guess) {
|
||||
@$this->tablenames[$matches[2]] += 1;
|
||||
continue; //oh? FIXME
|
||||
|
||||
continue; // oh? FIXME
|
||||
} else {
|
||||
$cleaned_tablename = \DB::getTablePrefix().preg_replace('/^'.$this->prefix.'/','',$matches[2]);
|
||||
$line = preg_replace($statement,'$1`'.$cleaned_tablename.'`$3' , $line);
|
||||
$cleaned_tablename = \DB::getTablePrefix().preg_replace('/^'.$this->prefix.'/', '', $matches[2]);
|
||||
$line = preg_replace($statement, '$1`'.$cleaned_tablename.'`$3', $line);
|
||||
}
|
||||
} else {
|
||||
// no explicit tablename in this one, leave the line alone
|
||||
}
|
||||
//how do we *replace* the tablename?
|
||||
// print "RETURNING LINE: $line";
|
||||
return $line . "\n"; //re-add newline
|
||||
|
||||
// how do we *replace* the tablename?
|
||||
// print "RETURNING LINE: $line";
|
||||
return $line."\n"; // re-add newline
|
||||
}
|
||||
}
|
||||
|
||||
// all that is not allowed is denied.
|
||||
return "";
|
||||
return '';
|
||||
}
|
||||
|
||||
//this is used in exactly *TWO* places, and in both cases should return a prefix I think?
|
||||
// this is used in exactly *TWO* places, and in both cases should return a prefix I think?
|
||||
// first - if you do the --sanitize-only one (which is mostly for testing/development)
|
||||
// next - when you run *without* a guessed prefix, this is run first to figure out the prefix
|
||||
// I think we have to *duplicate* the call to be able to run it again?
|
||||
public static function guess_prefix($input):string
|
||||
public static function guess_prefix($input): string
|
||||
{
|
||||
$parser = new self($input, null);
|
||||
$parser->should_guess = true;
|
||||
$parser->line_aware_piping(); // <----- THIS is doing the heavy lifting!
|
||||
|
||||
$check_tables = ['settings' => null, 'migrations' => null /* 'assets' => null */]; //TODO - move to statics?
|
||||
//can't use 'users' because the 'accessories_checkout' table?
|
||||
$check_tables = ['settings' => null, 'migrations' => null /* 'assets' => null */]; // TODO - move to statics?
|
||||
// can't use 'users' because the 'accessories_checkout' table?
|
||||
// can't use 'assets' because 'ver1_components_assets'
|
||||
foreach($check_tables as $check_table => $_ignore) {
|
||||
foreach ($check_tables as $check_table => $_ignore) {
|
||||
foreach ($parser->tablenames as $tablename => $_count) {
|
||||
// print "Comparing $tablename to $check_table\n";
|
||||
if (str_ends_with($tablename,$check_table)) {
|
||||
// print "Found one!\n";
|
||||
$check_tables[$check_table] = substr($tablename,0,-strlen($check_table));
|
||||
// print "Comparing $tablename to $check_table\n";
|
||||
if (str_ends_with($tablename, $check_table)) {
|
||||
// print "Found one!\n";
|
||||
$check_tables[$check_table] = substr($tablename, 0, -strlen($check_table));
|
||||
}
|
||||
}
|
||||
}
|
||||
$guessed_prefix = null;
|
||||
foreach ($check_tables as $clean_table => $prefix_guess) {
|
||||
if(is_null($prefix_guess)) {
|
||||
print("Couldn't find table $clean_table\n");
|
||||
die();
|
||||
if (is_null($prefix_guess)) {
|
||||
echo "Couldn't find table $clean_table\n";
|
||||
exit();
|
||||
}
|
||||
if(is_null($guessed_prefix)) {
|
||||
if (is_null($guessed_prefix)) {
|
||||
$guessed_prefix = $prefix_guess;
|
||||
} else {
|
||||
if ($guessed_prefix != $prefix_guess) {
|
||||
print("Prefix mismatch! Had guessed $guessed_prefix but got $prefix_guess\n");
|
||||
die();
|
||||
echo "Prefix mismatch! Had guessed $guessed_prefix but got $prefix_guess\n";
|
||||
exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,7 +139,7 @@ class SQLStreamer {
|
||||
{
|
||||
$bytes_read = 0;
|
||||
if (! $this->input) {
|
||||
throw new \Exception("No Input available for line_aware_piping");
|
||||
throw new \Exception('No Input available for line_aware_piping');
|
||||
}
|
||||
|
||||
while (($buffer = fgets($this->input, SQLStreamer::$buffer_size)) !== false) {
|
||||
@@ -142,25 +151,24 @@ class SQLStreamer {
|
||||
$bytes_written = fwrite($this->output, $cleaned_buffer);
|
||||
|
||||
if ($bytes_written === false) {
|
||||
throw new \Exception("Unable to write to pipe");
|
||||
throw new \Exception('Unable to write to pipe');
|
||||
}
|
||||
}
|
||||
}
|
||||
// if we got a newline at the end of this, then the _next_ read is the beginning of a line
|
||||
if($buffer[strlen($buffer)-1] === "\n") {
|
||||
if ($buffer[strlen($buffer) - 1] === "\n") {
|
||||
$this->reading_beginning_of_line = true;
|
||||
} else {
|
||||
$this->reading_beginning_of_line = false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return $bytes_read;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
class RestoreFromBackup extends Command
|
||||
{
|
||||
/**
|
||||
@@ -202,7 +210,7 @@ class RestoreFromBackup extends Command
|
||||
public function handle()
|
||||
{
|
||||
$dir = getcwd();
|
||||
if( $dir != base_path() ) { // usually only the case when running via webserver, not via command-line
|
||||
if ($dir != base_path()) { // usually only the case when running via webserver, not via command-line
|
||||
Log::debug("Current working directory is: $dir, changing directory to: ".base_path());
|
||||
chdir(base_path()); // TODO - is this *safe* to change on a running script?!
|
||||
}
|
||||
@@ -221,7 +229,7 @@ class RestoreFromBackup extends Command
|
||||
return $this->error('DB_CONNECTION must be MySQL in order to perform a restore. Detected: '.config('database.default'));
|
||||
}
|
||||
|
||||
$za = new ZipArchive();
|
||||
$za = new ZipArchive;
|
||||
|
||||
$errcode = $za->open($filename/* , ZipArchive::RDONLY */); // that constant only exists in PHP 7.4 and higher
|
||||
if ($errcode !== true) {
|
||||
@@ -240,13 +248,12 @@ class RestoreFromBackup extends Command
|
||||
return $this->error('Could not access file: '.$filename.' - '.array_key_exists($errcode, $errors) ? $errors[$errcode] : " Unknown reason: $errcode");
|
||||
}
|
||||
|
||||
|
||||
$private_dirs = [
|
||||
'storage/private_uploads/accessories',
|
||||
'storage/private_uploads/assetmodels' => 'storage/private_uploads/models', //this was changed from assetmodels => models Aug 10 2025
|
||||
'storage/private_uploads/asset_maintenances' => 'storage/private_uploads/maintenances', //this was changed from asset_maintenances => maintenances Aug 10 2025
|
||||
'storage/private_uploads/maintenances', //but let 'maintenances' take precedence
|
||||
'storage/private_uploads/models', //and let 'models' take precedence
|
||||
'storage/private_uploads/assetmodels' => 'storage/private_uploads/models', // this was changed from assetmodels => models Aug 10 2025
|
||||
'storage/private_uploads/asset_maintenances' => 'storage/private_uploads/maintenances', // this was changed from asset_maintenances => maintenances Aug 10 2025
|
||||
'storage/private_uploads/maintenances', // but let 'maintenances' take precedence
|
||||
'storage/private_uploads/models', // and let 'models' take precedence
|
||||
'storage/private_uploads/assets', // these are asset _files_, not the pictures.
|
||||
'storage/private_uploads/audits',
|
||||
'storage/private_uploads/components',
|
||||
@@ -297,10 +304,10 @@ class RestoreFromBackup extends Command
|
||||
|
||||
$good_extensions = config('filesystems.allowed_upload_extensions_array');
|
||||
|
||||
$private_extensions = array_merge($good_extensions, ["csv", "key"]); //add csv, and 'key'
|
||||
$public_extensions = array_diff($good_extensions, ["xml"]); //remove xml
|
||||
$private_extensions = array_merge($good_extensions, ['csv', 'key']); // add csv, and 'key'
|
||||
$public_extensions = array_diff($good_extensions, ['xml']); // remove xml
|
||||
|
||||
$sanitizer = new Sanitizer();
|
||||
$sanitizer = new Sanitizer;
|
||||
|
||||
/**
|
||||
* TODO: I _hate_ the "continue 3" thing we keep doing here
|
||||
@@ -315,29 +322,30 @@ class RestoreFromBackup extends Command
|
||||
// print_r($stat_results);
|
||||
|
||||
$raw_path = $stat_results['name'];
|
||||
if (strpos($raw_path, '\\') !== false) { //found a backslash, swap it to forward-slash
|
||||
if (strpos($raw_path, '\\') !== false) { // found a backslash, swap it to forward-slash
|
||||
$raw_path = strtr($raw_path, '\\', '/');
|
||||
//print "Translating file: ".$stat_results['name']." to: ".$raw_path."\n";
|
||||
// print "Translating file: ".$stat_results['name']." to: ".$raw_path."\n";
|
||||
}
|
||||
|
||||
// skip macOS resource fork files (?!?!?!)
|
||||
if (strpos($raw_path, '__MACOSX') !== false && strpos($raw_path, '._') !== false) {
|
||||
//print "SKIPPING macOS Resource fork file: $raw_path\n";
|
||||
// print "SKIPPING macOS Resource fork file: $raw_path\n";
|
||||
// $boring_files[] = $raw_path; //stop adding this to the boring files list; it's just confusing
|
||||
continue;
|
||||
}
|
||||
if (@pathinfo($raw_path, PATHINFO_EXTENSION) == 'sql') {
|
||||
Log::debug("Found a sql file!");
|
||||
Log::debug('Found a sql file!');
|
||||
$sqlfiles[] = $raw_path;
|
||||
$sqlfile_indices[] = $i;
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($raw_path[-1] == '/') {
|
||||
//last character is '/' - this is a directory, and we don't need it, and we don't need to warn about it
|
||||
// last character is '/' - this is a directory, and we don't need it, and we don't need to warn about it
|
||||
continue;
|
||||
}
|
||||
if (in_array(basename($raw_path), [".gitkeep", ".gitignore", ".DS_Store"])) {
|
||||
//skip these boring files silently without reporting on them; they're stupid
|
||||
if (in_array(basename($raw_path), ['.gitkeep', '.gitignore', '.DS_Store'])) {
|
||||
// skip these boring files silently without reporting on them; they're stupid
|
||||
continue;
|
||||
}
|
||||
$extension = strtolower(pathinfo($raw_path, PATHINFO_EXTENSION));
|
||||
@@ -351,20 +359,21 @@ class RestoreFromBackup extends Command
|
||||
if (is_int($dir)) {
|
||||
$dir = $destdir;
|
||||
}
|
||||
$last_pos = strrpos($raw_path, $dir . '/');
|
||||
$last_pos = strrpos($raw_path, $dir.'/');
|
||||
if ($last_pos !== false) {
|
||||
//print("INTERESTING - last_pos is $last_pos when searching $raw_path for $dir - last_pos+strlen(\$dir) is: ".($last_pos+strlen($dir))." and strlen(\$rawpath) is: ".strlen($raw_path)."\n");
|
||||
//print("We would copy $raw_path to $dir.\n"); //FIXME append to a path?
|
||||
//the CSV bit, below, is because we store CSV files as "blahcsv" - without an extension
|
||||
if (!in_array($extension, $allowed_extensions) && !($dir == "storage/private_uploads/imports" && substr($raw_path, -3) == "csv" && $extension == "")) {
|
||||
// print("INTERESTING - last_pos is $last_pos when searching $raw_path for $dir - last_pos+strlen(\$dir) is: ".($last_pos+strlen($dir))." and strlen(\$rawpath) is: ".strlen($raw_path)."\n");
|
||||
// print("We would copy $raw_path to $dir.\n"); //FIXME append to a path?
|
||||
// the CSV bit, below, is because we store CSV files as "blahcsv" - without an extension
|
||||
if (! in_array($extension, $allowed_extensions) && ! ($dir == 'storage/private_uploads/imports' && substr($raw_path, -3) == 'csv' && $extension == '')) {
|
||||
$unsafe_files[] = $raw_path;
|
||||
Log::debug($raw_path . ' from directory ' . $dir . ' is being skipped');
|
||||
Log::debug($raw_path.' from directory '.$dir.' is being skipped');
|
||||
} else {
|
||||
if ($dir != $destdir) {
|
||||
Log::debug("Getting ready to save file $raw_path to new directory $destdir");
|
||||
}
|
||||
$interesting_files[$raw_path] = ['dest' => $destdir, 'index' => $i];
|
||||
}
|
||||
|
||||
continue 3;
|
||||
}
|
||||
}
|
||||
@@ -378,28 +387,30 @@ class RestoreFromBackup extends Command
|
||||
foreach ($files as $file) {
|
||||
$has_wildcard = (strpos($file, '*') !== false);
|
||||
if ($has_wildcard) {
|
||||
$file = substr($file, 0, -1); //trim last character (which should be the wildcard)
|
||||
$file = substr($file, 0, -1); // trim last character (which should be the wildcard)
|
||||
}
|
||||
$last_pos = strrpos($raw_path, $file); // no trailing slash!
|
||||
if ($last_pos !== false) {
|
||||
if (!in_array($extension, $allowed_extensions)) {
|
||||
if (! in_array($extension, $allowed_extensions)) {
|
||||
// gathering potentially unsafe files here to return at exit
|
||||
$unsafe_files[] = $raw_path;
|
||||
Log::debug('Potentially unsafe file ' . $raw_path . ' is being skipped');
|
||||
Log::debug('Potentially unsafe file '.$raw_path.' is being skipped');
|
||||
$boring_files[] = $raw_path;
|
||||
|
||||
continue 3;
|
||||
}
|
||||
//print("INTERESTING - last_pos is $last_pos when searching $raw_path for $file - last_pos+strlen(\$file) is: ".($last_pos+strlen($file))." and strlen(\$rawpath) is: ".strlen($raw_path)."\n");
|
||||
//no wildcards found in $file, process 'normally'
|
||||
if ($last_pos + strlen($file) == strlen($raw_path) || $has_wildcard) { //again, no trailing slash. or this is a wildcard and we just take it.
|
||||
// print("INTERESTING - last_pos is $last_pos when searching $raw_path for $file - last_pos+strlen(\$file) is: ".($last_pos+strlen($file))." and strlen(\$rawpath) is: ".strlen($raw_path)."\n");
|
||||
// no wildcards found in $file, process 'normally'
|
||||
if ($last_pos + strlen($file) == strlen($raw_path) || $has_wildcard) { // again, no trailing slash. or this is a wildcard and we just take it.
|
||||
// print("FOUND THE EXACT FILE: $file AT: $raw_path!!!\n"); //we *do* care about this, though.
|
||||
$interesting_files[$raw_path] = ['dest' => dirname($file), 'index' => $i];
|
||||
|
||||
continue 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$boring_files[] = $raw_path; //if we've gotten to here and haven't continue'ed our way into the next iteration, we don't want this file
|
||||
$boring_files[] = $raw_path; // if we've gotten to here and haven't continue'ed our way into the next iteration, we don't want this file
|
||||
} // end of pre-processing the ZIP file for-loop
|
||||
// print_r($interesting_files);exit(-1);
|
||||
|
||||
@@ -408,12 +419,12 @@ class RestoreFromBackup extends Command
|
||||
}
|
||||
|
||||
if (strpos($sqlfiles[0], 'db-dumps') === false) {
|
||||
//return $this->error("SQL backup file is missing 'db-dumps' component of full pathname: ".$sqlfiles[0]);
|
||||
//older Snipe-IT installs don't have the db-dumps subdirectory component
|
||||
// return $this->error("SQL backup file is missing 'db-dumps' component of full pathname: ".$sqlfiles[0]);
|
||||
// older Snipe-IT installs don't have the db-dumps subdirectory component
|
||||
}
|
||||
|
||||
$sql_stat = $za->statIndex($sqlfile_indices[0]);
|
||||
//$this->info("SQL Stat is: ".print_r($sql_stat,true));
|
||||
// $this->info("SQL Stat is: ".print_r($sql_stat,true));
|
||||
$sql_contents = $za->getStream($sql_stat['name']); // maybe copy *THIS* thing?
|
||||
|
||||
// OKAY, now that we *found* the sql file if we're doing just the guess-prefix thing, we can do that *HERE* I think?
|
||||
@@ -428,27 +439,32 @@ class RestoreFromBackup extends Command
|
||||
if ($this->option('sql-stdout-only')) {
|
||||
$sql_importer = new SQLStreamer($sql_contents, STDOUT, $this->option('sanitize-with-prefix'));
|
||||
$bytes_read = $sql_importer->line_aware_piping();
|
||||
|
||||
return $this->warn("$bytes_read total bytes read");
|
||||
//TODO - it'd be nice to dump this message to STDERR so that STDOUT is just pure SQL,
|
||||
// TODO - it'd be nice to dump this message to STDERR so that STDOUT is just pure SQL,
|
||||
// which would be good for redirecting to a file, and not having to trim the last line off of it
|
||||
}
|
||||
|
||||
//how to invoke the restore?
|
||||
// how to invoke the restore?
|
||||
$pipes = [];
|
||||
|
||||
$env_vars = getenv();
|
||||
$env_vars['MYSQL_PWD'] = config('database.connections.mysql.password');
|
||||
// TODO notes: we are stealing the dump_binary_path (which *probably* also has your copy of the mysql binary in it. But it might not, so we might need to extend this)
|
||||
// we unilaterally prepend a slash to the `mysql` command. This might mean your path could look like /blah/blah/blah//mysql - which should be fine. But maybe in some environments it isn't?
|
||||
$mysql_binary = config('database.connections.mysql.dump.dump_binary_path').\DIRECTORY_SEPARATOR.'mysql'.(\DIRECTORY_SEPARATOR == '\\' ? ".exe" : "");
|
||||
if( ! file_exists($mysql_binary) ) {
|
||||
$mysql_binary = config('database.connections.mysql.dump.dump_binary_path').\DIRECTORY_SEPARATOR.'mysql'.(\DIRECTORY_SEPARATOR == '\\' ? '.exe' : '');
|
||||
if (! file_exists($mysql_binary)) {
|
||||
return $this->error("mysql tool at: '$mysql_binary' does not exist, cannot restore. Please edit DB_DUMP_PATH in your .env to point to a directory that contains the mysqldump and mysql binary");
|
||||
}
|
||||
$proc_results = proc_open("$mysql_binary -h ".escapeshellarg(config('database.connections.mysql.host')).' -u '.escapeshellarg(config('database.connections.mysql.username')).' '.escapeshellarg(config('database.connections.mysql.database')), // yanked -p since we pass via ENV
|
||||
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
|
||||
$pipes,
|
||||
null,
|
||||
$env_vars); // this is not super-duper awesome-secure, but definitely more secure than showing it on the CLI, or dropping temporary files with passwords in them.
|
||||
$proc_results = proc_open("$mysql_binary -h " .
|
||||
escapeshellarg(config('database.connections.mysql.host')) .
|
||||
' -u ' . escapeshellarg(config('database.connections.mysql.username')) . ' ' .
|
||||
' -P ' . escapeshellarg(config('database.connections.mysql.port')) . ' ' .
|
||||
escapeshellarg(config('database.connections.mysql.database')), // yanked -p since we pass via ENV
|
||||
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
|
||||
$pipes,
|
||||
null,
|
||||
$env_vars); // this is not super-duper awesome-secure, but definitely more secure than showing it on the CLI, or dropping temporary files with passwords in them.
|
||||
if ($proc_results === false) {
|
||||
return $this->error('Unable to invoke mysql via CLI');
|
||||
}
|
||||
@@ -462,7 +478,7 @@ class RestoreFromBackup extends Command
|
||||
// should we read stdout?
|
||||
// fwrite($pipes[0],config("database.connections.mysql.password")."\n"); //this doesn't work :(
|
||||
|
||||
//$sql_contents = fopen($sqlfiles[0], "r"); //NOPE! This isn't a real file yet, silly-billy!
|
||||
// $sql_contents = fopen($sqlfiles[0], "r"); //NOPE! This isn't a real file yet, silly-billy!
|
||||
|
||||
// FIXME - this feels like it wants to go somewhere else?
|
||||
// and it doesn't seem 'right' - if you can't get a stream to the .sql file,
|
||||
@@ -477,7 +493,7 @@ class RestoreFromBackup extends Command
|
||||
}
|
||||
|
||||
try {
|
||||
if ( $this->option('sanitize-with-prefix') === null) {
|
||||
if ($this->option('sanitize-with-prefix') === null) {
|
||||
// "Legacy" direct-piping
|
||||
$bytes_read = 0;
|
||||
while (($buffer = fgets($sql_contents, SQLStreamer::$buffer_size)) !== false) {
|
||||
@@ -486,7 +502,7 @@ class RestoreFromBackup extends Command
|
||||
$bytes_written = fwrite($pipes[0], $buffer);
|
||||
|
||||
if ($bytes_written === false) {
|
||||
throw new Exception("Unable to write to pipe");
|
||||
throw new Exception('Unable to write to pipe');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -494,37 +510,37 @@ class RestoreFromBackup extends Command
|
||||
$bytes_read = $sql_importer->line_aware_piping();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Error during restore!!!! ".$e->getMessage());
|
||||
Log::error('Error during restore!!!! '.$e->getMessage());
|
||||
// FIXME - put these back and/or put them in the right places?!
|
||||
$err_out = fgets($pipes[1]);
|
||||
$err_err = fgets($pipes[2]);
|
||||
Log::error("Error OUTPUT: ".$err_out);
|
||||
Log::error('Error OUTPUT: '.$err_out);
|
||||
$this->info($err_out);
|
||||
Log::error("Error ERROR : ".$err_err);
|
||||
Log::error('Error ERROR : '.$err_err);
|
||||
$this->error($err_err);
|
||||
throw $e;
|
||||
}
|
||||
if (!feof($sql_contents) || $bytes_read == 0) {
|
||||
return $this->error("Not at end of file for sql file, or zero bytes read. aborting!");
|
||||
if (! feof($sql_contents) || $bytes_read == 0) {
|
||||
return $this->error('Not at end of file for sql file, or zero bytes read. aborting!');
|
||||
}
|
||||
|
||||
|
||||
fclose($pipes[0]);
|
||||
fclose($sql_contents);
|
||||
|
||||
|
||||
$this->line(stream_get_contents($pipes[1]));
|
||||
fclose($pipes[1]);
|
||||
|
||||
$this->error(stream_get_contents($pipes[2]));
|
||||
fclose($pipes[2]);
|
||||
|
||||
//wait, have to do fclose() on all pipes first?
|
||||
// wait, have to do fclose() on all pipes first?
|
||||
$close_results = proc_close($proc_results);
|
||||
if ($close_results != 0) {
|
||||
return $this->error('There may have been a problem with the database import: Error number '.$close_results);
|
||||
}
|
||||
|
||||
//and now copy the files over too (right?)
|
||||
//FIXME - we don't prune the filesystem space yet!!!!
|
||||
// and now copy the files over too (right?)
|
||||
// FIXME - we don't prune the filesystem space yet!!!!
|
||||
if ($this->option('no-progress')) {
|
||||
$bar = null;
|
||||
} else {
|
||||
@@ -532,16 +548,16 @@ class RestoreFromBackup extends Command
|
||||
}
|
||||
foreach ($interesting_files as $pretty_file_name => $file_details) {
|
||||
$ugly_file_name = $za->statIndex($file_details['index'])['name'];
|
||||
$migrated_file_name = $file_details['dest'] . '/' . basename($pretty_file_name);
|
||||
if (strcasecmp(substr($pretty_file_name, -4), ".svg") === 0) {
|
||||
$migrated_file_name = $file_details['dest'].'/'.basename($pretty_file_name);
|
||||
if (strcasecmp(substr($pretty_file_name, -4), '.svg') === 0) {
|
||||
$svg_contents = $za->getFromIndex($file_details['index']);
|
||||
$cleaned_svg = $sanitizer->sanitize($svg_contents);
|
||||
file_put_contents($migrated_file_name, $cleaned_svg);
|
||||
} else {
|
||||
$fp = $za->getStream($ugly_file_name);
|
||||
//$this->info("Weird problem, here are file details? ".print_r($file_details,true));
|
||||
if (!is_dir($file_details['dest'])) {
|
||||
mkdir($file_details['dest'], 0755, true); //0755 is what Laravel uses, so we do that
|
||||
// $this->info("Weird problem, here are file details? ".print_r($file_details,true));
|
||||
if (! is_dir($file_details['dest'])) {
|
||||
mkdir($file_details['dest'], 0755, true); // 0755 is what Laravel uses, so we do that
|
||||
}
|
||||
$migrated_file = fopen($migrated_file_name, 'w');
|
||||
while (($buffer = fgets($fp, SQLStreamer::$buffer_size)) !== false) {
|
||||
@@ -549,7 +565,7 @@ class RestoreFromBackup extends Command
|
||||
}
|
||||
fclose($migrated_file);
|
||||
fclose($fp);
|
||||
//$this->info("Wrote $ugly_file_name to $pretty_file_name");
|
||||
// $this->info("Wrote $ugly_file_name to $pretty_file_name");
|
||||
}
|
||||
if ($bar) {
|
||||
$bar->advance();
|
||||
|
||||
@@ -5,10 +5,10 @@ namespace App\Console\Commands;
|
||||
use App\Models\Asset;
|
||||
use App\Models\CustomField;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Encryption\Encrypter;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
class RotateAppKey extends Command
|
||||
{
|
||||
@@ -46,12 +46,13 @@ class RotateAppKey extends Command
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
//make sure they specify only exactly one of --emergency, or a filename. Not neither, and not both.
|
||||
if ( (!$this->option('emergency') && !$this->argument('previous_key')) || ( $this->option('emergency') && $this->argument('previous_key'))) {
|
||||
$this->error("Specify only one of --emergency, or an app key value, in order to rotate keys");
|
||||
// make sure they specify only exactly one of --emergency, or a filename. Not neither, and not both.
|
||||
if ((! $this->option('emergency') && ! $this->argument('previous_key')) || ($this->option('emergency') && $this->argument('previous_key'))) {
|
||||
$this->error('Specify only one of --emergency, or an app key value, in order to rotate keys');
|
||||
|
||||
return 1;
|
||||
}
|
||||
if ( $this->option('emergency') ) {
|
||||
if ($this->option('emergency')) {
|
||||
$msg = "\n****************************************************\nTHIS WILL MODIFY YOUR APP_KEY AND DE-CRYPT YOUR ENCRYPTED CUSTOM FIELDS AND \nRE-ENCRYPT THEM WITH A NEWLY GENERATED KEY. \n\nThere is NO undo. \n\nMake SURE you have a database backup and a backup of your .env generated BEFORE running this command. \n\nIf you do not save the newly generated APP_KEY to your .env in this process, \nyour encrypted data will no longer be decryptable. \n\nAre you SURE you wish to continue, and have confirmed you have a database backup and an .env backup? ";
|
||||
} else {
|
||||
$msg = "\n****************************************************\nTHIS WILL DE-CRYPT YOUR ENCRYPTED CUSTOM FIELDS AND RE-ENCRYPT THEM WITH YOUR\nAPP_KEY.\n\nThere is NO undo. \n\nMake SURE you have a database backup BEFORE running this command. \n\nAre you SURE you wish to continue, and have confirmed you have a database backup? ";
|
||||
@@ -79,9 +80,9 @@ class RotateAppKey extends Command
|
||||
$new_app_key = config('app.key');
|
||||
}
|
||||
|
||||
$this->warn('Your app cipher is: ' . $cipher);
|
||||
$this->warn('Your old APP_KEY is: ' . $old_app_key);
|
||||
$this->warn('Your new APP_KEY is: ' . $new_app_key);
|
||||
$this->warn('Your app cipher is: '.$cipher);
|
||||
$this->warn('Your old APP_KEY is: '.$old_app_key);
|
||||
$this->warn('Your new APP_KEY is: '.$new_app_key);
|
||||
|
||||
// Manually create an old encrypter instance using the old app key
|
||||
// and also create a new encrypter instance so we can re-crypt the field
|
||||
@@ -97,12 +98,13 @@ class RotateAppKey extends Command
|
||||
foreach ($assets as $asset) {
|
||||
try {
|
||||
$asset->{$field->db_column} = $oldEncrypter->decrypt($asset->{$field->db_column});
|
||||
$this->line('DECRYPTED: ' . $field->db_column);
|
||||
$this->line('DECRYPTED: '.$field->db_column);
|
||||
} catch (DecryptException $e) {
|
||||
$this->line('Could not decrypt '. $field->db_column.' using "old key" - skipping...');
|
||||
$this->line('Could not decrypt '.$field->db_column.' using "old key" - skipping...');
|
||||
|
||||
continue;
|
||||
} catch (\Exception $e) {
|
||||
$this->error("Error decrypting ".$field->db_column.", reason: ".$e->getMessage().". Aborting key rotation");
|
||||
$this->error('Error decrypting '.$field->db_column.', reason: '.$e->getMessage().'. Aborting key rotation');
|
||||
throw $e;
|
||||
}
|
||||
$asset->{$field->db_column} = $newEncrypter->encrypt($asset->{$field->db_column});
|
||||
@@ -119,8 +121,8 @@ class RotateAppKey extends Command
|
||||
$setting->ldap_pword = $newEncrypter->encrypt($setting->ldap_pword);
|
||||
$setting->save();
|
||||
$this->warn('LDAP password has been re-encrypted.');
|
||||
} catch(DecryptException $e) {
|
||||
$this->warn("Unable to decrypt old LDAP password; skipping");
|
||||
} catch (DecryptException $e) {
|
||||
$this->warn('Unable to decrypt old LDAP password; skipping');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use App\Models\SamlNonce;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SamlClearExpiredNonces extends Command
|
||||
{
|
||||
@@ -38,7 +38,8 @@ class SamlClearExpiredNonces extends Command
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
SamlNonce::where('not_valid_after','<=',now())->delete();
|
||||
SamlNonce::where('not_valid_after', '<=', now())->delete();
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,7 @@ use App\Models\CheckoutAcceptance;
|
||||
use App\Models\Component;
|
||||
use App\Models\Consumable;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Notifications\CheckoutAssetNotification;
|
||||
use App\Notifications\CurrentInventory;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
@@ -54,11 +51,11 @@ class SendAcceptanceReminder extends Command
|
||||
->with([
|
||||
'checkoutable' => function (MorphTo $morph) {
|
||||
$morph->morphWith([
|
||||
Asset::class => ['model.category', 'assignedTo', 'adminuser', 'company', 'checkouts'],
|
||||
Accessory::class => ['category', 'company', 'checkouts'],
|
||||
Asset::class => ['model.category', 'assignedTo', 'adminuser', 'company', 'checkouts'],
|
||||
Accessory::class => ['category', 'company', 'checkouts'],
|
||||
LicenseSeat::class => ['user', 'license', 'checkouts'],
|
||||
Component::class => ['assignedTo', 'company', 'checkouts'],
|
||||
Consumable::class => ['company', 'checkouts'],
|
||||
Component::class => ['assignedTo', 'company', 'checkouts'],
|
||||
Consumable::class => ['company', 'checkouts'],
|
||||
]);
|
||||
},
|
||||
'assignedTo',
|
||||
@@ -74,15 +71,15 @@ class SendAcceptanceReminder extends Command
|
||||
|
||||
$count = 0;
|
||||
$unacceptedAssetGroups = $pending
|
||||
->map(function($acceptance) {
|
||||
->map(function ($acceptance) {
|
||||
return ['assetItem' => $acceptance->checkoutable, 'acceptance' => $acceptance];
|
||||
})
|
||||
->groupBy(function($item) {
|
||||
->groupBy(function ($item) {
|
||||
return $item['acceptance']->assignedTo ? $item['acceptance']->assignedTo->id : '';
|
||||
});
|
||||
$no_email_list= [];
|
||||
$no_email_list = [];
|
||||
|
||||
foreach($unacceptedAssetGroups as $unacceptedAssetGroup) {
|
||||
foreach ($unacceptedAssetGroups as $unacceptedAssetGroup) {
|
||||
// The [0] is weird, but it allows for the item_count to work and grabs the appropriate info for each user.
|
||||
// Collapsing and flattening the collection doesn't work above.
|
||||
$acceptance = $unacceptedAssetGroup[0]['acceptance'];
|
||||
@@ -90,7 +87,7 @@ class SendAcceptanceReminder extends Command
|
||||
$locale = $acceptance->assignedTo?->locale;
|
||||
$email = $acceptance->assignedTo?->email;
|
||||
|
||||
if(!$email){
|
||||
if (! $email) {
|
||||
$no_email_list[] = [
|
||||
'id' => $acceptance->assignedTo?->id,
|
||||
'name' => $acceptance->assignedTo?->display_name,
|
||||
@@ -116,12 +113,11 @@ class SendAcceptanceReminder extends Command
|
||||
$rows[] = [$user['id'], $user['name']];
|
||||
}
|
||||
|
||||
if (!empty($rows)) {
|
||||
$this->info("The following users do not have an email address:");
|
||||
if (! empty($rows)) {
|
||||
$this->info('The following users do not have an email address:');
|
||||
$this->table($headers, $rows);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Helpers\Helper;
|
||||
use App\Models\Asset;
|
||||
use App\Models\Recipients\AlertRecipient;
|
||||
use App\Models\Setting;
|
||||
@@ -10,7 +11,6 @@ use App\Notifications\ExpectedCheckinNotification;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use App\Helpers\Helper;
|
||||
|
||||
class SendExpectedCheckinAlerts extends Command
|
||||
{
|
||||
@@ -49,7 +49,7 @@ class SendExpectedCheckinAlerts extends Command
|
||||
$interval_date = $today->copy()->addDays($interval);
|
||||
$count = 0;
|
||||
|
||||
if (!$this->option('with-output')) {
|
||||
if (! $this->option('with-output')) {
|
||||
$this->info('Run this command with the --with-output option to see the full list in the console.');
|
||||
}
|
||||
|
||||
@@ -57,9 +57,8 @@ class SendExpectedCheckinAlerts extends Command
|
||||
|
||||
$this->info($assets->count().' assets must be checked on or before '.Helper::getFormattedDateObject($interval_date, 'date', false));
|
||||
|
||||
|
||||
foreach ($assets as $asset) {
|
||||
if ($asset->assignedTo && (isset($asset->assignedTo->email)) && ($asset->assignedTo->email!='') && $asset->checkedOutToUser()) {
|
||||
if ($asset->assignedTo && (isset($asset->assignedTo->email)) && ($asset->assignedTo->email != '') && $asset->checkedOutToUser()) {
|
||||
$asset->assignedTo->notify((new ExpectedCheckinNotification($asset)));
|
||||
$count++;
|
||||
}
|
||||
@@ -76,13 +75,13 @@ class SendExpectedCheckinAlerts extends Command
|
||||
trans('general.purchase_date'),
|
||||
trans('admin/hardware/form.expected_checkin'),
|
||||
],
|
||||
$assets->map(fn($assets) => [
|
||||
$assets->map(fn ($assets) => [
|
||||
trans('general.id') => $assets->id,
|
||||
trans('admin/hardware/form.tag') => $assets->asset_tag,
|
||||
trans('admin/hardware/form.model') => $assets->model->name,
|
||||
trans('general.model_no') => $assets->model->model_number,
|
||||
trans('general.purchase_date') => $assets->purchase_date_formatted,
|
||||
trans('admin/hardware/form.eol_date') => $assets->expected_checkin_formattedDate ? $assets->expected_checkin_formattedDate . ' (' . $assets->expected_checkin_diff_for_humans . ')' : '',
|
||||
trans('admin/hardware/form.eol_date') => $assets->expected_checkin_formattedDate ? $assets->expected_checkin_formattedDate.' ('.$assets->expected_checkin_diff_for_humans.')' : '',
|
||||
])
|
||||
);
|
||||
}
|
||||
@@ -96,7 +95,7 @@ class SendExpectedCheckinAlerts extends Command
|
||||
Notification::send($recipients, new ExpectedCheckinAdminNotification($assets));
|
||||
|
||||
}
|
||||
|
||||
|
||||
$this->info('Sent checkin reminders to to '.$count.' users.');
|
||||
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Helpers\Helper;
|
||||
use App\Mail\ExpiringAssetsMail;
|
||||
use App\Mail\ExpiringLicenseMail;
|
||||
use App\Models\Asset;
|
||||
@@ -15,7 +14,7 @@ class SendExpirationAlerts extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'snipeit:expiring-alerts {--expired-licenses}';
|
||||
@@ -49,8 +48,8 @@ class SendExpirationAlerts extends Command
|
||||
|
||||
// Send a rollup to the admin, if settings dictate
|
||||
$recipients = collect(explode(',', $settings->alert_email))
|
||||
->map(fn($item) => trim($item)) // Trim each email
|
||||
->filter(fn($item) => !empty($item))
|
||||
->map(fn ($item) => trim($item)) // Trim each email
|
||||
->filter(fn ($item) => ! empty($item))
|
||||
->all();
|
||||
// Expiring Assets
|
||||
$assets = Asset::getExpiringWarrantyOrEol($alert_interval);
|
||||
@@ -72,23 +71,22 @@ class SendExpirationAlerts extends Command
|
||||
trans('admin/hardware/form.eol_date'),
|
||||
trans('admin/hardware/form.warranty_expires'),
|
||||
],
|
||||
$assets->map(fn($item) =>
|
||||
[
|
||||
trans('general.id') => $item->id,
|
||||
$assets->map(fn ($item) => [
|
||||
trans('general.id') => $item->id,
|
||||
trans('admin/hardware/form.tag') => $item->asset_tag,
|
||||
trans('admin/hardware/form.model') => $item->model->name,
|
||||
trans('general.model_no') => $item->model->model_number,
|
||||
trans('general.purchase_date') => $item->purchase_date_formatted,
|
||||
trans('admin/hardware/form.eol_rate') => $item->model->eol,
|
||||
trans('admin/hardware/form.eol_date') => $item->eol_date ? $item->eol_formatted_date .' ('.$item->eol_diff_for_humans.')' : '',
|
||||
trans('admin/hardware/form.warranty_expires') => $item->warranty_expires ? $item->warranty_expires_formatted_date .' ('.$item->warranty_expires_diff_for_humans.')' : '',
|
||||
])
|
||||
);
|
||||
trans('admin/hardware/form.eol_rate') => $item->model->eol,
|
||||
trans('admin/hardware/form.eol_date') => $item->eol_date ? $item->eol_formatted_date.' ('.$item->eol_diff_for_humans.')' : '',
|
||||
trans('admin/hardware/form.warranty_expires') => $item->warranty_expires ? $item->warranty_expires_formatted_date.' ('.$item->warranty_expires_diff_for_humans.')' : '',
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
// Expiring licenses
|
||||
$licenses = License::query()->ExpiringLicenses($alert_interval, $this->option('expired-licenses'))
|
||||
->with('manufacturer','category')
|
||||
->with('manufacturer', 'category')
|
||||
->orderBy('expiration_date', 'ASC')
|
||||
->orderBy('termination_date', 'ASC')
|
||||
->get();
|
||||
@@ -104,14 +102,14 @@ class SendExpirationAlerts extends Command
|
||||
trans('mail.expires'),
|
||||
trans('admin/licenses/form.termination_date'),
|
||||
trans('mail.terminates')],
|
||||
$licenses->map(fn($item) => [
|
||||
$licenses->map(fn ($item) => [
|
||||
trans('general.id') => $item->id,
|
||||
trans('general.name') => $item->name,
|
||||
trans('general.purchase_date') => $item->purchase_date_formatted,
|
||||
trans('admin/licenses/form.expiration') => $item->expires_formatted_date,
|
||||
trans('mail.expires') => $item->expires_formatted_date ? $item->expires_diff_for_humans : '',
|
||||
trans('admin/licenses/form.termination_date') => $item->terminates_formatted_date,
|
||||
trans('mail.terminates') => $item->terminates_diff_for_humans
|
||||
trans('mail.terminates') => $item->terminates_diff_for_humans,
|
||||
])
|
||||
);
|
||||
}
|
||||
@@ -120,12 +118,10 @@ class SendExpirationAlerts extends Command
|
||||
$this->info(trans_choice('mail.assets_warrantee_alert', $assets->count(), ['count' => $assets->count(), 'threshold' => $alert_interval]));
|
||||
$this->info(trans_choice('mail.license_expiring_alert', $licenses->count(), ['count' => $licenses->count(), 'threshold' => $alert_interval]));
|
||||
|
||||
|
||||
|
||||
} else {
|
||||
if ($settings->alert_email == '') {
|
||||
$this->error('Could not send email. No alert email configured in settings');
|
||||
} elseif (1 != $settings->alerts_enabled) {
|
||||
} elseif ($settings->alerts_enabled != 1) {
|
||||
$this->info('Alerts are disabled in the settings. No mail will be sent');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ class SendInventoryAlerts extends Command
|
||||
} else {
|
||||
if ($settings->alert_email == '') {
|
||||
$this->error('Could not send email. No alert email configured in settings');
|
||||
} elseif (1 != $settings->alerts_enabled) {
|
||||
} elseif ($settings->alerts_enabled != 1) {
|
||||
$this->info('Alerts are disabled in the settings. No mail will be sent');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,12 +49,11 @@ class SendUpcomingAuditReport extends Command
|
||||
|
||||
$assets_query = Asset::whereNull('deleted_at')->dueOrOverdueForAudit($settings)->orderBy('assets.next_audit_date', 'asc')->with('supplier');
|
||||
$asset_count = $assets_query->count();
|
||||
$this->info(number_format($asset_count) . ' assets must be audited on or before ' . $interval_date);
|
||||
if (!$this->option('with-output')) {
|
||||
$this->info(number_format($asset_count).' assets must be audited on or before '.$interval_date);
|
||||
if (! $this->option('with-output')) {
|
||||
$this->info('Run this command with the --with-output option to see the full list in the console.');
|
||||
}
|
||||
|
||||
|
||||
if ($asset_count > 0) {
|
||||
|
||||
$assets_for_email = $assets_query->limit(30)->get();
|
||||
@@ -63,22 +62,19 @@ class SendUpcomingAuditReport extends Command
|
||||
if ($settings->alert_email != '') {
|
||||
|
||||
$recipients = collect(explode(',', $settings->alert_email))
|
||||
->map(fn($item) => trim($item))
|
||||
->filter(fn($item) => !empty($item))
|
||||
->map(fn ($item) => trim($item))
|
||||
->filter(fn ($item) => ! empty($item))
|
||||
->all();
|
||||
|
||||
Mail::to($recipients)->send(new SendUpcomingAuditMail($assets_for_email, $settings->audit_warning_days, $asset_count));
|
||||
$this->info('Audit notification sent to: ' . $settings->alert_email);
|
||||
$this->info('Audit notification sent to: '.$settings->alert_email);
|
||||
|
||||
} else {
|
||||
$this->info('There is no admin alert email set so no email will be sent.');
|
||||
}
|
||||
|
||||
|
||||
|
||||
if ($this->option('with-output')) {
|
||||
|
||||
|
||||
// Get the full list if the user wants output in the console
|
||||
$assets_for_output = $assets_query->limit(null)->get();
|
||||
|
||||
@@ -93,7 +89,7 @@ class SendUpcomingAuditReport extends Command
|
||||
trans('mail.assigned_to'),
|
||||
|
||||
],
|
||||
$assets_for_output->map(fn($item) => [
|
||||
$assets_for_output->map(fn ($item) => [
|
||||
trans('general.id') => $item->id,
|
||||
trans('general.name') => $item->display_name,
|
||||
trans('general.last_audit') => $item->last_audit_formatted_date,
|
||||
@@ -106,10 +102,8 @@ class SendUpcomingAuditReport extends Command
|
||||
}
|
||||
|
||||
} else {
|
||||
$this->info('There are no assets due for audit in the next ' . $interval . ' days.');
|
||||
$this->info('There are no assets due for audit in the next '.$interval.' days.');
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,16 +63,14 @@ class SyncAssetCounters extends Command
|
||||
|
||||
}
|
||||
|
||||
} else {
|
||||
$this->info('No assets to sync');
|
||||
}
|
||||
});
|
||||
|
||||
} else {
|
||||
$this->info('No assets to sync');
|
||||
}
|
||||
});
|
||||
|
||||
$bar->finish();
|
||||
$time_elapsed_secs = microtime(true) - $start;
|
||||
$this->info("\nSync of ".$assets_count.' assets executed in '.$time_elapsed_secs.' seconds');
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Asset;
|
||||
use App\Models\Location;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SyncAssetLocations extends Command
|
||||
@@ -57,7 +59,7 @@ class SyncAssetLocations extends Command
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$assigned_user_assets = Asset::where('assigned_type', \App\Models\User::class)->whereNotNull('assigned_to')->whereNull('deleted_at')->get();
|
||||
$assigned_user_assets = Asset::where('assigned_type', User::class)->whereNotNull('assigned_to')->whereNull('deleted_at')->get();
|
||||
$output['info'][] = 'There are '.$assigned_user_assets->count().' assets checked out to users.';
|
||||
foreach ($assigned_user_assets as $assigned_user_asset) {
|
||||
if (($assigned_user_asset->assignedTo) && ($assigned_user_asset->assignedTo->userLoc)) {
|
||||
@@ -73,7 +75,7 @@ class SyncAssetLocations extends Command
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$assigned_location_assets = Asset::where('assigned_type', \App\Models\Location::class)
|
||||
$assigned_location_assets = Asset::where('assigned_type', Location::class)
|
||||
->whereNotNull('assigned_to')->whereNull('deleted_at')->get();
|
||||
$output['info'][] = 'There are '.$assigned_location_assets->count().' assets checked out to locations.';
|
||||
|
||||
@@ -90,13 +92,13 @@ class SyncAssetLocations extends Command
|
||||
}
|
||||
|
||||
// Assigned to assets
|
||||
$assigned_asset_assets = Asset::where('assigned_type', \App\Models\Asset::class)
|
||||
$assigned_asset_assets = Asset::where('assigned_type', Asset::class)
|
||||
->whereNotNull('assigned_to')->whereNull('deleted_at')->get();
|
||||
$output['info'][] = 'Asset-assigned assets: '.$assigned_asset_assets->count();
|
||||
|
||||
foreach ($assigned_asset_assets as $assigned_asset_asset) {
|
||||
|
||||
// Check to make sure there aren't any invalid relationships
|
||||
// Check to make sure there aren't any invalid relationships
|
||||
if ($assigned_asset_asset->assetLoc()) {
|
||||
$assigned_asset_asset->location_id = $assigned_asset_asset->assetLoc()->id;
|
||||
$output['info'][] = 'Setting Asset Assigned asset '.$assigned_asset_asset->assetLoc()->id.' ('.$assigned_asset_asset->asset_tag.') location to: '.$assigned_asset_asset->assetLoc()->id;
|
||||
|
||||
@@ -37,13 +37,13 @@ class SystemBackup extends Command
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
ini_set('max_execution_time', env('BACKUP_TIME_LIMIT', 600)); //600 seconds = 10 minutes
|
||||
ini_set('max_execution_time', env('BACKUP_TIME_LIMIT', 600)); // 600 seconds = 10 minutes
|
||||
|
||||
if ($this->option('filename')) {
|
||||
$filename = $this->option('filename');
|
||||
|
||||
// Make sure the filename ends in .zip
|
||||
if (!ends_with($filename, '.zip')) {
|
||||
if (! ends_with($filename, '.zip')) {
|
||||
$filename = $filename.'.zip';
|
||||
}
|
||||
|
||||
|
||||
@@ -47,5 +47,4 @@ class TestLocationsFMCS extends Command
|
||||
$this->table($header, $mismatched);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ class ToggleCustomfieldEncryption extends Command
|
||||
protected $signature = 'snipeit:customfield-encryption
|
||||
{fieldname : the db_column_name of the field}';
|
||||
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
@@ -61,15 +60,15 @@ class ToggleCustomfieldEncryption extends Command
|
||||
$field->field_encrypted = 1;
|
||||
$field->save();
|
||||
|
||||
// This field is already encrypted. Do nothing.
|
||||
// This field is already encrypted. Do nothing.
|
||||
} else {
|
||||
$this->error('The custom field ' . $field->db_column.' is already encrypted. No action was taken.');
|
||||
$this->error('The custom field '.$field->db_column.' is already encrypted. No action was taken.');
|
||||
}
|
||||
});
|
||||
|
||||
// No matching column name found
|
||||
// No matching column name found
|
||||
} else {
|
||||
$this->error('No matching results for unencrypted custom fields with db_column name: ' . $fieldname.'. Please check the fieldname.');
|
||||
$this->error('No matching results for unencrypted custom fields with db_column name: '.$fieldname.'. Please check the fieldname.');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Asset;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\MessageBag;
|
||||
|
||||
class ValidateAssets extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'snipeit:validate-assets {--all : Display the valid assets in your table output as well} ';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'This runs through the list of assets and checks for any validation errors that would prevent it from being updated or checked in or out. ';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$showAll = (bool) $this->option('all');
|
||||
|
||||
$assets = Asset::query()
|
||||
->whereNull('deleted_at')
|
||||
->with('model')
|
||||
->orderBy('assets.created_at', 'desc')
|
||||
->get();
|
||||
|
||||
if (! $showAll) {
|
||||
$this->info('Run this command with the --all option to see the full list in the console.');
|
||||
}
|
||||
|
||||
$rows = $assets
|
||||
->filter(fn (Asset $asset) => $showAll || ! $asset->isValid())
|
||||
->map(fn (Asset $asset) => [
|
||||
trans('general.id') => $asset->id,
|
||||
trans('admin/hardware/form.tag') => $asset->asset_tag,
|
||||
trans('admin/hardware/form.serial') => $asset->serial ?? '',
|
||||
trans('admin/hardware/form.model') => $asset->model?->name ?? '',
|
||||
trans('general.model_no') => $asset->model?->model_number ?? '',
|
||||
trans('general.error') => $asset->isValid() ? '√ valid' : $this->formatValidationErrors($asset),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$this->table(
|
||||
[
|
||||
trans('general.id'),
|
||||
trans('admin/hardware/form.tag'),
|
||||
trans('admin/hardware/form.serial'),
|
||||
trans('admin/hardware/form.model'),
|
||||
trans('general.model_no'),
|
||||
trans('general.error'),
|
||||
],
|
||||
$rows
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function formatValidationErrors(Asset $asset): string
|
||||
{
|
||||
$errors = $asset->getErrors();
|
||||
$messages = [];
|
||||
|
||||
if ($errors instanceof MessageBag) {
|
||||
$messages = $errors->all();
|
||||
} elseif (is_array($errors)) {
|
||||
$messages = $errors;
|
||||
} else {
|
||||
$messages = [(string) $errors];
|
||||
}
|
||||
|
||||
$prefixedMessages = collect($messages)
|
||||
->map(fn ($message) => trim((string) $message))
|
||||
->filter()
|
||||
->map(fn (string $message) => str_starts_with($message, '✘') ? $message : '✘ '.$message)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return implode(PHP_EOL, $prefixedMessages);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
use function Laravel\Prompts\info;
|
||||
use function Laravel\Prompts\select;
|
||||
|
||||
class Version extends Command
|
||||
{
|
||||
/**
|
||||
@@ -11,7 +14,7 @@ class Version extends Command
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'version:update {--branch=master} {--type=patch}';
|
||||
protected $signature = 'version:update';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
@@ -37,30 +40,40 @@ class Version extends Command
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$use_branch = $this->option('branch');
|
||||
$use_type = $this->option('type');
|
||||
$use_branch = select(
|
||||
label: 'Which branch?',
|
||||
options: ['master', 'develop'],
|
||||
default: 'develop',
|
||||
);
|
||||
|
||||
$use_type = select(
|
||||
label: 'Which release type?',
|
||||
options: [
|
||||
'hash' => 'Hash bump',
|
||||
'patch' => 'Patch release',
|
||||
'minor' => 'Minor release',
|
||||
'major' => 'Major release',
|
||||
'pre-patch' => 'Pre-patch release',
|
||||
'pre-minor' => 'Pre-minor release',
|
||||
'pre-major' => 'Pre-major release',
|
||||
],
|
||||
default: 'hash',
|
||||
scroll: 7,
|
||||
);
|
||||
|
||||
$git_branch = trim(shell_exec('git rev-parse --abbrev-ref HEAD'));
|
||||
$build_version = trim(shell_exec('git rev-list --count '.$use_branch));
|
||||
$versionFile = 'config/version.php';
|
||||
$full_hash_version = str_replace("\n", '', shell_exec('git describe master --tags'));
|
||||
|
||||
$version = explode('-', $full_hash_version);
|
||||
$app_version = $current_app_version = $version[0];
|
||||
$app_version = $version[0];
|
||||
$hash_version = (array_key_exists('2', $version)) ? $version[2] : '';
|
||||
$prerelease_version = '';
|
||||
|
||||
$this->line('Branch is: '.$use_branch);
|
||||
$this->line('Type is: '.$use_type);
|
||||
$this->line('Current version is: '.$full_hash_version);
|
||||
|
||||
if (count($version) == 3) {
|
||||
$this->line('This does not look like an alpha/beta release.');
|
||||
} else {
|
||||
if (array_key_exists('3', $version)) {
|
||||
$this->line('The current version looks like a beta release.');
|
||||
$prerelease_version = $version[1];
|
||||
$hash_version = $version[3];
|
||||
}
|
||||
if (array_key_exists('3', $version)) {
|
||||
$prerelease_version = $version[1];
|
||||
$hash_version = $version[3];
|
||||
}
|
||||
|
||||
$app_version_raw = explode('.', $app_version);
|
||||
@@ -74,54 +87,52 @@ class Version extends Command
|
||||
$patch = $app_version_raw[2];
|
||||
}
|
||||
|
||||
if ($use_type == 'major') {
|
||||
if ($use_type === 'major') {
|
||||
$app_version = 'v'.($maj + 1).".$min.$patch";
|
||||
} elseif ($use_type == 'minor') {
|
||||
} elseif ($use_type === 'minor') {
|
||||
$app_version = 'v'."$maj.".($min + 1).".$patch";
|
||||
} elseif ($use_type == 'pre') {
|
||||
$pre_raw = str_replace('beta', '', $prerelease_version);
|
||||
$pre_raw = str_replace('alpha', '', $pre_raw);
|
||||
$pre_raw = str_ireplace('rc', '', $pre_raw);
|
||||
$pre_raw = $pre_raw++;
|
||||
$this->line('Setting the pre-release to '.$prerelease_version.'-'.$pre_raw);
|
||||
$app_version = 'v'."$maj.".($min + 1).".$patch";
|
||||
} elseif ($use_type == 'patch') {
|
||||
} elseif ($use_type === 'pre-patch') {
|
||||
$app_version = 'v'."$maj.$min.".($patch + 1).'-pre';
|
||||
} elseif ($use_type === 'pre-minor') {
|
||||
$app_version = 'v'."$maj.".($min + 1).'.0-pre';
|
||||
} elseif ($use_type === 'pre-major') {
|
||||
$app_version = 'v'.($maj + 1).'.0.0-pre';
|
||||
} elseif ($use_type === 'patch') {
|
||||
$app_version = 'v'."$maj.$min.".($patch + 1);
|
||||
// If nothing is passed, leave the version as it is, just increment the build
|
||||
} else {
|
||||
$app_version = 'v'."$maj.$min.".$patch;
|
||||
}
|
||||
|
||||
// Determine if this tag already exists, or if this prior to a release
|
||||
$this->line('Running: git rev-parse master '.$current_app_version);
|
||||
// $pre_release = trim(shell_exec('git rev-parse '.$use_branch.' '.$current_app_version.' 2>&1 1> /dev/null'));
|
||||
|
||||
if ($use_branch == 'develop') {
|
||||
if ($use_branch === 'develop' && ! str_ends_with($app_version, '-pre')) {
|
||||
$app_version = $app_version.'-pre';
|
||||
}
|
||||
|
||||
$full_hash_version = str_replace($version[0], $app_version, $full_hash_version);
|
||||
$full_app_version = $app_version.' - build '.$build_version.'-'.$hash_version;
|
||||
|
||||
$array = var_export(
|
||||
[
|
||||
'app_version' => $app_version,
|
||||
'full_app_version' => $full_app_version,
|
||||
'build_version' => $build_version,
|
||||
'prerelease_version' => $prerelease_version,
|
||||
'hash_version' => $hash_version,
|
||||
'full_hash' => $full_hash_version,
|
||||
'branch' => $git_branch, ],
|
||||
true
|
||||
);
|
||||
$content = <<<PHP
|
||||
<?php
|
||||
|
||||
// Construct our file content
|
||||
$content = <<<CON
|
||||
<?php
|
||||
return $array;
|
||||
CON;
|
||||
return [
|
||||
'app_version' => '$app_version',
|
||||
'full_app_version' => '$full_app_version',
|
||||
'build_version' => '$build_version',
|
||||
'prerelease_version' => '$prerelease_version',
|
||||
'hash_version' => '$hash_version',
|
||||
'full_hash' => '$full_hash_version',
|
||||
'branch' => '$git_branch',
|
||||
];
|
||||
|
||||
PHP;
|
||||
|
||||
// And finally write the file and output the current version
|
||||
\File::put($versionFile, $content);
|
||||
$this->info('Setting NEW version: '.$full_app_version.' ('.$git_branch.')');
|
||||
info('New version: '.$full_app_version.' ('.$git_branch.')');
|
||||
|
||||
info('Building JS/CSS assets...');
|
||||
passthru('npm run prod', $exitCode);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
$this->error('Asset build failed with exit code '.$exitCode);
|
||||
} else {
|
||||
info('Assets built successfully.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
|
||||
namespace App\Console;
|
||||
|
||||
use App\Console\Commands\ImportLocations;
|
||||
use App\Console\Commands\ReEncodeCustomFieldNames;
|
||||
use App\Console\Commands\RestoreDeletedUsers;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
||||
@@ -14,12 +11,11 @@ class Kernel extends ConsoleKernel
|
||||
/**
|
||||
* Define the application's command schedule.
|
||||
*
|
||||
* @param \Illuminate\Console\Scheduling\Schedule $schedule
|
||||
* @return void
|
||||
*/
|
||||
protected function schedule(Schedule $schedule)
|
||||
{
|
||||
if(Setting::getSettings()?->alerts_enabled === 1) {
|
||||
if (Setting::getSettings()?->alerts_enabled === 1) {
|
||||
$schedule->command('snipeit:inventory-alerts')->daily();
|
||||
$schedule->command('snipeit:expiring-alerts')->daily();
|
||||
$schedule->command('snipeit:expected-checkin')->daily();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ActionType: string
|
||||
{
|
||||
// General
|
||||
@@ -12,6 +13,7 @@ enum ActionType: string
|
||||
// Assets/Accessories/Components/Licenses/Consumables
|
||||
case Checkout = 'checkout';
|
||||
case CheckinFrom = 'checkin from';
|
||||
case ForceCheckin = 'force checkin';
|
||||
case Requested = 'requested';
|
||||
case RequestCanceled = 'request canceled';
|
||||
case Accepted = 'accepted';
|
||||
@@ -22,12 +24,17 @@ enum ActionType: string
|
||||
// Users
|
||||
case TwoFactorReset = '2FA reset';
|
||||
case Merged = 'merged';
|
||||
case TokenRevoked = 'token revoked';
|
||||
case TokenUnrevoked = 'token unrevoked';
|
||||
|
||||
// Licenses
|
||||
case DeleteSeats = 'delete seats';
|
||||
case AddSeats = 'add seats';
|
||||
|
||||
// Maintenances
|
||||
case MaintenanceComplete = 'completed';
|
||||
|
||||
// File Uploads
|
||||
case Uploaded = 'uploaded';
|
||||
case UploadDeleted = 'upload deleted';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\Contracts\Acceptable;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\Contracts\Acceptable;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
|
||||
@@ -11,10 +11,15 @@ class CheckoutableCheckedIn
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public $checkoutable;
|
||||
|
||||
public $checkedOutTo;
|
||||
|
||||
public $checkedInBy;
|
||||
|
||||
public $note;
|
||||
|
||||
public $action_date; // Date setted in the hardware.checkin view at the checkin_at input, for the action log
|
||||
|
||||
public $originalValues;
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,18 +11,25 @@ class CheckoutableCheckedOut
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public $checkoutable;
|
||||
|
||||
public $checkedOutTo;
|
||||
|
||||
public $checkedOutBy;
|
||||
|
||||
public $note;
|
||||
|
||||
public $originalValues;
|
||||
|
||||
public int $quantity;
|
||||
|
||||
public bool $signInPlace;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct($checkoutable, $checkedOutTo, User $checkedOutBy, $note, $originalValues = [], $quantity = 1)
|
||||
public function __construct($checkoutable, $checkedOutTo, User $checkedOutBy, $note, $originalValues = [], $quantity = 1, bool $signInPlace = false)
|
||||
{
|
||||
$this->checkoutable = $checkoutable;
|
||||
$this->checkedOutTo = $checkedOutTo;
|
||||
@@ -30,5 +37,6 @@ class CheckoutableCheckedOut
|
||||
$this->note = $note;
|
||||
$this->originalValues = $originalValues;
|
||||
$this->quantity = $quantity;
|
||||
$this->signInPlace = $signInPlace;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class CheckoutablesCheckedOutInBulk
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Collection $assets,
|
||||
public Model $target,
|
||||
public User $admin,
|
||||
public string $checkout_at,
|
||||
public string $expected_checkin,
|
||||
public string $note,
|
||||
) {}
|
||||
}
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use App\Models\User;
|
||||
|
||||
class UserMerged
|
||||
{
|
||||
@@ -17,8 +17,8 @@ class UserMerged
|
||||
*/
|
||||
public function __construct(User $from_user, User $to_user, ?User $admin)
|
||||
{
|
||||
$this->merged_from = $from_user;
|
||||
$this->merged_to = $to_user;
|
||||
$this->admin = $admin;
|
||||
$this->merged_from = $from_user;
|
||||
$this->merged_to = $to_user;
|
||||
$this->admin = $admin;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,4 @@ namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class AssetNotRequestable extends Exception
|
||||
{
|
||||
}
|
||||
class AssetNotRequestable extends Exception {}
|
||||
|
||||
+69
-44
@@ -2,16 +2,27 @@
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||
use App\Helpers\Helper;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Exceptions\SCIMException;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
use JsonException;
|
||||
use Carbon\Exceptions\InvalidFormatException;
|
||||
use Illuminate\Http\Exceptions\ThrottleRequestsException;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Session\TokenMismatchException;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Intervention\Image\Exception\NotSupportedException;
|
||||
use JsonException;
|
||||
use League\OAuth2\Server\Exception\OAuthServerException;
|
||||
use Livewire\Exceptions\ComponentNotFoundException;
|
||||
use Livewire\Exceptions\PublicPropertyNotFoundException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Throwable;
|
||||
|
||||
class Handler extends ExceptionHandler
|
||||
{
|
||||
@@ -21,17 +32,19 @@ class Handler extends ExceptionHandler
|
||||
* @var array
|
||||
*/
|
||||
protected $dontReport = [
|
||||
\Illuminate\Auth\AuthenticationException::class,
|
||||
\Illuminate\Auth\Access\AuthorizationException::class,
|
||||
\Symfony\Component\HttpKernel\Exception\HttpException::class,
|
||||
\Illuminate\Database\Eloquent\ModelNotFoundException::class,
|
||||
\Illuminate\Session\TokenMismatchException::class,
|
||||
\Illuminate\Validation\ValidationException::class,
|
||||
\Intervention\Image\Exception\NotSupportedException::class,
|
||||
\League\OAuth2\Server\Exception\OAuthServerException::class,
|
||||
AuthenticationException::class,
|
||||
AuthorizationException::class,
|
||||
HttpException::class,
|
||||
ModelNotFoundException::class,
|
||||
TokenMismatchException::class,
|
||||
ValidationException::class,
|
||||
NotSupportedException::class,
|
||||
OAuthServerException::class,
|
||||
JsonException::class,
|
||||
SCIMException::class, //these generally don't need to be reported
|
||||
SCIMException::class, // these generally don't need to be reported
|
||||
InvalidFormatException::class,
|
||||
PublicPropertyNotFoundException::class,
|
||||
ComponentNotFoundException::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -39,7 +52,6 @@ class Handler extends ExceptionHandler
|
||||
*
|
||||
* This is a great spot to send exceptions to Sentry, Bugsnag, etc.
|
||||
*
|
||||
* @param \Throwable $exception
|
||||
* @return void
|
||||
*/
|
||||
public function report(Throwable $exception)
|
||||
@@ -48,23 +60,34 @@ class Handler extends ExceptionHandler
|
||||
if (class_exists(Log::class)) {
|
||||
Log::error($exception);
|
||||
}
|
||||
|
||||
return parent::report($exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an exception into an HTTP response.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
*
|
||||
* @param Request $request
|
||||
* @param \Exception $e
|
||||
* @return \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse|\Illuminate\Http\Response
|
||||
* @return JsonResponse|RedirectResponse|Response
|
||||
*/
|
||||
public function render($request, Throwable $e)
|
||||
{
|
||||
|
||||
// Livewire tried to set a property that doesn't exist (e.g. stale browser state sending a bare "0" as a property name)
|
||||
if ($e instanceof PublicPropertyNotFoundException) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
// A request named a Livewire component that doesn't exist in this app (e.g. bots probing
|
||||
// for Filament endpoints). Return 404 so it doesn't surface as a 500.
|
||||
if ($e instanceof ComponentNotFoundException) {
|
||||
return response()->json(['message' => 'Component not found.'], 404);
|
||||
}
|
||||
|
||||
// CSRF token mismatch error
|
||||
if ($e instanceof \Illuminate\Session\TokenMismatchException) {
|
||||
if ($e instanceof TokenMismatchException) {
|
||||
return redirect()->back()->with('error', trans('general.token_expired'));
|
||||
}
|
||||
|
||||
@@ -78,9 +101,10 @@ class Handler extends ExceptionHandler
|
||||
if ($e instanceof SCIMException) {
|
||||
try {
|
||||
$e->report(); // logs as 'debug', so shouldn't get too noisy
|
||||
} catch(\Exception $reportException) {
|
||||
//do nothing
|
||||
} catch (\Exception $reportException) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
return $e->render($request); // ALL SCIMExceptions have the 'render()' method
|
||||
}
|
||||
|
||||
@@ -98,9 +122,10 @@ class Handler extends ExceptionHandler
|
||||
}
|
||||
|
||||
// Handle API requests that fail because the model doesn't exist
|
||||
if ($e instanceof \Illuminate\Database\Eloquent\ModelNotFoundException) {
|
||||
if ($e instanceof ModelNotFoundException) {
|
||||
$className = last(explode('\\', $e->getModel()));
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, $className . ' not found'), 200);
|
||||
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, $className.' not found'), 200);
|
||||
}
|
||||
|
||||
// Handle API requests that fail because of an HTTP status code and return a useful error message
|
||||
@@ -111,8 +136,8 @@ class Handler extends ExceptionHandler
|
||||
// API throttle requests are handled in the RouteServiceProvider configureRateLimiting() method, so we don't need to handle them here
|
||||
switch ($e->getStatusCode()) {
|
||||
case '404':
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, $statusCode . ' endpoint not found'), 404);
|
||||
case '405':
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, $statusCode.' endpoint not found'), 404);
|
||||
case '405':
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, 'Method not allowed'), 405);
|
||||
default:
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, $statusCode), $statusCode);
|
||||
@@ -124,19 +149,20 @@ class Handler extends ExceptionHandler
|
||||
// never even get to the controller where we normally nicely format JSON responses
|
||||
if ($e instanceof ValidationException) {
|
||||
$response = $this->invalidJson($request, $e);
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, $e->errors()), 200);
|
||||
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, $e->errors()), 200);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// This is traaaaash but it handles models that are not found while using route model binding :(
|
||||
// The only alternative is to set that at *each* route, which is crazypants
|
||||
if ($e instanceof \Illuminate\Database\Eloquent\ModelNotFoundException) {
|
||||
if ($e instanceof ModelNotFoundException) {
|
||||
$ids = method_exists($e, 'getIds') ? $e->getIds() : [];
|
||||
|
||||
if (in_array('bulkedit', $ids, true)) {
|
||||
$error_array = session()->get('bulk_asset_errors');
|
||||
$error_array = session()->get('bulk_asset_errors');
|
||||
|
||||
return redirect()
|
||||
->route('hardware.index')
|
||||
->withErrors($error_array, 'bulk_asset_errors')
|
||||
@@ -144,7 +170,7 @@ class Handler extends ExceptionHandler
|
||||
}
|
||||
|
||||
// This gets the MVC model name from the exception and formats in a way that's less fugly
|
||||
$model_name = trim(strtolower(implode(" ", preg_split('/(?=[A-Z])/', last(explode('\\', $e->getModel()))))));
|
||||
$model_name = trim(strtolower(implode(' ', preg_split('/(?=[A-Z])/', last(explode('\\', $e->getModel()))))));
|
||||
$route = str_plural(strtolower(last(explode('\\', $e->getModel())))).'.index';
|
||||
|
||||
// Sigh.
|
||||
@@ -162,6 +188,8 @@ class Handler extends ExceptionHandler
|
||||
$route = 'licenses.index';
|
||||
} elseif (($route === 'customfieldsets.index') || ($route === 'customfields.index')) {
|
||||
$route = 'fields.index';
|
||||
} elseif ($route == 'actionlogs.index') {
|
||||
$route = 'home';
|
||||
}
|
||||
|
||||
return redirect()
|
||||
@@ -169,28 +197,26 @@ class Handler extends ExceptionHandler
|
||||
->withError(trans('general.generic_model_not_found', ['model' => $model_name]));
|
||||
}
|
||||
|
||||
|
||||
if ($this->isHttpException($e) && (isset($statusCode)) && ($statusCode == '404' )) {
|
||||
if ($this->isHttpException($e) && (isset($statusCode)) && ($statusCode == '404')) {
|
||||
return response()->view('layouts/basic', [
|
||||
'content' => view('errors/404')
|
||||
],$statusCode);
|
||||
'content' => view('errors/404'),
|
||||
], $statusCode);
|
||||
}
|
||||
|
||||
return parent::render($request, $e);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Convert an authentication exception into an unauthenticated response.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param \Illuminate\Auth\AuthenticationException $exception
|
||||
* @return \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
|
||||
*/
|
||||
* @param Request $request
|
||||
* @return JsonResponse|RedirectResponse
|
||||
*/
|
||||
protected function unauthenticated($request, AuthenticationException $exception)
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['error' => 'Unauthorized or unauthenticated.'], 401);
|
||||
return response()->json(['error' => trans('general.unauthorized')], 401);
|
||||
}
|
||||
|
||||
return redirect()->guest('login');
|
||||
@@ -201,8 +227,7 @@ class Handler extends ExceptionHandler
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, $exception->errors()), 200);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
/**
|
||||
* A list of the inputs that are never flashed for validation exceptions.
|
||||
*
|
||||
* @var array
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class ItemStillHasAccessories extends ItemStillHasChildren
|
||||
{
|
||||
//
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class ItemStillHasAssetModels extends ItemStillHasChildren
|
||||
{
|
||||
//
|
||||
|
||||
@@ -2,8 +2,4 @@
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class ItemStillHasAssets extends ItemStillHasChildren
|
||||
{
|
||||
}
|
||||
class ItemStillHasAssets extends ItemStillHasChildren {}
|
||||
|
||||
@@ -6,9 +6,9 @@ use Exception;
|
||||
|
||||
class ItemStillHasChildren extends Exception
|
||||
{
|
||||
//public function __construct($message, $code = 0, Exception $previous = null, $parent, $children)
|
||||
//{
|
||||
// public function __construct($message, $code = 0, Exception $previous = null, $parent, $children)
|
||||
// {
|
||||
// trans()
|
||||
//
|
||||
//}
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class ItemStillHasComponents extends ItemStillHasChildren
|
||||
{
|
||||
//
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class ItemStillHasConsumables extends ItemStillHasChildren
|
||||
{
|
||||
//
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class ItemStillHasLicenses extends ItemStillHasChildren
|
||||
{
|
||||
//
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class ItemStillHasMaintenances extends ItemStillHasChildren
|
||||
{
|
||||
//
|
||||
|
||||
@@ -4,7 +4,4 @@ namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class UserDoestExistException extends Exception
|
||||
{
|
||||
|
||||
}
|
||||
class UserDoestExistException extends Exception {}
|
||||
|
||||
+404
-297
File diff suppressed because it is too large
Load Diff
+47
-18
@@ -4,17 +4,24 @@ namespace App\Helpers;
|
||||
|
||||
class IconHelper
|
||||
{
|
||||
|
||||
public static function icon($type) {
|
||||
public static function icon($type)
|
||||
{
|
||||
switch ($type) {
|
||||
case 'apple':
|
||||
return 'fa-brands fa-apple';
|
||||
case 'google':
|
||||
return 'fa-brands fa-google';
|
||||
case 'checkout':
|
||||
return 'fa-solid fa-rotate-left';
|
||||
case 'checkin':
|
||||
return 'fa-solid fa-rotate-right';
|
||||
case 'edit':
|
||||
case 'update':
|
||||
return 'fas fa-pencil-alt';
|
||||
case 'clone':
|
||||
return 'far fa-clone';
|
||||
case 'upload':
|
||||
return 'fa-solid fa-file-circle-plus';
|
||||
case 'delete':
|
||||
case 'upload deleted':
|
||||
return 'fas fa-trash';
|
||||
@@ -36,6 +43,8 @@ class IconHelper
|
||||
return 'fa-solid fa-user';
|
||||
case 'users':
|
||||
return 'fas fa-users';
|
||||
case 'supplier':
|
||||
return 'fa-solid fa-store';
|
||||
case 'restore':
|
||||
return 'fa-solid fa-trash-arrow-up';
|
||||
case 'external-link':
|
||||
@@ -69,6 +78,7 @@ class IconHelper
|
||||
case 'angle-right':
|
||||
return 'fas fa-angle-right';
|
||||
case 'warning':
|
||||
case 'alert':
|
||||
return 'fas fa-exclamation-triangle';
|
||||
case 'kits':
|
||||
return 'fas fa-object-group';
|
||||
@@ -87,8 +97,11 @@ class IconHelper
|
||||
case 'licenses':
|
||||
case 'license':
|
||||
return 'far fa-save';
|
||||
case 'requests':
|
||||
case 'requestable':
|
||||
return 'fas fa-laptop';
|
||||
case 'request':
|
||||
case 'requested':
|
||||
return 'fa-solid fa-bell-concierge';
|
||||
case 'reports':
|
||||
return 'fas fa-chart-bar';
|
||||
case 'heart':
|
||||
@@ -108,13 +121,14 @@ class IconHelper
|
||||
case 'password':
|
||||
return 'fa-solid fa-key';
|
||||
case 'api-key':
|
||||
return 'fa-solid fa-user-secret';
|
||||
return 'fas fa-user-secret';
|
||||
case 'nav-toggle':
|
||||
return 'fas fa-bars';
|
||||
case 'dashboard':
|
||||
return 'fas fa-tachometer-alt';
|
||||
case 'info-circle':
|
||||
return 'fas fa-info-circle';
|
||||
case 'info':
|
||||
return 'fas fa-info-circle';
|
||||
case 'caret-right':
|
||||
return 'fa fa-caret-right';
|
||||
case 'caret-up':
|
||||
@@ -131,19 +145,26 @@ class IconHelper
|
||||
return 'fa-regular fa-clipboard';
|
||||
case 'paperclip':
|
||||
return 'fas fa-paperclip';
|
||||
case 'files':
|
||||
return 'fa-solid fa-file-contract';
|
||||
case 'contact-card':
|
||||
return 'fa-regular fa-id-card';
|
||||
case 'files':
|
||||
return 'fa-regular fa-file';
|
||||
case 'eula':
|
||||
case 'eulas':
|
||||
return 'fa-regular fa-handshake';
|
||||
case 'star':
|
||||
case 'vip':
|
||||
return 'fa-solid fa-star';
|
||||
case 'remote':
|
||||
return 'fa-solid fa-house-laptop';
|
||||
case 'more-info':
|
||||
case 'help':
|
||||
case 'support':
|
||||
return 'far fa-life-ring';
|
||||
case 'calendar':
|
||||
return 'fas fa-calendar';
|
||||
case 'plus':
|
||||
return 'fas fa-plus';
|
||||
case 'history':
|
||||
return 'fas fa-history';
|
||||
return 'fa-solid fa-timeline';
|
||||
case 'more-files':
|
||||
return 'fa-solid fa-laptop-file';
|
||||
case 'maintenances':
|
||||
@@ -193,11 +214,11 @@ class IconHelper
|
||||
return 'fas fa-crosshairs';
|
||||
case 'oauth':
|
||||
return 'fas fa-user-secret';
|
||||
case 'employee_num' :
|
||||
case 'employee_num':
|
||||
return 'fa-regular fa-id-card';
|
||||
case 'department' :
|
||||
case 'department':
|
||||
return 'fa-solid fa-building-user';
|
||||
case 'home' :
|
||||
case 'home':
|
||||
return 'fa-solid fa-house';
|
||||
case 'note':
|
||||
case 'notes':
|
||||
@@ -207,7 +228,7 @@ class IconHelper
|
||||
case 'highlight':
|
||||
return 'fa-solid fa-highlighter';
|
||||
case 'manager':
|
||||
return 'fa-solid fa-building-user';
|
||||
return 'fa-solid fa-user-tie';
|
||||
case 'company':
|
||||
return 'fa-regular fa-building';
|
||||
case 'parent':
|
||||
@@ -216,18 +237,24 @@ class IconHelper
|
||||
return 'fa-solid fa-hashtag';
|
||||
case 'depreciation':
|
||||
return 'fa-solid fa-arrows-down-to-line';
|
||||
case 'calendar':
|
||||
return 'fas fa-calendar';
|
||||
case 'depreciation-calendar':
|
||||
case 'expiration':
|
||||
case 'terminates':
|
||||
return 'fa-regular fa-calendar-xmark';
|
||||
case 'manufacturer':
|
||||
return 'fa-solid fa-industry';
|
||||
case 'fieldset' :
|
||||
return 'fa-regular fa-rectangle-list';
|
||||
case 'deleted-date':
|
||||
case 'end_date':
|
||||
return 'fa-solid fa-calendar-xmark';
|
||||
case 'expected_checkin':
|
||||
case 'start_date':
|
||||
return 'fa-solid fa-calendar-check';
|
||||
case 'eol':
|
||||
return 'fa-regular fa-calendar-days';
|
||||
case 'manufacturer':
|
||||
return 'fa-solid fa-industry';
|
||||
case 'fieldset':
|
||||
return 'fa-regular fa-rectangle-list';
|
||||
case 'category':
|
||||
return 'fa-solid fa-icons';
|
||||
case 'cost':
|
||||
@@ -242,6 +269,8 @@ class IconHelper
|
||||
return 'fa-solid fa-file-invoice';
|
||||
case 'checkout-all':
|
||||
return 'fa-solid fa-arrows-down-to-people';
|
||||
case 'checkin-all':
|
||||
return 'fa-solid fa-arrows-turn-right';
|
||||
case 'square-right':
|
||||
return 'fa-regular fa-square-caret-right';
|
||||
case 'square-left':
|
||||
|
||||
@@ -2,32 +2,39 @@
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Illuminate\Contracts\Filesystem\FileNotFoundException;
|
||||
|
||||
class StorageHelper
|
||||
{
|
||||
public static function downloader($filename, $disk = 'default') : BinaryFileResponse | RedirectResponse | StreamedResponse
|
||||
public static function downloader($filename, $disk = 'default'): BinaryFileResponse|RedirectResponse|StreamedResponse
|
||||
{
|
||||
if ($disk == 'default') {
|
||||
$disk = config('filesystems.default');
|
||||
}
|
||||
switch (config("filesystems.disks.$disk.driver")) {
|
||||
case 'local':
|
||||
return response()->download(Storage::disk($disk)->path($filename)); //works for PRIVATE or public?!
|
||||
case 'local':
|
||||
return response()->download(Storage::disk($disk)->path($filename)); // works for PRIVATE or public?!
|
||||
|
||||
case 's3':
|
||||
return redirect()->away(Storage::disk($disk)->temporaryUrl($filename, now()->addMinutes(5))); //works for private or public, I guess?
|
||||
case 's3':
|
||||
Storage::disk($disk)->temporaryUrl(
|
||||
$filename,
|
||||
now()->addMinutes(5),
|
||||
[
|
||||
'ResponseContentType' => 'application/octet-stream',
|
||||
'ResponseContentDisposition' => 'attachment; filename=download-file',
|
||||
]
|
||||
);
|
||||
|
||||
default:
|
||||
return Storage::disk($disk)->download($filename);
|
||||
default:
|
||||
return Storage::disk($disk)->download($filename);
|
||||
}
|
||||
}
|
||||
|
||||
public static function getMediaType($file_with_path) {
|
||||
public static function getMediaType($file_with_path)
|
||||
{
|
||||
|
||||
// Get the file extension and determine the media type
|
||||
if (Storage::exists($file_with_path)) {
|
||||
@@ -64,6 +71,7 @@ class StorageHelper
|
||||
return $extension; // Default for unknown types
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -72,8 +80,9 @@ class StorageHelper
|
||||
* to determine that they are safe to display inline.
|
||||
*
|
||||
* @author <A. Gianotto> [<snipe@snipe.net]>
|
||||
*
|
||||
* @since v7.0.14
|
||||
* @param $file_with_path
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function allowSafeInline($file_with_path)
|
||||
@@ -96,11 +105,11 @@ class StorageHelper
|
||||
'webp',
|
||||
];
|
||||
|
||||
|
||||
// The file exists and is allowed to be displayed inline
|
||||
if (Storage::exists($file_with_path) && (in_array(pathinfo($file_with_path, PATHINFO_EXTENSION), $allowed_inline))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
}
|
||||
@@ -116,34 +125,4 @@ class StorageHelper
|
||||
return null;
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Decide whether to show the file inline or download it.
|
||||
*/
|
||||
public static function showOrDownloadFile($file, $filename)
|
||||
{
|
||||
|
||||
$headers = [];
|
||||
|
||||
if (request('inline') == 'true') {
|
||||
|
||||
$headers = [
|
||||
'Content-Disposition' => 'inline',
|
||||
];
|
||||
|
||||
// This is NOT allowed as inline - force it to be displayed as text in the browser
|
||||
if (self::allowSafeInline($file) != true) {
|
||||
$headers = array_merge($headers, ['Content-Type' => 'text/plain']);
|
||||
}
|
||||
}
|
||||
|
||||
// Everything else seems okay, but the file doesn't exist on the server.
|
||||
if (Storage::missing($file)) {
|
||||
throw new FileNotFoundException();
|
||||
}
|
||||
|
||||
return Storage::download($file, $filename, $headers);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ImageUploadRequest;
|
||||
use App\Models\Accessory;
|
||||
use App\Models\Company;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use \Illuminate\Contracts\View\View;
|
||||
use \Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/** This controller handles all actions related to Accessories for
|
||||
* the Snipe-IT Asset Management application.
|
||||
@@ -25,12 +25,14 @@ class AccessoriesController extends Controller
|
||||
* the content for the accessories listing, which is generated in getDatatable.
|
||||
*
|
||||
* @author [A. Gianotto] [<snipe@snipe.net>]
|
||||
*
|
||||
* @see AccessoriesController::getDatatable() method that generates the JSON response
|
||||
* @since [v1.0]
|
||||
*/
|
||||
public function index() : View
|
||||
public function index(): View
|
||||
{
|
||||
$this->authorize('index', Accessory::class);
|
||||
|
||||
return view('accessories.index');
|
||||
}
|
||||
|
||||
@@ -39,43 +41,42 @@ class AccessoriesController extends Controller
|
||||
*
|
||||
* @author [A. Gianotto] [<snipe@snipe.net>]
|
||||
*/
|
||||
public function create() : View
|
||||
public function create(): View
|
||||
{
|
||||
$this->authorize('create', Accessory::class);
|
||||
$category_type = 'accessory';
|
||||
|
||||
return view('accessories/edit')->with('category_type', $category_type)
|
||||
->with('item', new Accessory);
|
||||
->with('item', new Accessory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and save new Accessory from form post
|
||||
*
|
||||
* @author [A. Gianotto] [<snipe@snipe.net>]
|
||||
* @param ImageUploadRequest $request
|
||||
*/
|
||||
public function store(ImageUploadRequest $request) : RedirectResponse
|
||||
public function store(ImageUploadRequest $request): RedirectResponse
|
||||
{
|
||||
$this->authorize(Accessory::class);
|
||||
|
||||
|
||||
// create a new model instance
|
||||
$accessory = new Accessory();
|
||||
$accessory = new Accessory;
|
||||
|
||||
// Update the accessory data
|
||||
$accessory->name = request('name');
|
||||
$accessory->category_id = request('category_id');
|
||||
$accessory->location_id = request('location_id');
|
||||
$accessory->min_amt = request('min_amt');
|
||||
$accessory->company_id = Company::getIdForCurrentUser(request('company_id'));
|
||||
$accessory->order_number = request('order_number');
|
||||
$accessory->manufacturer_id = request('manufacturer_id');
|
||||
$accessory->model_number = request('model_number');
|
||||
$accessory->purchase_date = request('purchase_date');
|
||||
$accessory->purchase_cost = request('purchase_cost');
|
||||
$accessory->qty = request('qty');
|
||||
$accessory->created_by = auth()->id();
|
||||
$accessory->supplier_id = request('supplier_id');
|
||||
$accessory->notes = request('notes');
|
||||
$accessory->name = request('name');
|
||||
$accessory->category_id = request('category_id');
|
||||
$accessory->location_id = request('location_id');
|
||||
$accessory->min_amt = request('min_amt');
|
||||
$accessory->company_id = Company::getIdForCurrentUser(request('company_id'));
|
||||
$accessory->order_number = request('order_number');
|
||||
$accessory->manufacturer_id = request('manufacturer_id');
|
||||
$accessory->model_number = request('model_number');
|
||||
$accessory->purchase_date = request('purchase_date');
|
||||
$accessory->purchase_cost = request('purchase_cost');
|
||||
$accessory->qty = request('qty');
|
||||
$accessory->created_by = auth()->id();
|
||||
$accessory->supplier_id = request('supplier_id');
|
||||
$accessory->notes = request('notes');
|
||||
|
||||
if ($request->has('use_cloned_image')) {
|
||||
$cloned_model_img = Accessory::select('image')->find($request->input('clone_image_from_id'));
|
||||
@@ -90,7 +91,7 @@ class AccessoriesController extends Controller
|
||||
$accessory = $request->handleImages($accessory);
|
||||
}
|
||||
|
||||
if($request->input('redirect_option') === 'back'){
|
||||
if ($request->input('redirect_option') === 'back') {
|
||||
session()->put(['redirect_option' => 'index']);
|
||||
} else {
|
||||
session()->put(['redirect_option' => $request->input('redirect_option')]);
|
||||
@@ -110,11 +111,14 @@ class AccessoriesController extends Controller
|
||||
* Return view for the Accessory update form, prepopulated with existing data
|
||||
*
|
||||
* @author [A. Gianotto] [<snipe@snipe.net>]
|
||||
* @param int $accessoryId
|
||||
*
|
||||
* @param int $accessoryId
|
||||
*/
|
||||
public function edit(Accessory $accessory) : View | RedirectResponse
|
||||
public function edit(Accessory $accessory): View|RedirectResponse
|
||||
{
|
||||
$this->authorize('update', Accessory::class);
|
||||
$this->authorize('update', $accessory);
|
||||
session()->put('url.intended', url()->previous());
|
||||
|
||||
return view('accessories.edit')->with('item', $accessory)->with('category_type', 'accessory');
|
||||
}
|
||||
|
||||
@@ -122,13 +126,15 @@ class AccessoriesController extends Controller
|
||||
* Returns a view that presents a form to clone an accessory.
|
||||
*
|
||||
* @author [J. Vinsmoke]
|
||||
* @param int $accessoryId
|
||||
*
|
||||
* @param int $accessoryId
|
||||
*
|
||||
* @since [v6.0]
|
||||
*/
|
||||
public function getClone(Accessory $accessory) : View | RedirectResponse
|
||||
public function getClone(Accessory $accessory): View|RedirectResponse
|
||||
{
|
||||
|
||||
$this->authorize('create', Accessory::class);
|
||||
$this->authorize('create', $accessory);
|
||||
$cloned = clone $accessory;
|
||||
$accessory_to_clone = $accessory;
|
||||
$cloned->id = null;
|
||||
@@ -137,24 +143,24 @@ class AccessoriesController extends Controller
|
||||
return view('accessories/edit')
|
||||
->with('cloned_model', $accessory_to_clone)
|
||||
->with('item', $cloned);
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Save edited Accessory from form post
|
||||
*
|
||||
* @author [A. Gianotto] [<snipe@snipe.net>]
|
||||
* @param ImageUploadRequest $request
|
||||
* @param int $accessoryId
|
||||
*
|
||||
* @param int $accessoryId
|
||||
*/
|
||||
public function update(ImageUploadRequest $request, Accessory $accessory) : RedirectResponse
|
||||
public function update(ImageUploadRequest $request, Accessory $accessory): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $accessory);
|
||||
|
||||
if ($accessory = Accessory::withCount('checkouts as checkouts_count')->find($accessory->id)) {
|
||||
|
||||
$this->authorize($accessory);
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
"qty" => "required|numeric|min:$accessory->checkouts_count"
|
||||
'qty' => "required|numeric|min:$accessory->checkouts_count",
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@@ -163,8 +169,6 @@ class AccessoriesController extends Controller
|
||||
->withInput();
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Update the accessory data
|
||||
$accessory->name = request('name');
|
||||
$accessory->location_id = request('location_id');
|
||||
@@ -182,7 +186,7 @@ class AccessoriesController extends Controller
|
||||
|
||||
$accessory = $request->handleImages($accessory);
|
||||
|
||||
if($request->input('redirect_option') === 'back'){
|
||||
if ($request->input('redirect_option') === 'back') {
|
||||
session()->put(['redirect_option' => 'index']);
|
||||
} else {
|
||||
session()->put(['redirect_option' => $request->input('redirect_option')]);
|
||||
@@ -203,51 +207,48 @@ class AccessoriesController extends Controller
|
||||
* Delete the given accessory.
|
||||
*
|
||||
* @author [A. Gianotto] [<snipe@snipe.net>]
|
||||
* @param int $accessoryId
|
||||
*
|
||||
* @param int $accessoryId
|
||||
*/
|
||||
public function destroy($accessoryId) : RedirectResponse
|
||||
public function destroy(Accessory $accessory): RedirectResponse
|
||||
{
|
||||
if (is_null($accessory = Accessory::withCount('checkouts as checkouts_count')->find($accessoryId))) {
|
||||
return redirect()->route('accessories.index')->with('error', trans('admin/accessories/message.not_found'));
|
||||
}
|
||||
$this->authorize('delete', $accessory);
|
||||
$accessory->loadCount('checkouts as checkouts_count');
|
||||
|
||||
$this->authorize($accessory);
|
||||
|
||||
|
||||
if ($accessory->checkouts_count > 0) {
|
||||
return redirect()->route('accessories.index')->with('error', trans('admin/accessories/general.delete_disabled'));
|
||||
}
|
||||
|
||||
if ($accessory->image) {
|
||||
try {
|
||||
Storage::disk('public')->delete('accessories'.'/'.$accessory->image);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
if ($accessory->isDeletable()) {
|
||||
if ($accessory->image) {
|
||||
try {
|
||||
Storage::disk('public')->delete('accessories'.'/'.$accessory->image);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
}
|
||||
|
||||
$accessory->delete();
|
||||
|
||||
return redirect()->route('accessories.index')->with('success', trans('admin/accessories/message.delete.success'));
|
||||
}
|
||||
|
||||
$accessory->delete();
|
||||
|
||||
return redirect()->route('accessories.index')->with('success', trans('admin/accessories/message.delete.success'));
|
||||
return redirect()->route('accessories.index')->with('error', trans('admin/accessories/general.delete_disabled'));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns a view that invokes the ajax table which contains
|
||||
* the content for the accessory detail view, which is generated in getDataView.
|
||||
*
|
||||
* @author [A. Gianotto] [<snipe@snipe.net>]
|
||||
* @param int $accessoryID
|
||||
*
|
||||
* @param int $accessoryID
|
||||
*
|
||||
* @see AccessoriesController::getDataView() method that generates the JSON response
|
||||
* @since [v1.0]
|
||||
*/
|
||||
public function show(Accessory $accessory) : View | RedirectResponse
|
||||
public function show(Accessory $accessory): View|RedirectResponse
|
||||
{
|
||||
$accessory->loadCount('checkouts as checkouts_count');
|
||||
|
||||
$accessory->load(['adminuser' => fn($query) => $query->withTrashed()]);
|
||||
|
||||
$this->authorize('view', $accessory);
|
||||
$accessory->loadCount('checkouts as checkouts_count');
|
||||
$accessory->load(['adminuser' => fn ($query) => $query->withTrashed()]);
|
||||
|
||||
return view('accessories.view', compact('accessory'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Accessory;
|
||||
use App\Models\AccessoryCheckout;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use \Illuminate\Contracts\View\View;
|
||||
use \Illuminate\Http\RedirectResponse;
|
||||
|
||||
class AccessoryCheckinController extends Controller
|
||||
{
|
||||
@@ -18,25 +18,26 @@ class AccessoryCheckinController extends Controller
|
||||
* Check the accessory back into inventory
|
||||
*
|
||||
* @author [A. Gianotto] [<snipe@snipe.net>]
|
||||
* @param Request $request
|
||||
* @param int $accessoryUserId
|
||||
* @param string $backto
|
||||
*
|
||||
* @param Request $request
|
||||
* @param int $accessoryUserId
|
||||
* @param string $backto
|
||||
*/
|
||||
public function create($accessoryUserId = null, $backto = null) : View | RedirectResponse
|
||||
public function create($accessoryUserId = null, $backto = null): View|RedirectResponse
|
||||
{
|
||||
if (is_null($accessory_user = DB::table('accessories_checkout')->find($accessoryUserId))) {
|
||||
return redirect()->route('accessories.index')->with('error', trans('admin/accessories/message.not_found'));
|
||||
}
|
||||
|
||||
$accessory = Accessory::find($accessory_user->accessory_id);
|
||||
$this->authorize('checkin', $accessory);
|
||||
|
||||
//based on what the accessory is checked out to the target redirect option will be displayed accordingly.
|
||||
// based on what the accessory is checked out to the target redirect option will be displayed accordingly.
|
||||
$target_option = match ($accessory_user->assigned_type) {
|
||||
'App\Models\Asset' => trans('admin/hardware/form.redirect_to_type', ['type' => trans('general.asset')]),
|
||||
'App\Models\Location' => trans('admin/hardware/form.redirect_to_type', ['type' => trans('general.location')]),
|
||||
default => trans('admin/hardware/form.redirect_to_type', ['type' => trans('general.user')]),
|
||||
};
|
||||
$this->authorize('checkin', $accessory);
|
||||
|
||||
return view('accessories/checkin', compact('accessory', 'target_option'))->with('backto', $backto);
|
||||
|
||||
@@ -46,17 +47,20 @@ class AccessoryCheckinController extends Controller
|
||||
* Check in the item so that it can be checked out again to someone else
|
||||
*
|
||||
* @uses Accessory::checkin_email() to determine if an email can and should be sent
|
||||
*
|
||||
* @author [A. Gianotto] [<snipe@snipe.net>]
|
||||
* @param null $accessoryCheckoutId
|
||||
* @param string $backto
|
||||
*
|
||||
* @param null $accessoryCheckoutId
|
||||
* @param string $backto
|
||||
*/
|
||||
public function store(Request $request, $accessoryCheckoutId = null, $backto = null) : RedirectResponse
|
||||
public function store(Request $request, $accessoryCheckoutId = null, $backto = null): RedirectResponse
|
||||
{
|
||||
if (is_null($accessory_checkout = AccessoryCheckout::find($accessoryCheckoutId))) {
|
||||
return redirect()->route('accessories.index')->with('error', trans('admin/accessories/message.does_not_exist'));
|
||||
}
|
||||
|
||||
$accessory = Accessory::find($accessory_checkout->accessory_id);
|
||||
$this->authorize('checkin', $accessory);
|
||||
|
||||
session()->put('checkedInFrom', $accessory_checkout->assigned_to);
|
||||
session()->put('checkout_to_type', match ($accessory_checkout->assigned_type) {
|
||||
@@ -65,7 +69,6 @@ class AccessoryCheckinController extends Controller
|
||||
'App\Models\Asset' => 'asset',
|
||||
});
|
||||
|
||||
$this->authorize('checkin', $accessory);
|
||||
$checkin_hours = date('H:i:s');
|
||||
$checkin_at = date('Y-m-d H:i:s');
|
||||
if ($request->filled('checkin_at')) {
|
||||
@@ -81,6 +84,7 @@ class AccessoryCheckinController extends Controller
|
||||
return Helper::getRedirectOption($request, $accessory->id, 'Accessories')
|
||||
->with('success', trans('admin/accessories/message.checkin.success'));
|
||||
}
|
||||
|
||||
// Redirect to the accessory management page with error
|
||||
return redirect()->route('accessories.index')->with('error', trans('admin/accessories/message.checkin.error'));
|
||||
}
|
||||
|
||||
@@ -4,54 +4,47 @@ namespace App\Http\Controllers\Accessories;
|
||||
|
||||
use App\Events\CheckoutableCheckedOut;
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\CheckInOutRequest;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\AccessoryCheckoutRequest;
|
||||
use App\Http\Traits\CheckInOutTrait;
|
||||
use App\Models\Accessory;
|
||||
use App\Models\AccessoryCheckout;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use \Illuminate\Contracts\View\View;
|
||||
use \Illuminate\Http\RedirectResponse;
|
||||
|
||||
class AccessoryCheckoutController extends Controller
|
||||
{
|
||||
|
||||
use CheckInOutRequest;
|
||||
use CheckInOutTrait;
|
||||
|
||||
/**
|
||||
* Return the form to checkout an Accessory to a user.
|
||||
*
|
||||
* @author [A. Gianotto] [<snipe@snipe.net>]
|
||||
* @param int $id
|
||||
*
|
||||
* @param int $id
|
||||
*/
|
||||
public function create($id) : View | RedirectResponse
|
||||
public function create(Accessory $accessory): View|RedirectResponse
|
||||
{
|
||||
|
||||
if ($accessory = Accessory::withCount('checkouts as checkouts_count')->find($id)) {
|
||||
$this->authorize('checkout', $accessory);
|
||||
|
||||
$this->authorize('checkout', $accessory);
|
||||
|
||||
if ($accessory->category) {
|
||||
// Make sure there is at least one available to checkout
|
||||
if ($accessory->numRemaining() <= 0){
|
||||
return redirect()->route('accessories.index')->with('error', trans('admin/accessories/message.checkout.unavailable'));
|
||||
}
|
||||
|
||||
// Return the checkout view
|
||||
return view('accessories/checkout', compact('accessory'));
|
||||
if ($accessory->category) {
|
||||
// Make sure there is at least one available to checkout
|
||||
if ($accessory->numRemaining() <= 0) {
|
||||
return redirect()->route('accessories.index')->with('error', trans('admin/accessories/message.checkout.unavailable'));
|
||||
}
|
||||
|
||||
// Invalid category
|
||||
return redirect()->route('accessories.edit', ['accessory' => $accessory->id])
|
||||
->with('error', trans('general.invalid_item_category_single', ['type' => trans('general.accessory')]));
|
||||
|
||||
// Return the checkout view
|
||||
return view('accessories/checkout', compact('accessory'));
|
||||
}
|
||||
|
||||
// Not found
|
||||
return redirect()->route('accessories.index')->with('error', trans('admin/accessories/message.not_found'));
|
||||
// Invalid category
|
||||
return redirect()->route('accessories.edit', ['accessory' => $accessory->id])
|
||||
->with('error', trans('general.invalid_item_category_single', ['type' => trans('general.accessory')]));
|
||||
|
||||
}
|
||||
|
||||
@@ -62,19 +55,33 @@ class AccessoryCheckoutController extends Controller
|
||||
* trigger a Slack message and send an email.
|
||||
*
|
||||
* @author [A. Gianotto] [<snipe@snipe.net>]
|
||||
* @param Request $request
|
||||
* @param Accessory $accessory
|
||||
*
|
||||
* @param Request $request
|
||||
*/
|
||||
public function store(AccessoryCheckoutRequest $request, Accessory $accessory) : RedirectResponse
|
||||
public function store(AccessoryCheckoutRequest $request, Accessory $accessory): RedirectResponse
|
||||
{
|
||||
|
||||
$this->authorize('checkout', $accessory);
|
||||
|
||||
$target = $this->determineCheckoutTarget();
|
||||
session()->put(['checkout_to_type' => $target]);
|
||||
|
||||
|
||||
if (! $accessory->canCheckoutTo($target)) {
|
||||
$targetType = match (class_basename($target)) {
|
||||
'User' => trans('general.user'),
|
||||
'Location' => trans('general.location'),
|
||||
default => trans('general.asset'),
|
||||
};
|
||||
|
||||
return redirect()->back()->with('error', trans('general.error_checkout_company_mismatch', [
|
||||
'item' => trans('general.accessory').' "'.$accessory->name.'"',
|
||||
'item_company' => $accessory->company?->name ?? trans('general.unassigned'),
|
||||
'target' => $targetType.' "'.($target->name ?? $target->username ?? $target->id).'"',
|
||||
]));
|
||||
}
|
||||
|
||||
$accessory->checkout_qty = $request->input('checkout_qty', 1);
|
||||
|
||||
|
||||
for ($i = 0; $i < $accessory->checkout_qty; $i++) {
|
||||
|
||||
$accessory_checkout = new AccessoryCheckout([
|
||||
@@ -96,13 +103,53 @@ class AccessoryCheckoutController extends Controller
|
||||
$request->input('note'),
|
||||
[],
|
||||
$accessory->checkout_qty,
|
||||
$request->boolean('sign_in_place'),
|
||||
));
|
||||
|
||||
$request->request->add(['checkout_to_type' => request('checkout_to_type')]);
|
||||
$request->request->add(['assigned_to' => $target->id]);
|
||||
|
||||
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => $request->input('checkout_to_type')]);
|
||||
session()->put([
|
||||
'redirect_option' => $request->input('redirect_option'),
|
||||
'checkout_to_type' => $request->input('checkout_to_type'),
|
||||
'sign_in_place' => $request->boolean('sign_in_place'),
|
||||
]);
|
||||
|
||||
// When sign_in_place is requested for a user checkout, redirect to the
|
||||
// acceptance/signature page so the user can sign in person.
|
||||
if ($request->boolean('sign_in_place') && ! in_array($request->input('checkout_to_type'), ['asset', 'location'], true)) {
|
||||
$targetUser = User::find($target->id);
|
||||
|
||||
if (! $targetUser instanceof User) {
|
||||
return redirect()->route('accessories.checkout.show', $accessory)
|
||||
->with('error', trans('admin/accessories/message.checkout.user_does_not_exist'));
|
||||
}
|
||||
|
||||
$acceptance = CheckoutAcceptance::where('checkoutable_type', Accessory::class)
|
||||
->where('checkoutable_id', $accessory->id)
|
||||
->where('assigned_to_id', $targetUser->id)
|
||||
->pending()
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
// If requireAcceptance() is false the listener won't have created one; create it now.
|
||||
if (! $acceptance) {
|
||||
$acceptance = new CheckoutAcceptance;
|
||||
$acceptance->checkoutable()->associate($accessory);
|
||||
$acceptance->assignedTo()->associate($targetUser);
|
||||
$acceptance->qty = $accessory->checkout_qty;
|
||||
$acceptance->save();
|
||||
}
|
||||
|
||||
session([
|
||||
'sign_in_place_acceptance_id' => $acceptance->id,
|
||||
'sign_in_place_item_id' => $accessory->id,
|
||||
'sign_in_place_resource_type' => 'Accessories',
|
||||
]);
|
||||
|
||||
return redirect()->route('account.accept.item', $acceptance->id)
|
||||
->with('success', trans('admin/accessories/message.checkout.success'));
|
||||
}
|
||||
|
||||
// Redirect to the new accessory page
|
||||
return Helper::getRedirectOption($request, $accessory->id, 'Accessories')
|
||||
|
||||
@@ -4,36 +4,40 @@ namespace App\Http\Controllers\Account;
|
||||
|
||||
use App\Events\CheckoutAccepted;
|
||||
use App\Events\CheckoutDeclined;
|
||||
use App\Events\ItemAccepted;
|
||||
use App\Events\ItemDeclined;
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\CheckoutAcceptanceResponseMail;
|
||||
use App\Models\Accessory;
|
||||
use App\Models\Actionlog;
|
||||
use App\Models\Asset;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contracts\Acceptable;
|
||||
use App\Models\Consumable;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Notifications\AcceptanceItemAcceptedNotification;
|
||||
use App\Notifications\AcceptanceItemAcceptedToUserNotification;
|
||||
use App\Notifications\AcceptanceItemDeclinedNotification;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use \Illuminate\Contracts\View\View;
|
||||
use \Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use App\Helpers\Helper;
|
||||
|
||||
class AcceptanceController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show a listing of pending checkout acceptances for the current user
|
||||
*/
|
||||
public function index() : View
|
||||
public function index(): View
|
||||
{
|
||||
$acceptances = CheckoutAcceptance::forUser(auth()->user())->pending()->get();
|
||||
|
||||
return view('account/accept.index', compact('acceptances'));
|
||||
}
|
||||
|
||||
@@ -42,20 +46,32 @@ class AcceptanceController extends Controller
|
||||
*
|
||||
* @param int $id
|
||||
*/
|
||||
public function create($id) : View | RedirectResponse
|
||||
public function create(Request $request, $id): View|RedirectResponse
|
||||
{
|
||||
$currentUser = auth()->user();
|
||||
|
||||
if (! $currentUser instanceof User) {
|
||||
abort(403, trans('general.insufficient_permissions'));
|
||||
}
|
||||
|
||||
$acceptance = CheckoutAcceptance::find($id);
|
||||
|
||||
|
||||
if (is_null($acceptance)) {
|
||||
if (! $acceptance) {
|
||||
return redirect()->route('account.accept')->with('error', trans('admin/hardware/message.does_not_exist'));
|
||||
}
|
||||
|
||||
if (! $acceptance->isPending()) {
|
||||
if ($this->isStaleSignInPlaceAdminAttempt($acceptance, $currentUser)) {
|
||||
return $this->redirectToIntendedSignInPlaceDestination($request, $acceptance)
|
||||
->with('warning', trans('admin/users/message.error.asset_already_accepted'));
|
||||
}
|
||||
|
||||
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.asset_already_accepted'));
|
||||
}
|
||||
|
||||
if (! $acceptance->isCheckedOutTo(auth()->user())) {
|
||||
$isSignInPlaceAdminFlow = $this->isSignInPlaceAdminFlow($acceptance);
|
||||
|
||||
if (! $acceptance->isCheckedOutTo($currentUser) && (! $isSignInPlaceAdminFlow)) {
|
||||
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.incorrect_user_accepted'));
|
||||
}
|
||||
|
||||
@@ -63,32 +79,49 @@ class AcceptanceController extends Controller
|
||||
return redirect()->route('account.accept')->with('error', trans('general.error_user_company'));
|
||||
}
|
||||
|
||||
return view('account/accept.create', compact('acceptance'));
|
||||
$checkedOutAt = Helper::getFormattedDateObject($acceptance->created_at, 'datetime', false);
|
||||
$checkedOutBy = $this->resolveCheckoutActorName($acceptance);
|
||||
|
||||
return view('account/accept.create', compact('acceptance', 'isSignInPlaceAdminFlow', 'checkedOutAt', 'checkedOutBy'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the accept/decline of the checkout acceptance
|
||||
*
|
||||
* @param Request $request
|
||||
* @param int $id
|
||||
*/
|
||||
public function store(Request $request, $id) : RedirectResponse
|
||||
public function store(Request $request, $id): RedirectResponse
|
||||
{
|
||||
$currentUser = auth()->user();
|
||||
|
||||
if (! $currentUser instanceof User) {
|
||||
abort(403, trans('general.insufficient_permissions'));
|
||||
}
|
||||
|
||||
$acceptance = CheckoutAcceptance::find($id);
|
||||
$assigned_user = User::find($acceptance->assigned_to_id);
|
||||
$settings = Setting::getSettings();
|
||||
$sig_filename='';
|
||||
|
||||
|
||||
if (is_null($acceptance)) {
|
||||
if (! $acceptance) {
|
||||
return redirect()->route('account.accept')->with('error', trans('admin/hardware/message.does_not_exist'));
|
||||
}
|
||||
|
||||
$assignedUser = User::find($acceptance->assigned_to_id);
|
||||
$settings = Setting::getSettings();
|
||||
$requiresSignature = (string) $settings->require_accept_signature === '1';
|
||||
$sig_filename = '';
|
||||
$encodedSignatureImage = null;
|
||||
|
||||
if (! $acceptance->isPending()) {
|
||||
if ($this->isStaleSignInPlaceAdminAttempt($acceptance, $currentUser)) {
|
||||
return $this->redirectToIntendedSignInPlaceDestination($request, $acceptance)
|
||||
->with('warning', trans('admin/users/message.error.asset_already_accepted'));
|
||||
}
|
||||
|
||||
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.asset_already_accepted'));
|
||||
}
|
||||
|
||||
if (! $acceptance->isCheckedOutTo(auth()->user())) {
|
||||
$isSignInPlaceAdminFlow = $this->isSignInPlaceAdminFlow($acceptance);
|
||||
|
||||
if (! $acceptance->isCheckedOutTo($currentUser) && (! $isSignInPlaceAdminFlow)) {
|
||||
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.incorrect_user_accepted'));
|
||||
}
|
||||
|
||||
@@ -116,16 +149,30 @@ class AcceptanceController extends Controller
|
||||
|
||||
$item = $acceptance->checkoutable_type::find($acceptance->checkoutable_id);
|
||||
|
||||
$username_slug = Str::slug($assignedUser->username);
|
||||
$asset_tag_slug = ($item instanceof Asset && $item->asset_tag) ? '-'.Str::slug($item->asset_tag) : '';
|
||||
|
||||
// If signatures are required, make sure we have one
|
||||
if (Setting::getSettings()->require_accept_signature == '1') {
|
||||
if ($requiresSignature) {
|
||||
|
||||
// The item was accepted, check for a signature
|
||||
if ($request->filled('signature_output')) {
|
||||
$sig_filename = 'siglog-' . Str::uuid() . '-' . date('Y-m-d-his') . '.png';
|
||||
$data_uri = $request->input('signature_output');
|
||||
$encoded_image = explode(',', $data_uri);
|
||||
$decoded_image = base64_decode($encoded_image[1]);
|
||||
Storage::put('private_uploads/signatures/' . $sig_filename, (string)$decoded_image);
|
||||
$sig_filename = 'siglog-'.Str::uuid().'-'.date('Y-m-d-his').'.png';
|
||||
$dataUri = (string) $request->input('signature_output');
|
||||
$encodedSignatureImage = Str::contains($dataUri, ',')
|
||||
? Str::after($dataUri, ',')
|
||||
: $dataUri;
|
||||
|
||||
$decoded_image = base64_decode($encodedSignatureImage, true);
|
||||
|
||||
if ($decoded_image === false) {
|
||||
return redirect()->back()->with('error', trans('general.shitty_browser'));
|
||||
}
|
||||
|
||||
$decoded_image = $this->flattenSignatureBackgroundToWhite($decoded_image);
|
||||
$encodedSignatureImage = base64_encode($decoded_image);
|
||||
|
||||
Storage::put('private_uploads/signatures/'.$sig_filename, (string) $decoded_image);
|
||||
|
||||
// No image data is present, kick them back.
|
||||
// This mostly only applies to users on super-duper crapola browsers *cough* IE *cough*
|
||||
@@ -134,12 +181,11 @@ class AcceptanceController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Convert PDF logo to base64 for TCPDF
|
||||
// This is needed for TCPDF to properly embed the image if it's a png and the cache isn't writable
|
||||
$encoded_logo = null;
|
||||
if (($settings->acceptance_pdf_logo) && (Storage::disk('public')->exists($settings->acceptance_pdf_logo))) {
|
||||
$encoded_logo = base64_encode(file_get_contents(public_path() . '/uploads/' . $settings->acceptance_pdf_logo));
|
||||
$encoded_logo = base64_encode(file_get_contents(public_path().'/uploads/'.basename($settings->acceptance_pdf_logo)));
|
||||
}
|
||||
|
||||
// Get the data array ready for the notifications and PDF generation
|
||||
@@ -148,56 +194,79 @@ class AcceptanceController extends Controller
|
||||
'item_name' => $item->display_name, // this handles licenses seats, which don't have a 'name' field
|
||||
'item_model' => $item->model?->name,
|
||||
'item_serial' => $item->serial,
|
||||
'item_status' => $item->assetstatus?->name,
|
||||
'item_status' => $item->status?->name,
|
||||
'eula' => $item->getEula(),
|
||||
'note' => $request->input('note'),
|
||||
'check_out_date' => Helper::getFormattedDateObject($acceptance->created_at, 'datetime', false),
|
||||
'accepted_date' => Helper::getFormattedDateObject(now()->format('Y-m-d H:i:s'), 'datetime', false),
|
||||
'declined_date' => Helper::getFormattedDateObject(now()->format('Y-m-d H:i:s'), 'datetime', false),
|
||||
'assigned_to' => $assigned_user->display_name,
|
||||
'email' => $assigned_user->email,
|
||||
'employee_num' => $assigned_user->employee_num,
|
||||
'assigned_to' => $assignedUser->display_name,
|
||||
'email' => $assignedUser->email,
|
||||
'employee_num' => $assignedUser->employee_num,
|
||||
'site_name' => $settings->site_name,
|
||||
'company_name' => $item->company?->name?? $settings->site_name,
|
||||
'signature' => (($sig_filename && array_key_exists('1', $encoded_image))) ? $encoded_image[1] : null,
|
||||
'company_name' => $item->company?->name ?? $settings->site_name,
|
||||
'signature' => ($sig_filename !== '') ? $encodedSignatureImage : null,
|
||||
'logo' => ($encoded_logo) ?? null,
|
||||
'date_settings' => $settings->date_display_format,
|
||||
'qty' => $acceptance->qty ?? 1,
|
||||
];
|
||||
|
||||
if ($request->input('asset_acceptance') == 'accepted') {
|
||||
// Include asset custom fields that are explicitly allowed in outbound emails/PDFs.
|
||||
if ($item instanceof Asset && $item->model && $item->model->fieldset) {
|
||||
$customFields = [];
|
||||
$fields = $item->model->fieldset->fields
|
||||
->where('show_in_email', true)
|
||||
->where('field_encrypted', false);
|
||||
|
||||
foreach ($fields as $field) {
|
||||
$dbColumn = $field->db_column;
|
||||
$value = $item->{$dbColumn};
|
||||
|
||||
$pdf_filename = 'accepted-'.$acceptance->checkoutable_id.'-'.$acceptance->display_checkoutable_type.'-eula-'.date('Y-m-d-h-i-s').'.pdf';
|
||||
if (! is_null($value) && $value !== '') {
|
||||
$customFields[] = [
|
||||
'label' => $field->name,
|
||||
'value' => $value,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($customFields)) {
|
||||
$data['custom_fields'] = $customFields;
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->input('asset_acceptance') === 'accepted') {
|
||||
|
||||
$pdf_filename = 'accepted-'.$username_slug.$asset_tag_slug.'-'.date('Y-m-d-h-i-s').'.pdf';
|
||||
|
||||
// Generate the PDF content
|
||||
$pdf_content = $acceptance->generateAcceptancePdf($data, $acceptance);
|
||||
Storage::put('private_uploads/eula-pdfs/' .$pdf_filename, $pdf_content);
|
||||
Storage::put('private_uploads/eula-pdfs/'.$pdf_filename, $pdf_content);
|
||||
|
||||
// Log the acceptance
|
||||
$acceptance->accept($sig_filename, $item->getEula(), $pdf_filename, $request->input('note'));
|
||||
|
||||
// Send the PDF to the signing user
|
||||
if (($request->input('send_copy') == '1') && ($assigned_user->email !='')) {
|
||||
if (($request->input('send_copy') === '1') && ($assignedUser->email !== '')) {
|
||||
|
||||
// Add the attachment for the signing user into the $data array
|
||||
$data['file'] = $pdf_filename;
|
||||
try {
|
||||
$assigned_user->notify((new AcceptanceItemAcceptedToUserNotification($data))->locale($assigned_user->locale));
|
||||
} catch (\Exception $e) {
|
||||
$assignedUser->notify((new AcceptanceItemAcceptedToUserNotification($data))->locale($assignedUser->locale));
|
||||
} catch (Exception $e) {
|
||||
Log::warning($e);
|
||||
}
|
||||
}
|
||||
try {
|
||||
$acceptance->notify((new AcceptanceItemAcceptedNotification($data))->locale(Setting::getSettings()->locale));
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
Log::warning($e);
|
||||
}
|
||||
event(new CheckoutAccepted($acceptance));
|
||||
|
||||
$return_msg = trans('admin/users/message.accepted');
|
||||
|
||||
// Item was declined
|
||||
// Item was declined
|
||||
} else {
|
||||
|
||||
for ($i = 0; $i < ($acceptance->qty ?? 1); $i++) {
|
||||
@@ -210,7 +279,6 @@ class AcceptanceController extends Controller
|
||||
$return_msg = trans('admin/users/message.declined');
|
||||
}
|
||||
|
||||
|
||||
// Send an email notification if one is requested
|
||||
if ($acceptance->alert_on_response_id) {
|
||||
try {
|
||||
@@ -223,17 +291,171 @@ class AcceptanceController extends Controller
|
||||
$recipient,
|
||||
$request->input('asset_acceptance') === 'accepted',
|
||||
));
|
||||
Log::debug('Send email notification sucess on checkout acceptance response.');
|
||||
Log::debug('Send email notification success on checkout acceptance response.');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
Log::error($e->getMessage());
|
||||
Log::warning($e);
|
||||
}
|
||||
}
|
||||
|
||||
if ($isSignInPlaceAdminFlow) {
|
||||
$request->request->add(['assigned_user' => $assignedUser?->id]);
|
||||
|
||||
$redirect = Helper::getRedirectOption(
|
||||
$request,
|
||||
session('sign_in_place_item_id'),
|
||||
session('sign_in_place_resource_type'),
|
||||
);
|
||||
|
||||
session()->forget([
|
||||
'sign_in_place_acceptance_id',
|
||||
'sign_in_place_item_id',
|
||||
'sign_in_place_resource_type',
|
||||
]);
|
||||
|
||||
return $redirect->with('success', $return_msg);
|
||||
}
|
||||
|
||||
return redirect()->to('account/accept')->with('success', $return_msg);
|
||||
|
||||
}
|
||||
|
||||
private function isSignInPlaceAdminFlow(CheckoutAcceptance $acceptance): bool
|
||||
{
|
||||
$currentUser = auth()->user();
|
||||
|
||||
return ((int) session('sign_in_place_acceptance_id') === (int) $acceptance->id)
|
||||
&& ($currentUser?->can('checkout', $acceptance->checkoutable));
|
||||
}
|
||||
|
||||
private function resolveCheckoutActorName(CheckoutAcceptance $acceptance): ?string
|
||||
{
|
||||
[$itemType, $itemId] = $this->resolveCheckoutLogItem($acceptance);
|
||||
|
||||
$checkoutLog = Actionlog::query()
|
||||
->where('action_type', 'checkout')
|
||||
->where('item_type', $itemType)
|
||||
->where('item_id', $itemId)
|
||||
->where('target_type', User::class)
|
||||
->where('target_id', $acceptance->assigned_to_id)
|
||||
->where('created_at', '<=', $acceptance->created_at->copy()->addMinutes(5))
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
return $checkoutLog?->adminuser?->display_name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Action logs normalize license seat checkouts to the parent license.
|
||||
*
|
||||
* @return array{0: class-string, 1: int}
|
||||
*/
|
||||
private function resolveCheckoutLogItem(CheckoutAcceptance $acceptance): array
|
||||
{
|
||||
$checkoutable = $acceptance->checkoutable;
|
||||
|
||||
if ($checkoutable instanceof LicenseSeat) {
|
||||
return [License::class, (int) $checkoutable->license_id];
|
||||
}
|
||||
|
||||
return [$acceptance->checkoutable_type, (int) $acceptance->checkoutable_id];
|
||||
}
|
||||
|
||||
private function isStaleSignInPlaceAdminAttempt(CheckoutAcceptance $acceptance, User $currentUser): bool
|
||||
{
|
||||
$redirectOption = session('redirect_option');
|
||||
$checkoutToType = session('checkout_to_type');
|
||||
|
||||
if (session('sign_in_place') !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($redirectOption === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($redirectOption === 'target' && $checkoutToType === 'user' && empty($acceptance->assigned_to_id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! $acceptance->isCheckedOutTo($currentUser)
|
||||
&& $currentUser->can('checkout', $acceptance->checkoutable)
|
||||
&& ($checkoutToType === 'user');
|
||||
}
|
||||
|
||||
private function redirectToIntendedSignInPlaceDestination(Request $request, CheckoutAcceptance $acceptance): RedirectResponse
|
||||
{
|
||||
if (empty($acceptance->assigned_to_id)) {
|
||||
return redirect()->route('account.accept');
|
||||
}
|
||||
|
||||
[$itemId, $resourceType] = $this->resolveRedirectTarget($acceptance);
|
||||
|
||||
$request->request->add(['assigned_user' => $acceptance->assigned_to_id]);
|
||||
|
||||
return Helper::getRedirectOption($request, $itemId, $resourceType);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: int, 1: string}
|
||||
*/
|
||||
private function resolveRedirectTarget(CheckoutAcceptance $acceptance): array
|
||||
{
|
||||
$checkoutable = $acceptance->checkoutable;
|
||||
|
||||
if ($checkoutable instanceof Asset) {
|
||||
return [(int) $checkoutable->id, 'Assets'];
|
||||
}
|
||||
|
||||
if ($checkoutable instanceof Accessory) {
|
||||
return [(int) $checkoutable->id, 'Accessories'];
|
||||
}
|
||||
|
||||
if ($checkoutable instanceof Consumable) {
|
||||
return [(int) $checkoutable->id, 'Consumables'];
|
||||
}
|
||||
|
||||
if ($checkoutable instanceof LicenseSeat) {
|
||||
return [(int) $checkoutable->license_id, 'Licenses'];
|
||||
}
|
||||
|
||||
return [(int) $acceptance->checkoutable_id, session('sign_in_place_resource_type', 'Assets')];
|
||||
}
|
||||
|
||||
private function flattenSignatureBackgroundToWhite(string $signatureBinary): string
|
||||
{
|
||||
if (! function_exists('imagecreatefromstring') || ! function_exists('imagecreatetruecolor')) {
|
||||
return $signatureBinary;
|
||||
}
|
||||
|
||||
$source = @imagecreatefromstring($signatureBinary);
|
||||
|
||||
if ($source === false) {
|
||||
return $signatureBinary;
|
||||
}
|
||||
|
||||
$width = imagesx($source);
|
||||
$height = imagesy($source);
|
||||
$flattened = imagecreatetruecolor($width, $height);
|
||||
|
||||
if ($flattened === false) {
|
||||
imagedestroy($source);
|
||||
|
||||
return $signatureBinary;
|
||||
}
|
||||
|
||||
$white = imagecolorallocate($flattened, 255, 255, 255);
|
||||
imagefilledrectangle($flattened, 0, 0, $width, $height, $white);
|
||||
imagecopy($flattened, $source, 0, 0, 0, 0, $width, $height);
|
||||
|
||||
ob_start();
|
||||
imagepng($flattened);
|
||||
$output = ob_get_clean();
|
||||
|
||||
imagedestroy($source);
|
||||
imagedestroy($flattened);
|
||||
|
||||
return is_string($output) ? $output : $signatureBinary;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user