View Javadoc
1   /**
2    * This file Copyright (c) 2010-2017 Magnolia International
3    * Ltd.  (http://www.magnolia-cms.com). All rights reserved.
4    *
5    *
6    * This file is dual-licensed under both the Magnolia
7    * Network Agreement and the GNU General Public License.
8    * You may elect to use one or the other of these licenses.
9    *
10   * This file is distributed in the hope that it will be
11   * useful, but AS-IS and WITHOUT ANY WARRANTY; without even the
12   * implied warranty of MERCHANTABILITY or FITNESS FOR A
13   * PARTICULAR PURPOSE, TITLE, or NONINFRINGEMENT.
14   * Redistribution, except as permitted by whichever of the GPL
15   * or MNA you select, is prohibited.
16   *
17   * 1. For the GPL license (GPL), you can redistribute and/or
18   * modify this file under the terms of the GNU General
19   * Public License, Version 3, as published by the Free Software
20   * Foundation.  You should have received a copy of the GNU
21   * General Public License, Version 3 along with this program;
22   * if not, write to the Free Software Foundation, Inc., 51
23   * Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
24   *
25   * 2. For the Magnolia Network Agreement (MNA), this file
26   * and the accompanying materials are made available under the
27   * terms of the MNA which accompanies this distribution, and
28   * is available at http://www.magnolia-cms.com/mna.html
29   *
30   * Any modifications to this file must keep this entire header
31   * intact.
32   *
33   */
34  package info.magnolia.module.groovy.support.classes;
35  
36  import static info.magnolia.module.groovy.GroovyModule.SCRIPTS_REPOSITORY_NAME;
37  
38  import info.magnolia.context.Context;
39  import info.magnolia.context.SystemContext;
40  import info.magnolia.jcr.RuntimeRepositoryException;
41  import info.magnolia.module.groovy.support.HierarchyManagerProvider;
42  import info.magnolia.objectfactory.Components;
43  
44  import java.io.IOException;
45  import java.net.URL;
46  import java.net.URLConnection;
47  import java.security.CodeSource;
48  import java.util.List;
49  
50  import javax.inject.Provider;
51  import javax.jcr.RepositoryException;
52  import javax.jcr.Session;
53  
54  import org.apache.commons.lang3.StringUtils;
55  import org.codehaus.groovy.ast.ASTNode;
56  import org.codehaus.groovy.ast.ClassNode;
57  import org.codehaus.groovy.ast.builder.AstBuilder;
58  import org.codehaus.groovy.control.CompilationFailedException;
59  import org.codehaus.groovy.control.CompilationUnit;
60  import org.codehaus.groovy.control.CompilationUnit.SourceUnitOperation;
61  import org.codehaus.groovy.control.CompilePhase;
62  import org.codehaus.groovy.control.CompilerConfiguration;
63  import org.codehaus.groovy.control.Phases;
64  import org.codehaus.groovy.control.SourceUnit;
65  import org.codehaus.groovy.control.io.URLReaderSource;
66  import org.slf4j.Logger;
67  import org.slf4j.LoggerFactory;
68  
69  import groovy.lang.GroovyClassLoader;
70  
71  /**
72   * Magnolia class loader which extends {@link GroovyClassLoader} and internally uses {@link MgnlGroovyResourceLoader}.
73   *
74   * @see MgnlGroovyClassLoader#isSourceNewer(URL, Class)
75   * @see MgnlGroovyClassLoader.AddDefaultImportOperation
76   * @see MgnlGroovyClassLoader.PackageAndClassNameConsistencyOperation
77   */
78  public final class MgnlGroovyClassLoader extends GroovyClassLoader {
79      private static final Logger log = LoggerFactory.getLogger(MgnlGroovyClassLoader.class);
80  
81      private final Provider<Context> provider;
82  
83      private boolean compileTimeChecks;
84      private String path;
85  
86      public MgnlGroovyClassLoader() {
87          this.provider = new Provider<Context>() {
88              @Override
89              public Context get() {
90                  return Components.getComponent(SystemContext.class);
91              }
92          };
93          this.setShouldRecompile(true);
94          this.setResourceLoader(new MgnlGroovyResourceLoader(super.getResourceLoader(), provider, SCRIPTS_REPOSITORY_NAME));
95      }
96  
97      /**
98       * @deprecated since 2.4.4 please use {@link #MgnlGroovyClassLoader()} instead.
99       */
100     @Deprecated
101     public MgnlGroovyClassLoader(Context context) {
102         this();
103     }
104 
105     /**
106      * @deprecated since 2.4.3 please use {@link #MgnlGroovyClassLoader()} instead.
107      */
108     @Deprecated
109     public MgnlGroovyClassLoader(HierarchyManagerProvider hmp) {
110         this();
111     }
112 
113     @Override
114     protected CompilationUnit createCompilationUnit(CompilerConfiguration config, CodeSource codeSource) {
115         CompilationUnit cu = super.createCompilationUnit(config, codeSource);
116         if (compileTimeChecks) {
117             log.debug("enforcing compile-time checks...");
118             /*
119              * "Generally speaking, there is more type information available later in the phases. If your transformation is concerned with
120              * reading the AST, then a later phase where information is more plentiful might be a good choice. If your transformation is
121              * concerned with writing AST, then an earlier phase where the tree is more sparse might be more convenient."
122              * see http://groovy.codehaus.org/Compiler+Phase+Guide
123              */
124             try {
125                 cu.addPhaseOperation(new PackageAndClassNameConsistencyOperation(provider.get().getJCRSession(SCRIPTS_REPOSITORY_NAME), path), Phases.CONVERSION);
126             } catch (RepositoryException e) {
127                 throw new RuntimeRepositoryException(e);
128             }
129         }
130 
131         cu.addPhaseOperation(new AddDefaultImportOperation(), Phases.CONVERSION);
132         return cu;
133     }
134 
135     /**
136      * <em>Source newer</em> in our case means that the groovy source
137      * representing a certain class in the <em>scripts</em> repository is more recent than that of
138      * the corresponding class currently loaded in this classloader (i.e. it has been saved more recently).
139      * Please note that if source protocol is <em>file</em>, then <strong>recompilation is forced</strong>
140      * otherwise the caller gets the old class in the current classloader which might be the compiled script
141      * from the scripts repository if the latter (the script, that is) was just disabled.
142      */
143     @Override
144     protected boolean isSourceNewer(URL source, Class cls) throws IOException {
145         long lastMod;
146         if ("file".equals(source.getProtocol())) {
147             return true;
148         } else {
149             URLConnection conn = source.openConnection();
150             lastMod = conn.getLastModified();
151             conn.getInputStream().close();
152         }
153         boolean isNewer = lastMod > getTimeStamp(cls);
154         if (isNewer) {
155             log.debug("{} source has changed", cls.getName());
156         }
157         return isNewer;
158     }
159 
160     /**
161      * Checks that the given source compiles correctly and, in case of a script which has to act as a
162      * class, that some consistency constraints imposed by our classloading mechanism are enforced. Throws a
163      * {@link CompilationFailedException} in case of compilation failure.
164      * For the applied constraints, see {@link MgnlGroovyClassLoader.PackageAndClassNameConsistencyOperation}.
165      *
166      * @param source String the Groovy source
167      * @param enforceCompileChecks boolean if <code>true</code> enforce additional compile time checks
168      * @param path String the path to the script in the repository. The substring after the last '/' is assumed to be
169      * the script name itself. Will be ignored if <em>enforceCompileChecks</em> is <code>false</code>. Cannot be
170      * <code>null</code> if <em>enforceCompileChecks</em> is <code>true</code>.
171      */
172     public final void verify(final String source, final boolean enforceCompileChecks, final String path) throws CompilationFailedException {
173         this.compileTimeChecks = enforceCompileChecks;
174         this.path = path;
175         if (compileTimeChecks && path == null) {
176             throw new IllegalArgumentException("When compilation checks are enforced, mgnlPath cannot be null");
177         }
178         parseClass(source);
179 
180     }
181 
182     private static final class AddDefaultImportOperation extends SourceUnitOperation {
183 
184         @Override
185         public void call(SourceUnit source) throws CompilationFailedException {
186             // yes, to import all the classes of a certain package you have to omit the * at the end
187             // see http://jira.codehaus.org/browse/GROOVY-3041
188             log.debug("adding default imports...");
189             source.getAST().addStarImport("info.magnolia.context.");
190             source.getAST().addStarImport("info.magnolia.cms.core.");
191             source.getAST().addStarImport("info.magnolia.cms.util.");
192             source.getAST().addStarImport("info.magnolia.jcr.util.");
193             source.getAST().addStarImport("info.magnolia.module.groovy.support.");
194             source.getAST().addStarImport("info.magnolia.module.groovy.xml.");
195         }
196     }
197 
198     /**
199      * Checks package and class name consistency on source code during Groovy's
200      * compilation phase. The checks performed are
201      * <ol>
202      * <li>Ensure that the class declares a package, unless it is being saved at root level of the <code>scripts</code>
203      * repository</li>
204      * <li>Ensure that the package matches the path to the source in Magnolia's scripts repository</li>
205      * <li>Ensure that the source contains at least one class named as the script itself.</li>
206      * <li>Ensure that the source doesn't mix and match class declarations and scripting, as it can lead to duplicate class definition error</li>
207      * </ol>
208      */
209     private static final class PackageAndClassNameConsistencyOperation extends SourceUnitOperation {
210         private static final String COMPILATION_ERROR_PARENT_NODE_UNMATCHED = "%s compilation failed: class package '%s' does not match parent node '%s' path in the %s repository";
211         private static final String COMPILATION_ERROR_EXISTING_PATH_UNMATCHED = "%s compilation failed: class package '%s' does not match an existing '%s' path in the %s repository";
212         private static final String COMPILATION_ERROR_CLASSNAME_AND_PACKAGE = "%s compilation failed: %s should declare at least one class named %s %s";
213         private static final String COMPILATION_ERROR_CLASSNAME_UNDER_ROOT_WITH_PACKAGE = "%s compilation failed: your class is under root but declares a package %s";
214         private static final String COMPILATION_ERROR_NO_PACKAGE = "%s compilation failed: you must specify a package for your class.";
215         private static final String COMPILATION_ERROR_CLASS_AND_SCRIPT_MIXED = "%s compilation failed: you cannot mix classes declarations and scripts here. " +
216                 "Please, either remove the script part or set this source as a script in the UI";
217 
218         private final Session session;
219         private final String mgnlPath;
220 
221         public PackageAndClassNameConsistencyOperation(Session session, String mgnlPath) {
222             this.session = session;
223             this.mgnlPath = mgnlPath;
224         }
225 
226         @Override
227         public void call(SourceUnit source) throws CompilationFailedException {
228             final String parentPath = StringUtils.defaultIfEmpty(StringUtils.substringBeforeLast(mgnlPath, "/"), "/");
229             final String scriptName = StringUtils.substringAfterLast(mgnlPath, "/");
230             final boolean isParentRoot = "/".equals(parentPath);
231             final String packageName = source.getAST().hasPackageName() ? source.getAST().getPackageName() : "";
232             final String packageNameAsPath = "/" + packageName.replace('.', '/');
233 
234             log.debug("parent path is {}, script name is {}, isParentRoot? {}", new Object[]{parentPath, scriptName, isParentRoot});
235 
236             if (!source.getAST().getStatementBlock().isEmpty()) {
237                 final String msg = String.format(COMPILATION_ERROR_CLASS_AND_SCRIPT_MIXED, scriptName);
238                 throw new CompilationFailedException(source.getPhase(), source, new Throwable(msg));
239             }
240 
241             if (isParentRoot) {
242                 if (StringUtils.isNotBlank(packageName)) {
243                     final String msg = String.format(COMPILATION_ERROR_CLASSNAME_UNDER_ROOT_WITH_PACKAGE, scriptName, packageName);
244                     throw new CompilationFailedException(source.getPhase(), source, new Throwable(msg));
245                 }
246             } else {
247                 if (StringUtils.isBlank(packageName)) {
248                     String msg = String.format(COMPILATION_ERROR_NO_PACKAGE, scriptName);
249                     throw new CompilationFailedException(source.getPhase(), source, new Throwable(msg));
250                 }
251 
252                 if (!packageNameAsPath.equals(parentPath + "/")) {
253                     final String msg = String.format(COMPILATION_ERROR_PARENT_NODE_UNMATCHED, scriptName, packageName, parentPath, SCRIPTS_REPOSITORY_NAME);
254                     throw new CompilationFailedException(source.getPhase(), source, new Throwable(msg));
255                 }
256 
257                 try {
258                     if (!session.nodeExists(packageNameAsPath)) {
259                         final String msg = String.format(COMPILATION_ERROR_EXISTING_PATH_UNMATCHED, scriptName, packageName, packageNameAsPath, SCRIPTS_REPOSITORY_NAME);
260                         throw new CompilationFailedException(source.getPhase(), source, new Throwable(msg));
261                     }
262                 } catch (RepositoryException e) {
263                     throw new RuntimeRepositoryException(e);
264                 }
265             }
266             // we don't need to check that class A used in class B (included via URLReaderSource) declare class A in current script
267             if (source.getSource() instanceof URLReaderSource) {
268                 return;
269             }
270 
271             // now checking that at least one class declared in the source matches the source file name
272             boolean match = false;
273             final String fullyQualifiedClassName = StringUtils.isNotBlank(packageName) ? packageName + scriptName : scriptName;
274             for (ClassNode cn : source.getAST().getClasses()) {
275                 if (fullyQualifiedClassName.equals(cn.getName())) {
276                     log.debug("found a matching class name {}, we can proceed", fullyQualifiedClassName);
277                     match = true;
278                     break;
279                 }
280             }
281             if (!match) {
282                 final String msg = String.format(COMPILATION_ERROR_CLASSNAME_AND_PACKAGE, scriptName, fullyQualifiedClassName, scriptName, (StringUtils.isNotBlank(packageName) ? "and a package named " + packageName : ""));
283                 throw new CompilationFailedException(source.getPhase(), source, new Throwable(msg));
284             }
285         }
286     }
287 
288     /**
289      * Utility method to resolve the script or class name from source.
290      */
291     public static final String resolveScriptNameFromSource(final String source) throws RepositoryException {
292         final List<ASTNode> nodes = new AstBuilder().buildFromString(CompilePhase.CONVERSION, source);
293         for (ASTNode astNode : nodes) {
294             if (astNode instanceof ClassNode) {
295                 ClassNode cn = (ClassNode) astNode;
296                 String name = cn.getNameWithoutPackage();
297                 if (!name.startsWith("script")) {
298                     return name;
299                 }
300             }
301         }
302         return "untitledGroovyScript";
303     }
304 }