Skip to content

Commit

Permalink
[JENKINS-22936] Move rename logic to AbstractItem (#3289)
Browse files Browse the repository at this point in the history
* Move rename infrastructure to a dedicated page at the AbstractItem level

* Preserve existing translations where applicable

* Keep doDoRename method at Job level and remove unneeded compatibility page

* Fix tests

* Fix Javadoc

* Update existing rename tests to use new page

* Update test names for clarity

* Add since tag for newly introduced APIs

* Apply project naming strategy to all renamed items, not just jobs

* Use shorthand accessor

* Clean up Job#doDoRename and deprecate it

* Use new-style web method for doDoRename2

* Remove CheckForNull tag from void method and update Javadoc

* Change names to doConfirmRename and confirm-rename.jelly

* Rename index.jelly to action.jelly and use j:if tag correctly

* Update permission check and remove unused import

* Use new url and update method name in test

* Fix Javadoc

* Fix URL in JobTest

* Update Javadoc for AbstractItem#isNameEditable
  • Loading branch information
dwnusbaum authored and oleg-nenashev committed Mar 4, 2018
1 parent 8f51260 commit c816f96
Show file tree
Hide file tree
Showing 34 changed files with 384 additions and 484 deletions.
111 changes: 111 additions & 0 deletions core/src/main/java/hudson/model/AbstractItem.java
Expand Up @@ -36,12 +36,14 @@
import hudson.model.listeners.SaveableListener;
import hudson.model.queue.Tasks;
import hudson.model.queue.WorkUnit;
import hudson.security.ACLContext;
import hudson.security.AccessControlled;
import hudson.security.Permission;
import hudson.security.ACL;
import hudson.util.AlternativeUiTextProvider;
import hudson.util.AlternativeUiTextProvider.Message;
import hudson.util.AtomicFileWriter;
import hudson.util.FormValidation;
import hudson.util.IOUtils;
import hudson.util.Secret;
import java.util.Iterator;
Expand Down Expand Up @@ -72,12 +74,16 @@
import java.util.regex.Pattern;
import javax.annotation.Nonnull;

import org.acegisecurity.AccessDeniedException;
import org.kohsuke.stapler.HttpResponse;
import org.kohsuke.stapler.HttpResponses;
import org.kohsuke.stapler.StaplerRequest;
import org.kohsuke.stapler.StaplerResponse;
import org.kohsuke.stapler.Stapler;
import org.kohsuke.stapler.HttpDeletable;
import org.kohsuke.args4j.Argument;
import org.kohsuke.args4j.CmdLineException;
import org.kohsuke.stapler.QueryParameter;
import org.kohsuke.stapler.interceptor.RequirePOST;
import org.xml.sax.SAXException;

Expand Down Expand Up @@ -228,6 +234,111 @@ protected void doSetName(String name) {
this.name = name;
}

/**
* Controls whether the default rename action is available for this item.
*
* @return whether {@link #name} can be modified by a user
* @see #checkRename
* @see #renameTo
* @since FIXME
*/
public boolean isNameEditable() {
return false;
}

/**
* Renames this item
*/
@RequirePOST
@Restricted(NoExternalUse.class)
public HttpResponse doConfirmRename(@QueryParameter String newName) throws IOException {
newName = newName == null ? null : newName.trim();
FormValidation validationError = doCheckNewName(newName);
if (validationError.kind != FormValidation.Kind.OK) {
throw new Failure(validationError.getMessage());
}

renameTo(newName);
// send to the new job page
// note we can't use getUrl() because that would pick up old name in the
// Ancestor.getUrl()
return HttpResponses.redirectTo("../" + newName);
}

/**
* Called by {@link #doConfirmRename} and {@code rename.jelly} to validate renames.
* @return {@link FormValidation#ok} if this item can be renamed as specified, otherwise
* {@link FormValidation#error} with a message explaining the problem.
*/
@Restricted(NoExternalUse.class)
public @Nonnull FormValidation doCheckNewName(@QueryParameter String newName) {
// TODO: Create an Item.RENAME permission to use here, see JENKINS-18649.
if (!hasPermission(Item.CONFIGURE)) {
if (parent instanceof AccessControlled) {
((AccessControlled)parent).checkPermission(Item.CREATE);
}
checkPermission(Item.DELETE);
}

newName = newName == null ? null : newName.trim();
try {
Jenkins.checkGoodName(newName);
assert newName != null; // Would have thrown Failure
Jenkins.get().getProjectNamingStrategy().checkName(newName);
checkIfNameIsUsed(newName);
checkRename(newName);
} catch (Failure e) {
return FormValidation.error(e.getMessage());
}
return FormValidation.ok();
}

/**
* Check new name for job
* @param newName - New name for job.
*/
private void checkIfNameIsUsed(@Nonnull String newName) throws Failure {
try {
Item item = getParent().getItem(newName);
if (item != null) {
throw new Failure(Messages.AbstractItem_NewNameInUse(newName));
}
try (ACLContext ctx = ACL.as(ACL.SYSTEM)) {
item = getParent().getItem(newName);
if (item != null) {
if (LOGGER.isLoggable(Level.FINE)) {
LOGGER.log(Level.FINE, "Unable to rename the job {0}: name {1} is already in use. " +
"User {2} has no {3} permission for existing job with the same name",
new Object[] {this.getFullName(), newName, ctx.getPreviousContext().getAuthentication().getName(), Item.DISCOVER.name} );
}
// Don't explicitly mention that there is another item with the same name.
throw new Failure(Messages.Jenkins_NotAllowedName(newName));
}
}
} catch(AccessDeniedException ex) {
if (LOGGER.isLoggable(Level.FINE)) {
LOGGER.log(Level.FINE, "Unable to rename the job {0}: name {1} is already in use. " +
"User {2} has {3} permission, but no {4} for existing job with the same name",
new Object[] {this.getFullName(), newName, User.current(), Item.DISCOVER.name, Item.READ.name} );
}
throw new Failure(Messages.AbstractItem_NewNameInUse(newName));
}
}

/**
* Allows subclasses to block renames for domain-specific reasons. Generic validation of the new name
* (e.g., null checking, checking for illegal characters, and checking that the name is not in use)
* always happens prior to calling this method.
*
* @param newName the new name for the item
* @throws Failure if the rename should be blocked
* @since FIXME
* @see Job#checkRename
*/
protected void checkRename(@Nonnull String newName) throws Failure {

}

/**
* Renames this item.
* Not all the Items need to support this operation, but if you decide to do so,
Expand Down
109 changes: 11 additions & 98 deletions core/src/main/java/hudson/model/Job.java
Expand Up @@ -56,7 +56,6 @@
import hudson.util.FormApply;
import hudson.util.Graph;
import hudson.util.ProcessTree;
import hudson.util.QuotedStringTokenizer;
import hudson.util.RunList;
import hudson.util.ShiftedCategoryAxis;
import hudson.util.StackedAreaRenderer2;
Expand All @@ -68,7 +67,6 @@
import java.awt.Paint;
import java.io.File;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Calendar;
Expand Down Expand Up @@ -122,9 +120,6 @@

import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT;
import org.acegisecurity.AccessDeniedException;
import org.acegisecurity.context.SecurityContext;
import org.acegisecurity.context.SecurityContextHolder;

/**
* A job is an runnable entity under the monitoring of Hudson.
Expand Down Expand Up @@ -324,6 +319,7 @@ public String getPronoun() {
/**
* Returns whether the name of this job can be changed by user.
*/
@Override
public boolean isNameEditable() {
return true;
}
Expand Down Expand Up @@ -1356,39 +1352,17 @@ public synchronized void doConfigSubmit(StaplerRequest req,
}
ItemListener.fireOnUpdated(this);

String newName = req.getParameter("name");
final ProjectNamingStrategy namingStrategy = Jenkins.getInstance().getProjectNamingStrategy();
if (validRename(name, newName)) {
newName = newName.trim();
// check this error early to avoid HTTP response splitting.
Jenkins.checkGoodName(newName);
namingStrategy.checkName(newName);
if (FormApply.isApply(req)) {
FormApply.applyResponse("notificationBar.show(" + QuotedStringTokenizer.quote(Messages.Job_you_must_use_the_save_button_if_you_wish()) + ",notificationBar.WARNING)").generateResponse(req, rsp, null);
} else {
rsp.sendRedirect("rename?newName=" + URLEncoder.encode(newName, "UTF-8"));
}
} else {
if(namingStrategy.isForceExistingJobs()){
namingStrategy.checkName(name);
}
FormApply.success(".").generateResponse(req, rsp, null);
}
} catch (JSONException e) {
LOGGER.log(Level.WARNING, "failed to parse " + json, e);
sendError(e, req, rsp);
}
}

