Änderungen von Dokument ClassEditSheet
Zuletzt geändert von Holger Engels am 2026/09/22 10:06
Von Version 1.1
bearbeitet von Holger Engels
am 2025/08/05 10:26
am 2025/08/05 10:26
Änderungskommentar:
Install extension [org.xwiki.platform:xwiki-platform-appwithinminutes-ui/17.6.0]
Auf Version 2.1
bearbeitet von Holger Engels
am 2026/09/22 10:06
am 2026/09/22 10:06
Änderungskommentar:
Install extension [org.xwiki.platform:xwiki-platform-appwithinminutes-ui/18.7.0]
Zusammenfassung
-
Seiteneigenschaften (2 geändert, 0 hinzugefügt, 0 gelöscht)
-
Objekte (1 geändert, 0 hinzugefügt, 0 gelöscht)
Details
- Seiteneigenschaften
-
- Dokument-Autor
-
... ... @@ -1,1 +1,1 @@ 1 -XWiki.hengels 1 +XWiki.holgerengels - Inhalt
-
... ... @@ -329,6 +329,9 @@ 329 329 #set ($xclass = $doc.getxWikiClass().getXWikiClass()) 330 330 #set ($discard = $xclass.addField($field.name, $field)) 331 331 #set ($discard = $field.setObject($xclass)) 332 + ## Set the class document author to the current user so that customDisplay code is rendered with the current user's 333 + ## rights, not the class document author's rights. 334 + #set ($discard = $doc.document.authors.setEffectiveMetadataAuthor($services.user.currentUserReference)) 332 332 #displayField($doc.getxWikiClass().get($field.name) $formFieldDoc) 333 333 #else 334 334 Unsupported form field. ... ... @@ -381,6 +381,9 @@ 381 381 ## 382 382 ## Display the field. 383 383 #set ($field = $doc.getxWikiClass().get($fieldName)) 387 + ## Set the class document author to the current user so that customDisplay code is rendered with the current user's 388 + ## rights, not the class document author's rights. 389 + #set ($discard = $doc.document.authors.setEffectiveMetadataAuthor($services.user.currentUserReference)) 384 384 #displayPropertyEditInput($field, "${doc.fullName}_0_", $object) 385 385 #end 386 386 ... ... @@ -389,6 +389,9 @@ 389 389 *# 390 390 #macro (displayEditForm) 391 391 #set ($discard = $xwiki.jsx.use('AppWithinMinutes.ClassEditSheet')) 398 + ## Reusable helper used to recover gracefully when a preview or field-add request is rejected because the CSRF 399 + ## token expired (e.g. after a server restart). 400 + #set ($discard = $xwiki.jsx.use('AppWithinMinutes.Code.CSRFTokenRefresh')) 392 392 #set ($discard = $xwiki.ssx.use('AppWithinMinutes.ClassEditSheet')) 393 393 #set ($discard = $xwiki.ssx.use('AppWithinMinutes.ClassSheetGenerator')) 394 394 #if ("$!request.wizard" == 'true') ... ... @@ -407,10 +407,21 @@ 407 407 * Displays either the edit class form or a new form field. The later is used when adding a new form field via AJAX. 408 408 *# 409 409 #macro (doEdit) 410 - #if ("$!request.field" != '') 411 - #displayNewField() 412 - #elseif ("$!request.preview" == 'true') 413 - #previewField() 419 + #if ("$!request.field" != '' || "$!request.preview" == 'true') 420 + ## CSRF protection. 421 + #if (!$services.csrf.isTokenValid($request.form_token)) 422 + $response.setStatus(403) 423 + ## Return a fresh token (using the same JSON shape as XWikiAction#csrfTokenCheck) so that the client can recover 424 + ## from an expired token (e.g. after a server restart) by refreshing it, after an explicit user confirmation, 425 + ## and retrying the request. Returning the token in the response is safe because the same-origin policy prevents 426 + ## a CSRF attacker from reading it. 427 + $response.setContentType('application/json') 428 + $jsontool.serialize({'errorType': 'CSRF', 'newToken': $services.csrf.getToken()}) 429 + #elseif ("$!request.field" != '') 430 + #displayNewField() 431 + #else 432 + #previewField() 433 + #end 414 414 #else 415 415 ## Make sure that only the sheet content is rendered when the class is saved using AJAX. 416 416 <div class="hidden"> ... ... @@ -418,6 +418,9 @@ 418 418 #if ($request.wizard == 'true') 419 419 ## Preserve the wizard mode. 420 420 <input type="hidden" name="wizard" value="true" /> 441 + ## Provide the URL of the next wizard step as xredirect value (used by the AJAX Save & View implementation). 442 + #set ($appWizardNextStepURL = "#getNextStepURL()") 443 + <input type="hidden" id="xredirect" name="xredirect" value="$escapetool.xml($appWizardNextStepURL)" /> 421 421 #end 422 422 ## Compute the application title to be used as the wizard step title. 423 423 #getAppTitle ... ... @@ -655,6 +655,26 @@ 655 655 #end 656 656 657 657 #** 681 + * Prints the URL of the next wizard step. 682 + *# 683 +#macro (getNextStepURL) 684 + #set ($className = $stringtool.removeEnd($doc.fullName, 'Class')) 685 + #set ($templateProviderReference = $services.model.resolveDocument("${className}TemplateProvider")) 686 + #set ($queryString = { 687 + 'wizard': true, 688 + 'sheet': 'AppWithinMinutes.TemplateProviderEditSheet' 689 + }) 690 + #if (!$xwiki.exists($templateProviderReference)) 691 + #set ($discard = $queryString.putAll({ 692 + 'form_token': $services.csrf.getToken(), 693 + 'template': 'XWiki.TemplateProviderTemplate', 694 + 'parent': $doc.fullName 695 + })) 696 + #end 697 +$xwiki.getURL($templateProviderReference, 'edit', $escapetool.url($queryString))## 698 +#end 699 + 700 +#** 658 658 * Updates and saves the class definition, the class sheet and the class template. 659 659 *# 660 660 #macro (doSave) ... ... @@ -665,24 +665,19 @@ 665 665 #updateAndSaveSheet 666 666 #updateAndSaveTranslations 667 667 #if ($action == 'save') 668 - #if ($errorMessage) 711 + #if ($request.getHeader('X-Requested-With') == 'XMLHttpRequest') 712 + ## When saving through AJAX, don't redirect and don't render an error page. Let the client-side handle both. 713 + #if ($errorMessage) 714 + $response.setStatus(400) 715 + #rawResponse($errorMessage, 'text/plain') 716 + #else 717 + $response.setStatus(204) 718 + #end 719 + #elseif ($errorMessage) 669 669 #error($errorMessage) 670 670 #elseif ("$!request.wizard" == 'true') 671 671 ## Redirect to next wizard step. 672 - #set ($className = $stringtool.removeEnd($doc.fullName, 'Class')) 673 - #set ($templateProviderReference = $services.model.resolveDocument("${className}TemplateProvider")) 674 - #set ($queryString = { 675 - 'wizard': true, 676 - 'sheet': 'AppWithinMinutes.TemplateProviderEditSheet' 677 - }) 678 - #if (!$xwiki.exists($templateProviderReference)) 679 - #set ($discard = $queryString.putAll({ 680 - 'form_token': $services.csrf.getToken(), 681 - 'template': 'XWiki.TemplateProviderTemplate', 682 - 'parent': $doc.fullName 683 - })) 684 - #end 685 - $response.sendRedirect($xwiki.getURL($templateProviderReference, 'edit', $escapetool.url($queryString))) 723 + $response.sendRedirect("#getNextStepURL()") 686 686 #else 687 687 ## Redirect to view mode. 688 688 $response.sendRedirect($doc.getURL()) ... ... @@ -689,7 +689,8 @@ 689 689 #end 690 690 #else 691 691 #if ($errorMessage) 692 - $response.sendError(400, $errorMessage) 730 + $response.setStatus(400) 731 + #rawResponse($errorMessage, 'text/plain') 693 693 #else 694 694 $response.setStatus(204) 695 695 #end
- XWiki.JavaScriptExtension[2]
-
- Code
-
... ... @@ -10,15 +10,42 @@ 10 10 enhance : function(data) { 11 11 this.container.insert({top: this._createToolBox()}); 12 12 if (data) { 13 - this.container.addClassName('loading'); 14 - new Ajax.Request(data, { 15 - method: 'get', 16 - onSuccess: this._display.bind(this) 17 - }); 13 + this._loadField(data); 18 18 } else { 19 19 this._display(null); 20 20 } 21 21 }, 18 + _loadField : function (url) { 19 + // The field URL embeds the CSRF token that was valid when the page was rendered. Always refresh it from the 20 + // current form_token input (the single source of truth, updated when the token is refreshed after an expired 21 + // token recovery) so that, once the token has been refreshed, adding further fields does not prompt again. 22 + const formToken = this.container.up('form')?.down('input[name="form_token"]'); 23 + if (formToken) { 24 + const fieldURL = new URL(url, window.location.href); 25 + fieldURL.searchParams.set('form_token', formToken.value); 26 + url = fieldURL.toString(); 27 + } 28 + this.container.addClassName('loading'); 29 + const self = this; 30 + new Ajax.Request(url, { 31 + method: 'get', 32 + onSuccess: this._display.bind(this), 33 + // A 403 means the CSRF token expired (e.g. the server was restarted while the user was editing). Offer to 34 + // refresh the token and retry, instead of leaving the loading spinner spinning forever. 35 + on403: function (response) { 36 + this.container.removeClassName('loading'); 37 + require(['xwiki-appwithinminutes-csrf'], function (csrf) { 38 + csrf.recoverFromExpiredToken(response, function () { 39 + // The token has been refreshed in the form: retry, _loadField will pick up the fresh token. 40 + self._loadField(url); 41 + }); 42 + }); 43 + }.bind(this), 44 + onFailure: function () { 45 + this.container.removeClassName('loading'); 46 + }.bind(this) 47 + }); 48 + }, 22 22 getContainer : function() { 23 23 return this.container; 24 24 }, ... ... @@ -138,6 +138,12 @@ 138 138 // Add parameters required by the AJAX preview request. 139 139 this.previewData.set('preview', true); 140 140 this.previewData.set('xpage', 'plain'); 168 + // Find and add the mandatory CSRF token. 169 + const editForm = this.container.up('form'); 170 + const formToken = editForm && editForm.down('input[name="form_token"]'); 171 + if (formToken) { 172 + this.previewData.set('form_token', formToken.value); 173 + } 141 141 }, 142 142 _maybePreview : function() { 143 143 if(!this.previewData) { ... ... @@ -169,6 +169,7 @@ 169 169 var preview = this.getViewer().down('dd'); 170 170 if (preview) { 171 171 preview.addClassName('loading'); 205 + const self = this; 172 172 // Firefox 3.6 doesn't resolve the empty string to the current page URL so we have to explicitly specify it. 173 173 new Ajax.Request(window.location.href, { 174 174 parameters: data.toQueryString(), ... ... @@ -178,6 +178,20 @@ 178 178 this.previewData = data; 179 179 document.fire('xwiki:class:previewField', {field: this}); 180 180 document.fire('xwiki:dom:updated', {elements: [preview]}); 215 + }.bind(this), 216 + // A 403 means the CSRF token expired (e.g. the server was restarted while the user was editing). Offer to 217 + // refresh the token and retry the preview, instead of leaving the loading spinner spinning forever. 218 + on403: function (response) { 219 + preview.removeClassName('loading'); 220 + require(['xwiki-appwithinminutes-csrf'], function (csrf) { 221 + csrf.recoverFromExpiredToken(response, function (token) { 222 + data.set('form_token', token); 223 + self._preview(data); 224 + }); 225 + }); 226 + }.bind(this), 227 + onFailure: function () { 228 + preview.removeClassName('loading'); 181 181 }.bind(this) 182 182 }); 183 183 } ... ... @@ -303,7 +303,7 @@ 303 303 prettyNameInput.ariaLabel = '$escapetool.javascript($services.localization.render('platform.appwithinminutes.classEditorFieldPrettyNameToolTip'))'; 304 304 // Replace the preview label with the pretty name input. 305 305 var previewLabel = field.getViewer().down('label'); 306 - // We unset the for attribute, so that this label will not be set implicit ely OR explicitely anymore.354 + // We unset the for attribute, so that this label will not be set implicitly OR explicitly anymore. 307 307 previewLabel.writeAttribute('for', ''); 308 308 previewLabel.removeChild(previewLabel.lastChild); 309 309 previewLabel.insert(prettyNameInput); ... ... @@ -532,21 +532,14 @@ 532 532 // Remove the preview button. 533 533 form.down("input[name='action_preview']").remove(); 534 534 535 - // Rename the save buttons. 536 - form.down("input[name='action_save']").name = 'xaction_save'; 537 - form.down("input[name='action_saveandcontinue']").name = 'xaction_saveandcontinue'; 583 + // Let actionButtons.js submit a custom value for the submit buttons to avoid triggering the standard save action 584 + // on the server side. 585 + form.down("input[name='action_save']").dataset.submitValue = 'xaction_save'; 586 + form.down("input[name='action_saveandcontinue']").dataset.submitValue = 'xaction_saveandcontinue'; 538 538 539 - // Make sure the 'Save & Continue' button is submitted when clicked. 540 - document.observe('xwiki:class:save', function(event) { 541 - event = event.memo.originalEvent; 542 - if (event.memo['continue']) { 543 - var submitButton = event.memo.originalEvent.element(); 544 - submitButton.insert({after: new Element('input', {type: 'hidden', 'name': submitButton.name})}); 545 - (function () { 546 - submitButton.next().remove(); 547 - }).defer(); 548 - } 549 - }.bindAsEventListener(window)); 588 + // actionButtons.js detects this as a custom form due to the submit URL that is not the standard one. 589 + // Force the standard behavior by setting the customForm attribute to false. 590 + form.dataset.customForm = 'false'; 550 550 551 551 // Be prepared to enhance some of the field properties. 552 552 new XWiki.FormFieldNameManager();