diff --git a/Build/Sources/Sass/component/_table.scss b/Build/Sources/Sass/component/_table.scss index 56230e3584e51bbd0e83847bf90b7c0ed2e6fb70..3d5d0874f87f60e2411255c25f9e09bb7a3286de 100644 --- a/Build/Sources/Sass/component/_table.scss +++ b/Build/Sources/Sass/component/_table.scss @@ -119,6 +119,7 @@ .col-selector { span.form-check { display: inline-block; + margin-bottom: 0; } } } diff --git a/composer.json b/composer.json index 250a2ce4681561073b32bcca46bcb70e0beecc46..ecac9f03237437f08ae966c70dced8d9a37cc546 100644 --- a/composer.json +++ b/composer.json @@ -216,6 +216,7 @@ "typo3/cms-t3editor": "self.version", "typo3/cms-tstemplate": "self.version", "typo3/cms-viewpage": "self.version", + "typo3/cms-webhooks": "self.version", "typo3/cms-workspaces": "self.version" }, "autoload": { @@ -253,6 +254,7 @@ "TYPO3\\CMS\\T3editor\\": "typo3/sysext/t3editor/Classes/", "TYPO3\\CMS\\Tstemplate\\": "typo3/sysext/tstemplate/Classes/", "TYPO3\\CMS\\Viewpage\\": "typo3/sysext/viewpage/Classes/", + "TYPO3\\CMS\\Webhooks\\": "typo3/sysext/webhooks/Classes/", "TYPO3\\CMS\\Workspaces\\": "typo3/sysext/workspaces/Classes/" }, "classmap": [ @@ -294,6 +296,7 @@ "TYPO3\\CMS\\Seo\\Tests\\": "typo3/sysext/seo/Tests/", "TYPO3\\CMS\\Setup\\Tests\\": "typo3/sysext/setup/Tests/", "TYPO3\\CMS\\SysNote\\Tests\\": "typo3/sysext/sys_note/Tests/", + "TYPO3\\CMS\\Webhooks\\Tests\\": "typo3/sysext/webhooks/Tests/", "TYPO3\\CMS\\Workspaces\\Tests\\": "typo3/sysext/workspaces/Tests/", "TYPO3\\CMS\\Recycler\\Tests\\": "typo3/sysext/recycler/Tests/", "TYPO3\\CMS\\T3editor\\Tests\\": "typo3/sysext/t3editor/Tests/", diff --git a/composer.lock b/composer.lock index e525df56dd4f96554f9f6a66bc593a6b24c6a2da..eb69d330a75293abcf8a4ea84f1d0e6b00e92822 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ffa2bec04e4f097057a4b6b5847fc8b7", + "content-hash": "4ab387b2c2c70ed65bc6b34cca55675b", "packages": [ { "name": "bacon/bacon-qr-code", diff --git a/typo3/sysext/backend/Resources/Public/Css/backend.css b/typo3/sysext/backend/Resources/Public/Css/backend.css index fd30f82c64034275c144104e9a47e70f3e979277..f04332d62227e6a7fd6a57066a150756f66c4501 100644 --- a/typo3/sysext/backend/Resources/Public/Css/backend.css +++ b/typo3/sysext/backend/Resources/Public/Css/backend.css @@ -49,7 +49,7 @@ margin-bottom: 0 !important; } } -.table .table{margin:0}.table .table+.table{margin-top:6px}.table>tbody>tr>th,.table>tfoot>tr>th,.table>thead>tr>th{white-space:nowrap;vertical-align:middle}.table>tbody>tr>td,.table>tfoot>tr>td,.table>thead>tr>td{vertical-align:middle}.table>thead>tr th.col-checkbox+th.col-title label{margin-bottom:0}.table>thead>tr td i,.table>thead>tr th i{font-weight:400}.table>tbody>tr{border-color:#ccc}.table .pagination{margin:0}.table td:first-child,.table th:first-child{padding-left:var(--typo3-component-padding-x)}.table td:last-child,.table th:last-child{padding-right:var(--typo3-component-padding-x)}.table .col-icon{text-align:center}.table .col-checkbox,.table .col-icon{padding-right:0}.table .col-task,.table .col-title{width:99%}.table .col-state{min-width:120px}.table .col-task{min-width:400px}.table .col-clipboard,.table .col-control,.table .col-nowrap{white-space:nowrap!important}.table .col-clipboard,.table .col-control{text-align:right}.table .col-border-left{border-left:1px solid var(--bs-table-border-color)}.table .col-min{min-width:150px}.table .col-responsive{max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}@media (min-width:768px){.table .col-word-break{word-wrap:break-word;word-break:break-all}}.table .col-selector span.form-check{display:inline-block}.table-transparent{--bs-table-bg:transparent}.table-vertical-top td,.table-vertical-top th{vertical-align:top}.table-center td,.table-center th{text-align:center}.table-fit{width:100%;border-radius:var(--typo3-component-border-radius);box-shadow:var(--typo3-component-box-shadow);margin-bottom:var(--typo3-component-spacing);overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar;border:1px solid #ccc}.table-fit .table-bordered>:not(caption)>*{border-width:0}.table-fit .table-bordered>:not(caption)>*>*{border-width:1px}.table-fit>.table{margin-bottom:0}.table-fit>.table colgroup:first-child+tbody tr:first-child td,.table-fit>.table colgroup:first-child+tbody tr:first-child th,.table-fit>.table tbody:first-child tr:first-child td,.table-fit>.table tbody:first-child tr:first-child th{border-top:0!important}.table-fit>.table tr>td:first-child,.table-fit>.table tr>th:first-child{border-left:0!important}.table-fit>.table tr>td:last-child,.table-fit>.table tr>th:last-child{border-right:0!important}.table-fit>.table tr:last-child td{border-bottom:0!important}.table-fit-wrap td,.table-fit-wrap th{white-space:normal}.table-fit-inline-block{max-width:100%;width:auto;display:inline-block;margin:0}.table-fit-inline-block>.table{width:auto}.media{display:grid;grid-template-columns:2rem auto;grid-gap:1rem}.media .media-body{grid-column:1/3}.media .media-left+.media-body{grid-column:2/3;align-self:center}.modal .close{background:0 0;border:none;color:inherit;padding:0;margin:0;text-shadow:rgba(0,0,0,.5) 0 0 3px;opacity:.5;-webkit-user-select:none;-moz-user-select:none;user-select:none}.modal .close:active,.modal .close:hover{box-shadow:none;outline:0;background:0 0;opacity:1}.modal-dialog{display:flex;flex-direction:column;align-items:stretch;justify-content:center;margin:0 auto!important;height:100vh;width:100vw}.modal-content{display:flex;flex-direction:column;margin:0 auto;max-height:calc(100vh - 40px);max-width:calc(100vw - 40px)}.modal-content .help-block{margin-bottom:0}.modal-body{overflow-y:auto}.modal-body>:first-child{margin-top:0}.modal-body>:last-child{margin-bottom:0}.modal-footer,.modal-header{padding:calc(var(--bs-modal-padding)/ 2) var(--bs-modal-padding)}.modal-footer .btn{margin-right:0;padding-left:1em;padding-right:1em;display:inline-flex;align-items:center}.modal-footer .btn typo3-backend-icon{margin-left:-2px;margin-right:4px}.modal-footer .progress{flex-basis:100%;margin-right:0;margin-left:0}.modal-footer .modal-btn-group{margin-right:0;margin-left:0}.modal-content{transition:margin-top .1s ease-in;border:none}.modal-content .modal-loading{height:100%;display:flex;flex:1 0 auto;justify-content:center;align-items:center}.modal-image-manipulation .modal-body{padding:0}@media (min-width:768px){.modal-image-manipulation .modal-body{display:flex;flex-direction:row}}.modal-image-manipulation .modal-panel-main{overflow:visible;background-image:url(../Images/cropper-background.png);display:flex;align-items:center;justify-content:center;padding:20px;width:100%}@media (min-width:768px){.modal-image-manipulation .modal-panel-main{width:calc(100% - 250px)}}@media (min-width:992px){.modal-image-manipulation .modal-panel-main{width:calc(100% - 300px)}}.modal-image-manipulation .modal-panel-main img{max-width:100%;max-height:100%;height:auto}.modal-image-manipulation .modal-panel-sidebar{padding:15px;flex-shrink:0;border-left:1px solid rgba(0,0,0,.25);position:relative;overflow:auto;-webkit-overflow-scrolling:touch;width:100%}@media (min-width:768px){.modal-image-manipulation .modal-panel-sidebar{width:250px}}@media (min-width:992px){.modal-image-manipulation .modal-panel-sidebar{width:300px}}.modal-image-manipulation .panel-heading .is-active{pointer-events:none}.modal-image-manipulation .panel{margin-bottom:0}.modal-image-manipulation .panel-body{border-left:2px solid #ff8700}.modal-image-manipulation .panel-body label{margin-bottom:4px}.modal-type-iframe{padding:0}.modal-type-iframe .modal-body{padding:0}.modal-iframe{display:block;border:0;height:100%;width:100%;position:absolute;top:0;left:0}.modal-size-small .modal-content{width:440px}.modal-size-default .modal-content{width:600px}.modal-size-medium .modal-content{width:800px;height:520px}.modal-size-large .modal-content{width:1000px;height:800px}.modal-size-full .modal-content{width:100%;height:100%}.modal-severity-primary .modal-header{background-color:#0078e6;color:#fff;border-bottom-color:#006ccf}.modal-severity-secondary .modal-header{background-color:#737373;color:#fff;border-bottom-color:#686868}.modal-severity-success .modal-header{background-color:#107c10;color:#fff;border-bottom-color:#0e700e}.modal-severity-info .modal-header{background-color:#6daae0;color:#000;border-bottom-color:#6299ca}.modal-severity-warning .modal-header{background-color:#e8a33d;color:#000;border-bottom-color:#d19337}.modal-severity-danger .modal-header{background-color:#c83c3c;color:#fff;border-bottom-color:#b43636}.modal-severity-light .modal-header{background-color:#f5f5f5;color:#000;border-bottom-color:#ddd}.modal-severity-default .modal-header{background-color:#f5f5f5;color:#000;border-bottom-color:#ddd}.modal-severity-notice .modal-header{background-color:#333;color:#fff;border-bottom-color:#2e2e2e}.modal-severity-dark .modal-header{background-color:#1e1e1e;color:#fff;border-bottom-color:#1b1b1b}.modal-style-dark{color:#fff}.modal-style-dark .modal-header{color:#fff;background-color:#484848;border-bottom-color:#000}.modal-style-dark .modal-content{overflow:hidden;background-color:#292929}.modal-style-dark .modal-body,.modal-style-dark .modal-footer{background-color:#292929;color:#fff}.modal-style-dark .modal-footer{border-top:1px solid #000}.t3js-modal-footer .form-inline{display:block;margin:1em 0;width:100%}.t3js-modal-footer label{margin-right:10px}.modal-multi-step-wizard .modal-body .carousel.slide{min-height:21em}.modal-multi-step-wizard .modal-body .carousel-inner{width:auto;margin:0 -5px;padding:0 5px}.modal-multi-step-wizard .modal-footer .btn+.btn{margin-left:.5em}.modal-multi-step-wizard .modal-footer .progress-bar.inactive{background:0 0;color:#000}:root{--note-primary-color:#000;--note-primary-bg:#d9ebfb;--note-primary-header-color:#000;--note-primary-header-bg:#99c9f5;--note-secondary-color:#000;--note-secondary-bg:#eaeaea;--note-secondary-header-color:#000;--note-secondary-header-bg:#c7c7c7;--note-success-color:#000;--note-success-bg:#dbebdb;--note-success-header-color:#000;--note-success-header-bg:#9fcb9f;--note-info-color:#000;--note-info-bg:#e9f2fa;--note-info-header-color:#000;--note-info-header-bg:#c5ddf3;--note-warning-color:#000;--note-warning-bg:#fcf1e2;--note-warning-header-color:#000;--note-warning-header-bg:#f6dab1;--note-danger-color:#000;--note-danger-bg:#f7e2e2;--note-danger-header-color:#000;--note-danger-header-bg:#e9b1b1;--note-light-color:#000;--note-light-bg:#fefefe;--note-light-header-color:#000;--note-light-header-bg:#fbfbfb;--note-default-color:#000;--note-default-bg:#fefefe;--note-default-header-color:#000;--note-default-header-bg:#fbfbfb;--note-notice-color:#000;--note-notice-bg:#e0e0e0;--note-notice-header-color:#000;--note-notice-header-bg:#adadad;--note-dark-color:#000;--note-dark-bg:#dddddd;--note-dark-header-color:#000;--note-dark-header-bg:#a5a5a5;--note-light-bg:rgb(245, 245, 245);--note-light-header-bg:#dddddd;--note-default-bg:rgb(238, 238, 238);--note-default-header-bg:#d6d6d6}.note-list{display:grid;gap:calc(var(--typo3-component-spacing)/ 2);margin-bottom:var(--typo3-component-spacing)}.note-list .note{margin-bottom:0}.note{overflow:hidden;position:relative;z-index:1;color:var(--note-color);background-color:var(--note-bg);border-radius:var(--typo3-component-border-radius);margin-bottom:var(--typo3-component-spacing);box-shadow:var(--typo3-component-box-shadow);--note-color:var(--note-default-color);--note-bg:var(--note-default-bg);--note-header-color:var(--note-default-header-color);--note-header-bg:var(--note-default-header-bg)}.note-header{color:var(--note-header-color);background-color:var(--note-header-bg);padding:.5rem 1rem}.note-header-bar{display:flex;align-items:center;flex-wrap:wrap;gap:.5rem}.note-actions{margin-left:auto}.note-body{padding:1rem}.note-body>:first-child{margin-top:0}.note-body>:last-child{margin-bottom:0}.note-primary{--note-color:var(--note-primary-color);--note-bg:var(--note-primary-bg);--note-header-color:var(--note-primary-header-color);--note-header-bg:var(--note-primary-header-bg)}.note-secondary{--note-color:var(--note-secondary-color);--note-bg:var(--note-secondary-bg);--note-header-color:var(--note-secondary-header-color);--note-header-bg:var(--note-secondary-header-bg)}.note-success{--note-color:var(--note-success-color);--note-bg:var(--note-success-bg);--note-header-color:var(--note-success-header-color);--note-header-bg:var(--note-success-header-bg)}.note-info{--note-color:var(--note-info-color);--note-bg:var(--note-info-bg);--note-header-color:var(--note-info-header-color);--note-header-bg:var(--note-info-header-bg)}.note-warning{--note-color:var(--note-warning-color);--note-bg:var(--note-warning-bg);--note-header-color:var(--note-warning-header-color);--note-header-bg:var(--note-warning-header-bg)}.note-danger{--note-color:var(--note-danger-color);--note-bg:var(--note-danger-bg);--note-header-color:var(--note-danger-header-color);--note-header-bg:var(--note-danger-header-bg)}.note-light{--note-color:var(--note-light-color);--note-bg:var(--note-light-bg);--note-header-color:var(--note-light-header-color);--note-header-bg:var(--note-light-header-bg)}.note-default{--note-color:var(--note-default-color);--note-bg:var(--note-default-bg);--note-header-color:var(--note-default-header-color);--note-header-bg:var(--note-default-header-bg)}.note-notice{--note-color:var(--note-notice-color);--note-bg:var(--note-notice-bg);--note-header-color:var(--note-notice-header-color);--note-header-bg:var(--note-notice-header-bg)}.note-dark{--note-color:var(--note-dark-color);--note-bg:var(--note-dark-bg);--note-header-color:var(--note-dark-header-color);--note-header-bg:var(--note-dark-header-bg)}.note-category-1{--note-color:var(--note-info-color);--note-bg:var(--note-info-bg);--note-header-color:var(--note-info-header-color);--note-header-bg:var(--note-info-header-bg)}.note-category-2{--note-color:var(--note-warning-color);--note-bg:var(--note-warning-bg);--note-header-color:var(--note-warning-header-color);--note-header-bg:var(--note-warning-header-bg)}.note-category-3{--note-color:var(--note-notice-color);--note-bg:var(--note-notice-bg);--note-header-color:var(--note-notice-header-color);--note-header-bg:var(--note-notice-header-bg)}.note-category-4{--note-color:var(--note-success-color);--note-bg:var(--note-success-bg);--note-header-color:var(--note-success-header-color);--note-header-bg:var(--note-success-header-bg)}.card{overflow:hidden;box-shadow:0 1px 1px rgba(0,0,0,.2);border-color:#ccc;margin-bottom:20px;transition:all .2s ease-in-out;transition-property:box-shadow,border,transform}a.card:hover{text-decoration:none;border:1px solid #b3b3b3;transform:translate(0,-1px);box-shadow:0 2px 1px rgba(0,0,0,.3)}.card-container{display:flex;flex-wrap:wrap;margin:10px -10px}.card-container .card{margin-left:10px;margin-right:10px}.card-size-large,.card-size-medium,.card-size-small{width:calc(100% - 20px)}@media (min-width:768px){.card-size-small{width:calc(50% - 20px)}}@media (min-width:992px){.card-size-small{width:calc(25% - 20px)}}@media (min-width:768px){.card-size-medium{width:calc(50% - 20px)}}.card-size-fixed-small{width:calc(100% - 20px)}@media (min-width:624px){.card-size-fixed-small{width:calc(50% - 20px)}}@media (min-width:768px){.card-size-fixed-small{width:300px}}.card-disabled{opacity:.4}.card-body,.card-footer,.card-header,.card-image{padding:1.5em 1.5em 0 1.5em}.card-body:last-child,.card-footer:last-child,.card-header:last-child,.card-image:last-child{padding-bottom:1.5em}.card-body :first-child,.card-footer :first-child,.card-header :first-child,.card-image :first-child{margin-top:0}.card-body :last-child,.card-footer :last-child,.card-header :last-child,.card-image :last-child{margin-bottom:0}.card-image{position:relative;padding-left:0;padding-right:0}.card-image:first-child{padding-top:0}.card-image:first-child .card-image-badge{top:.75em}.card-image:last-child{padding-bottom:0}.card-image .card-image-badge{position:absolute;top:1.5em;right:.75em}.card-image img{display:block;height:auto;width:100%;margin:0 auto}.card-header{border-bottom:none}.card-header .card-icon{float:left;margin-right:.75em}.card-header .card-header-body{display:block;overflow:hidden}.card-header .card-title{font-family:inherit;font-weight:500;display:block;font-size:1.35em;line-height:1.2em;margin:0}.card-header .card-subtitle{display:block;margin-top:.5em;font-size:1em;line-height:1.2em;opacity:.5}.card-header .card-longdesc{margin-top:1em}.card-footer{border-top:none}.card-table td:first-child,.card-table th:first-child{padding-left:1.5em}.card-table td:last-child,.card-table th:last-child{padding-right:1.5em}.form{margin-bottom:var(--typo3-component-spacing)}.form-wizard-icon-list{color:var(--typo3-component-color);background:var(--typo3-component-bg);border:var(--typo3-component-border-width) solid var(--typo3-component-border-color);border-radius:.125rem;margin-top:.25rem;padding:calc(var(--typo3-spacing)/ 4);display:flex;flex-wrap:wrap;gap:2px}.form-wizard-icon-list-item a{display:flex;height:100%;color:var(--typo3-component-color);border-radius:calc(var(--typo3-component-border-radius)/ 2);padding:calc(var(--typo3-spacing)/ 2);align-items:center;justify-content:center;outline-offset:-1px}.form-wizard-icon-list-item a:hover{color:var(--typo3-list-item-hover-color);background-color:var(--typo3-list-item-hover-bg);outline:1px solid var(--typo3-list-item-hover-border-color)}.form-wizard-icon-list-item a:focus{color:var(--typo3-list-item-focus-color);background-color:var(--typo3-list-item-focus-bg);outline:1px solid var(--typo3-list-item-focus-border-color)}.form-wizard-icon-list-item a.active,.form-wizard-icon-list-item a:active{color:var(--typo3-list-item-active-color);background-color:var(--typo3-list-item-active-bg);outline:1px solid var(--typo3-list-item-active-border-color)}.form-wizard-icon-list-item a,.form-wizard-icon-list-item a>span[title]{display:block;line-height:1}.form-wizard-icon-list-item img{display:block;min-width:16px;max-width:128px;max-height:128px}.context-menu{font-size:var(--typo3-component-font-size);line-height:var(--typo3-component-line-height);padding:2px;position:absolute;z-index:310;color:var(--typo3-component-color);border:var(--typo3-component-border-width) solid var(--typo3-component-border-color);background-color:var(--typo3-component-bg);box-shadow:var(--typo3-component-box-shadow);border-radius:var(--typo3-component-border-radius)}.context-menu-group{position:relative;display:flex;flex-direction:column;gap:1px;list-style:none;padding:0;margin:0;min-width:150px}.context-menu-item{position:relative;display:flex;border-radius:calc(var(--typo3-component-border-radius) - var(--typo3-component-border-width));gap:.5em;padding:var(--typo3-list-item-padding-y) var(--typo3-list-item-padding-x);cursor:pointer;text-decoration:none}.context-menu-item:focus,.context-menu-item:hover{z-index:1;outline-offset:-1px}.context-menu-item:hover{color:var(--typo3-component-hover-color);background-color:var(--typo3-component-hover-bg);outline:1px solid var(--typo3-component-hover-border-color)}.context-menu-item:focus{color:var(--typo3-component-focus-color);background-color:var(--typo3-component-focus-bg);outline:1px solid var(--typo3-component-focus-border-color)}.context-menu-item-icon{flex-shrink:0;flex-grow:0;width:var(--icon-size-small)}.context-menu-item-label{flex-grow:1}.context-menu-item-indicator{flex-shrink:0;flex-grow:0;width:var(--icon-size-small)}.context-menu-item-divider{padding:0;height:0;margin-top:var(--typo3-list-item-padding-y);margin-bottom:var(--typo3-list-item-padding-y);border-top:var(--typo3-component-border-width) solid var(--typo3-component-border-color)}typo3-backend-live-search{display:flex;flex-direction:column;height:100%}typo3-backend-live-search .sticky-form-actions{z-index:50}typo3-backend-live-search .search-option-badge{position:absolute!important;transform:translate(-50%,-50%);top:0!important;left:100%;--bs-badge-border-radius:.65rem}typo3-backend-live-search-result-item-container,typo3-backend-live-search-result-item-detail-container{position:relative;flex-grow:1;flex-basis:50%;overflow:auto;padding:var(--bs-modal-padding)}typo3-backend-live-search-result-item-container{padding-top:0}typo3-backend-live-search-result-container{--livesearch-item-opacity:.5;--livesearch-preamble-delimiter-border-width:1px;--livesearch-preamble-delimiter-border-color:rgb(215, 215, 215);display:flex;flex-direction:row;margin:calc(var(--bs-modal-padding) * -1);height:100%;overflow:auto}typo3-backend-live-search-result-action-list,typo3-backend-live-search-result-list{display:flex;flex-direction:column;gap:1px}typo3-backend-live-search-result-list .livesearch-result-item-group-label{font-weight:700;line-height:inherit;padding:var(--typo3-list-item-padding-y) 0;border-bottom:1px solid rgba(0,0,0,.1);margin-bottom:var(--typo3-list-item-padding-y);background-color:#fff;z-index:20}typo3-backend-live-search-result-list .livesearch-result-item-group-label.sticky{position:sticky;top:0;z-index:15}typo3-backend-live-search-result-item-detail-container .livesearch-detail-preamble{text-align:center;padding-bottom:1em;margin-bottom:1em;border-bottom:var(--livesearch-preamble-delimiter-border-width) solid var(--livesearch-preamble-delimiter-border-color)}typo3-backend-live-search-result-item-detail-container .livesearch-detail-preamble .livesearch-detail-preamble-type{opacity:var(--livesearch-item-opacity)}typo3-backend-live-search-result-item,typo3-backend-live-search-result-item-action{display:flex;gap:1.5em;font-size:var(--typo3-component-font-size);line-height:var(--typo3-component-line-height);padding:var(--typo3-list-item-padding-y) var(--typo3-list-item-padding-x);border-radius:calc(var(--typo3-component-border-radius) - var(--typo3-component-border-width));color:#000;background-color:#fff;cursor:pointer}typo3-backend-live-search-result-item-action.active,typo3-backend-live-search-result-item-action:focus,typo3-backend-live-search-result-item-action:hover,typo3-backend-live-search-result-item.active,typo3-backend-live-search-result-item:focus,typo3-backend-live-search-result-item:hover{z-index:1;outline-offset:-1px}typo3-backend-live-search-result-item-action:hover,typo3-backend-live-search-result-item:hover{background-color:#f2f8fe;outline:1px solid #d9ebfb}typo3-backend-live-search-result-item-action.active,typo3-backend-live-search-result-item-action:focus,typo3-backend-live-search-result-item.active,typo3-backend-live-search-result-item:focus{background-color:#f2f8fe;outline:1px solid #3393eb}typo3-backend-live-search-result-item .livesearch-expand-action,typo3-backend-live-search-result-item-action .livesearch-expand-action{flex:0;align-items:center;margin:calc(var(--typo3-list-item-padding-y) * -1) calc(var(--typo3-list-item-padding-x) * -1);padding:var(--typo3-list-item-padding-y) calc(var(--typo3-list-item-padding-x)/ 2);border-left:1px solid transparent}typo3-backend-live-search-result-item .livesearch-expand-action:hover,typo3-backend-live-search-result-item-action .livesearch-expand-action:hover{border-left:1px solid #d9ebfb}typo3-backend-live-search-result-item-action>*,typo3-backend-live-search-result-item>*{display:flex;gap:.5em;flex:1}typo3-backend-live-search-result-item-action>* .livesearch-result-item-icon,typo3-backend-live-search-result-item>* .livesearch-result-item-icon{display:flex;gap:.5em;flex-grow:0;flex-shrink:0;align-items:center}typo3-backend-live-search-result-item-action>* .livesearch-result-item-title,typo3-backend-live-search-result-item>* .livesearch-result-item-title{flex-grow:1;word-break:break-word}typo3-backend-live-search-result-item-action>* .livesearch-result-item-title .small,typo3-backend-live-search-result-item-action>* .livesearch-result-item-title small,typo3-backend-live-search-result-item>* .livesearch-result-item-title .small,typo3-backend-live-search-result-item>* .livesearch-result-item-title small{opacity:var(--livesearch-item-opacity)}.recordlist{overflow:hidden;background:var(--panel-bg);box-shadow:var(--panel-box-shadow);border-radius:var(--panel-border-radius);border:var(--panel-border-width) solid var(--panel-default-border-color);margin-bottom:var(--panel-spacing)}.recordlist table tr td.deletePlaceholder{text-decoration:line-through}.recordlist .table-fit{box-shadow:none;border-radius:0;border-left:0;border-right:0;border-bottom:0;margin-bottom:0}.recordlist .pagination{display:inline-flex}.recordlist-heading{display:flex;align-items:center;flex-wrap:wrap;color:var(--panel-default-heading-color);background:var(--panel-default-heading-bg);padding:var(--panel-header-padding-y) var(--panel-header-padding-x);gap:var(--panel-header-padding-y) var(--panel-header-padding-x)}.recordlist-heading-row{flex-grow:1;display:flex;align-items:center;flex-wrap:wrap;max-width:100%;gap:var(--panel-header-padding-y) var(--panel-header-padding-x)}.recordlist-heading-title{font-weight:700;flex-grow:1;width:250px;max-width:100%}.recordlist-heading-actions,.recordlist-heading-selection{display:flex;align-items:center;flex-wrap:wrap;gap:.25rem}.recordlist-heading-actions [data-recordlist-action=new]{order:1}.recordlist-heading-actions [data-recordlist-action=download]{order:2}.recordlist-heading-actions [data-recordlist-action=columns]{order:3}.recordlist-heading-actions [data-recordlist-action=toggle]{order:99}.resource-tiles{--resource-tiles-grid-spacing:.5rem;--resource-tiles-grid-width:150px;--resource-tile-spacing:1rem;--resource-tile-border-radius:4px;--resource-tile-nameplate-size:12px;--resource-tile-nameplate-activity-size:10px;--resource-tile-checkbox-size:16px;--resource-tile-color:#313131;--resource-tile-bg:#fff;--resource-tile-border-color:rgb(215, 215, 215);--resource-tile-hover-color:var(--resource-tile-color);--resource-tile-hover-bg:rgba(0, 0, 0, .05);--resource-tile-hover-border-color:rgb(215, 215, 215);--resource-tile-focus-color:var(--resource-tile-color);--resource-tile-focus-bg:rgba(0, 0, 0, .05);--resource-tile-focus-border-color:rgb(187, 187, 187);--resource-tile-active-color:var(--resource-tile-color);--resource-tile-active-bg:#f2f8fe;--resource-tile-active-border-color:#3393eb;--resource-tile-success-color:var(--resource-tile-color);--resource-tile-success-bg:#f3f8f3;--resource-tile-success-border-color:#409640;--resource-tile-info-color:var(--resource-tile-color);--resource-tile-info-bg:#f8fbfd;--resource-tile-info-border-color:#8abbe6;--resource-tile-danger-color:var(--resource-tile-color);--resource-tile-danger-bg:#fcf5f5;--resource-tile-danger-border-color:#d36363;--resource-tile-warning-color:var(--resource-tile-color);--resource-tile-warning-bg:#fefaf5;--resource-tile-warning-border-color:#edb564;display:grid;grid-template-columns:repeat(auto-fill,var(--resource-tiles-grid-width));gap:var(--resource-tiles-grid-spacing);-webkit-user-select:none;-moz-user-select:none;user-select:none} +.table .table{margin:0}.table .table+.table{margin-top:6px}.table>tbody>tr>th,.table>tfoot>tr>th,.table>thead>tr>th{white-space:nowrap;vertical-align:middle}.table>tbody>tr>td,.table>tfoot>tr>td,.table>thead>tr>td{vertical-align:middle}.table>thead>tr th.col-checkbox+th.col-title label{margin-bottom:0}.table>thead>tr td i,.table>thead>tr th i{font-weight:400}.table>tbody>tr{border-color:#ccc}.table .pagination{margin:0}.table td:first-child,.table th:first-child{padding-left:var(--typo3-component-padding-x)}.table td:last-child,.table th:last-child{padding-right:var(--typo3-component-padding-x)}.table .col-icon{text-align:center}.table .col-checkbox,.table .col-icon{padding-right:0}.table .col-task,.table .col-title{width:99%}.table .col-state{min-width:120px}.table .col-task{min-width:400px}.table .col-clipboard,.table .col-control,.table .col-nowrap{white-space:nowrap!important}.table .col-clipboard,.table .col-control{text-align:right}.table .col-border-left{border-left:1px solid var(--bs-table-border-color)}.table .col-min{min-width:150px}.table .col-responsive{max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}@media (min-width:768px){.table .col-word-break{word-wrap:break-word;word-break:break-all}}.table .col-selector span.form-check{display:inline-block;margin-bottom:0}.table-transparent{--bs-table-bg:transparent}.table-vertical-top td,.table-vertical-top th{vertical-align:top}.table-center td,.table-center th{text-align:center}.table-fit{width:100%;border-radius:var(--typo3-component-border-radius);box-shadow:var(--typo3-component-box-shadow);margin-bottom:var(--typo3-component-spacing);overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar;border:1px solid #ccc}.table-fit .table-bordered>:not(caption)>*{border-width:0}.table-fit .table-bordered>:not(caption)>*>*{border-width:1px}.table-fit>.table{margin-bottom:0}.table-fit>.table colgroup:first-child+tbody tr:first-child td,.table-fit>.table colgroup:first-child+tbody tr:first-child th,.table-fit>.table tbody:first-child tr:first-child td,.table-fit>.table tbody:first-child tr:first-child th{border-top:0!important}.table-fit>.table tr>td:first-child,.table-fit>.table tr>th:first-child{border-left:0!important}.table-fit>.table tr>td:last-child,.table-fit>.table tr>th:last-child{border-right:0!important}.table-fit>.table tr:last-child td{border-bottom:0!important}.table-fit-wrap td,.table-fit-wrap th{white-space:normal}.table-fit-inline-block{max-width:100%;width:auto;display:inline-block;margin:0}.table-fit-inline-block>.table{width:auto}.media{display:grid;grid-template-columns:2rem auto;grid-gap:1rem}.media .media-body{grid-column:1/3}.media .media-left+.media-body{grid-column:2/3;align-self:center}.modal .close{background:0 0;border:none;color:inherit;padding:0;margin:0;text-shadow:rgba(0,0,0,.5) 0 0 3px;opacity:.5;-webkit-user-select:none;-moz-user-select:none;user-select:none}.modal .close:active,.modal .close:hover{box-shadow:none;outline:0;background:0 0;opacity:1}.modal-dialog{display:flex;flex-direction:column;align-items:stretch;justify-content:center;margin:0 auto!important;height:100vh;width:100vw}.modal-content{display:flex;flex-direction:column;margin:0 auto;max-height:calc(100vh - 40px);max-width:calc(100vw - 40px)}.modal-content .help-block{margin-bottom:0}.modal-body{overflow-y:auto}.modal-body>:first-child{margin-top:0}.modal-body>:last-child{margin-bottom:0}.modal-footer,.modal-header{padding:calc(var(--bs-modal-padding)/ 2) var(--bs-modal-padding)}.modal-footer .btn{margin-right:0;padding-left:1em;padding-right:1em;display:inline-flex;align-items:center}.modal-footer .btn typo3-backend-icon{margin-left:-2px;margin-right:4px}.modal-footer .progress{flex-basis:100%;margin-right:0;margin-left:0}.modal-footer .modal-btn-group{margin-right:0;margin-left:0}.modal-content{transition:margin-top .1s ease-in;border:none}.modal-content .modal-loading{height:100%;display:flex;flex:1 0 auto;justify-content:center;align-items:center}.modal-image-manipulation .modal-body{padding:0}@media (min-width:768px){.modal-image-manipulation .modal-body{display:flex;flex-direction:row}}.modal-image-manipulation .modal-panel-main{overflow:visible;background-image:url(../Images/cropper-background.png);display:flex;align-items:center;justify-content:center;padding:20px;width:100%}@media (min-width:768px){.modal-image-manipulation .modal-panel-main{width:calc(100% - 250px)}}@media (min-width:992px){.modal-image-manipulation .modal-panel-main{width:calc(100% - 300px)}}.modal-image-manipulation .modal-panel-main img{max-width:100%;max-height:100%;height:auto}.modal-image-manipulation .modal-panel-sidebar{padding:15px;flex-shrink:0;border-left:1px solid rgba(0,0,0,.25);position:relative;overflow:auto;-webkit-overflow-scrolling:touch;width:100%}@media (min-width:768px){.modal-image-manipulation .modal-panel-sidebar{width:250px}}@media (min-width:992px){.modal-image-manipulation .modal-panel-sidebar{width:300px}}.modal-image-manipulation .panel-heading .is-active{pointer-events:none}.modal-image-manipulation .panel{margin-bottom:0}.modal-image-manipulation .panel-body{border-left:2px solid #ff8700}.modal-image-manipulation .panel-body label{margin-bottom:4px}.modal-type-iframe{padding:0}.modal-type-iframe .modal-body{padding:0}.modal-iframe{display:block;border:0;height:100%;width:100%;position:absolute;top:0;left:0}.modal-size-small .modal-content{width:440px}.modal-size-default .modal-content{width:600px}.modal-size-medium .modal-content{width:800px;height:520px}.modal-size-large .modal-content{width:1000px;height:800px}.modal-size-full .modal-content{width:100%;height:100%}.modal-severity-primary .modal-header{background-color:#0078e6;color:#fff;border-bottom-color:#006ccf}.modal-severity-secondary .modal-header{background-color:#737373;color:#fff;border-bottom-color:#686868}.modal-severity-success .modal-header{background-color:#107c10;color:#fff;border-bottom-color:#0e700e}.modal-severity-info .modal-header{background-color:#6daae0;color:#000;border-bottom-color:#6299ca}.modal-severity-warning .modal-header{background-color:#e8a33d;color:#000;border-bottom-color:#d19337}.modal-severity-danger .modal-header{background-color:#c83c3c;color:#fff;border-bottom-color:#b43636}.modal-severity-light .modal-header{background-color:#f5f5f5;color:#000;border-bottom-color:#ddd}.modal-severity-default .modal-header{background-color:#f5f5f5;color:#000;border-bottom-color:#ddd}.modal-severity-notice .modal-header{background-color:#333;color:#fff;border-bottom-color:#2e2e2e}.modal-severity-dark .modal-header{background-color:#1e1e1e;color:#fff;border-bottom-color:#1b1b1b}.modal-style-dark{color:#fff}.modal-style-dark .modal-header{color:#fff;background-color:#484848;border-bottom-color:#000}.modal-style-dark .modal-content{overflow:hidden;background-color:#292929}.modal-style-dark .modal-body,.modal-style-dark .modal-footer{background-color:#292929;color:#fff}.modal-style-dark .modal-footer{border-top:1px solid #000}.t3js-modal-footer .form-inline{display:block;margin:1em 0;width:100%}.t3js-modal-footer label{margin-right:10px}.modal-multi-step-wizard .modal-body .carousel.slide{min-height:21em}.modal-multi-step-wizard .modal-body .carousel-inner{width:auto;margin:0 -5px;padding:0 5px}.modal-multi-step-wizard .modal-footer .btn+.btn{margin-left:.5em}.modal-multi-step-wizard .modal-footer .progress-bar.inactive{background:0 0;color:#000}:root{--note-primary-color:#000;--note-primary-bg:#d9ebfb;--note-primary-header-color:#000;--note-primary-header-bg:#99c9f5;--note-secondary-color:#000;--note-secondary-bg:#eaeaea;--note-secondary-header-color:#000;--note-secondary-header-bg:#c7c7c7;--note-success-color:#000;--note-success-bg:#dbebdb;--note-success-header-color:#000;--note-success-header-bg:#9fcb9f;--note-info-color:#000;--note-info-bg:#e9f2fa;--note-info-header-color:#000;--note-info-header-bg:#c5ddf3;--note-warning-color:#000;--note-warning-bg:#fcf1e2;--note-warning-header-color:#000;--note-warning-header-bg:#f6dab1;--note-danger-color:#000;--note-danger-bg:#f7e2e2;--note-danger-header-color:#000;--note-danger-header-bg:#e9b1b1;--note-light-color:#000;--note-light-bg:#fefefe;--note-light-header-color:#000;--note-light-header-bg:#fbfbfb;--note-default-color:#000;--note-default-bg:#fefefe;--note-default-header-color:#000;--note-default-header-bg:#fbfbfb;--note-notice-color:#000;--note-notice-bg:#e0e0e0;--note-notice-header-color:#000;--note-notice-header-bg:#adadad;--note-dark-color:#000;--note-dark-bg:#dddddd;--note-dark-header-color:#000;--note-dark-header-bg:#a5a5a5;--note-light-bg:rgb(245, 245, 245);--note-light-header-bg:#dddddd;--note-default-bg:rgb(238, 238, 238);--note-default-header-bg:#d6d6d6}.note-list{display:grid;gap:calc(var(--typo3-component-spacing)/ 2);margin-bottom:var(--typo3-component-spacing)}.note-list .note{margin-bottom:0}.note{overflow:hidden;position:relative;z-index:1;color:var(--note-color);background-color:var(--note-bg);border-radius:var(--typo3-component-border-radius);margin-bottom:var(--typo3-component-spacing);box-shadow:var(--typo3-component-box-shadow);--note-color:var(--note-default-color);--note-bg:var(--note-default-bg);--note-header-color:var(--note-default-header-color);--note-header-bg:var(--note-default-header-bg)}.note-header{color:var(--note-header-color);background-color:var(--note-header-bg);padding:.5rem 1rem}.note-header-bar{display:flex;align-items:center;flex-wrap:wrap;gap:.5rem}.note-actions{margin-left:auto}.note-body{padding:1rem}.note-body>:first-child{margin-top:0}.note-body>:last-child{margin-bottom:0}.note-primary{--note-color:var(--note-primary-color);--note-bg:var(--note-primary-bg);--note-header-color:var(--note-primary-header-color);--note-header-bg:var(--note-primary-header-bg)}.note-secondary{--note-color:var(--note-secondary-color);--note-bg:var(--note-secondary-bg);--note-header-color:var(--note-secondary-header-color);--note-header-bg:var(--note-secondary-header-bg)}.note-success{--note-color:var(--note-success-color);--note-bg:var(--note-success-bg);--note-header-color:var(--note-success-header-color);--note-header-bg:var(--note-success-header-bg)}.note-info{--note-color:var(--note-info-color);--note-bg:var(--note-info-bg);--note-header-color:var(--note-info-header-color);--note-header-bg:var(--note-info-header-bg)}.note-warning{--note-color:var(--note-warning-color);--note-bg:var(--note-warning-bg);--note-header-color:var(--note-warning-header-color);--note-header-bg:var(--note-warning-header-bg)}.note-danger{--note-color:var(--note-danger-color);--note-bg:var(--note-danger-bg);--note-header-color:var(--note-danger-header-color);--note-header-bg:var(--note-danger-header-bg)}.note-light{--note-color:var(--note-light-color);--note-bg:var(--note-light-bg);--note-header-color:var(--note-light-header-color);--note-header-bg:var(--note-light-header-bg)}.note-default{--note-color:var(--note-default-color);--note-bg:var(--note-default-bg);--note-header-color:var(--note-default-header-color);--note-header-bg:var(--note-default-header-bg)}.note-notice{--note-color:var(--note-notice-color);--note-bg:var(--note-notice-bg);--note-header-color:var(--note-notice-header-color);--note-header-bg:var(--note-notice-header-bg)}.note-dark{--note-color:var(--note-dark-color);--note-bg:var(--note-dark-bg);--note-header-color:var(--note-dark-header-color);--note-header-bg:var(--note-dark-header-bg)}.note-category-1{--note-color:var(--note-info-color);--note-bg:var(--note-info-bg);--note-header-color:var(--note-info-header-color);--note-header-bg:var(--note-info-header-bg)}.note-category-2{--note-color:var(--note-warning-color);--note-bg:var(--note-warning-bg);--note-header-color:var(--note-warning-header-color);--note-header-bg:var(--note-warning-header-bg)}.note-category-3{--note-color:var(--note-notice-color);--note-bg:var(--note-notice-bg);--note-header-color:var(--note-notice-header-color);--note-header-bg:var(--note-notice-header-bg)}.note-category-4{--note-color:var(--note-success-color);--note-bg:var(--note-success-bg);--note-header-color:var(--note-success-header-color);--note-header-bg:var(--note-success-header-bg)}.card{overflow:hidden;box-shadow:0 1px 1px rgba(0,0,0,.2);border-color:#ccc;margin-bottom:20px;transition:all .2s ease-in-out;transition-property:box-shadow,border,transform}a.card:hover{text-decoration:none;border:1px solid #b3b3b3;transform:translate(0,-1px);box-shadow:0 2px 1px rgba(0,0,0,.3)}.card-container{display:flex;flex-wrap:wrap;margin:10px -10px}.card-container .card{margin-left:10px;margin-right:10px}.card-size-large,.card-size-medium,.card-size-small{width:calc(100% - 20px)}@media (min-width:768px){.card-size-small{width:calc(50% - 20px)}}@media (min-width:992px){.card-size-small{width:calc(25% - 20px)}}@media (min-width:768px){.card-size-medium{width:calc(50% - 20px)}}.card-size-fixed-small{width:calc(100% - 20px)}@media (min-width:624px){.card-size-fixed-small{width:calc(50% - 20px)}}@media (min-width:768px){.card-size-fixed-small{width:300px}}.card-disabled{opacity:.4}.card-body,.card-footer,.card-header,.card-image{padding:1.5em 1.5em 0 1.5em}.card-body:last-child,.card-footer:last-child,.card-header:last-child,.card-image:last-child{padding-bottom:1.5em}.card-body :first-child,.card-footer :first-child,.card-header :first-child,.card-image :first-child{margin-top:0}.card-body :last-child,.card-footer :last-child,.card-header :last-child,.card-image :last-child{margin-bottom:0}.card-image{position:relative;padding-left:0;padding-right:0}.card-image:first-child{padding-top:0}.card-image:first-child .card-image-badge{top:.75em}.card-image:last-child{padding-bottom:0}.card-image .card-image-badge{position:absolute;top:1.5em;right:.75em}.card-image img{display:block;height:auto;width:100%;margin:0 auto}.card-header{border-bottom:none}.card-header .card-icon{float:left;margin-right:.75em}.card-header .card-header-body{display:block;overflow:hidden}.card-header .card-title{font-family:inherit;font-weight:500;display:block;font-size:1.35em;line-height:1.2em;margin:0}.card-header .card-subtitle{display:block;margin-top:.5em;font-size:1em;line-height:1.2em;opacity:.5}.card-header .card-longdesc{margin-top:1em}.card-footer{border-top:none}.card-table td:first-child,.card-table th:first-child{padding-left:1.5em}.card-table td:last-child,.card-table th:last-child{padding-right:1.5em}.form{margin-bottom:var(--typo3-component-spacing)}.form-wizard-icon-list{color:var(--typo3-component-color);background:var(--typo3-component-bg);border:var(--typo3-component-border-width) solid var(--typo3-component-border-color);border-radius:.125rem;margin-top:.25rem;padding:calc(var(--typo3-spacing)/ 4);display:flex;flex-wrap:wrap;gap:2px}.form-wizard-icon-list-item a{display:flex;height:100%;color:var(--typo3-component-color);border-radius:calc(var(--typo3-component-border-radius)/ 2);padding:calc(var(--typo3-spacing)/ 2);align-items:center;justify-content:center;outline-offset:-1px}.form-wizard-icon-list-item a:hover{color:var(--typo3-list-item-hover-color);background-color:var(--typo3-list-item-hover-bg);outline:1px solid var(--typo3-list-item-hover-border-color)}.form-wizard-icon-list-item a:focus{color:var(--typo3-list-item-focus-color);background-color:var(--typo3-list-item-focus-bg);outline:1px solid var(--typo3-list-item-focus-border-color)}.form-wizard-icon-list-item a.active,.form-wizard-icon-list-item a:active{color:var(--typo3-list-item-active-color);background-color:var(--typo3-list-item-active-bg);outline:1px solid var(--typo3-list-item-active-border-color)}.form-wizard-icon-list-item a,.form-wizard-icon-list-item a>span[title]{display:block;line-height:1}.form-wizard-icon-list-item img{display:block;min-width:16px;max-width:128px;max-height:128px}.context-menu{font-size:var(--typo3-component-font-size);line-height:var(--typo3-component-line-height);padding:2px;position:absolute;z-index:310;color:var(--typo3-component-color);border:var(--typo3-component-border-width) solid var(--typo3-component-border-color);background-color:var(--typo3-component-bg);box-shadow:var(--typo3-component-box-shadow);border-radius:var(--typo3-component-border-radius)}.context-menu-group{position:relative;display:flex;flex-direction:column;gap:1px;list-style:none;padding:0;margin:0;min-width:150px}.context-menu-item{position:relative;display:flex;border-radius:calc(var(--typo3-component-border-radius) - var(--typo3-component-border-width));gap:.5em;padding:var(--typo3-list-item-padding-y) var(--typo3-list-item-padding-x);cursor:pointer;text-decoration:none}.context-menu-item:focus,.context-menu-item:hover{z-index:1;outline-offset:-1px}.context-menu-item:hover{color:var(--typo3-component-hover-color);background-color:var(--typo3-component-hover-bg);outline:1px solid var(--typo3-component-hover-border-color)}.context-menu-item:focus{color:var(--typo3-component-focus-color);background-color:var(--typo3-component-focus-bg);outline:1px solid var(--typo3-component-focus-border-color)}.context-menu-item-icon{flex-shrink:0;flex-grow:0;width:var(--icon-size-small)}.context-menu-item-label{flex-grow:1}.context-menu-item-indicator{flex-shrink:0;flex-grow:0;width:var(--icon-size-small)}.context-menu-item-divider{padding:0;height:0;margin-top:var(--typo3-list-item-padding-y);margin-bottom:var(--typo3-list-item-padding-y);border-top:var(--typo3-component-border-width) solid var(--typo3-component-border-color)}typo3-backend-live-search{display:flex;flex-direction:column;height:100%}typo3-backend-live-search .sticky-form-actions{z-index:50}typo3-backend-live-search .search-option-badge{position:absolute!important;transform:translate(-50%,-50%);top:0!important;left:100%;--bs-badge-border-radius:.65rem}typo3-backend-live-search-result-item-container,typo3-backend-live-search-result-item-detail-container{position:relative;flex-grow:1;flex-basis:50%;overflow:auto;padding:var(--bs-modal-padding)}typo3-backend-live-search-result-item-container{padding-top:0}typo3-backend-live-search-result-container{--livesearch-item-opacity:.5;--livesearch-preamble-delimiter-border-width:1px;--livesearch-preamble-delimiter-border-color:rgb(215, 215, 215);display:flex;flex-direction:row;margin:calc(var(--bs-modal-padding) * -1);height:100%;overflow:auto}typo3-backend-live-search-result-action-list,typo3-backend-live-search-result-list{display:flex;flex-direction:column;gap:1px}typo3-backend-live-search-result-list .livesearch-result-item-group-label{font-weight:700;line-height:inherit;padding:var(--typo3-list-item-padding-y) 0;border-bottom:1px solid rgba(0,0,0,.1);margin-bottom:var(--typo3-list-item-padding-y);background-color:#fff;z-index:20}typo3-backend-live-search-result-list .livesearch-result-item-group-label.sticky{position:sticky;top:0;z-index:15}typo3-backend-live-search-result-item-detail-container .livesearch-detail-preamble{text-align:center;padding-bottom:1em;margin-bottom:1em;border-bottom:var(--livesearch-preamble-delimiter-border-width) solid var(--livesearch-preamble-delimiter-border-color)}typo3-backend-live-search-result-item-detail-container .livesearch-detail-preamble .livesearch-detail-preamble-type{opacity:var(--livesearch-item-opacity)}typo3-backend-live-search-result-item,typo3-backend-live-search-result-item-action{display:flex;gap:1.5em;font-size:var(--typo3-component-font-size);line-height:var(--typo3-component-line-height);padding:var(--typo3-list-item-padding-y) var(--typo3-list-item-padding-x);border-radius:calc(var(--typo3-component-border-radius) - var(--typo3-component-border-width));color:#000;background-color:#fff;cursor:pointer}typo3-backend-live-search-result-item-action.active,typo3-backend-live-search-result-item-action:focus,typo3-backend-live-search-result-item-action:hover,typo3-backend-live-search-result-item.active,typo3-backend-live-search-result-item:focus,typo3-backend-live-search-result-item:hover{z-index:1;outline-offset:-1px}typo3-backend-live-search-result-item-action:hover,typo3-backend-live-search-result-item:hover{background-color:#f2f8fe;outline:1px solid #d9ebfb}typo3-backend-live-search-result-item-action.active,typo3-backend-live-search-result-item-action:focus,typo3-backend-live-search-result-item.active,typo3-backend-live-search-result-item:focus{background-color:#f2f8fe;outline:1px solid #3393eb}typo3-backend-live-search-result-item .livesearch-expand-action,typo3-backend-live-search-result-item-action .livesearch-expand-action{flex:0;align-items:center;margin:calc(var(--typo3-list-item-padding-y) * -1) calc(var(--typo3-list-item-padding-x) * -1);padding:var(--typo3-list-item-padding-y) calc(var(--typo3-list-item-padding-x)/ 2);border-left:1px solid transparent}typo3-backend-live-search-result-item .livesearch-expand-action:hover,typo3-backend-live-search-result-item-action .livesearch-expand-action:hover{border-left:1px solid #d9ebfb}typo3-backend-live-search-result-item-action>*,typo3-backend-live-search-result-item>*{display:flex;gap:.5em;flex:1}typo3-backend-live-search-result-item-action>* .livesearch-result-item-icon,typo3-backend-live-search-result-item>* .livesearch-result-item-icon{display:flex;gap:.5em;flex-grow:0;flex-shrink:0;align-items:center}typo3-backend-live-search-result-item-action>* .livesearch-result-item-title,typo3-backend-live-search-result-item>* .livesearch-result-item-title{flex-grow:1;word-break:break-word}typo3-backend-live-search-result-item-action>* .livesearch-result-item-title .small,typo3-backend-live-search-result-item-action>* .livesearch-result-item-title small,typo3-backend-live-search-result-item>* .livesearch-result-item-title .small,typo3-backend-live-search-result-item>* .livesearch-result-item-title small{opacity:var(--livesearch-item-opacity)}.recordlist{overflow:hidden;background:var(--panel-bg);box-shadow:var(--panel-box-shadow);border-radius:var(--panel-border-radius);border:var(--panel-border-width) solid var(--panel-default-border-color);margin-bottom:var(--panel-spacing)}.recordlist table tr td.deletePlaceholder{text-decoration:line-through}.recordlist .table-fit{box-shadow:none;border-radius:0;border-left:0;border-right:0;border-bottom:0;margin-bottom:0}.recordlist .pagination{display:inline-flex}.recordlist-heading{display:flex;align-items:center;flex-wrap:wrap;color:var(--panel-default-heading-color);background:var(--panel-default-heading-bg);padding:var(--panel-header-padding-y) var(--panel-header-padding-x);gap:var(--panel-header-padding-y) var(--panel-header-padding-x)}.recordlist-heading-row{flex-grow:1;display:flex;align-items:center;flex-wrap:wrap;max-width:100%;gap:var(--panel-header-padding-y) var(--panel-header-padding-x)}.recordlist-heading-title{font-weight:700;flex-grow:1;width:250px;max-width:100%}.recordlist-heading-actions,.recordlist-heading-selection{display:flex;align-items:center;flex-wrap:wrap;gap:.25rem}.recordlist-heading-actions [data-recordlist-action=new]{order:1}.recordlist-heading-actions [data-recordlist-action=download]{order:2}.recordlist-heading-actions [data-recordlist-action=columns]{order:3}.recordlist-heading-actions [data-recordlist-action=toggle]{order:99}.resource-tiles{--resource-tiles-grid-spacing:.5rem;--resource-tiles-grid-width:150px;--resource-tile-spacing:1rem;--resource-tile-border-radius:4px;--resource-tile-nameplate-size:12px;--resource-tile-nameplate-activity-size:10px;--resource-tile-checkbox-size:16px;--resource-tile-color:#313131;--resource-tile-bg:#fff;--resource-tile-border-color:rgb(215, 215, 215);--resource-tile-hover-color:var(--resource-tile-color);--resource-tile-hover-bg:rgba(0, 0, 0, .05);--resource-tile-hover-border-color:rgb(215, 215, 215);--resource-tile-focus-color:var(--resource-tile-color);--resource-tile-focus-bg:rgba(0, 0, 0, .05);--resource-tile-focus-border-color:rgb(187, 187, 187);--resource-tile-active-color:var(--resource-tile-color);--resource-tile-active-bg:#f2f8fe;--resource-tile-active-border-color:#3393eb;--resource-tile-success-color:var(--resource-tile-color);--resource-tile-success-bg:#f3f8f3;--resource-tile-success-border-color:#409640;--resource-tile-info-color:var(--resource-tile-color);--resource-tile-info-bg:#f8fbfd;--resource-tile-info-border-color:#8abbe6;--resource-tile-danger-color:var(--resource-tile-color);--resource-tile-danger-bg:#fcf5f5;--resource-tile-danger-border-color:#d36363;--resource-tile-warning-color:var(--resource-tile-color);--resource-tile-warning-bg:#fefaf5;--resource-tile-warning-border-color:#edb564;display:grid;grid-template-columns:repeat(auto-fill,var(--resource-tiles-grid-width));gap:var(--resource-tiles-grid-spacing);-webkit-user-select:none;-moz-user-select:none;user-select:none} .resource-tiles-container { container-type: inline-size; margin-bottom: var(--typo3-component-spacing); diff --git a/typo3/sysext/core/Classes/Attribute/RemoteEvent.php b/typo3/sysext/core/Classes/Attribute/WebhookMessage.php similarity index 66% rename from typo3/sysext/core/Classes/Attribute/RemoteEvent.php rename to typo3/sysext/core/Classes/Attribute/WebhookMessage.php index 6756bbf7c86eca00c93751c683d03cad1eba3195..1ba72d1d0710f81a17918f3f9965b31f48677f35 100644 --- a/typo3/sysext/core/Classes/Attribute/RemoteEvent.php +++ b/typo3/sysext/core/Classes/Attribute/WebhookMessage.php @@ -20,17 +20,17 @@ namespace TYPO3\CMS\Core\Attribute; use Attribute; /** - * Service tag to mark an event as remote event (Webhook) + * Service tag to mark a message as a webhook-compatible message */ #[Attribute(Attribute::TARGET_CLASS)] -class RemoteEvent +class WebhookMessage { - public function __construct(private string $description) - { - } + public const TAG_NAME = 'core.webhook_message'; - public function getDescription(): string - { - return $this->description; + public function __construct( + public string $identifier, + public string $description, + public ?string $method = null, + ) { } } diff --git a/typo3/sysext/core/Classes/Messaging/WebhookMessageInterface.php b/typo3/sysext/core/Classes/Messaging/WebhookMessageInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..e87fd627b69ffd4c7bb2b3ab7065a05854066554 --- /dev/null +++ b/typo3/sysext/core/Classes/Messaging/WebhookMessageInterface.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Messaging; + +/** + * A semantic interface for messages that can be put into + * a message bus in order to be serialized. + * + * Recommendations for webhook messages: + * - POPOs like DTOs or custom message objects + * - No services, events, requests, or models + */ +interface WebhookMessageInterface extends \JsonSerializable +{ +} diff --git a/typo3/sysext/core/Documentation/Changelog/12.2/Feature-99632-IntroducePHPAttributeToMarkAWebhookMessage.rst b/typo3/sysext/core/Documentation/Changelog/12.2/Feature-99632-IntroducePHPAttributeToMarkAWebhookMessage.rst new file mode 100644 index 0000000000000000000000000000000000000000..c6a1b391f5638f6e0a9baf27df8b79b55fa5f288 --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/12.2/Feature-99632-IntroducePHPAttributeToMarkAWebhookMessage.rst @@ -0,0 +1,49 @@ +.. include:: /Includes.rst.txt + +.. _feature-99632-1674121967: + +=================================================================== +Feature: #99632 - Introduce PHP attribute to mark a webhook message +=================================================================== + +See :issue:`99632` + +Description +=========== + +A new custom PHP attribute :php:`\TYPO3\CMS\Core\Attribute\WebhookMessage` has +been added in order to register a message as a specific webhook message, +to send as remote status. + +The attribute must have an identifier for the webhook type (unique), +and a description that explains the purpose of the message. + +Optionally, a property method can be set for the attribute, +that contains the factory method. By default this is `createFromEvent`, +which is typically used when creating a message by a event listener, see +webhooks documentation for more details. + +Example +------- + +.. code-block:: php + + use TYPO3\CMS\Core\Attribute\WebhookMessage; + + #[WebhookMessage( + identifier: 'typo3/file-updated', + description: 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.webhook_type.typo3-file-updated' + )] + final class AnyKindOfMessage + { + // ... + } + + +Impact +====== + +It is now possible to tag any PHP class as webhook message by the PHP attribute +:php:`\TYPO3\CMS\Core\Attribute\WebhookMessage`. + +.. index:: Backend, Frontend, PHP-API, ext:core diff --git a/typo3/sysext/core/Documentation/Changelog/12.2/Feature-99632-IntroducePHPAttributeToMarkAnEventAsRemoteEventWebhook.rst b/typo3/sysext/core/Documentation/Changelog/12.2/Feature-99632-IntroducePHPAttributeToMarkAnEventAsRemoteEventWebhook.rst deleted file mode 100644 index 4a2d6cce74f99e6a89845fe5cf9eaf30f1a6957d..0000000000000000000000000000000000000000 --- a/typo3/sysext/core/Documentation/Changelog/12.2/Feature-99632-IntroducePHPAttributeToMarkAnEventAsRemoteEventWebhook.rst +++ /dev/null @@ -1,39 +0,0 @@ -.. include:: /Includes.rst.txt - -.. _feature-99632-1674121967: - -==================================================================================== -Feature: #99632 - Introduce PHP attribute to mark an event as remote event (webhook) -==================================================================================== - -See :issue:`99632` - -Description -=========== - -A new custom PHP attribute :php:`\TYPO3\CMS\Core\Attribute\RemoteEvent` has -been added in order to register an event as remote event. - -The attribute must have a description that explains the purpose of the event. - -Example -------- - -.. code-block:: php - - use TYPO3\CMS\Core\Attribute\RemoteEvent; - - #[RemoteEvent(description: 'Event fired when ...')] - final class AnyKindOfEvent - { - // ... - } - - -Impact -====== - -It is now possible to tag an event as remote event by the PHP attribute -:php:`\TYPO3\CMS\Core\Attribute\RemoteEvent`. - -.. index:: Backend, Frontend, PHP-API, ext:core diff --git a/typo3/sysext/core/Documentation/Changelog/12.3/Feature-99629-Webhooks-OutgoingWebhooksForTYPO3.rst b/typo3/sysext/core/Documentation/Changelog/12.3/Feature-99629-Webhooks-OutgoingWebhooksForTYPO3.rst new file mode 100644 index 0000000000000000000000000000000000000000..b0cd93612dc915da164f54afc1da706a9adb8c15 --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/12.3/Feature-99629-Webhooks-OutgoingWebhooksForTYPO3.rst @@ -0,0 +1,223 @@ +.. include:: /Includes.rst.txt + +.. _feature-99629-1674550092: + +======================================================== +Feature: #99629 - Webhooks - Outgoing webhooks for TYPO3 +======================================================== + +See :issue:`99629` + +Description +=========== + +A webhook is an automated message sent from one application to another via HTTP. + +This feature adds the possibility to configure webhooks in TYPO3. + +A new backend module :guilabel:`System > Webhooks` provides the possibility to +configure webhooks. The module is available in the TYPO3 backend for users with +administrative rights. + +A webhook is defined as an authorized POST or GET request to a defined URL. +For example, a webhook can be used to send a notification to a Slack channel +when a new page is created in TYPO3. + +Any webhook record is defined by a unique uid (UUID), a speaking name, an optional +description, a trigger, the target URL and a signing-secret. +Both the unique identifier and the signing-secret are generated in the backend +when a new webhook is created. + +Triggers provided by the TYPO3 core +----------------------------------- + +The TYPO3 core currently provides the following triggers for webhooks: + +* Page Modification: Triggers when a page is created, updated or deleted +* File Added: Triggers when a file is added +* File Updated: Triggers when a file is updated +* File Removed: Triggers when a file is removed +* Login Error Occurred: Triggers when a login error occurred + +These triggers are meant as a first set of triggers that can be used to send webhooks, +further triggers will be added in the future. In most projects however, it's likely +that custom triggers are required. + +Custom triggers +--------------- + +Trigger by PSR-14 Events +~~~~~~~~~~~~~~~~~~~~~~~~ + +Custom triggers can be added by creating a `Message` for an specific PSR-14 event and +tagging that message as a webhook message. + +The following example shows how to create a simple webhook message for the +:php:`\TYPO3\CMS\Core\Resource\Event\AfterFolderAddedEvent`: + +.. code-block:: php + + namespace TYPO3\CMS\Webhooks\Message; + + use TYPO3\CMS\Core\Attribute\WebhookMessage; + use TYPO3\CMS\Core\Messaging\WebhookMessageInterface; + use TYPO3\CMS\Core\Resource\Event\AfterFolderAddedEvent; + + #[WebhookMessage( + identifier: 'typo3/folder-added', + description: 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.webhook_type.typo3-folder-added' + )] + final class FolderAddedMessage implements WebhookMessageInterface + { + public function __construct( + private readonly int $storageUid, + private readonly string $identifier, + private readonly string $publicUrl + ) { + } + + public static function createFromEvent(AfterFolderAddedEvent $event): self + { + $file = $event->getFile(); + return new self($file->getStorage()->getUid(), $file->getIdentifier(), $file->getPublicUrl()); + } + + public function jsonSerialize(): array + { + return [ + 'storage' => $this->storageUid, + 'identifier' => $this->identifier, + 'url' => $this->publicUrl, + ]; + } + } + +#. Create a final class implementing the `WebhookMessageInterface`. +#. Add the :php:`WebhookMessage` attribute to the class. The attribute requires the + following information: + + * `identifier`: The identifier of the webhook message. + * `description`: The description of the webhook message. This description + is used to describe the trigger in the TYPO3 backend. +#. Add a static method `createFromEvent` that creates a new instance of the message from the event you want to use as a trigger. +#. Add a method `jsonSerialize` that returns an array with the data that should be send with the webhook. + +Trigger by hooks or custom code +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In case a trigger is not provided by the TYPO3 core or a PSR-14 event is not available, +it's possible to create a custom trigger - for example by using a TYPO3 hook. + +The message itself should look similar to the example above, but does not need the +:php:`createFromEvent` method. + +Instead, the custom code (hook implementation) will create the message +and dispatch it. + +Example hook implementation for a datahandler hook (see :php:`\TYPO3\CMS\Webhooks\Listener\PageModificationListener`): + +.. code-example:: php + + public function __construct( + protected readonly \Symfony\Component\Messenger\MessageBusInterface $bus + ) { + } + + public function processDatamap_afterDatabaseOperations($status, $table, $id, $fieldArray, DataHandler $dataHandler) + { + if ($table !== 'pages') { + return; + } + // ... + $message = new PageModificationMessage( + 'new', + $id, + $fieldArray, + $site->getIdentifier(), + (string)$site->getRouter()->generateUri($id), + $dataHandler->BE_USER, + ); + // ... + $this->bus->dispatch($message); + } + +Use :file:`services.yaml` instead of the PHP attribute +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Instead of the PHP attribute the :file:`services.yaml` can be used to define the +webhook message. The following example shows how to define the webhook message +from the example above in the :file:`services.yaml`: + +.. code-block:: yaml + + TYPO3\CMS\Webhooks\Message\FolderAddedMessage: + tags: + - name: 'core.webhook_message' + identifier: 'typo3/folder-added' + description: 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.webhook_type.typo3-folder-added' + + +HTTP Headers of every webhook +----------------------------- + +With every webhook request, the following HTTP headers are sent: + +* Content-Type: application/json +* Webhook-Signature-Algo: sha256 +* Webhook-Signature: <hash> + +The hash is calculated with the secret of the webhook and the JSON encoded data +of the request. The hash is created with the PHP function :php:`hash_hmac`. +See the following section about the hash calculation. + +Hash calculation +---------------- + +The hash is calculated with the following PHP code: + +.. code-block:: php + + $hash = hash_hmac('sha256', sprintf( + '%s:%s', + $identifier, // The identifier of the webhook (uuid) + $body // The JSON encoded body of the request + ), $secret); // The secret of the webhook + +The hash is sent as HTTP header `Webhook-Signature` and should be used to +validate that the request was sent from the TYPO3 instance and has not been +manipulated. +To verify this on the receiving end, build the hash with the same algorithm and +secret and compare it with the hash that was sent with the request. + +The hash is not meant to be used as a security mechanism, but as a way to verify +that the request was sent from the TYPO3 instance. + +Technical background and advanced usage +--------------------------------------- + +The webhook system is based on the Symfony Messenger component. The messages +are simple PHP objects that implement an interface that denotes +them as Webhook messages. + +That message is then dispatched to the Symfony Messenger bus. The TYPO3 core +provides a `WebhookMessageHandler` that is responsible for sending the webhook +requests to the third-party system if configured to do so. The handler looks up +the webhook configuration and sends the request to the configured URL. + +Messages are sent to the bus in any case. The handler is then responsible for checking +whether or not an external request (webhook) should be sent. + +If advanced request handling is necessary or a custom implementation should be used, +a custom handler can be created that handles `WebhookMessageInterface` +messages. See the TYPO3 queue documentation for more information on messages and their +handlers. + +Impact +====== + +The TYPO3 core does now provide a convenient GUI to create and send webhooks to +third party systems. +In combination with the system extension `reactions` TYPO3 can now be used as a +low-code/no-code integration platform between multiple systems. + +.. index:: Backend, Frontend, PHP-API, ext:webhooks diff --git a/typo3/sysext/reactions/Resources/Private/Templates/Management/Overview.html b/typo3/sysext/reactions/Resources/Private/Templates/Management/Overview.html index 3554cb8babe49b3aec34865522bf570d51cbc470..ea120376944dcc09d40b517e67b8c3702345edb3 100644 --- a/typo3/sysext/reactions/Resources/Private/Templates/Management/Overview.html +++ b/typo3/sysext/reactions/Resources/Private/Templates/Management/Overview.html @@ -118,13 +118,13 @@ <f:for each="{reactionRecords}" key="reactionId" as="reaction"> <f:variable name="reactionRecord" value="{reaction as array}"/> <tr data-uid="{reaction.uid}" data-multi-record-selection-element="true"> + <td class="col-selector nowrap"> + <span class="form-check form-toggle"> + <input class="form-check-input t3js-multi-record-selection-check" type="checkbox"> + </span> + </td> <f:if condition="{reactionTypes.{reaction.type}}"> <f:then> - <td class="col-selector nowrap"> - <span class="form-check form-toggle"> - <input class="form-check-input t3js-multi-record-selection-check" type="checkbox"> - </span> - </td> <td class="col-icon"> <a href="#" @@ -246,24 +246,15 @@ <f:section name="controls"> <div class="btn-group"> - <f:if condition="{reactionTypes.{reaction.type}}"> - <f:then> - <be:link.editRecord - returnUrl="{returnUrl}" - class="btn btn-default" - table="sys_reaction" - uid="{reaction.uid}" - title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:edit')}" - > - <core:icon identifier="actions-open" /> - </be:link.editRecord> - </f:then> - <f:else> - <span class="btn btn-default disabled"> - <core:icon identifier="empty-empty" /> - </span> - </f:else> - </f:if> + <be:link.editRecord + returnUrl="{returnUrl}" + class="btn btn-default" + table="sys_reaction" + uid="{reaction.uid}" + title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:edit')}" + > + <core:icon identifier="actions-open" /> + </be:link.editRecord> <f:if condition="{reactionRecord.disabled} == 1"> <f:then> <a diff --git a/typo3/sysext/webhooks/.gitattributes b/typo3/sysext/webhooks/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..79ede152a8388ffb534b68d44b887ef6d9e759ab --- /dev/null +++ b/typo3/sysext/webhooks/.gitattributes @@ -0,0 +1,2 @@ +/.gitattributes export-ignore +/Tests/ export-ignore diff --git a/typo3/sysext/webhooks/Classes/ConfigurationModuleProvider/WebhookTypesProvider.php b/typo3/sysext/webhooks/Classes/ConfigurationModuleProvider/WebhookTypesProvider.php new file mode 100644 index 0000000000000000000000000000000000000000..d9f75cb286eb34ac27a5913e9ca29d6424a2db6e --- /dev/null +++ b/typo3/sysext/webhooks/Classes/ConfigurationModuleProvider/WebhookTypesProvider.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Webhooks\ConfigurationModuleProvider; + +use TYPO3\CMS\Lowlevel\ConfigurationModuleProvider\AbstractProvider; +use TYPO3\CMS\Webhooks\WebhookTypesRegistry; + +/** + * Shows configured webhook types in the EXT:lowlevel configuration module. + * + * @internal not part of TYPO3's Core API + */ +class WebhookTypesProvider extends AbstractProvider +{ + public function __construct( + private readonly WebhookTypesRegistry $webhookTypesRegistry + ) { + } + + public function getConfiguration(): array + { + $configuration = []; + foreach ($this->webhookTypesRegistry->getAvailableWebhookTypes() as $identifier => $webhookType) { + $configuration[$identifier] = [ + 'messageName' => $webhookType->getServiceName(), + 'description' => $webhookType->getDescription(), + 'connectedEvent' => $webhookType->getConnectedEvent() ?? 'none', + ]; + } + return $configuration; + } +} diff --git a/typo3/sysext/webhooks/Classes/Controller/ManagementController.php b/typo3/sysext/webhooks/Classes/Controller/ManagementController.php new file mode 100644 index 0000000000000000000000000000000000000000..db71df24e5f4210400d950b20d2fc48306bf9481 --- /dev/null +++ b/typo3/sysext/webhooks/Classes/Controller/ManagementController.php @@ -0,0 +1,132 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Webhooks\Controller; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use TYPO3\CMS\Backend\Attribute\Controller; +use TYPO3\CMS\Backend\Routing\UriBuilder; +use TYPO3\CMS\Backend\Template\Components\ButtonBar; +use TYPO3\CMS\Backend\Template\ModuleTemplate; +use TYPO3\CMS\Backend\Template\ModuleTemplateFactory; +use TYPO3\CMS\Core\Imaging\Icon; +use TYPO3\CMS\Core\Imaging\IconFactory; +use TYPO3\CMS\Core\Localization\LanguageService; +use TYPO3\CMS\Core\Pagination\SimplePagination; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Webhooks\Pagination\DemandedArrayPaginator; +use TYPO3\CMS\Webhooks\Repository\WebhookDemand; +use TYPO3\CMS\Webhooks\Repository\WebhookRepository; +use TYPO3\CMS\Webhooks\WebhookTypesRegistry; + +/** + * The System > Webhooks module: Rendering the listing of webhooks. + * + * @internal This class is a specific Backend controller implementation and is not part of the TYPO3's Core API. + */ +#[Controller] +class ManagementController +{ + public function __construct( + private readonly UriBuilder $uriBuilder, + private readonly IconFactory $iconFactory, + private readonly ModuleTemplateFactory $moduleTemplateFactory, + private readonly WebhookTypesRegistry $webhookTypesRegistry, + private readonly WebhookRepository $webhookRepository + ) { + } + + public function overviewAction(ServerRequestInterface $request): ResponseInterface + { + $view = $this->moduleTemplateFactory->create($request); + $demand = WebhookDemand::fromRequest($request); + $requestUri = $request->getAttribute('normalizedParams')->getRequestUri(); + $languageService = $this->getLanguageService(); + + $this->registerDocHeaderButtons($view, $requestUri, $demand); + + $webhookRecords = $this->webhookRepository->getWebhookRecords($demand); + $paginator = new DemandedArrayPaginator($webhookRecords, $demand->getPage(), $demand->getLimit(), $this->webhookRepository->countAll()); + $pagination = new SimplePagination($paginator); + + return $view->assignMultiple([ + 'demand' => $demand, + 'webhookTypes' => $this->webhookTypesRegistry->getAvailableWebhookTypes(), + 'paginator' => $paginator, + 'pagination' => $pagination, + 'webhookRecords' => $webhookRecords, + 'editActionConfiguration' => GeneralUtility::jsonEncodeForHtmlAttribute([ + 'idField' => 'uid', + 'tableName' => 'sys_webhook', + 'returnUrl' => $request->getAttribute('normalizedParams')->getRequestUri(), + ]), + 'deleteActionConfiguration' => GeneralUtility::jsonEncodeForHtmlAttribute([ + 'idField' => 'uid', + 'tableName' => 'sys_webhook', + 'title' => $languageService->sL('LLL:EXT:webhooks/Resources/Private/Language/locallang_module_webhooks.xlf:labels.delete.title'), + 'content' => $languageService->sL('LLL:EXT:webhooks/Resources/Private/Language/locallang_module_webhooks.xlf:labels.delete.message'), + 'ok' => $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.delete'), + 'cancel' => $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.cancel'), + 'returnUrl' => $requestUri, + ]), + ])->renderResponse('Management/Overview'); + } + + protected function registerDocHeaderButtons(ModuleTemplate $view, string $requestUri, WebhookDemand $demand): void + { + $languageService = $this->getLanguageService(); + $buttonBar = $view->getDocHeaderComponent()->getButtonBar(); + + // Create new + $newRecordButton = $buttonBar->makeLinkButton() + ->setHref((string)$this->uriBuilder->buildUriFromRoute( + 'record_edit', + [ + 'edit' => ['sys_webhook' => ['new']], + 'returnUrl' => (string)$this->uriBuilder->buildUriFromRoute('webhooks_management'), + ] + )) + ->setShowLabelText(true) + ->setTitle($languageService->sL('LLL:EXT:webhooks/Resources/Private/Language/locallang_module_webhooks.xlf:webhook_create')) + ->setIcon($this->iconFactory->getIcon('actions-add', Icon::SIZE_SMALL)); + $buttonBar->addButton($newRecordButton, ButtonBar::BUTTON_POSITION_LEFT, 10); + + // Reload + $reloadButton = $buttonBar->makeLinkButton() + ->setHref($requestUri) + ->setTitle($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.reload')) + ->setIcon($this->iconFactory->getIcon('actions-refresh', Icon::SIZE_SMALL)); + $buttonBar->addButton($reloadButton, ButtonBar::BUTTON_POSITION_RIGHT); + + // Shortcut + $shortcutButton = $buttonBar->makeShortcutButton() + ->setRouteIdentifier('webhooks_management') + ->setDisplayName($languageService->sL('LLL:EXT:webhooks/Resources/Private/Language/locallang_module_webhooks.xlf:mlang_labels_tablabel')) + ->setArguments(array_filter([ + 'demand' => $demand->getParameters(), + 'orderField' => $demand->getOrderField(), + 'orderDirection' => $demand->getOrderDirection(), + ])); + $buttonBar->addButton($shortcutButton, ButtonBar::BUTTON_POSITION_RIGHT); + } + + protected function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } +} diff --git a/typo3/sysext/webhooks/Classes/DependencyInjection/WebhookCompilerPass.php b/typo3/sysext/webhooks/Classes/DependencyInjection/WebhookCompilerPass.php new file mode 100644 index 0000000000000000000000000000000000000000..a95d4504aafb8792859512066908f5a565b2ccd2 --- /dev/null +++ b/typo3/sysext/webhooks/Classes/DependencyInjection/WebhookCompilerPass.php @@ -0,0 +1,142 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Webhooks\DependencyInjection; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Exception\RuntimeException; +use TYPO3\CMS\Core\EventDispatcher\ListenerProvider; +use TYPO3\CMS\Webhooks\Listener\MessageListener; +use TYPO3\CMS\Webhooks\WebhookTypesRegistry; + +/** + * A compiler pass to wire Messages which can be used as "webhook" into the webhook registry when needed. + * + * Also, if the Message has an Event name as identifier, we automatically register the MessageListener + * to the ListenerProvider as well, and the MessageFactory can then automatically create the message for us. + * + * What does this mean? If your Message class has one object within the __construct() method, + * we assume this is an Event class, and we connect to it. + * + * @internal + */ +final class WebhookCompilerPass implements CompilerPassInterface +{ + private ContainerBuilder $container; + public function __construct( + private readonly string $tagName + ) { + } + + public function process(ContainerBuilder $container): void + { + if (!$container->hasDefinition(WebhookTypesRegistry::class)) { + return; + } + $this->container = $container; + $webhookTypesRegistryDefinition = $container->findDefinition(WebhookTypesRegistry::class); + + foreach ($container->findTaggedServiceIds($this->tagName) as $serviceName => $tags) { + $service = $container->findDefinition($serviceName); + $description = ''; + $identifier = $serviceName; + $eventIdentifier = null; + // see if we should also auto-wire the message to the event, and register our main message listener + $listenerProviderDefinition = $container->findDefinition(ListenerProvider::class); + // we can have multiple tags on a service, so we need to loop over them and add all webhook configuration + // for this message type + foreach ($tags as $attributes) { + $description = $attributes['description'] ?? ''; + if (isset($attributes['identifier'])) { + $identifier = $attributes['identifier']; + } + $method = $attributes['method'] ?? 'createFromEvent'; + $eventIdentifier = $attributes['event'] ?? $this->getParameterType($serviceName, $service, $method); + if ($eventIdentifier !== null && $eventIdentifier !== false) { + $listenerProviderDefinition->addMethodCall( + 'addListener', + [ + $eventIdentifier, + MessageListener::class, + '__invoke', + ] + ); + } + $webhookTypesRegistryDefinition->addMethodCall('addWebhookType', [$identifier, $description, $serviceName, $method, $eventIdentifier]); + } + } + } + + /** + * Derives the class type of the first argument of a given method. + */ + protected function getParameterType(string $serviceName, Definition $definition, string $method = 'createFromEvent'): ?string + { + // A Reflection exception should never actually get thrown here, but linters want a try-catch just in case. + try { + if (!$definition->isAutowired()) { + throw new \InvalidArgumentException( + sprintf('Service "%s" has webhooks defined but does not declare an event to listen to and is not configured to autowire it from the listener method. Set autowire: true to enable auto-detection of the listener event.', $serviceName), + 1679613099, + ); + } + $params = $this->getReflectionMethod($definition, $method)?->getParameters(); + // Only check if the method has really just one argument + if ($params === null || count($params) !== 1) { + return null; + } + $rType = $params[0]->getType(); + if (!$rType instanceof \ReflectionNamedType) { + // Don't connect this webhook message to an event + return null; + } + return $rType->getName(); + } catch (\ReflectionException $e) { + // Don't autowire this to an event + return null; + } + } + + /** + * @throws RuntimeException|\ReflectionException + * This method borrowed very closely from Symfony's AbstractRecursivePass (and the ListenerProviderPass). + * @see \TYPO3\CMS\Core\DependencyInjection\ListenerProviderPass::getReflectionMethod() + */ + private function getReflectionMethod(Definition $definition, string $method): ?\ReflectionFunctionAbstract + { + if (!$class = $definition->getClass()) { + return null; + } + + if (!$r = $this->container->getReflectionClass($class)) { + return null; + } + + if (!$r->hasMethod($method)) { + return null; + } + + $r = $r->getMethod($method); + if (!$r->isPublic()) { + return null; + } + + return $r; + } +} diff --git a/typo3/sysext/webhooks/Classes/Factory/WebhookInstructionFactory.php b/typo3/sysext/webhooks/Classes/Factory/WebhookInstructionFactory.php new file mode 100644 index 0000000000000000000000000000000000000000..6e23f5afab7a3501978541e6f3517a87ea2e262c --- /dev/null +++ b/typo3/sysext/webhooks/Classes/Factory/WebhookInstructionFactory.php @@ -0,0 +1,96 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Webhooks\Factory; + +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Webhooks\Model\WebhookInstruction; +use TYPO3\CMS\Webhooks\Model\WebhookType; +use TYPO3\CMS\Webhooks\WebhookTypesRegistry; + +/** + * A factory to create webhook instructions from a database row. + * + * @internal not part of TYPO3's Core API + */ +class WebhookInstructionFactory +{ + private static array $defaults = [ + 'method' => 'POST', + 'verify_ssl' => true, + 'additional_headers' => [], + 'name' => null, + 'description' => null, + 'webhook_type' => null, + 'identifier' => null, + 'uid' => null, + ]; + + public static function create( + string $url, + string $secret, + string $method = 'POST', + bool $verifySSL = true, + array $additionalHeaders = [], + string $name = null, + string $description = null, + WebhookType $webhookType = null, + string $identifier = null, + int $uid = null, + ): WebhookInstruction { + return new WebhookInstruction( + $url, + $secret, + $method, + $verifySSL, + $additionalHeaders, + $name, + $description, + $webhookType, + $identifier, + $uid + ); + } + + public static function createFromRow(array $row): WebhookInstruction + { + $data = array_merge(self::$defaults, $row); + + if ($data['webhook_type'] !== null) { + try { + $data['webhook_type'] = GeneralUtility::makeInstance(WebhookTypesRegistry::class) + ->getWebhookByType($data['webhook_type']); + } catch (\UnexpectedValueException $e) { + // Webhook type not found + $data['webhook_type'] = null; + } + } + + return new WebhookInstruction( + $data['url'], + $data['secret'], + $data['method'], + (bool)$data['verify_ssl'], + $data['additional_headers'], + $data['name'], + $data['description'], + $data['webhook_type'], + $data['identifier'], + $data['uid'], + ); + } +} diff --git a/typo3/sysext/webhooks/Classes/Listener/MessageListener.php b/typo3/sysext/webhooks/Classes/Listener/MessageListener.php new file mode 100644 index 0000000000000000000000000000000000000000..8b7c335cb632b61ed1186a83e04f49fdb8bde261 --- /dev/null +++ b/typo3/sysext/webhooks/Classes/Listener/MessageListener.php @@ -0,0 +1,57 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Webhooks\Listener; + +use Psr\Log\LoggerInterface; +use Symfony\Component\Messenger\MessageBusInterface; +use TYPO3\CMS\Webhooks\Message\WebhookMessageFactory; + +/** + * Listens to registered PSR-14 events and creates a message out of it. + * The MessageListener is automatically attached to the respecting Events by DI. + * + * Messages are dispatched to the message bus after creation. + * + * @internal not part of TYPO3 Core API + */ +class MessageListener +{ + public function __construct( + protected readonly MessageBusInterface $bus, + protected readonly WebhookMessageFactory $messageFactory, + protected readonly LoggerInterface $logger, + ) { + } + + public function __invoke(mixed $object): void + { + $message = $this->messageFactory->createMessageFromEvent($object); + if ($message === null) { + return; + } + try { + $this->bus->dispatch($message); + } catch (\Throwable $e) { + // At the moment we ignore every exception here, but we log them. + // An exception here means that an error happens while sending the webhook, + // and we should not block the execution of other configured webhooks. + // This can happen if no transport is configured, and the message is handled directly. + $this->logger->error(get_class($message) . ': ' . $e->getMessage()); + } + } +} diff --git a/typo3/sysext/webhooks/Classes/Listener/PageModificationListener.php b/typo3/sysext/webhooks/Classes/Listener/PageModificationListener.php new file mode 100644 index 0000000000000000000000000000000000000000..52986ecb0edd9ab04a191ba78124d579ac1c5e8a --- /dev/null +++ b/typo3/sysext/webhooks/Classes/Listener/PageModificationListener.php @@ -0,0 +1,94 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Webhooks\Listener; + +use Psr\Log\LoggerInterface; +use Symfony\Component\Messenger\MessageBusInterface; +use TYPO3\CMS\Backend\Utility\BackendUtility; +use TYPO3\CMS\Core\DataHandling\DataHandler; +use TYPO3\CMS\Core\Site\SiteFinder; +use TYPO3\CMS\Core\Utility\MathUtility; +use TYPO3\CMS\Webhooks\Message\PageModificationMessage; + +/** + * Creates a message everytime something changed within a page. + * + * This example does not use PSR-14 events, but creates a message manually, + * which is then dispatched. + * + * @internal not part of TYPO3 Core API + */ +class PageModificationListener +{ + public function __construct( + protected readonly MessageBusInterface $bus, + protected readonly LoggerInterface $logger, + protected readonly SiteFinder $siteFinder, + ) { + } + + public function processDatamap_afterDatabaseOperations($status, $table, $id, $fieldArray, DataHandler $dataHandler) + { + if ($table !== 'pages') { + return; + } + if (!MathUtility::canBeInterpretedAsInteger($id)) { + $id = $dataHandler->substNEWwithIDs[$id]; + } + $site = $this->siteFinder->getSiteByPageId($id); + if ($status === 'new') { + $message = new PageModificationMessage( + 'new', + $id, + $fieldArray, + (string)$site->getRouter()->generateUri($id), + $site->getIdentifier(), + $dataHandler->BE_USER, + ); + } else { + if (isset($fieldArray['hidden'])) { + $action = $fieldArray['hidden'] ? 'unpublished' : 'published'; + } else { + $action = 'modified'; + } + $message = new PageModificationMessage( + $action, + $id, + BackendUtility::getRecord('pages', $id), + (string)$site->getRouter()->generateUri($id), + $site->getIdentifier(), + $dataHandler->BE_USER, + $fieldArray + ); + } + $this->dispatchMessage($message); + } + + protected function dispatchMessage(PageModificationMessage $message): void + { + try { + $this->bus->dispatch($message); + } catch (\Throwable $e) { + // At the moment we ignore every exception here, but we log them. + // An exception here means that an error happens while sending the webhook, + // and we should not block the execution of other configured webhooks. + // This can happen if no transport is configured, and the message is handled directly. + $this->logger->error(get_class($message) . ': ' . $e->getMessage()); + } + } +} diff --git a/typo3/sysext/webhooks/Classes/Message/FileAddedMessage.php b/typo3/sysext/webhooks/Classes/Message/FileAddedMessage.php new file mode 100644 index 0000000000000000000000000000000000000000..93f68062a019664cdb2f877533124bf8e9d75ba6 --- /dev/null +++ b/typo3/sysext/webhooks/Classes/Message/FileAddedMessage.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Webhooks\Message; + +use TYPO3\CMS\Core\Attribute\WebhookMessage; +use TYPO3\CMS\Core\Messaging\WebhookMessageInterface; +use TYPO3\CMS\Core\Resource\Event\AfterFileAddedEvent; + +/** + * A message that is triggered after a file was added to TYPO3. + * + * @internal not part of TYPO3 Core API + */ +#[WebhookMessage( + identifier: 'typo3/file-added', + description: 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.webhook_type.typo3-file-added' +)] +final class FileAddedMessage implements WebhookMessageInterface +{ + public function __construct( + private readonly int $storageUid, + private readonly string $identifier, + private readonly string $publicUrl + ) { + } + + public static function createFromEvent(AfterFileAddedEvent $event): self + { + $file = $event->getFile(); + return new self($file->getStorage()->getUid(), $file->getIdentifier(), $file->getPublicUrl()); + } + + public function jsonSerialize(): array + { + return [ + 'storage' => $this->storageUid, + 'identifier' => $this->identifier, + 'url' => $this->publicUrl, + ]; + } +} diff --git a/typo3/sysext/webhooks/Classes/Message/FileRemovedMessage.php b/typo3/sysext/webhooks/Classes/Message/FileRemovedMessage.php new file mode 100644 index 0000000000000000000000000000000000000000..8ed8230d0e799f1cd89e51809982af618a213070 --- /dev/null +++ b/typo3/sysext/webhooks/Classes/Message/FileRemovedMessage.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Webhooks\Message; + +use TYPO3\CMS\Core\Attribute\WebhookMessage; +use TYPO3\CMS\Core\Messaging\WebhookMessageInterface; +use TYPO3\CMS\Core\Resource\Event\BeforeFileDeletedEvent; + +/** + * A message that is triggered when a file was deleted. + * + * @internal not part of TYPO3 Core API + */ +#[WebhookMessage( + identifier: 'typo3/file-removed', + description: 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.webhook_type.typo3-file-removed' +)] +final class FileRemovedMessage implements WebhookMessageInterface +{ + public function __construct( + private readonly int $storageUid, + private readonly string $identifier, + private readonly string $publicUrl + ) { + } + + public static function createFromEvent(BeforeFileDeletedEvent $event): self + { + $file = $event->getFile(); + return new self($file->getStorage()->getUid(), $file->getIdentifier(), $file->getPublicUrl()); + } + + public function jsonSerialize(): array + { + return [ + 'storage' => $this->storageUid, + 'identifier' => $this->identifier, + 'url' => $this->publicUrl, + ]; + } +} diff --git a/typo3/sysext/webhooks/Classes/Message/FileUpdatedMessage.php b/typo3/sysext/webhooks/Classes/Message/FileUpdatedMessage.php new file mode 100644 index 0000000000000000000000000000000000000000..8e0c8841544ebcb030f475d68f8b12b28334c5f1 --- /dev/null +++ b/typo3/sysext/webhooks/Classes/Message/FileUpdatedMessage.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Webhooks\Message; + +use TYPO3\CMS\Core\Attribute\WebhookMessage; +use TYPO3\CMS\Core\Messaging\WebhookMessageInterface; +use TYPO3\CMS\Core\Resource\Event\AfterFileUpdatedInIndexEvent; + +/** + * @internal not part of TYPO3 Core API + */ +#[WebhookMessage( + identifier: 'typo3/file-updated', + description: 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.webhook_type.typo3-file-updated' +)] +final class FileUpdatedMessage implements WebhookMessageInterface +{ + public function __construct( + private readonly int $storageUid, + private readonly string $identifier, + private readonly string $publicUrl, + private readonly array $relevantProperties, + private readonly array $updatedFields + ) { + } + + public static function createFromEvent(AfterFileUpdatedInIndexEvent $event): FileUpdatedMessage + { + $file = $event->getFile(); + return new self($file->getStorage()->getUid(), $file->getIdentifier(), $file->getPublicUrl(), $event->getRelevantProperties(), $event->getUpdatedFields()); + } + + public function jsonSerialize(): array + { + return [ + 'storage' => $this->storageUid, + 'identifier' => $this->identifier, + 'url' => $this->publicUrl, + 'relevantProperties' => $this->relevantProperties, + 'updatedFields' => $this->updatedFields, + ]; + } +} diff --git a/typo3/sysext/webhooks/Classes/Message/LoginErrorOccurredMessage.php b/typo3/sysext/webhooks/Classes/Message/LoginErrorOccurredMessage.php new file mode 100644 index 0000000000000000000000000000000000000000..cc56387bbde340db0427f6da1e5a0cbf78682d90 --- /dev/null +++ b/typo3/sysext/webhooks/Classes/Message/LoginErrorOccurredMessage.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Webhooks\Message; + +use Psr\Http\Message\UriInterface; +use TYPO3\CMS\Core\Attribute\WebhookMessage; +use TYPO3\CMS\Core\Authentication\Event\LoginAttemptFailedEvent; +use TYPO3\CMS\Core\Messaging\WebhookMessageInterface; + +/** + * @internal not part of TYPO3's Core API + */ +#[WebhookMessage( + identifier: 'typo3/login-error', + description: 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.webhook_type.typo3-login-error' +)] +final class LoginErrorOccurredMessage implements WebhookMessageInterface +{ + public function __construct( + private readonly bool $isFrontend, + private readonly UriInterface $url, + private readonly array $loginData, + ) { + } + + public function jsonSerialize(): array + { + return [ + 'context' => $this->isFrontend ? 'frontend' : 'backend', + 'url' => (string)$this->url, + 'loginData' => $this->loginData, + ]; + } + + public static function createFromEvent(LoginAttemptFailedEvent $event): self + { + return new self( + $event->isFrontendAttempt(), + $event->getRequest()->getUri(), + $event->getLoginData() + ); + } +} diff --git a/typo3/sysext/webhooks/Classes/Message/PageModificationMessage.php b/typo3/sysext/webhooks/Classes/Message/PageModificationMessage.php new file mode 100644 index 0000000000000000000000000000000000000000..a496763f3e9467a9ceb0b6d8abc8aab45441f02f --- /dev/null +++ b/typo3/sysext/webhooks/Classes/Message/PageModificationMessage.php @@ -0,0 +1,66 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Webhooks\Message; + +use TYPO3\CMS\Core\Attribute\WebhookMessage; +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\Messaging\WebhookMessageInterface; + +/** + * @internal not part of TYPO3's Core API + */ +#[WebhookMessage( + identifier: 'typo3/content/page-modification', + description: 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.webhook_type.typo3-content-page-modification' +)] +final class PageModificationMessage implements WebhookMessageInterface +{ + public function __construct( + private readonly string $action, + private readonly int $uid, + private readonly array $record, + private readonly string $url, + private readonly string $siteIdentifier, + private readonly ?BackendUserAuthentication $author = null, + private readonly ?array $modifiedFields = null + ) { + } + + public function jsonSerialize(): array + { + $data = [ + 'action' => $this->action, + 'identifier' => $this->uid, + 'record' => $this->record, + 'url' => $this->url, + 'site' => $this->siteIdentifier, + 'workspace' => $this->record['t3ver_wsid'] ?? 0, + ]; + if ($this->author instanceof BackendUserAuthentication) { + $data['author'] = [ + 'uid' => $this->author->user['uid'], + 'username' => $this->author->user['username'], + 'isAdmin' => $this->author->isAdmin(), + ]; + } + if (is_array($this->modifiedFields)) { + $data['changedFields'] = $this->modifiedFields; + } + return $data; + } +} diff --git a/typo3/sysext/webhooks/Classes/Message/WebhookMessageFactory.php b/typo3/sysext/webhooks/Classes/Message/WebhookMessageFactory.php new file mode 100644 index 0000000000000000000000000000000000000000..ea0480e73a66d54e4f9a02f8da92cabef08f5b6d --- /dev/null +++ b/typo3/sysext/webhooks/Classes/Message/WebhookMessageFactory.php @@ -0,0 +1,42 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Webhooks\Message; + +use TYPO3\CMS\Webhooks\WebhookTypesRegistry; + +/** + * A factory to create webhook messages from events. + */ +class WebhookMessageFactory +{ + public function __construct( + protected readonly WebhookTypesRegistry $typesRegistry + ) { + } + + public function createMessageFromEvent(object $eventObject): ?object + { + $eventName = get_class($eventObject); + $type = $this->typesRegistry->getWebhookByEventIdentifier($eventName); + if (!$type) { + return null; + } + + return $type->getFactoryMethod()($eventObject); + } +} diff --git a/typo3/sysext/webhooks/Classes/MessageHandler/WebhookMessageHandler.php b/typo3/sysext/webhooks/Classes/MessageHandler/WebhookMessageHandler.php new file mode 100644 index 0000000000000000000000000000000000000000..74e542712709af9fe0b4da58c9506eb6a503cda0 --- /dev/null +++ b/typo3/sysext/webhooks/Classes/MessageHandler/WebhookMessageHandler.php @@ -0,0 +1,101 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Webhooks\MessageHandler; + +use Psr\Http\Message\ResponseInterface; +use Psr\Log\LoggerInterface; +use TYPO3\CMS\Core\Http\RequestFactory; +use TYPO3\CMS\Core\Messaging\WebhookMessageInterface; +use TYPO3\CMS\Webhooks\Model\WebhookInstruction; +use TYPO3\CMS\Webhooks\Repository\WebhookRepository; + +/** + * A Message Handler to deal with a webhook message. + * It sends the message to all registered HTTP endpoints. + */ +class WebhookMessageHandler +{ + private string $algo = 'sha256'; + + public function __construct( + private readonly WebhookRepository $repository, + private readonly RequestFactory $requestFactory, + private readonly LoggerInterface $logger, + ) { + } + + public function __invoke(WebhookMessageInterface $message): void + { + $configuredWebhooks = $this->repository->getConfiguredWebhooksByType(get_class($message)); + foreach ($configuredWebhooks as $webhookInstruction) { + $this->logger->info('Sending webhook', [ + 'webhook-identifier' => $webhookInstruction->getIdentifier(), + ]); + try { + $response = $this->sendRequest($webhookInstruction, $message); + $this->logger->debug('Webhook sent', [ + 'target_url' => $webhookInstruction->getTargetUrl(), + 'response_code' => $response->getStatusCode(), + ]); + } catch (\Exception $e) { + $this->logger->error('Webhook sending failed', [ + 'failure_message' => $e->getMessage(), + ]); + } + } + } + + protected function sendRequest(WebhookInstruction $webhookInstruction, WebhookMessageInterface $message): ResponseInterface + { + $body = json_encode($message, JSON_THROW_ON_ERROR); + $headers = $this->buildHeaders($webhookInstruction, $body); + + $options = [ + 'headers' => $headers, + 'body' => $body, + ]; + if (!$webhookInstruction->verifySSL()) { + $options['verify'] = false; + } + + return $this->requestFactory->request( + $webhookInstruction->getTargetUrl(), + $webhookInstruction->getHttpMethod(), + $options + ); + } + + private function buildHash(WebhookInstruction $webhookInstruction, string $body): string + { + return hash_hmac($this->algo, sprintf( + '%s:%s', + $webhookInstruction->getIdentifier(), + $body + ), $webhookInstruction->getSecret()); + } + + private function buildHeaders(WebhookInstruction $webhookInstruction, string $body): array + { + $headers = $GLOBALS['TYPO3_CONF_VARS']['HTTP']['headers'] ?? []; + $headers['Content-Type'] = 'application/json'; + $headers['Webhook-Signature-Algo'] = $this->algo; + $headers = array_merge($headers, $webhookInstruction->getAdditionalHeaders()); + $headers['Webhook-Signature'] = $this->buildHash($webhookInstruction, $body); + return $headers; + } +} diff --git a/typo3/sysext/webhooks/Classes/Model/WebhookInstruction.php b/typo3/sysext/webhooks/Classes/Model/WebhookInstruction.php new file mode 100644 index 0000000000000000000000000000000000000000..f00ccf14d3d16f4894278900db271379c0d8f193 --- /dev/null +++ b/typo3/sysext/webhooks/Classes/Model/WebhookInstruction.php @@ -0,0 +1,92 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Webhooks\Model; + +/** + * DTO for an instruction - that is a representation of a configured + * webhook to a specific remote - contains all configuration information. + * + * What type of message should be sent (WebhookType), where should it be sent + * to and what additional headers etc. should be sent. + */ +class WebhookInstruction +{ + public function __construct( + private readonly string $url, + private readonly string $secret, + private readonly string $method = 'POST', + private readonly bool $verifySSL = true, + private readonly array $additionalHeaders = [], + private readonly ?string $name = null, + private readonly ?string $description = null, + private readonly ?WebhookType $webhookType = null, + private readonly ?string $identifier = null, + private readonly ?int $uid = null, + ) { + } + + public function getUid(): int + { + return $this->uid ?? 0; + } + + public function getName(): string + { + return $this->name ?? ''; + } + + public function getDescription(): string + { + return $this->description ?? ''; + } + + public function getWebhookType(): ?WebhookType + { + return $this->webhookType; + } + + public function getIdentifier(): ?string + { + return $this->identifier; + } + + public function getTargetUrl(): string + { + return $this->url; + } + + public function getHttpMethod(): string + { + return strtoupper($this->method); + } + + public function verifySSL(): bool + { + return $this->verifySSL; + } + + public function getSecret(): string + { + return $this->secret; + } + + public function getAdditionalHeaders(): ?array + { + return $this->additionalHeaders; + } +} diff --git a/typo3/sysext/webhooks/Classes/Model/WebhookType.php b/typo3/sysext/webhooks/Classes/Model/WebhookType.php new file mode 100644 index 0000000000000000000000000000000000000000..e5aa75975828be6393af1b57fe9be599745d64f0 --- /dev/null +++ b/typo3/sysext/webhooks/Classes/Model/WebhookType.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Webhooks\Model; + +/** + * @internal not part of TYPO3's Core API + */ +class WebhookType +{ + public function __construct( + protected readonly string $identifier, + protected readonly string $description, + protected readonly string $serviceName, + protected readonly string $factoryMethodName, + protected readonly ?string $connectedEvent = null + ) { + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getServiceName(): string + { + return $this->serviceName; + } + + public function getFactoryMethod(): string + { + return $this->serviceName . '::' . $this->factoryMethodName; + } + + public function getConnectedEvent(): ?string + { + return $this->connectedEvent; + } +} diff --git a/typo3/sysext/webhooks/Classes/Pagination/DemandedArrayPaginator.php b/typo3/sysext/webhooks/Classes/Pagination/DemandedArrayPaginator.php new file mode 100644 index 0000000000000000000000000000000000000000..5274c6c41f79ff277189821883d8da67a6e802fa --- /dev/null +++ b/typo3/sysext/webhooks/Classes/Pagination/DemandedArrayPaginator.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Webhooks\Pagination; + +use TYPO3\CMS\Core\Pagination\AbstractPaginator; + +/** + * A custom Paginator for dealing with the demand object. + * + * @internal not part of TYPO3's Core API + * @todo should be replaced with the regular ArrayPaginator + */ +final class DemandedArrayPaginator extends AbstractPaginator +{ + private array $items; + private int $allCount; + + private array $paginatedItems = []; + + public function __construct( + array $items, + int $currentPageNumber = 1, + int $itemsPerPage = 10, + int $allCount = 0 + ) { + $this->items = $items; + $this->setCurrentPageNumber($currentPageNumber); + $this->setItemsPerPage($itemsPerPage); + $this->allCount = $allCount; + + $this->updateInternalState(); + } + + public function getPaginatedItems(): iterable + { + return $this->paginatedItems; + } + + protected function updatePaginatedItems(int $itemsPerPage, int $offset): void + { + $this->paginatedItems = $this->items; + } + + protected function getTotalAmountOfItems(): int + { + return $this->allCount; + } + + protected function getAmountOfItemsOnCurrentPage(): int + { + return count($this->paginatedItems); + } +} diff --git a/typo3/sysext/webhooks/Classes/Repository/WebhookDemand.php b/typo3/sysext/webhooks/Classes/Repository/WebhookDemand.php new file mode 100644 index 0000000000000000000000000000000000000000..0dff6089e5e8c5513becf1333b8278add87a9b8e --- /dev/null +++ b/typo3/sysext/webhooks/Classes/Repository/WebhookDemand.php @@ -0,0 +1,138 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Webhooks\Repository; + +use Psr\Http\Message\ServerRequestInterface; + +/** + * Demand Object for filtering webhooks in the backend module + * + * @internal + */ +class WebhookDemand +{ + protected const ORDER_DESCENDING = 'desc'; + protected const ORDER_ASCENDING = 'asc'; + protected const DEFAULT_ORDER_FIELD = 'name'; + protected const ORDER_FIELDS = ['name', 'webhook_type']; + + protected int $limit = 15; + + public function __construct( + protected int $page = 1, + protected string $orderField = self::DEFAULT_ORDER_FIELD, + protected string $orderDirection = self::ORDER_ASCENDING, + protected string $name = '', + protected string $webhookType = '' + ) { + if (!in_array($orderField, self::ORDER_FIELDS, true)) { + $orderField = self::DEFAULT_ORDER_FIELD; + } + $this->orderField = $orderField; + if (!in_array($orderDirection, [self::ORDER_DESCENDING, self::ORDER_ASCENDING], true)) { + $orderDirection = self::ORDER_ASCENDING; + } + $this->orderDirection = $orderDirection; + } + + public static function fromRequest(ServerRequestInterface $request): self + { + $page = (int)($request->getQueryParams()['page'] ?? $request->getParsedBody()['page'] ?? 1); + $orderField = (string)($request->getQueryParams()['orderField'] ?? $request->getParsedBody()['orderField'] ?? self::DEFAULT_ORDER_FIELD); + $orderDirection = (string)($request->getQueryParams()['orderDirection'] ?? $request->getParsedBody()['orderDirection'] ?? self::ORDER_ASCENDING); + $demand = $request->getQueryParams()['demand'] ?? $request->getParsedBody()['demand'] ?? []; + if (!is_array($demand) || $demand === []) { + return new self($page, $orderField, $orderDirection); + } + $name = (string)($demand['name'] ?? ''); + $webhookTypeType = (string)($demand['webhook_type'] ?? ''); + return new self($page, $orderField, $orderDirection, $name, $webhookTypeType); + } + + public function getOrderField(): string + { + return $this->orderField; + } + + public function getOrderDirection(): string + { + return $this->orderDirection; + } + + public function getDefaultOrderDirection(): string + { + return self::ORDER_ASCENDING; + } + + public function getReverseOrderDirection(): string + { + return $this->orderDirection === self::ORDER_ASCENDING ? self::ORDER_DESCENDING : self::ORDER_ASCENDING; + } + + public function getName(): string + { + return $this->name; + } + + public function hasName(): bool + { + return $this->name !== ''; + } + + public function getWebhookType(): string + { + return $this->webhookType; + } + + public function hasWebhookType(): bool + { + return $this->webhookType !== ''; + } + + public function hasConstraints(): bool + { + return $this->hasName() || $this->hasWebhookType(); + } + + public function getPage(): int + { + return $this->page; + } + + public function getLimit(): int + { + return $this->limit; + } + + public function getOffset(): int + { + return ($this->page - 1) * $this->limit; + } + + public function getParameters(): array + { + $parameters = []; + if ($this->hasName()) { + $parameters['name'] = $this->getName(); + } + if ($this->hasWebhookType()) { + $parameters['webhook_type'] = $this->getWebhookType(); + } + return $parameters; + } +} diff --git a/typo3/sysext/webhooks/Classes/Repository/WebhookRepository.php b/typo3/sysext/webhooks/Classes/Repository/WebhookRepository.php new file mode 100644 index 0000000000000000000000000000000000000000..6b5cd5e06a6f369781fa4c17b4aab9976075a0fb --- /dev/null +++ b/typo3/sysext/webhooks/Classes/Repository/WebhookRepository.php @@ -0,0 +1,177 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Webhooks\Repository; + +use TYPO3\CMS\Backend\Utility\BackendUtility; +use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Database\Query\QueryBuilder; +use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Webhooks\Factory\WebhookInstructionFactory; +use TYPO3\CMS\Webhooks\Model\WebhookInstruction; + +/** + * Accessing webhook records from the database + * + * @internal This class is not part of TYPO3's Core API. + */ +class WebhookRepository +{ + protected string $cacheIdentifierPrefix = 'webhooks_'; + + public function __construct( + protected readonly ConnectionPool $connectionPool, + protected readonly FrontendInterface $runtimeCache, + ) { + } + + /** + * @return WebhookInstruction[] + */ + public function findAll(): array + { + $cacheIdentifier = $this->cacheIdentifierPrefix . 'all'; + if (!$this->runtimeCache->has($cacheIdentifier)) { + $data = $this->map( + $this->getQueryBuilder()->executeQuery()->fetchAllAssociative() + ); + $this->runtimeCache->set($cacheIdentifier, $data); + } else { + $data = $this->runtimeCache->get($cacheIdentifier); + } + return $data; + } + + public function countAll(): int + { + return (int)$this->getQueryBuilder(false) + ->count('*') + ->executeQuery() + ->fetchOne(); + } + + public function getWebhookRecords(?WebhookDemand $demand = null): array + { + return $demand !== null ? $this->findByDemand($demand) : $this->findAll(); + } + + /** + * @return WebhookInstruction[] + */ + public function findByDemand(WebhookDemand $demand): array + { + return $this->map($this->getQueryBuilderForDemand($demand) + ->setMaxResults($demand->getLimit()) + ->setFirstResult($demand->getOffset()) + ->executeQuery() + ->fetchAllAssociative()); + } + + /** + * @return array<string, WebhookInstruction> + */ + protected function getConfiguredWebhooks(): array + { + $webhooks = []; + foreach ($this->findAll() as $webhook) { + $webhooks[$webhook->getIdentifier()] = $webhook; + } + return $webhooks; + } + + /** + * @return array<string, WebhookInstruction> + */ + public function getConfiguredWebhooksByType(string $type): array + { + $webhooks = $this->getConfiguredWebhooks(); + return array_filter($webhooks, static fn ($webhook) => $webhook->getWebhookType()?->getServiceName() === $type); + } + + protected function getQueryBuilderForDemand(WebhookDemand $demand): QueryBuilder + { + $queryBuilder = $this->getQueryBuilder(false); + $queryBuilder->orderBy( + $demand->getOrderField(), + $demand->getOrderDirection() + ); + // Ensure deterministic ordering. + if ($demand->getOrderField() !== 'uid') { + $queryBuilder->addOrderBy('uid', 'asc'); + } + + $constraints = []; + if ($demand->hasName()) { + $escapedLikeString = '%' . $queryBuilder->escapeLikeWildcards($demand->getName()) . '%'; + $constraints[] = $queryBuilder->expr()->or( + $queryBuilder->expr()->like( + 'name', + $queryBuilder->createNamedParameter($escapedLikeString) + ), + $queryBuilder->expr()->like( + 'description', + $queryBuilder->createNamedParameter($escapedLikeString) + ) + ); + } + if ($demand->hasWebhookType()) { + $constraints[] = $queryBuilder->expr()->eq( + 'webhook_type', + $queryBuilder->createNamedParameter($demand->getWebhookType()) + ); + } + + if (!empty($constraints)) { + $queryBuilder->where(...$constraints); + } + return $queryBuilder; + } + + protected function map(array $rows): array + { + $items = []; + foreach ($rows as $row) { + $items[] = $this->mapSingleRow($row); + } + return $items; + } + + protected function mapSingleRow(array $row): WebhookInstruction + { + $row = BackendUtility::convertDatabaseRowValuesToPhp('sys_webhook', $row); + return WebhookInstructionFactory::createFromRow($row); + } + + protected function getQueryBuilder(bool $addDefaultOrderByClause = true): QueryBuilder + { + $queryBuilder = $this->connectionPool + ->getQueryBuilderForTable('sys_webhook'); + $queryBuilder->getRestrictions() + ->removeAll() + ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + $queryBuilder->select('*')->from('sys_webhook'); + if ($addDefaultOrderByClause) { + $queryBuilder + ->orderBy('name', 'asc') + // Ensure deterministic ordering. + ->addOrderBy('uid', 'asc'); + } + return $queryBuilder; + } +} diff --git a/typo3/sysext/webhooks/Classes/Tca/ItemsProcFunc/WebhookTypesItemsProcFunc.php b/typo3/sysext/webhooks/Classes/Tca/ItemsProcFunc/WebhookTypesItemsProcFunc.php new file mode 100644 index 0000000000000000000000000000000000000000..97f170183adb334bd6e15baeadc19ffe3a4a4030 --- /dev/null +++ b/typo3/sysext/webhooks/Classes/Tca/ItemsProcFunc/WebhookTypesItemsProcFunc.php @@ -0,0 +1,46 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Webhooks\Tca\ItemsProcFunc; + +use TYPO3\CMS\Core\Localization\LanguageServiceFactory; +use TYPO3\CMS\Webhooks\WebhookTypesRegistry; + +/** + * Custom TCA renderings and itemsProcFunc. + * + * @internal not part of TYPO3's Core API + */ +class WebhookTypesItemsProcFunc +{ + public function __construct( + private readonly WebhookTypesRegistry $webhookTypesRegistry, + private readonly LanguageServiceFactory $languageServiceFactory + ) { + } + + public function getWebhookTypes(&$fieldDefinition): void + { + $lang = $this->languageServiceFactory->createFromUserPreferences($GLOBALS['BE_USER']); + foreach ($this->webhookTypesRegistry->getAvailableWebhookTypes() as $identifier => $webhookType) { + $fieldDefinition['items'][] = [ + 'label' => $lang->sL($webhookType->getDescription()) ?: $webhookType->getDescription(), + 'value' => $identifier, + ]; + } + } +} diff --git a/typo3/sysext/webhooks/Classes/WebhookTypesRegistry.php b/typo3/sysext/webhooks/Classes/WebhookTypesRegistry.php new file mode 100644 index 0000000000000000000000000000000000000000..06b12d1130dc591dba396f8287c6fae52789b55b --- /dev/null +++ b/typo3/sysext/webhooks/Classes/WebhookTypesRegistry.php @@ -0,0 +1,74 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Webhooks; + +use TYPO3\CMS\Webhooks\Model\WebhookType; + +/** + * Registry contains all possible webhooks types which are available to the system + * To register a webhook your class must be tagged with the Webhook attribute. + * + * @internal not part of TYPO3's Core API + */ +class WebhookTypesRegistry +{ + /** + * @var WebhookType[] + */ + private array $webhookTypes = []; + + /** + * @return WebhookType[] + */ + public function getAvailableWebhookTypes(): array + { + return $this->webhookTypes; + } + + /** + * Whether a registered webhook type exists + */ + public function hasWebhookType(string $type): bool + { + return isset($this->webhookTypes[$type]); + } + + public function getWebhookByType(string $type): WebhookType + { + if (!$this->hasWebhookType($type)) { + throw new \UnexpectedValueException('No webhook with type ' . $type . ' registered.', 1679348837); + } + + return $this->webhookTypes[$type]; + } + + public function getWebhookByEventIdentifier(string $eventIdentifier): ?WebhookType + { + foreach ($this->webhookTypes as $type) { + if ($type->getConnectedEvent() === $eventIdentifier) { + return $type; + } + } + return null; + } + + public function addWebhookType(string $identifier, string $description, string $serviceName, string $factoryMethod, ?string $connectedEvent): void + { + $this->webhookTypes[$identifier] = new WebhookType($identifier, $description, $serviceName, $factoryMethod, $connectedEvent); + } +} diff --git a/typo3/sysext/webhooks/Configuration/Backend/Modules.php b/typo3/sysext/webhooks/Configuration/Backend/Modules.php new file mode 100644 index 0000000000000000000000000000000000000000..ed10c553087108d0c0f941c1407dda05c47c0e1a --- /dev/null +++ b/typo3/sysext/webhooks/Configuration/Backend/Modules.php @@ -0,0 +1,23 @@ +<?php + +use TYPO3\CMS\Webhooks\Controller\ManagementController; + +/** + * Definitions for modules provided by EXT:webhooks + */ +return [ + 'webhooks_management' => [ + 'parent' => 'system', + 'position' => ['after' => 'system_BeuserTxBeuser'], + 'access' => 'admin', + 'workspaces' => 'live', + 'path' => '/module/webhooks', + 'iconIdentifier' => 'module-webhooks', + 'labels' => 'LLL:EXT:webhooks/Resources/Private/Language/locallang_module_webhooks.xlf', + 'routes' => [ + '_default' => [ + 'target' => ManagementController::class . '::overviewAction', + ], + ], + ], +]; diff --git a/typo3/sysext/webhooks/Configuration/Icons.php b/typo3/sysext/webhooks/Configuration/Icons.php new file mode 100644 index 0000000000000000000000000000000000000000..b7e26211c8124294c023d6a4eac4d970323270a3 --- /dev/null +++ b/typo3/sysext/webhooks/Configuration/Icons.php @@ -0,0 +1,10 @@ +<?php + +use TYPO3\CMS\Core\Imaging\IconProvider\SvgIconProvider; + +return [ + 'module-webhooks' => [ + 'provider' => SvgIconProvider::class, + 'source' => 'EXT:webhooks/Resources/Public/Icons/Extension.svg', + ], +]; diff --git a/typo3/sysext/webhooks/Configuration/Services.php b/typo3/sysext/webhooks/Configuration/Services.php new file mode 100644 index 0000000000000000000000000000000000000000..d8af5e7c695a803cd3e16d9ba2a3812366fea813 --- /dev/null +++ b/typo3/sysext/webhooks/Configuration/Services.php @@ -0,0 +1,20 @@ +<?php + +declare(strict_types=1); + +namespace TYPO3\CMS\Webhooks; + +use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use TYPO3\CMS\Core\Attribute\WebhookMessage; + +return static function (ContainerConfigurator $container, ContainerBuilder $containerBuilder) { + $containerBuilder->registerAttributeForAutoconfiguration( + WebhookMessage::class, + static function (ChildDefinition $definition, WebhookMessage $attribute): void { + $definition->addTag(WebhookMessage::TAG_NAME, ['identifier' => $attribute->identifier, 'description' => $attribute->description, 'method' => $attribute->method]); + } + ); + $containerBuilder->addCompilerPass(new DependencyInjection\WebhookCompilerPass(WebhookMessage::TAG_NAME)); +}; diff --git a/typo3/sysext/webhooks/Configuration/Services.yaml b/typo3/sysext/webhooks/Configuration/Services.yaml new file mode 100644 index 0000000000000000000000000000000000000000..15f3772aa4ecc1995e7e329e4e4a8349076aaa39 --- /dev/null +++ b/typo3/sysext/webhooks/Configuration/Services.yaml @@ -0,0 +1,38 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + TYPO3\CMS\Webhooks\: + resource: '../Classes/*' + + TYPO3\CMS\Webhooks\WebhookTypesRegistry: + public: true + + TYPO3\CMS\Webhooks\Listener\PageModificationListener: + public: true + + # public true is required in ajax backend calls (e.g. when adding a file) + TYPO3\CMS\Webhooks\Listener\MessageListener: + public: true + + TYPO3\CMS\Webhooks\MessageHandler\WebhookMessageHandler: + tags: + - name: 'messenger.message_handler' + + TYPO3\CMS\Webhooks\Repository\WebhookRepository: + public: true # Required in test context + arguments: + $runtimeCache: '@cache.runtime' + + TYPO3\CMS\Webhooks\Tca\ItemsProcFunc\WebhookTypesItemsProcFunc: + public: true + + lowlevel.configuration.module.provider.webhooks: + class: 'TYPO3\CMS\Webhooks\ConfigurationModuleProvider\WebhookTypesProvider' + tags: + - name: 'lowlevel.configuration.module.provider' + identifier: 'webhooks' + label: 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:webhooks' + after: 'reactions' diff --git a/typo3/sysext/webhooks/Configuration/TCA/sys_webhook.php b/typo3/sysext/webhooks/Configuration/TCA/sys_webhook.php new file mode 100644 index 0000000000000000000000000000000000000000..cf67b4873882adcd1dd0e1810a5c3bc7356f596b --- /dev/null +++ b/typo3/sysext/webhooks/Configuration/TCA/sys_webhook.php @@ -0,0 +1,193 @@ +<?php + +return [ + 'ctrl' => [ + 'title' => 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook', + 'label' => 'name', + 'descriptionColumn' => 'description', + 'crdate' => 'createdon', + 'tstamp' => 'updatedon', + 'adminOnly' => true, + 'rootLevel' => 1, + 'groupName' => 'system', + 'default_sortby' => 'name', + 'type' => 'webhook_type', + 'typeicon_column' => 'webhook_type', + 'typeicon_classes' => [ + 'default' => 'content-webhook', + ], + 'delete' => 'deleted', + 'enablecolumns' => [ + 'disabled' => 'disabled', + 'starttime' => 'starttime', + 'endtime' => 'endtime', + ], + 'searchFields' => 'name, secret', + 'versioningWS_alwaysAllowLiveEdit' => true, + ], + 'types' => [ + '1' => [ + 'showitem' => ' + --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:general, + --palette--;;config, + --div--;LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:palette.http_settings, + --palette--;;http_settings, + --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access, + --palette--;;access', + ], + ], + 'palettes' => [ + 'config' => [ + 'label' => 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:palette.config', + 'description' => 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:palette.config.description', + 'showitem' => 'webhook_type, identifier, --linebreak--, name, description, --linebreak--, url, secret', + ], + 'http_settings' => [ + 'label' => 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:palette.http_settings', + 'description' => 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:palette.http_settings.description', + 'showitem' => 'method, verify_ssl, --linebreak--, additional_headers', + ], + 'access' => [ + 'label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.palettes.access', + 'showitem' => 'disabled, starttime, endtime', + ], + ], + 'columns' => [ + 'webhook_type' => [ + 'label' => 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.webhook_type', + 'description' => 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.webhook_type.description', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'required' => true, + 'items' => [ + [ + 'label' => 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.webhook_type.select', + 'vaule' => '', + ], + ], + 'itemsProcFunc' => \TYPO3\CMS\Webhooks\Tca\ItemsProcFunc\WebhookTypesItemsProcFunc::class . '->getWebhookTypes', + ], + ], + 'name' => [ + 'label' => 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.name', + 'description' => 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.name.description', + 'config' => [ + 'type' => 'input', + 'required' => true, + 'eval' => 'trim', + ], + ], + 'description' => [ + 'label' => 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.description', + 'description' => 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.description.description', + 'config' => [ + 'type' => 'text', + 'rows' => 5, + 'cols' => 30, + ], + ], + 'identifier' => [ + 'label' => 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.identifier', + 'description' => 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.identifier.description', + 'config' => [ + 'type' => 'uuid', + ], + ], + 'secret' => [ + 'label' => 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.secret', + 'description' => 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.secret.description', + 'config' => [ + 'type' => 'password', + 'hashed' => false, // Can't be hashed because it's used to create the signature + 'required' => true, + 'fieldControl' => [ + 'passwordGenerator' => [ + 'renderType' => 'passwordGenerator', + 'options' => [ + 'title' => 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.secret.passwordGenerator', + 'allowEdit' => false, + 'passwordRules' => [ + 'length' => 40, + 'random' => 'hex', + ], + ], + ], + ], + ], + ], + 'url' => [ + 'label' => 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.url', + 'description' => 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.url.description', + 'config' => [ + 'type' => 'link', + 'required' => true, + 'allowedTypes' => ['url'], + ], + ], + 'method' => [ + 'label' => 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.method', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'required' => true, + 'items' => [ + [ + 'label' => 'POST', + 'value' => 'POST', + ], + [ + 'label' => 'GET', + 'value' => 'GET', + ], + ], + ], + ], + 'verify_ssl' => [ + 'label' => 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.verify_ssl', + 'description' => 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.verify_ssl.description', + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + 'default' => 1, + ], + ], + 'additional_headers' => [ + 'label' => 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.additional_headers', + 'description' => 'LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:sys_webhook.additional_headers.description', + 'config' => [ + 'type' => 'json', + ], + ], + 'disabled' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.enabled', + 'config' => [ + 'type' => 'check', + 'renderType' => 'checkboxToggle', + 'items' => [ + [ + 'label' => '', + 'invertStateDisplay' => true, + ], + ], + ], + ], + 'starttime' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.starttime', + 'config' => [ + 'type' => 'datetime', + 'default' => 0, + ], + ], + 'endtime' => [ + 'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.endtime', + 'config' => [ + 'type' => 'datetime', + 'default' => 0, + 'range' => [ + 'upper' => mktime(0, 0, 0, 1, 1, 2038), + ], + ], + ], + ], +]; diff --git a/typo3/sysext/webhooks/LICENSE.txt b/typo3/sysext/webhooks/LICENSE.txt new file mode 100644 index 0000000000000000000000000000000000000000..d159169d1050894d3ea3b98e1c965c4058208fe1 --- /dev/null +++ b/typo3/sysext/webhooks/LICENSE.txt @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + <signature of Ty Coon>, 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/typo3/sysext/webhooks/README.rst b/typo3/sysext/webhooks/README.rst new file mode 100644 index 0000000000000000000000000000000000000000..ab7cecff12d74029ff9016a19c522dd076b7b2f8 --- /dev/null +++ b/typo3/sysext/webhooks/README.rst @@ -0,0 +1,12 @@ +========================== +TYPO3 extension `webhooks` +========================== + +This extension handles outgoing webhooks to TYPO3. It also provides +a corresponding backend module to manage webhook records in the TYPO3 +backend (`System > Webhooks`). + +:Repository: https://github.com/typo3/typo3 +:Issues: https://forge.typo3.org/ +:Read online: https://docs.typo3.org/c/typo3/cms-webhooks/main/en-us/ +:TER: https://extensions.typo3.org/extension/webhooks/ diff --git a/typo3/sysext/webhooks/Resources/Private/Language/locallang_db.xlf b/typo3/sysext/webhooks/Resources/Private/Language/locallang_db.xlf new file mode 100644 index 0000000000000000000000000000000000000000..9e2fd2c1f1a5b8d6030630371d622ecb0189e4ab --- /dev/null +++ b/typo3/sysext/webhooks/Resources/Private/Language/locallang_db.xlf @@ -0,0 +1,112 @@ +<?xml version="1.0" encoding="UTF-8"?> +<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2"> + <file source-language="en" datatype="plaintext" original="EXT:webhooks/Resources/Private/Language/locallang_db.xlf" date="2023-01-11T20:22:32Z" product-name="webhooks"> + <header/> + <body> + <trans-unit id="webhooks" resname="webhooks"> + <source>Webhooks</source> + </trans-unit> + + <trans-unit id="sys_webhook" resname="sys_webhook"> + <source>System webhooks</source> + </trans-unit> + <trans-unit id="sys_webhook.name" resname="sys_webhook.name"> + <source>Name</source> + </trans-unit> + <trans-unit id="sys_webhook.name.description" resname="sys_webhook.name.description"> + <source>Meaningful name of the webhook</source> + </trans-unit> + <trans-unit id="sys_webhook.description" resname="sys_webhook.description"> + <source>Description</source> + </trans-unit> + <trans-unit id="sys_webhook.description.description" resname="sys_webhook.description.description"> + <source>Additional information about the webhook for your internal reference.</source> + </trans-unit> + <trans-unit id="sys_webhook.url" resname="sys_webhook.url"> + <source>URL</source> + </trans-unit> + <trans-unit id="sys_webhook.url.description" resname="sys_webhook.url.description"> + <source>Target URL that should be called.</source> + </trans-unit> + <trans-unit id="sys_webhook.method" resname="sys_webhook.method"> + <source>HTTP Method</source> + </trans-unit> + <trans-unit id="sys_webhook.verify_ssl" resname="sys_webhook.verify_ssl"> + <source>Verify SSL</source> + </trans-unit> + <trans-unit id="sys_webhook.verify_ssl.description" resname="sys_webhook.verify_ssl.description"> + <source>Verify that the connection is secure and valid, this setting should only be disabled in case a verification is not possible.</source> + </trans-unit> + <trans-unit id="sys_webhook.additional_headers" resname="sys_webhook.additional_headers"> + <source>Additional Headers</source> + </trans-unit> + <trans-unit id="sys_webhook.additional_headers.description" resname="sys_webhook.additional_headers.description"> + <source>Additional headers that should be added to the HTTP request. Data must be provided as valid JSON string.</source> + </trans-unit> + <trans-unit id="sys_webhook.identifier" resname="sys_webhook.identifier"> + <source>Identifier</source> + </trans-unit> + <trans-unit id="sys_webhook.identifier.description" resname="sys_webhook.identifier.description"> + <source>This is your unique webhook identifier</source> + </trans-unit> + <trans-unit id="sys_webhook.webhook_type" resname="sys_webhook.webhook_type"> + <source>Webhook Trigger</source> + </trans-unit> + <trans-unit id="sys_webhook.secret" resname="sys_webhook.secret"> + <source>Secret</source> + </trans-unit> + <trans-unit id="sys_webhook.secret.description" resname="sys_webhook.secret.description"> + <source>The secret is mandatory to create a hash that is sent with the request. The secret can be used to verify the payload has not been modified, see the documentation for more details.</source> + </trans-unit> + <trans-unit id="sys_webhook.secret.passwordGenerator" resname="sys_webhook.secret.passwordGenerator"> + <source>Generate secret</source> + </trans-unit> + <trans-unit id="sys_webhook.webhook_type.description" resname="sys_webhook.webhook_type.description"> + <source>Trigger the webhook, ...</source> + </trans-unit> + <trans-unit id="sys_webhook.webhook_type.select" resname="sys_webhook.webhook_type.select"> + <source>Select a trigger</source> + </trans-unit> + <trans-unit id="sys_webhook.webhook_type.typo3-content-page-modification" resname="sys_webhook.webhook_type.typo3-content-page-modification"> + <source>... when a page is added or changed</source> + </trans-unit> + <trans-unit id="sys_webhook.webhook_type.typo3-login-error" resname="sys_webhook.webhook_type.typo3-login-error"> + <source>... when an error occurs on log in</source> + </trans-unit> + <trans-unit id="sys_webhook.webhook_type.typo3-file-added" resname="sys_webhook.webhook_type.typo3-file-added"> + <source>... when a file is added</source> + </trans-unit> + <trans-unit id="sys_webhook.webhook_type.typo3-file-removed" resname="sys_webhook.webhook_type.typo3-file-removed"> + <source>... when a file is removed</source> + </trans-unit> + <trans-unit id="sys_webhook.webhook_type.typo3-file-updated" resname="sys_webhook.webhook_type.typo3-file-updated"> + <source>... when a file is updated</source> + </trans-unit> + <trans-unit id="sys_webhook.storage_pid" resname="sys_webhook.storage_pid"> + <source>Storage PID</source> + </trans-unit> + <trans-unit id="sys_webhook.storage_pid.description" resname="sys_webhook.storage_pid.description"> + <source>Select the page on which a new record is created on.</source> + </trans-unit> + + <trans-unit id="palette.config" resname="palette.config"> + <source>Configuration</source> + </trans-unit> + <trans-unit id="palette.config.description" resname="palette.config.description"> + <source>General configuration of the webhook.</source> + </trans-unit> + <trans-unit id="palette.http_settings" resname="palette.http_settings"> + <source>HTTP Settings</source> + </trans-unit> + <trans-unit id="palette.http_settings.description" resname="palette.http_settings.description"> + <source>Advanced HTTP request settings.</source> + </trans-unit> + <trans-unit id="palette.additional" resname="palette.additional"> + <source>Additional configuration</source> + </trans-unit> + <trans-unit id="palette.additional.description" resname="palette.additional.description"> + <source>Specific configuration for the selected webhook type</source> + </trans-unit> + </body> + </file> +</xliff> diff --git a/typo3/sysext/webhooks/Resources/Private/Language/locallang_module_webhooks.xlf b/typo3/sysext/webhooks/Resources/Private/Language/locallang_module_webhooks.xlf new file mode 100644 index 0000000000000000000000000000000000000000..d8575c2f23d0bf32215e93a4ad80eae2d0b0c9eb --- /dev/null +++ b/typo3/sysext/webhooks/Resources/Private/Language/locallang_module_webhooks.xlf @@ -0,0 +1,90 @@ +<?xml version="1.0" encoding="UTF-8"?> +<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2"> + <file source-language="en" datatype="plaintext" original="EXT:webhooks/Resources/Private/Language/locallang_module_webhooks.xlf" date="2022-08-17T12:12:34Z" product-name="webhooks"> + <header/> + <body> + <trans-unit id="mlang_labels_tablabel" resname="mlang_labels_tablabel"> + <source>Webhook Administration</source> + </trans-unit> + <trans-unit id="mlang_labels_tabdescr" resname="mlang_labels_tabdescr"> + <source>This is the administration area for Webhooks.</source> + </trans-unit> + <trans-unit id="mlang_tabs_tab" resname="mlang_tabs_tab"> + <source>Webhooks</source> + </trans-unit> + + <trans-unit id="heading_text" resname="heading_text"> + <source>Webhooks — Manage Outgoing HTTP Webhooks</source> + </trans-unit> + <trans-unit id="webhook_not_found.title" resname="webhook_not_found.title"> + <source>No webhooks found!</source> + </trans-unit> + <trans-unit id="webhook_not_found.message" resname="webhook_not_found.message"> + <source>There are currently no webhooks records found in the database.</source> + </trans-unit> + <trans-unit id="webhook_create" resname="webhook_create"> + <source>Create new webhook</source> + </trans-unit> + <trans-unit id="webhook_not_found_with_filter.title" resname="webhook_not_found_with_filter.title"> + <source>No webhooks found!</source> + </trans-unit> + <trans-unit id="webhook_not_found_with_filter.message" resname="webhook_not_found_with_filter.message"> + <source>With the current set of filters applied, no webhooks could be found.</source> + </trans-unit> + <trans-unit id="webhook_no_filter" resname="webhook_no_filter"> + <source>Remove all filter</source> + </trans-unit> + <trans-unit id="webhook_no_implementation_class" resname="webhook_no_implementation_class"> + <source>Implementation class for webhook type "%s" is missing.</source> + </trans-unit> + <trans-unit id="webhook_example" resname="webhook_example"> + <source>Example</source> + </trans-unit> + + <trans-unit id="filter.sendButton" resname="filter.sendButton"> + <source>Filter</source> + </trans-unit> + <trans-unit id="filter.resetButton" resname="filter.resetButton"> + <source>Reset</source> + </trans-unit> + <trans-unit id="filter.name" resname="filter.name"> + <source>Name</source> + </trans-unit> + <trans-unit id="filter.webhook_type" resname="filter.webhook_type"> + <source>Webhook Trigger</source> + </trans-unit> + <trans-unit id="filter.webhook_type.showAll" resname="filter.webhook_type.showAll"> + <source>Show all</source> + </trans-unit> + + <trans-unit id="pagination.previous" resname="pagination.previous"> + <source>previous</source> + </trans-unit> + <trans-unit id="pagination.next" resname="pagination.next"> + <source>next</source> + </trans-unit> + <trans-unit id="pagination.first" resname="pagination.first"> + <source>first</source> + </trans-unit> + <trans-unit id="pagination.last" resname="pagination.last"> + <source>last</source> + </trans-unit> + <trans-unit id="pagination.records" resname="pagination.records"> + <source>Records</source> + </trans-unit> + <trans-unit id="pagination.page" resname="pagination.page"> + <source>Page</source> + </trans-unit> + <trans-unit id="pagination.refresh" resname="pagination.refresh"> + <source>Refresh</source> + </trans-unit> + + <trans-unit id="labels.delete.title" resname="labels.delete.title"> + <source>Delete webhooks</source> + </trans-unit> + <trans-unit id="labels.delete.message" resname="labels.delete.message"> + <source>Are you sure you want to delete all marked webhooks?</source> + </trans-unit> + </body> + </file> +</xliff> diff --git a/typo3/sysext/webhooks/Resources/Private/Partials/Pagination.html b/typo3/sysext/webhooks/Resources/Private/Partials/Pagination.html new file mode 100644 index 0000000000000000000000000000000000000000..b9fda8550717874befe80fd552bb31f68f01e9f0 --- /dev/null +++ b/typo3/sysext/webhooks/Resources/Private/Partials/Pagination.html @@ -0,0 +1,96 @@ +<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" + xmlns:core="http://typo3.org/ns/TYPO3/CMS/Core/ViewHelpers" + data-namespace-typo3-fluid="true" +> +<f:if condition="{paginator.numberOfPages} > 1"> + <nav class="pagination-wrap"> + <ul class="pagination"> + <f:if condition="{pagination.previousPageNumber} && {pagination.previousPageNumber} >= {pagination.firstPageNumber}"> + <f:then> + <li class="page-item"> + <a href="{f:be.uri(route:'webhooks_management', parameters: '{demand: demand.parameters, orderField: demand.orderField, orderDirection: demand.orderDirection, page: 1}')}" title="{f:translate(key:'LLL:EXT:webhooks/Resources/Private/Language/locallang_module_webhooks.xlf:pagination.first')}" class="page-link"> + <core:icon identifier="actions-view-paging-first" /> + </a> + </li> + <li class="page-item"> + <a href="{f:be.uri(route:'webhooks_management', parameters: '{demand: demand.parameters, orderField: demand.orderField, orderDirection: demand.orderDirection, page: pagination.previousPageNumber}')}" title="{f:translate(key:'LLL:EXT:webhooks/Resources/Private/Language/locallang_module_webhooks.xlf:pagination.previous')}" class="page-link"> + <core:icon identifier="actions-view-paging-previous" /> + </a> + </li> + </f:then> + <f:else> + <li class="page-item disabled"> + <span class="page-link"> + <core:icon identifier="empty-empty"/> + </span> + </li> + <li class="page-item disabled"> + <span class="page-link"> + <core:icon identifier="empty-empty"/> + </span> + </li> + </f:else> + </f:if> + <li class="page-item"> + <span class="page-link"> + <f:translate key="LLL:EXT:webhooks/Resources/Private/Language/locallang_module_webhooks.xlf:pagination.records" /> {pagination.startRecordNumber} - {pagination.endRecordNumber} + </span> + </li> + <li class="page-item"> + <span class="page-link"> + <f:translate key="LLL:EXT:webhooks/Resources/Private/Language/locallang_module_webhooks.xlf:pagination.page" /> + <form style="display:inline;" + data-global-event="submit" + data-action-navigate="$form=~s/$value/" + data-navigate-value="{f:be.uri(route:'webhooks_management', parameters: '{demand: demand.parameters, orderField: demand.orderField, orderDirection: demand.orderDirection, page: \'$[value]\'}')}" + data-value-selector="input[name='paginator-target-page']"> + <input + min="{pagination.firstPageNumber}" + max="{pagination.lastPageNumber}" + data-number-of-pages="{paginator.numberOfPages}" + name="paginator-target-page" + class="form-control form-control-sm paginator-input" + size="5" + value="{paginator.currentPageNumber}" + type="number" + /> + </form> + / {pagination.lastPageNumber} + </span> + </li> + + <f:if condition="{pagination.nextPageNumber} && {pagination.nextPageNumber} <= {pagination.lastPageNumber}"> + <f:then> + <li class="page-item"> + <a href="{f:be.uri(route:'webhooks_management', parameters: '{demand: demand.parameters, orderField: demand.orderField, orderDirection: demand.orderDirection, page: pagination.nextPageNumber}')}" title="{f:translate(key:'LLL:EXT:webhooks/Resources/Private/Language/locallang_module_webhooks.xlf:pagination.next')}" class="page-link"> + <core:icon identifier="actions-view-paging-next" /> + </a> + </li> + <li class="page-item"> + <a href="{f:be.uri(route:'webhooks_management', parameters: '{demand: demand.parameters, orderField: demand.orderField, orderDirection: demand.orderDirection, page: pagination.lastPageNumber}')}" title="{f:translate(key:'LLL:EXT:webhooks/Resources/Private/Language/locallang_module_webhooks.xlf:pagination.next')}" class="page-link"> + <core:icon identifier="actions-view-paging-last" /> + </a> + </li> + </f:then> + <f:else> + <li class="page-item disabled"> + <span class="page-link"> + <core:icon identifier="empty-empty"/> + </span> + </li> + <li class="page-item disabled"> + <span class="page-link"> + <core:icon identifier="empty-empty"/> + </span> + </li> + </f:else> + </f:if> + <li class="page-item"> + <a href="{f:be.uri(route:'webhooks_management', parameters: '{demand: demand.parameters, orderField: demand.orderField, orderDirection: demand.orderDirection, page: pagination.currentPageNumber}')}" title="{f:translate(key:'LLL:EXT:webhooks/Resources/Private/Language/locallang_module_webhooks.xlf:pagination.refresh')}" class="page-link"> + <core:icon identifier="actions-refresh" /> + </a> + </li> + </ul> + </nav> +</f:if> +</html> diff --git a/typo3/sysext/webhooks/Resources/Private/Templates/Management/Overview.html b/typo3/sysext/webhooks/Resources/Private/Templates/Management/Overview.html new file mode 100644 index 0000000000000000000000000000000000000000..8349eef75e6c14428bd1233ec8cd14a71881fbb0 --- /dev/null +++ b/typo3/sysext/webhooks/Resources/Private/Templates/Management/Overview.html @@ -0,0 +1,305 @@ +<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" + xmlns:be="http://typo3.org/ns/TYPO3/CMS/Backend/ViewHelpers" + xmlns:core="http://typo3.org/ns/TYPO3/CMS/Core/ViewHelpers" + data-namespace-typo3-fluid="true" +> +<f:layout name="Module" /> + +<f:section name="Content"> + <f:be.pageRenderer + includeJavaScriptModules="{ + 0: '@typo3/backend/context-menu.js', + 1: '@typo3/backend/modal.js', + 2: '@typo3/backend/multi-record-selection.js', + 3: '@typo3/backend/multi-record-selection-edit-action.js', + 4: '@typo3/backend/multi-record-selection-delete-action.js' + }" + /> + <h1><f:translate key="LLL:EXT:webhooks/Resources/Private/Language/locallang_module_webhooks.xlf:heading_text"/></h1> + <f:variable + name="returnUrl" + value="{f:be.uri(route:'webhooks_management')}" + /> + <f:if condition="{webhookRecords -> f:count()}"> + <f:then> + <f:render section="filter" arguments="{_all}" /> + <f:render section="table" arguments="{_all}" /> + </f:then> + <f:else> + <f:if condition="{demand.constraints}"> + <f:then> + <f:render section="filter" arguments="{_all}" /> + <f:be.infobox state="-2" title="{f:translate(key: 'LLL:EXT:webhooks/Resources/Private/Language/locallang_module_webhooks.xlf:webhook_not_found_with_filter.title')}"> + <p><f:translate key="LLL:EXT:webhooks/Resources/Private/Language/locallang_module_webhooks.xlf:webhook_not_found_with_filter.message"/></p> + <a class="btn btn-default" href="{f:be.uri(route:'webhooks_management')}"> + <f:translate key="LLL:EXT:webhooks/Resources/Private/Language/locallang_module_webhooks.xlf:webhook_no_filter"/> + </a> + <be:link.newRecord returnUrl="{returnUrl}" class="btn btn-primary" table="sys_webhook"> + <f:translate key="LLL:EXT:webhooks/Resources/Private/Language/locallang_module_webhooks.xlf:webhook_create"/> + </be:link.newRecord> + </f:be.infobox> + + <f:variable name="gotToPageUrl"><f:be.uri route="webhooks_management" parameters="{demand: demand.parameters, page: 987654322}" /></f:variable> + <form data-on-submit="processNavigate"> + <input type="hidden" value="1" name="paginator-target-page" data-number-of-pages="1" data-url="{gotToPageUrl}"/> + </form> + </f:then> + <f:else> + <f:be.infobox state="-1" title="{f:translate(key: 'LLL:EXT:webhooks/Resources/Private/Language/locallang_module_webhooks.xlf:webhook_not_found.title')}"> + <p><f:translate key="LLL:EXT:webhooks/Resources/Private/Language/locallang_module_webhooks.xlf:webhook_not_found.message"/></p> + <be:link.newRecord returnUrl="{returnUrl}" class="btn btn-primary" table="sys_webhook"> + <f:translate key="LLL:EXT:webhooks/Resources/Private/Language/locallang_module_webhooks.xlf:webhook_create"/> + </be:link.newRecord> + </f:be.infobox> + </f:else> + </f:if> + </f:else> + </f:if> +</f:section> + +<f:section name="table"> + <f:render section="multiRecordSelectionActions" arguments="{_all}" /> + <div class="table-fit mb-0"> + <table class="table table-striped table-hover"> + <thead> + <tr> + <th><f:render section="multiRecordSelectionCheckboxActions" /></th> + <th colspan="2"><f:render section="listHeaderSorting" arguments="{field: 'name', label: 'sys_webhook.name', demand: demand}"/></th> + <th><f:render section="listHeaderSorting" arguments="{field: 'webhook_type', label: 'sys_webhook.webhook_type', demand: demand}"/></th> + <th>URL</th> + <th></th> + </tr> + </thead> + <tbody data-multi-record-selection-row-selection="true"> + <f:for each="{webhookRecords}" key="webhookId" as="webhook"> + <f:variable name="webhookRecord" value="{webhook as array}"/> + <tr data-uid="{webhook.uid}" data-multi-record-selection-element="true"> + <td class="col-selector nowrap"> + <span class="form-check form-toggle"> + <input class="form-check-input t3js-multi-record-selection-check" type="checkbox"/> + </span> + </td> + <f:if condition="{webhookTypes.{webhook.webhookType.identifier}}"> + <f:then> + <td class="col-icon"> + <a + href="#" + data-contextmenu-trigger="click" + data-contextmenu-table="sys_webhook" + data-contextmenu-uid="{webhook.uid}" + title="{webhook.name}" + > + <core:iconForRecord table="sys_webhook" row="{webhookRecord}" /> + </a> + </td> + <td> + <be:link.editRecord + returnUrl="{returnUrl}" + table="sys_webhook" + uid="{webhook.uid}" + title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:edit')}: {webhook.name}" + > + {webhook.name} + </be:link.editRecord> + </td> + <td> + <f:translate key="{webhookTypes.{webhook.webhookType.identifier}.description}" default="{webhookTypes.{webhook.webhookType.identifier}.description}"/> + </td> + <td> + <f:render section="codesnippet" arguments="{webhook: webhook}" /> + </td> + <td class="col-control"> + <f:render section="controls" arguments="{_all}" /> + </td> + </f:then> + <f:else> + <td class="col-icon"> + <core:iconForRecord table="sys_webhook" row="{webhookRecord}" /> + </td> + <td> + {webhook.name} + </td> + <td colspan="2"> + <span class="badge badge-danger"> + <f:translate key="LLL:EXT:webhooks/Resources/Private/Language/locallang_module_webhooks.xlf:webhook_no_implementation_class" arguments="{0: webhook.webhookType.identifier}" /> + </span> + </td> + <td class="col-control"> + <f:render section="controls" arguments="{_all}" /> + </td> + </f:else> + </f:if> + </tr> + </f:for> + </tbody> + </table> + </div> + <f:render section="multiRecordSelectionActions" arguments="{_all}" /> + <f:render partial="Pagination" arguments="{_all}" /> +</f:section> + +<f:section name="listHeaderSorting"> + <f:if condition="{demand.orderField} === {field}"> + <f:then> + <a href="{f:be.uri(route:'webhooks_management', parameters: '{demand: demand.parameters, orderField: field, orderDirection: demand.reverseOrderDirection}')}"> + <f:translate key="LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:{label}"/> + </a> + <core:icon identifier="status-status-sorting-{demand.orderDirection}"/> + </f:then> + <f:else> + <a href="{f:be.uri(route:'webhooks_management', parameters: '{demand: demand.parameters, orderField: field, orderDirection: demand.defaultOrderDirection}')}"> + <f:translate key="LLL:EXT:webhooks/Resources/Private/Language/locallang_db.xlf:{label}"/> + </a> + </f:else> + </f:if> +</f:section> + +<f:section name="filter"> + <form action="{f:be.uri(route:'webhooks_management')}" method="post" enctype="multipart/form-data" name="demand"> + <input type="hidden" name="orderField" value="{demand.orderField}"> + <input type="hidden" name="orderDirection" value="{demand.orderDirection}"> + <div class="row row-cols-auto align-items-end g-3 mb-4"> + <div class="col"> + <label for="demand-name" class="form-label"><f:translate key="LLL:EXT:webhooks/Resources/Private/Language/locallang_module_webhooks.xlf:filter.name"/></label> + <input type="text" id="demand-name" class="form-control" name="demand[name]" value="{demand.name}"/> + </div> + <div class="col"> + <label for="demand-webhook-type" class="form-label"><f:translate key="LLL:EXT:webhooks/Resources/Private/Language/locallang_module_webhooks.xlf:filter.webhook_type"/></label> + <select id="demand-webhook-type" class="form-select" name="demand[webhook_type]" data-on-change="submit"> + <option value=""><f:translate key="LLL:EXT:webhooks/Resources/Private/Language/locallang_module_webhooks.xlf:filter.webhook_type.showAll"/></option> + <f:for each="{webhookTypes}" key="identifier" as="webhookType"> + <option value="{identifier}" {f:if(condition: '{identifier} == {demand.webhookType}', then: 'selected')}> + <f:translate key="{webhookType.description}" default="{webhookType.description}"/> + </option> + </f:for> + </select> + </div> + <div class="col"> + <input type="submit" value="{f:translate(key: 'LLL:EXT:webhooks/Resources/Private/Language/locallang_module_webhooks.xlf:filter.sendButton')}" class="btn btn-default" /> + <a href="{f:be.uri(route:'webhooks_management')}" class="btn btn-link"><f:translate key="LLL:EXT:webhooks/Resources/Private/Language/locallang_module_webhooks.xlf:filter.resetButton"/></a> + </div> + </div> + </form> +</f:section> + +<f:section name="codesnippet"> + <f:if condition="{webhook.targetUrl}"> + <code class="my-1 p-2 me-2 bg-dark text-bg-primary"> + {webhook.targetUrl} + </code> + </f:if> +</f:section> + +<f:section name="controls"> + <div class="btn-group"> + <be:link.editRecord + returnUrl="{returnUrl}" + class="btn btn-default" + table="sys_webhook" + uid="{webhook.uid}" + title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:edit')}" + > + <core:icon identifier="actions-open" /> + </be:link.editRecord> + <f:if condition="{webhookRecord.disabled} == 1"> + <f:then> + <a + class="btn btn-default" + href="{be:moduleLink(route:'tce_db', query:'data[sys_webhook][{webhook.uid}][disabled]=0', arguments:'{redirect: returnUrl}')}" + title="{f:translate(key:'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:unHide')}" + > + <core:icon identifier="actions-edit-unhide" /> + </a> + </f:then> + <f:else> + <a + class="btn btn-default" + href="{be:moduleLink(route:'tce_db', query:'data[sys_webhook][{webhook.uid}][disabled]=1', arguments:'{redirect: returnUrl}')}" + title="{f:translate(key:'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:hide')}" + > + <core:icon identifier="actions-edit-hide" /> + </a> + </f:else> + </f:if> + <a class="btn btn-default t3js-modal-trigger" + href="{be:moduleLink(route:'tce_db', query:'cmd[sys_webhook][{webhook.uid}][delete]=1', arguments:'{redirect: returnUrl}')}" + title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:delete')}" + data-severity="warning" + data-title="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:label.confirm.delete_record.title')}" + data-bs-content="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:deleteWarning')}" + data-button-close-text="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:buttons.confirm.delete_record.no')}"> + <core:icon identifier="actions-delete" /> + </a> + </div> +</f:section> + +<f:section name="multiRecordSelectionCheckboxActions"> + <div class="btn-group dropdown"> + <a href="javascript:;" class="dropdown-toggle t3js-multi-record-selection-check-actions-toggle" data-bs-toggle="dropdown" data-bs-boundary="window" aria-expanded="false"> + <core:icon identifier="actions-selection" size="small" /> + </a> + <ul class="dropdown-menu t3js-multi-record-selection-check-actions"> + <li> + <button type="button" class="dropdown-item disabled" data-multi-record-selection-check-action="check-all" title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.checkAll')}"> + <span class="dropdown-item-columns"> + <span class="dropdown-item-column dropdown-item-column-icon" aria-hidden="true"> + <core:icon identifier="actions-selection-elements-all" size="small" /> + </span> + <span class="dropdown-item-column dropdown-item-column-title"> + <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.checkAll" /> + </span> + </span> + </button> + </li> + <li> + <button type="button" class="dropdown-item disabled" data-multi-record-selection-check-action="check-none" title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.uncheckAll')}"> + <span class="dropdown-item-columns"> + <span class="dropdown-item-column dropdown-item-column-icon" aria-hidden="true"> + <core:icon identifier="actions-selection-elements-none" size="small" /> + </span> + <span class="dropdown-item-column dropdown-item-column-title"> + <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.uncheckAll" /> + </span> + </span> + </button> + </li> + <li> + <button type="button" class="dropdown-item" data-multi-record-selection-check-action="toggle" title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.toggleSelection')}"> + <span class="dropdown-item-columns"> + <span class="dropdown-item-column dropdown-item-column-icon" aria-hidden="true"> + <core:icon identifier="actions-selection-elements-invert" size="small" /> + </span> + <span class="dropdown-item-column dropdown-item-column-title"> + <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.toggleSelection" /> + </span> + </span> + </button> + </li> + </ul> + </div> +</f:section> + +<f:section name="multiRecordSelectionActions"> + <div class="multi-record-selection-actions-wrapper"> + <div class="row row-cols-auto align-items-center g-2 t3js-multi-record-selection-actions hidden"> + <div class="col"> + <strong><f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.selection"/></strong> + </div> + <div class="col"> + <button class="btn btn-default btn-sm" data-multi-record-selection-action="edit" data-multi-record-selection-action-config='{editActionConfiguration -> f:format.raw()}'> + <core:icon identifier="actions-open" size="small" /> + <span class="text"> + <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.edit" /> + </span> + </button> + <button class="btn btn-default btn-sm" data-multi-record-selection-action="delete" data-multi-record-selection-action-config='{deleteActionConfiguration -> f:format.raw()}'> + <core:icon identifier="actions-edit-delete" size="small" /> + <span class="text"> + <f:translate key="LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.delete" /> + </span> + </button> + </div> + </div> + </div> +</f:section> +</html> diff --git a/typo3/sysext/webhooks/Resources/Public/Icons/Extension.svg b/typo3/sysext/webhooks/Resources/Public/Icons/Extension.svg new file mode 100644 index 0000000000000000000000000000000000000000..e3f075b44be417790237d6d588cf56e1b5230885 --- /dev/null +++ b/typo3/sysext/webhooks/Resources/Public/Icons/Extension.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 64 64"><path fill="#631EE9" d="M0 0h64v64H0z"/><path fill="#FFF" d="M43.002 37a3.99 3.99 0 0 0-3.858 3h-9.969a9.202 9.202 0 0 0-1.456-4.09l-1.492 2.487c.336.801.523 1.68.523 2.603 0 3.722-3.028 6.75-6.75 6.75s-6.75-3.028-6.75-6.75c0-3.508 2.69-6.397 6.116-6.718l1.493-2.489A9.339 9.339 0 0 0 20 31.75 9.25 9.25 0 1 0 29.194 42h9.95a3.99 3.99 0 0 0 3.858 3 4 4 0 0 0 0-8zm0 6c-1.103 0-2-.897-2-2s.897-2 2-2 2 .897 2 2-.897 2-2 2z"/><path fill="#FFF" d="M41.251 20.998a9.25 9.25 0 1 0-18.5 0c0 3.008 1.443 5.673 3.667 7.362l-5.292 8.82A3.956 3.956 0 0 0 20 37a4 4 0 1 0 4 4c0-1.093-.44-2.082-1.151-2.804l5.283-8.805a9.2 9.2 0 0 0 3.87.857c.17 0 .337-.016.505-.026l-1.397-2.54c-3.301-.438-5.859-3.265-5.859-6.684a6.758 6.758 0 0 1 6.75-6.75 6.758 6.758 0 0 1 6.75 6.75c0 1.012-.23 1.968-.63 2.83l1.399 2.544a9.197 9.197 0 0 0 1.731-5.374zM20 43c-1.103 0-2-.897-2-2s.897-2 2-2 2 .897 2 2-.897 2-2 2z"/><path fill="#FFF" d="M43.002 31.75a9.16 9.16 0 0 0-3.549.727l-4.716-8.574a3.978 3.978 0 0 0 1.264-2.905 4 4 0 1 0-4 4c.341 0 .666-.056.982-.136l4.721 8.584A9.299 9.299 0 0 0 34.672 37h2.906a6.738 6.738 0 0 1 5.424-2.75c3.722 0 6.75 3.028 6.75 6.75s-3.028 6.75-6.75 6.75A6.738 6.738 0 0 1 37.578 45h-2.906c1.493 3.103 4.657 5.25 8.33 5.25a9.25 9.25 0 1 0 0-18.5zm-11-8.752c-1.104 0-2-.897-2-2s.896-2 2-2c1.102 0 2 .897 2 2s-.898 2-2 2z"/></svg> \ No newline at end of file diff --git a/typo3/sysext/webhooks/Tests/Functional/Fixtures/sys_webhooks.csv b/typo3/sysext/webhooks/Tests/Functional/Fixtures/sys_webhooks.csv new file mode 100644 index 0000000000000000000000000000000000000000..0ab71c3ee099fef7f27053993481d03580f3eea7 --- /dev/null +++ b/typo3/sysext/webhooks/Tests/Functional/Fixtures/sys_webhooks.csv @@ -0,0 +1,5 @@ +"sys_webhook" +,"uid","pid","updatedon","createdon","deleted","disabled","starttime","endtime","description","name","url","method","secret","webhook_type","identifier","verify_ssl","additional_headers" +,1,0,1674072816,1674066297,0,0,0,0,"Test","Test with valid SSL","https://t3main.devbox.example.com/","POST","9699783a11b233aa07beb5c5a33fc7056d639d62","typo3/login-error","06f39184-6a7c-4756-81e1-c170f114d689",1,"[]" +,2,0,1674072864,1674072838,0,0,0,0,"Test with invalid SSL","Test with invalid SSL","https://localhost","POST","f73e4475886379ce1926f20da4acffb13e7b6268","typo3/login-error","ee828ed4-5e81-49af-8139-5678a22e7715",0,"[]" +,3,0,1674072864,1674072838,0,0,0,0,"Test with other settings","Test with other settings","https://localhost","POST","f73e4475886379ce1926f20da4acffb13e7b6268","typo3/content/page-modification","73cbf4cf-ed1b-497e-ae4e-345abe936783",0,"[]" diff --git a/typo3/sysext/webhooks/Tests/Functional/WebhookExecutionTest.php b/typo3/sysext/webhooks/Tests/Functional/WebhookExecutionTest.php new file mode 100644 index 0000000000000000000000000000000000000000..140f11f8ad0f64be52af01721e3b8a5d5a9e2a11 --- /dev/null +++ b/typo3/sysext/webhooks/Tests/Functional/WebhookExecutionTest.php @@ -0,0 +1,134 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Webhooks\Tests\Functional; + +use Psr\Http\Message\RequestInterface; +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\Context\Context; +use TYPO3\CMS\Core\Context\SecurityAspect; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Http\Response; +use TYPO3\CMS\Core\Http\ServerRequest; +use TYPO3\CMS\Core\Security\RequestToken; +use TYPO3\CMS\Core\Tests\Functional\DataScenarios\AbstractDataHandlerActionTestCase; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Tests that check if a certain message is triggered and about to be sent + * out via HTTP. + * + * It simulates a full scenario to trigger a webhook message to a remote URL. + */ +class WebhookExecutionTest extends AbstractDataHandlerActionTestCase +{ + protected array $coreExtensionsToLoad = ['webhooks']; + + protected function setUp(): void + { + parent::setUp(); + $this->importCSVDataSet(__DIR__ . '/../../../core/Tests/Functional/Fixtures/pages.csv'); + $this->importCSVDataSet(__DIR__ . '/Fixtures/sys_webhooks.csv'); + $this->setUpFrontendSite(1, $this->siteLanguageConfiguration); + } + + /** + * @test + */ + public function requestIsSentOutForMessagesWithAGivenType(): void + { + $numberOfRequestsFired = 0; + $inspector = function (RequestInterface $request) use (&$numberOfRequestsFired) { + $payload = json_decode($request->getBody()->getContents(), true); + $numberOfRequestsFired++; + self::assertSame('modified', $payload['action']); + self::assertSame('Dummy Modified', $payload['changedFields']['title']); + }; + $this->registerRequestInspector($inspector); + + // Catch any requests, evaluate their payload + $this->actionService->modifyRecord('pages', 10, ['title' => 'Dummy Modified']); + // @todo: this is a bug in DataHandler, because it triggers the option twice. + self::assertEquals(2, $numberOfRequestsFired); + } + + /** + * @test + */ + public function oneMessageWithMultipleRequestsIsTriggeredAndDispatched(): void + { + $numberOfRequestsFired = 0; + $inspector = function (RequestInterface $request) use (&$numberOfRequestsFired) { + $payload = json_decode($request->getBody()->getContents(), true); + self::assertSame('backend', $payload['context']); + self::assertSame('han-solo', $payload['loginData']['uname']); + self::assertSame('********', $payload['loginData']['uident']); + $numberOfRequestsFired++; + }; + $this->registerRequestInspector($inspector); + $context = GeneralUtility::makeInstance(Context::class); + $securityAspect = SecurityAspect::provideIn($context); + $nonce = $securityAspect->provideNonce(); + $requestToken = RequestToken::create('core/user-auth/be'); + $securityAspect->setReceivedRequestToken($requestToken); + + $request = new ServerRequest('https://example.com/site1/', 'POST'); + $request = $request->withParsedBody([ + 'login_status' => 'login', + 'username' => 'han-solo', + 'userident' => 'chewbaka', + RequestToken::PARAM_NAME => $requestToken->toHashSignedJwt($nonce), + ]); + + $userRequest = GeneralUtility::makeInstance(BackendUserAuthentication::class); + $userRequest->start($request); + self::assertEquals(2, $numberOfRequestsFired); + } + + /** + * @test + */ + public function messageWithoutConfiguredTypesDoesNotSendARequest(): void + { + // Just empty the table for the request, other ways are possible to do this + GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_webhook')->truncate('sys_webhook'); + $numberOfRequestsFired = 0; + $inspector = function () use (&$numberOfRequestsFired) { + $numberOfRequestsFired++; + }; + $this->registerRequestInspector($inspector); + + // Catch any requests, evaluate their payload + $this->actionService->modifyRecord('pages', 10, ['title' => 'Dummy Modified']); + self::assertEquals(0, $numberOfRequestsFired); + } + + protected function assertCleanReferenceIndex(): void + { + // do not do anything here yet + } + + protected function registerRequestInspector(callable $inspector): void + { + $GLOBALS['TYPO3_CONF_VARS']['HTTP']['handler']['logger'] = function () use ($inspector) { + return function (RequestInterface $request) use ($inspector) { + $inspector($request); + return new Response('success', 200); + }; + }; + } +} diff --git a/typo3/sysext/webhooks/Tests/Unit/Factory/WebhookInstructionFactoryTest.php b/typo3/sysext/webhooks/Tests/Unit/Factory/WebhookInstructionFactoryTest.php new file mode 100644 index 0000000000000000000000000000000000000000..3821cff7aed8cfec2a9daa658d6a14c81d38ba0a --- /dev/null +++ b/typo3/sysext/webhooks/Tests/Unit/Factory/WebhookInstructionFactoryTest.php @@ -0,0 +1,136 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Webhooks\Tests\Unit\Factory; + +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Webhooks\Factory\WebhookInstructionFactory; +use TYPO3\CMS\Webhooks\Model\WebhookType; +use TYPO3\CMS\Webhooks\WebhookTypesRegistry; +use TYPO3\TestingFramework\Core\Unit\UnitTestCase; + +class WebhookInstructionFactoryTest extends UnitTestCase +{ + /** + * Simulate a tt_content record + */ + protected array $mockRecord = [ + 'url' => 'https://example.com', + 'secret' => 'a random secret', + 'method' => 'POST', + 'verify_ssl' => true, + 'additional_headers' => [ + 'X-My-Header' => 'My Header Value', + ], + 'name' => 'My Webhook', + 'description' => 'My Webhook Description', + 'webhook_type' => null, + 'identifier' => '033c049f-7762-4755-b072-805350a8726a', + 'uid' => 200413, + ]; + + protected WebhookType $webhookType; + + protected function setUp(): void + { + parent::setUp(); + $this->webhookType = new WebhookType('typo3/test-webhook', 'My WebhookType description', 'My\Webhook\Type', 'myFactoryMethod'); + } + + /** + * @test + */ + public function createWebhookInstructionWithMinimalData(): void + { + $webhookInstruction = WebhookInstructionFactory::create( + $this->mockRecord['url'], + $this->mockRecord['secret'], + ); + self::assertSame($this->mockRecord['url'], $webhookInstruction->getTargetUrl()); + self::assertSame($this->mockRecord['secret'], $webhookInstruction->getSecret()); + self::assertSame('POST', $webhookInstruction->getHttpMethod()); + self::assertTrue($webhookInstruction->verifySSL()); + self::assertSame([], $webhookInstruction->getAdditionalHeaders()); + self::assertSame('', $webhookInstruction->getName()); + self::assertSame('', $webhookInstruction->getDescription()); + self::assertNull($webhookInstruction->getWebhookType()); + self::assertNull($webhookInstruction->getIdentifier()); + self::assertSame(0, $webhookInstruction->getUid()); + } + + /** + * @test + */ + public function createWebhookInstructionWithAllData(): void + { + $this->mockRecord['webhook_type'] = $this->webhookType; + $webhookInstruction = WebhookInstructionFactory::create( + $this->mockRecord['url'], + $this->mockRecord['secret'], + $this->mockRecord['method'], + $this->mockRecord['verify_ssl'], + $this->mockRecord['additional_headers'], + $this->mockRecord['name'], + $this->mockRecord['description'], + $this->mockRecord['webhook_type'], + $this->mockRecord['identifier'], + $this->mockRecord['uid'], + ); + self::assertSame($this->mockRecord['url'], $webhookInstruction->getTargetUrl()); + self::assertSame($this->mockRecord['secret'], $webhookInstruction->getSecret()); + self::assertSame('POST', $webhookInstruction->getHttpMethod()); + self::assertTrue($webhookInstruction->verifySSL()); + self::assertSame([ 'X-My-Header' => 'My Header Value'], $webhookInstruction->getAdditionalHeaders()); + self::assertSame('My Webhook', $webhookInstruction->getName()); + self::assertSame('My Webhook Description', $webhookInstruction->getDescription()); + self::assertSame($this->webhookType, $webhookInstruction->getWebhookType()); + self::assertSame($this->webhookType->getIdentifier(), $webhookInstruction->getWebhookType()->getIdentifier()); + self::assertSame($this->webhookType->getDescription(), $webhookInstruction->getWebhookType()->getDescription()); + self::assertSame('033c049f-7762-4755-b072-805350a8726a', $webhookInstruction->getIdentifier()); + self::assertSame(200413, $webhookInstruction->getUid()); + } + + /** + * @test + */ + public function createWebhookInstructionFromRow(): void + { + $webhookTypesRegistryMock = $this->createMock(WebhookTypesRegistry::class); + $webhookTypesRegistryMock + ->method('getWebhookByType') + ->with($this->webhookType->getIdentifier()) + ->willReturn($this->webhookType); + + GeneralUtility::addInstance(WebhookTypesRegistry::class, $webhookTypesRegistryMock); + + $this->mockRecord['webhook_type'] = 'typo3/test-webhook'; + $webhookInstruction = WebhookInstructionFactory::createFromRow($this->mockRecord); + self::assertSame($this->mockRecord['url'], $webhookInstruction->getTargetUrl()); + self::assertSame($this->mockRecord['url'], $webhookInstruction->getTargetUrl()); + self::assertSame($this->mockRecord['secret'], $webhookInstruction->getSecret()); + self::assertSame('POST', $webhookInstruction->getHttpMethod()); + self::assertTrue($webhookInstruction->verifySSL()); + self::assertSame([ 'X-My-Header' => 'My Header Value'], $webhookInstruction->getAdditionalHeaders()); + self::assertSame('My Webhook', $webhookInstruction->getName()); + self::assertSame('My Webhook Description', $webhookInstruction->getDescription()); + self::assertSame($this->webhookType, $webhookInstruction->getWebhookType()); + self::assertSame($this->webhookType->getIdentifier(), $webhookInstruction->getWebhookType()->getIdentifier()); + self::assertSame($this->webhookType->getDescription(), $webhookInstruction->getWebhookType()->getDescription()); + self::assertSame('033c049f-7762-4755-b072-805350a8726a', $webhookInstruction->getIdentifier()); + self::assertSame(200413, $webhookInstruction->getUid()); + } +} diff --git a/typo3/sysext/webhooks/composer.json b/typo3/sysext/webhooks/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..89e400edd5b5cef4855e37ab0bd0bd15b45083fb --- /dev/null +++ b/typo3/sysext/webhooks/composer.json @@ -0,0 +1,44 @@ +{ + "name": "typo3/cms-webhooks", + "type": "typo3-cms-framework", + "description": "TYPO3 CMS Webhooks - Handle outgoing Webhooks for TYPO3", + "homepage": "https://typo3.org", + "license": ["GPL-2.0-or-later"], + "authors": [{ + "name": "TYPO3 Core Team", + "email": "typo3cms@typo3.org", + "role": "Developer" + }], + "support": { + "chat": "https://typo3.org/help", + "docs": "https://docs.typo3.org/c/typo3/cms-webhooks/main/en-us/", + "issues": "https://forge.typo3.org", + "source": "https://github.com/typo3/typo3" + }, + "config": { + "sort-packages": true + }, + "require": { + "symfony/uid": "^6.2", + "typo3/cms-core": "12.3.*@dev" + }, + "suggest": { + "typo3/cms-lowlevel": "To display registered webhooks in the configuration module" + }, + "conflict": { + "typo3/cms": "*" + }, + "extra": { + "branch-alias": { + "dev-main": "12.3.x-dev" + }, + "typo3/cms": { + "extension-key": "webhooks" + } + }, + "autoload": { + "psr-4": { + "TYPO3\\CMS\\Webhooks\\": "Classes/" + } + } +} diff --git a/typo3/sysext/webhooks/ext_emconf.php b/typo3/sysext/webhooks/ext_emconf.php new file mode 100644 index 0000000000000000000000000000000000000000..e920fa2d14634e17c445f8868cbf44a532eeb419 --- /dev/null +++ b/typo3/sysext/webhooks/ext_emconf.php @@ -0,0 +1,19 @@ +<?php + +$EM_CONF[$_EXTKEY] = [ + 'title' => 'TYPO3 CMS Webhooks', + 'description' => 'Handle outgoing Webhooks for TYPO3', + 'category' => 'module', + 'author' => 'TYPO3 Core Team', + 'author_email' => 'typo3cms@typo3.org', + 'author_company' => '', + 'state' => 'stable', + 'version' => '12.3.0', + 'constraints' => [ + 'depends' => [ + 'typo3' => '12.3.0', + ], + 'conflicts' => [], + 'suggests' => [], + ], +]; diff --git a/typo3/sysext/webhooks/ext_localconf.php b/typo3/sysext/webhooks/ext_localconf.php new file mode 100644 index 0000000000000000000000000000000000000000..b915e98c2081ad35eb6e176d3e3f9177e54fe708 --- /dev/null +++ b/typo3/sysext/webhooks/ext_localconf.php @@ -0,0 +1,7 @@ +<?php + +declare(strict_types=1); + +defined('TYPO3') or die(); + +$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass'][] = \TYPO3\CMS\Webhooks\Listener\PageModificationListener::class; diff --git a/typo3/sysext/webhooks/ext_tables.sql b/typo3/sysext/webhooks/ext_tables.sql new file mode 100644 index 0000000000000000000000000000000000000000..c1a92b39d50e0d440217fc7a46990b05f1c8d75f --- /dev/null +++ b/typo3/sysext/webhooks/ext_tables.sql @@ -0,0 +1,14 @@ +# +# Table structure for table 'sys_webhook' +# +CREATE TABLE sys_webhook ( + name varchar(100) DEFAULT '' NOT NULL, + url varchar(2048) DEFAULT '' NOT NULL, + method varchar(10) DEFAULT '' NOT NULL, + secret varchar(255) DEFAULT '' NOT NULL, + webhook_type varchar(255) DEFAULT '' NOT NULL, + verify_ssl int(1) DEFAULT 1 NOT NULL, + + UNIQUE identifier_key (identifier), + KEY index_source (webhook_type(5)) +);