private boolean validRename(String oldName, String newName) {
if (newName == null) {
return false;
}
boolean noChange = oldName.equals(newName);
boolean spaceAdded = oldName.equals(newName.trim());
return !noChange && !spaceAdded;
}

/**
* Derived class can override this to perform additional config submission
* work.
Expand Down Expand Up @@ -1586,32 +1560,25 @@ private Calendar getLastBuildTime() {

/**
* Renames this job.
* @deprecated Exists for backwards compatibility, use {@link #doConfirmRename} instead.
*/
@Deprecated
@RequirePOST
public/* not synchronized. see renameTo() */void doDoRename(
StaplerRequest req, StaplerResponse rsp) throws IOException,
ServletException {

if (!hasPermission(CONFIGURE)) {
// rename is essentially delete followed by a create
checkPermission(CREATE);
checkPermission(DELETE);
}

String newName = req.getParameter("newName");
Jenkins.checkGoodName(newName);
doConfirmRename(newName).generateResponse(req, rsp, null);
}

/**
* {@inheritDoc}
*/
@Override
protected void checkRename(String newName) throws Failure {
if (isBuilding()) {
// redirect to page explaining that we can't rename now
rsp.sendRedirect("rename?newName=" + URLEncoder.encode(newName, "UTF-8"));
return;
throw new Failure(Messages.Job_NoRenameWhileBuilding());
}

renameTo(newName);
// send to the new job page
// note we can't use getUrl() because that would pick up old name in the
// Ancestor.getUrl()
rsp.sendRedirect2("../" + newName);
}

