/* * The MIT License * * Copyright 2013 Stefan Brausch, Mirko Friedenhagen. * * 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 hudson.plugins.jobConfigHistory; import static java.util.logging.Level.FINEST; import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.logging.Logger; import javax.servlet.ServletException; import org.kohsuke.stapler.StaplerRequest; import org.kohsuke.stapler.StaplerResponse; import org.kohsuke.stapler.export.Exported; import org.kohsuke.stapler.export.ExportedBean; import hudson.Extension; import hudson.XmlFile; import hudson.model.AbstractItem; import hudson.model.Api; import hudson.model.Item; import hudson.model.RootAction; import hudson.model.TopLevelItem; import hudson.plugins.jobConfigHistory.SideBySideView.Line; import hudson.security.AccessControlled; import hudson.security.Permission; import hudson.util.MultipartFormDataParser; /** * * @author Stefan Brausch * @author Mirko Friedenhagen */ @ExportedBean(defaultVisibility = -1) @Extension public class JobConfigHistoryRootAction extends JobConfigHistoryBaseAction implements RootAction { /** Our logger. */ private static final Logger LOG = Logger .getLogger(JobConfigHistoryRootAction.class.getName()); /** * Constructor necessary for testing. */ public JobConfigHistoryRootAction() { super(); } /** * {@inheritDoc} * * This actions always starts from the context directly, so prefix * {@link JobConfigHistoryConsts#URLNAME} with a slash. */ @Override public final String getUrlName() { return "/" + JobConfigHistoryConsts.URLNAME; } /** * {@inheritDoc} * * Make method final, as we always want the same icon file. Returns * {@literal null} to hide the icon if the user is not allowed to configure * jobs. */ public final String getIconFileName() { if (hasConfigurePermission() || hasJobConfigurePermission() || hasReadExtensionPermission()) { return JobConfigHistoryConsts.ICONFILENAME; } else { return null; } } /** * Returns the configuration history entries for either {@link AbstractItem} * s or system changes or deleted jobs or all of the above. * * @return list of configuration histories (as ConfigInfo) * @throws IOException * if one of the history entries might not be read. */ @Exported(visibility = 1) public final List<ConfigInfo> getConfigs() throws IOException { final String filter = getRequestParameter("filter"); List<ConfigInfo> configs = null; if (filter == null || "system".equals(filter)) { configs = getSystemConfigs(); } else if ("all".equals(filter)) { configs = getJobConfigs("jobs"); configs.addAll(getJobConfigs("deleted")); configs.addAll(getSystemConfigs()); } else { configs = getJobConfigs(filter); } Collections.sort(configs, ParsedDateComparator.DESCENDING); return configs; } /** * Returns the configuration history entries for all system files in this * Jenkins instance. * * @return List of config infos. * @throws IOException * if one of the history entries might not be read. */ public List<ConfigInfo> getSystemConfigs() throws IOException { final List<ConfigInfo> configs = new ArrayList<ConfigInfo>(); if (!hasConfigurePermission()) { return configs; } final File[] itemDirs = getOverviewHistoryDao().getSystemConfigs(); for (final File itemDir : itemDirs) { final String itemName = itemDir.getName(); configs.addAll(HistoryDescrToConfigInfo.convert(itemName, true, getOverviewHistoryDao().getSystemHistory(itemName).values(), false)); } return configs; } /** * Returns the configuration history entries for all jobs or deleted jobs in * this Jenkins instance. * * @param type * Whether we want to see all jobs or just the deleted jobs. * @return List of config infos. * @throws IOException * if one of the history entries might not be read. */ public List<ConfigInfo> getJobConfigs(String type) throws IOException { if (!hasJobConfigurePermission() && !hasReadExtensionPermission()) { return Collections.emptyList(); } else { return new ConfigInfoCollector(type, getOverviewHistoryDao()) .collect(""); } } /** * Returns the configuration history entries for one group of system files * or deleted jobs. * * @param name * of the item. * @return Configs list for one group of system configuration files. * @throws IOException * if one of the history entries might not be read. */ public final List<ConfigInfo> getSingleConfigs(String name) throws IOException { final Collection<HistoryDescr> historyDescriptions; if (name.contains(DeletedFileFilter.DELETED_MARKER)) { historyDescriptions = getOverviewHistoryDao().getJobHistory(name) .values(); } else { historyDescriptions = getOverviewHistoryDao().getSystemHistory(name) .values(); } final List<ConfigInfo> configs = HistoryDescrToConfigInfo.convert(name, true, historyDescriptions, false); Collections.sort(configs, ParsedDateComparator.DESCENDING); return configs; } /** * Returns {@link JobConfigHistoryBaseAction#getConfigXml(String)} as * String. * * @return content of the {@literal config.xml} found in directory given by * the request parameter {@literal file}. * @throws IOException * if the config file could not be read or converted to an xml * string. */ public final String getFile() throws IOException { final String name = getRequestParameter("name"); if ((name.contains(DeletedFileFilter.DELETED_MARKER) && hasJobConfigurePermission()) || hasConfigurePermission()) { final String timestamp = getRequestParameter("timestamp"); final XmlFile xmlFile = getOldConfigXml(name, timestamp); return xmlFile.asString(); } else { return "No permission to view config files"; } } /** * Creates links to the correct configOutput.jellys for job history vs. * system history and for xml vs. plain text. * * @param config * ConfigInfo. * @param type * Output type ('xml' or 'plain'). * @return The link as String. */ public final String createLinkToFiles(ConfigInfo config, String type) { String link = null; final String name = config.getJob(); String timestamp = config.getDate(); if (name.contains(DeletedFileFilter.DELETED_MARKER)) { // last config.xml for deleted job usually doesn't exist try { if (getSingleConfigs(name).size() > 1) { timestamp = getSingleConfigs(name).get(1).getDate(); link = "configOutput?type=" + type + "&name=" + name + "×tamp=" + timestamp; } } catch (IOException ex) { LOG.log(FINEST, "Unable to get config for {0}", name); } } else if (config.getIsJob()) { link = getJenkins().getRootUrl() + "job/" + name + getUrlName() + "/configOutput?type=" + type + "×tamp=" + timestamp; } else { link = "configOutput?type=" + type + "&name=" + name + "×tamp=" + timestamp; } return link; } @Override public AccessControlled getAccessControlledObject() { return getJenkins(); } @Override public void checkConfigurePermission() { getAccessControlledObject().checkPermission(Permission.CONFIGURE); } @Override public boolean hasConfigurePermission() { return getAccessControlledObject().hasPermission(Permission.CONFIGURE); } /** * Returns whether the current user may configure jobs. * * @return true if the current user may configure jobs. */ public boolean hasJobConfigurePermission() { return getAccessControlledObject().hasPermission(Item.CONFIGURE); } /** * Returns whether the current user may read configure jobs. * * @return true if the current user may read configure jobs. */ public boolean hasReadExtensionPermission() { return getAccessControlledObject().hasPermission(Item.EXTENDED_READ); } /** * Parses the incoming {@literal POST} request and redirects as * {@literal GET showDiffFiles}. * * @param req * incoming request * @param rsp * outgoing response * @throws ServletException * when parsing the request as {@link MultipartFormDataParser} * does not succeed. * @throws IOException * when the redirection does not succeed. */ @Override public final void doDiffFiles(StaplerRequest req, StaplerResponse rsp) throws ServletException, IOException { final MultipartFormDataParser parser = new MultipartFormDataParser(req); rsp.sendRedirect("showDiffFiles?name=" + parser.get("name") + "×tamp1=" + parser.get("timestamp1") + "×tamp2=" + parser.get("timestamp2")); } /** * Returns the diff between two config files as a list of single lines. * Takes the two timestamps and the name of the system property or the * deleted job from the url parameters. * * @return Differences between two config versions as list of lines. * @throws IOException * If diff doesn't work or xml files can't be read. */ public final List<Line> getLines() throws IOException { final String name = getRequestParameter("name"); if ((name.contains(DeletedFileFilter.DELETED_MARKER) && hasJobConfigurePermission()) || hasConfigurePermission()) { final String timestamp1 = getRequestParameter("timestamp1"); final String timestamp2 = getRequestParameter("timestamp2"); final XmlFile configXml1 = getOldConfigXml(name, timestamp1); final String[] configXml1Lines = configXml1.asString().split("\\n"); final XmlFile configXml2 = getOldConfigXml(name, timestamp2); final String[] configXml2Lines = configXml2.asString().split("\\n"); final String diffAsString = getDiffAsString(configXml1.getFile(), configXml2.getFile(), configXml1Lines, configXml2Lines); final List<String> diffLines = Arrays .asList(diffAsString.split("\n")); return getDiffLines(diffLines); } else { return Collections.emptyList(); } } /** * Gets the version of the config.xml that was saved at a certain time. * * @param name * The name of the system property or deleted job. * @param timestamp * The timestamp as String. * @return The config file as XmlFile. */ public XmlFile getOldConfigXml(String name, String timestamp) { if (checkParameters(name, timestamp)) { if (name.contains(DeletedFileFilter.DELETED_MARKER)) { return getHistoryDao().getOldRevision("jobs/" + name, timestamp); } else { if (!hasConfigurePermission() && !hasReadExtensionPermission() && !hasJobConfigurePermission()) { checkConfigurePermission(); return null; } return getHistoryDao().getOldRevision(name, timestamp); } } else { throw new IllegalArgumentException( "Unable to get history from: " + name); } } /** * Checks the url parameters 'name' and 'timestamp' and returns true if they * are neither null nor suspicious. * * @param name * Name of deleted job or system property. * @param timestamp * Timestamp of config change. * @return True if parameters are okay. */ public boolean checkParameters(String name, String timestamp) { checkTimestamp(timestamp); if (name == null || "null".equals(name)) { return false; } if (name.contains("..")) { throw new IllegalArgumentException( "Invalid directory name because of '..': " + name); } return true; } /** * Action when 'restore' button is pressed: Restore deleted project. * * @param req * Incoming StaplerRequest * @param rsp * Outgoing StaplerResponse * @throws IOException * If something goes wrong */ public final void doRestore(StaplerRequest req, StaplerResponse rsp) throws IOException { getAccessControlledObject().checkPermission(Item.CONFIGURE); final String deletedName = req.getParameter("name"); final String newName = deletedName.split("_deleted_")[0]; final XmlFile configXml = getLastAvailableConfigXml(deletedName); final InputStream is = new ByteArrayInputStream( configXml.asString().getBytes("UTF-8")); final String calculatedNewName = findNewName(newName); final TopLevelItem project = getJenkins() .createProjectFromXML(calculatedNewName, is); // TODO: Casting here should be removed. ((FileHistoryDao) getHistoryDao()).copyHistoryAndDelete(deletedName, calculatedNewName); rsp.sendRedirect(getJenkins().getRootUrl() + project.getUrl()); } /** * Retrieves the last or second to last config.xml. The latter is necessary * when the last config.xml is missing although the history entry exists, * which happens when a project is deleted while being disabled. * * @param name * The name of the deleted project. * @return The last or second to last config as XmlFile or null. */ public XmlFile getLastAvailableConfigXml(String name) { XmlFile configXml = null; final List<ConfigInfo> configInfos; try { configInfos = getSingleConfigs(name); } catch (IOException ex) { LOG.log(FINEST, "Unable to get config history for {0}", name); return configXml; } if (configInfos.size() > 1) { Collections.sort(configInfos, ParsedDateComparator.DESCENDING); final ConfigInfo lastChange = configInfos.get(1); configXml = getOldConfigXml(name, lastChange.getDate()); } return configXml; } /** * Finds a name for the project to be restored. If the old name is already * in use by another project, "_" plus a number is appended to the name * until an unused name is found. * * @param name * The old name as String. * @return the new name as String. */ public String findNewName(String name) { String newName = name; int i = 1; while (getJenkins().getItem(newName) != null) { newName = name + "_" + String.valueOf(i); i++; } return newName; } /** * Action when 'restore' button in history.jelly is pressed. Gets required * parameter and forwards to restoreQuestion.jelly. * * @param req * StaplerRequest created by pressing the button * @param rsp * Outgoing StaplerResponse * @throws IOException * If redirect goes wrong */ public final void doForwardToRestoreQuestion(StaplerRequest req, StaplerResponse rsp) throws IOException { final String name = req.getParameter("name"); rsp.sendRedirect("restoreQuestion?name=" + name); } /** * For tests. * * @return historyDao */ protected HistoryDao getHistoryDao() { return PluginUtils.getHistoryDao(); } /** * For tests. * * @return historyDao */ protected OverviewHistoryDao getOverviewHistoryDao() { return PluginUtils.getHistoryDao(); } public Api getApi() { return new Api(this); } }