public void doRssAll(StaplerRequest req, StaplerResponse rsp)
Expand Down Expand Up @@ -1645,58 +1612,4 @@ public BuildTimelineWidget getTimeline() {
}

private final static HexStringConfidentialKey SERVER_COOKIE = new HexStringConfidentialKey(Job.class,"serverCookie",16);

/**
* Check new name for job
* @param newName - New name for job
* @return {@code true} - if newName occupied and user has permissions for this job
* {@code false} - if newName occupied and user hasn't permissions for this job
* {@code null} - if newName didn't occupied
*
* @throws Failure if the given name is not good
*/
@CheckForNull
@Restricted(NoExternalUse.class)
public Boolean checkIfNameIsUsed(@Nonnull String newName) throws Failure{

Item item = null;
Jenkins.checkGoodName(newName);

try {
item = getParent().getItem(newName);
} catch(AccessDeniedException ex) {
if (LOGGER.isLoggable(Level.FINE)) {
LOGGER.log(Level.FINE, "Unable to rename the job {0}: name {1} is already in use. " +
"User {2} has {3} permission, but no {4} for existing job with the same name",
new Object[] {this.getFullName(), newName, User.current().getFullName(), Item.DISCOVER.name, Item.READ.name} );
}
return true;
}

if (item != null) {
// User has Read permissions for existing job with the same name
return true;
} else {
SecurityContext initialContext = null;
try {
initialContext = hudson.security.ACL.impersonate(ACL.SYSTEM);
item = getParent().getItem(newName);

if (item != null) {
if (LOGGER.isLoggable(Level.FINE)) {
LOGGER.log(Level.FINE, "Unable to rename the job {0}: name {1} is already in use. " +
"User {2} has no {3} permission for existing job with the same name",
new Object[] {this.getFullName(), newName, initialContext.getAuthentication().getName(), Item.DISCOVER.name} );
}
return false;
}

} finally {
if (initialContext != null) {
SecurityContextHolder.setContext(initialContext);
}
}
}
return null;
}
}
70 changes: 70 additions & 0 deletions core/src/main/java/jenkins/model/RenameAction.java
@@ -0,0 +1,70 @@
/*
* The MIT License
*
* Copyright 2018 CloudBees, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/

package jenkins.model;

import hudson.Extension;
import hudson.model.AbstractItem;
import hudson.model.Action;
import java.util.Collection;
import java.util.Collections;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;

@Restricted(NoExternalUse.class)
public class RenameAction implements Action {

@Override
public String getIconFileName() {
return "notepad.png";
}

@Override
public String getDisplayName() {
return "Rename";
}

@Override
public String getUrlName() {
return "confirm-rename";
}

@Extension
public static class TransientActionFactoryImpl extends TransientActionFactory<AbstractItem> {

@Override
public Class<AbstractItem> type() {
return AbstractItem.class;
}

@Override
public Collection<? extends Action> createFor(AbstractItem target) {
if (target.isNameEditable()) {
return Collections.singleton(new RenameAction());
} else {
return Collections.emptyList();
}
}
}
}

0 comments on commit c816f96

Please sign in to comment.