1 /*
2  * CDDL HEADER START
3  *
4  * The contents of this file are subject to the terms of the
5  * Common Development and Distribution License (the "License").
6  * You may not use this file except in compliance with the License.
7  *
8  * See LICENSE.txt included in this distribution for the specific
9  * language governing permissions and limitations under the License.
10  *
11  * When distributing Covered Code, include this CDDL HEADER in each
12  * file and include the License file at LICENSE.txt.
13  * If applicable, add the following below this CDDL HEADER, with the
14  * fields enclosed by brackets "[]" replaced with your own identifying
15  * information: Portions Copyright [yyyy] [name of copyright owner]
16  *
17  * CDDL HEADER END
18  */
19 
20 /*
21  * Copyright (c) 2011, 2019, Oracle and/or its affiliates. All rights reserved.
22  * Portions copyright (c) 2011 Jens Elkner.
23  * Portions Copyright (c) 2017-2018, 2020, Chris Fraire <cfraire@me.com>.
24  */
25 package org.opengrok.indexer.web;
26 
27 import static org.opengrok.indexer.index.Indexer.PATH_SEPARATOR;
28 import static org.opengrok.indexer.index.Indexer.PATH_SEPARATOR_STRING;
29 
30 import java.io.BufferedReader;
31 import java.io.File;
32 import java.io.FileNotFoundException;
33 import java.io.IOException;
34 import java.io.InputStream;
35 import java.io.UnsupportedEncodingException;
36 import java.net.URI;
37 import java.net.URISyntaxException;
38 import java.net.URLDecoder;
39 import java.nio.charset.StandardCharsets;
40 import java.nio.file.Paths;
41 import java.security.InvalidParameterException;
42 import java.util.ArrayList;
43 import java.util.Arrays;
44 import java.util.Collections;
45 import java.util.Comparator;
46 import java.util.EnumSet;
47 import java.util.List;
48 import java.util.Objects;
49 import java.util.Set;
50 import java.util.SortedSet;
51 import java.util.TreeSet;
52 import java.util.concurrent.ExecutorService;
53 import java.util.concurrent.Future;
54 import java.util.logging.Level;
55 import java.util.logging.Logger;
56 import java.util.regex.Pattern;
57 import java.util.stream.Collectors;
58 import javax.servlet.ServletRequest;
59 import javax.servlet.http.Cookie;
60 import javax.servlet.http.HttpServletRequest;
61 import javax.servlet.http.HttpServletResponse;
62 import javax.ws.rs.core.HttpHeaders;
63 
64 import org.opengrok.indexer.Info;
65 import org.opengrok.indexer.analysis.AbstractAnalyzer;
66 import org.opengrok.indexer.analysis.AnalyzerGuru;
67 import org.opengrok.indexer.analysis.ExpandTabsReader;
68 import org.opengrok.indexer.analysis.StreamSource;
69 import org.opengrok.indexer.authorization.AuthorizationFramework;
70 import org.opengrok.indexer.configuration.Group;
71 import org.opengrok.indexer.configuration.Project;
72 import org.opengrok.indexer.configuration.RuntimeEnvironment;
73 import org.opengrok.indexer.history.Annotation;
74 import org.opengrok.indexer.history.History;
75 import org.opengrok.indexer.history.HistoryEntry;
76 import org.opengrok.indexer.history.HistoryException;
77 import org.opengrok.indexer.history.HistoryGuru;
78 import org.opengrok.indexer.index.IgnoredNames;
79 import org.opengrok.indexer.logger.LoggerFactory;
80 import org.opengrok.indexer.search.QueryBuilder;
81 import org.opengrok.indexer.util.IOUtils;
82 import org.opengrok.indexer.util.LineBreaker;
83 import org.opengrok.indexer.util.TandemPath;
84 import org.opengrok.indexer.web.messages.MessagesContainer.AcceptedMessage;
85 import org.suigeneris.jrcs.diff.Diff;
86 import org.suigeneris.jrcs.diff.DifferentiationFailedException;
87 
88 /**
89  * A simple container to lazy initialize common vars wrt. a single request. It
90  * MUST NOT be shared between several requests and
91  * {@link #cleanup(ServletRequest)} should be called before the page context
92  * gets destroyed (e.g.when leaving the {@code service} method).
93  * <p>
94  * Purpose is to decouple implementation details from web design, so that the
95  * JSP developer does not need to know every implementation detail and normally
96  * has to deal with this class/wrapper, only (so some people may like to call
97  * this class a bean with request scope ;-)). Furthermore it helps to keep the
98  * pages (how content gets generated) consistent and to document the request
99  * parameters used.
100  * <p>
101  * General contract for this class (i.e. if not explicitly documented): no
102  * method of this class changes neither the request nor the response.
103  *
104  * @author Jens Elkner
105  * @version $Revision$
106  */
107 public final class PageConfig {
108 
109     private static final Logger LOGGER = LoggerFactory.getLogger(PageConfig.class);
110 
111     // cookie name
112     public static final String OPEN_GROK_PROJECT = "OpenGrokProject";
113 
114     // query parameters
115     protected static final String ALL_PROJECT_SEARCH = "searchall";
116     protected static final String PROJECT_PARAM_NAME = "project";
117     protected static final String GROUP_PARAM_NAME = "group";
118     private static final String DEBUG_PARAM_NAME = "debug";
119 
120     // TODO if still used, get it from the app context
121 
122     private final AuthorizationFramework authFramework;
123     private RuntimeEnvironment env;
124     private IgnoredNames ignoredNames;
125     private String path;
126     private File resourceFile;
127     private String resourcePath;
128     private EftarFileReader eftarReader;
129     private String sourceRootPath;
130     private Boolean isDir;
131     private String uriEncodedPath;
132     private Prefix prefix;
133     private String pageTitle;
134     private String dtag;
135     private String rev;
136     private String fragmentIdentifier; // Also settable via match offset translation
137     private Boolean hasAnnotation;
138     private Boolean annotate;
139     private Annotation annotation;
140     private Boolean hasHistory;
141     private static final EnumSet<AbstractAnalyzer.Genre> txtGenres
142             = EnumSet.of(AbstractAnalyzer.Genre.DATA, AbstractAnalyzer.Genre.PLAIN, AbstractAnalyzer.Genre.HTML);
143     private SortedSet<String> requestedProjects;
144     private String requestedProjectsString;
145     private List<String> dirFileList;
146     private QueryBuilder queryBuilder;
147     private File dataRoot;
148     private StringBuilder headLines;
149     /**
150      * Page java scripts.
151      */
152     private final Scripts scripts = new Scripts();
153 
154     private static final String ATTR_NAME = PageConfig.class.getCanonicalName();
155     private HttpServletRequest req;
156 
157     private ExecutorService executor;
158 
159     /**
160      * Sets current request's attribute.
161      *
162      * @param attr attribute
163      * @param val value
164      */
setRequestAttribute(String attr, Object val)165     public void setRequestAttribute(String attr, Object val) {
166         this.req.setAttribute(attr, val);
167     }
168 
169     /**
170      * Gets current request's attribute.
171      * @param attr attribute
172      * @return Object attribute value or null if attribute does not exist
173      */
getRequestAttribute(String attr)174     public Object getRequestAttribute(String attr) {
175         return this.req.getAttribute(attr);
176     }
177 
178     /**
179      * Removes an attribute from the current request.
180      * @param string the attribute
181      */
removeAttribute(String string)182     public void removeAttribute(String string) {
183         req.removeAttribute(string);
184     }
185 
186     /**
187      * Add the given data to the &lt;head&gt; section of the html page to
188      * generate.
189      *
190      * @param data data to add. It is copied as is, so remember to escape
191      * special characters ...
192      */
addHeaderData(String data)193     public void addHeaderData(String data) {
194         if (data == null || data.length() == 0) {
195             return;
196         }
197         if (headLines == null) {
198             headLines = new StringBuilder();
199         }
200         headLines.append(data);
201     }
202 
203     /**
204      * Get addition data, which should be added as is to the &lt;head&gt;
205      * section of the html page.
206      *
207      * @return an empty string if nothing to add, the data otherwise.
208      */
getHeaderData()209     public String getHeaderData() {
210         return headLines == null ? "" : headLines.toString();
211     }
212 
213     /**
214      * Get all data required to create a diff view w.r.t. to this request in one
215      * go.
216      *
217      * @return an instance with just enough information to render a sufficient
218      * view. If not all required parameters were given either they are
219      * supplemented with reasonable defaults if possible, otherwise the related
220      * field(s) are {@code null}. {@link DiffData#errorMsg}
221      *  {@code != null} indicates, that an error occured and one should not try
222      * to render a view.
223      */
getDiffData()224     public DiffData getDiffData() {
225         DiffData data = new DiffData();
226         data.path = getPath().substring(0, path.lastIndexOf(PATH_SEPARATOR));
227         data.filename = Util.htmlize(getResourceFile().getName());
228 
229         String srcRoot = getSourceRootPath();
230         String context = req.getContextPath();
231 
232         String[] filepath = new String[2];
233         data.rev = new String[2];
234         data.file = new String[2][];
235         data.param = new String[2];
236 
237         /*
238          * Basically the request URI looks like this:
239          * http://$site/$webapp/diff/$resourceFile?r1=$fileA@$revA&r2=$fileB@$revB
240          * The code below extracts file path and revision from the URI.
241          */
242         for (int i = 1; i <= 2; i++) {
243             String p = req.getParameter(QueryParameters.REVISION_PARAM + i);
244             if (p != null) {
245                 int j = p.lastIndexOf("@");
246                 if (j != -1) {
247                     filepath[i - 1] = p.substring(0, j);
248                     data.rev[i - 1] = p.substring(j + 1);
249                 }
250             }
251         }
252         if (data.rev[0] == null || data.rev[1] == null
253                 || data.rev[0].length() == 0 || data.rev[1].length() == 0
254                 || data.rev[0].equals(data.rev[1])) {
255             data.errorMsg = "Please pick two revisions to compare the changed "
256                     + "from the <a href=\"" + context + Prefix.HIST_L
257                     + getUriEncodedPath() + "\">history</a>";
258             return data;
259         }
260         data.genre = AnalyzerGuru.getGenre(getResourceFile().getName());
261 
262         if (data.genre == null || txtGenres.contains(data.genre)) {
263             InputStream[] in = new InputStream[2];
264             try {
265                 // Get input stream for both older and newer file.
266                 ExecutorService executor = this.executor;
267                 Future<?>[] future = new Future<?>[2];
268                 for (int i = 0; i < 2; i++) {
269                     File f = new File(srcRoot + filepath[i]);
270                     final String revision = data.rev[i];
271                     future[i] = executor.submit(() -> HistoryGuru.getInstance().
272                             getRevision(f.getParent(), f.getName(), revision));
273                 }
274 
275                 for (int i = 0; i < 2; i++) {
276                     // The Executor used by given repository will enforce the timeout.
277                     in[i] = (InputStream) future[i].get();
278                     if (in[i] == null) {
279                         data.errorMsg = "Unable to get revision "
280                                 + Util.htmlize(data.rev[i]) + " for file: "
281                                 + Util.htmlize(getPath());
282                         return data;
283                     }
284                 }
285 
286                 /*
287                  * If the genre of the older revision cannot be determined,
288                  * (this can happen if the file was empty), try with newer
289                  * version.
290                  */
291                 for (int i = 0; i < 2 && data.genre == null; i++) {
292                     try {
293                         data.genre = AnalyzerGuru.getGenre(in[i]);
294                     } catch (IOException e) {
295                         data.errorMsg = "Unable to determine the file type: "
296                                 + Util.htmlize(e.getMessage());
297                     }
298                 }
299 
300                 if (data.genre != AbstractAnalyzer.Genre.PLAIN && data.genre != AbstractAnalyzer.Genre.HTML) {
301                     return data;
302                 }
303 
304                 ArrayList<String> lines = new ArrayList<>();
305                 Project p = getProject();
306                 for (int i = 0; i < 2; i++) {
307                     // All files under source root are read with UTF-8 as a default.
308                     try (BufferedReader br = new BufferedReader(
309                         ExpandTabsReader.wrap(IOUtils.createBOMStrippedReader(
310                         in[i], StandardCharsets.UTF_8.name()), p))) {
311                         String line;
312                         while ((line = br.readLine()) != null) {
313                             lines.add(line);
314                         }
315                         data.file[i] = lines.toArray(new String[0]);
316                         lines.clear();
317                     }
318                     in[i] = null;
319                 }
320             } catch (Exception e) {
321                 data.errorMsg = "Error reading revisions: "
322                         + Util.htmlize(e.getMessage());
323             } finally {
324                 for (int i = 0; i < 2; i++) {
325                     IOUtils.close(in[i]);
326                 }
327             }
328             if (data.errorMsg != null) {
329                 return data;
330             }
331             try {
332                 data.revision = Diff.diff(data.file[0], data.file[1]);
333             } catch (DifferentiationFailedException e) {
334                 data.errorMsg = "Unable to get diffs: "
335                         + Util.htmlize(e.getMessage());
336             }
337             for (int i = 0; i < 2; i++) {
338                 try {
339                     URI u = new URI(null, null, null,
340                             filepath[i] + "@" + data.rev[i], null);
341                     data.param[i] = u.getRawQuery();
342                 } catch (URISyntaxException e) {
343                     LOGGER.log(Level.WARNING, "Failed to create URI: ", e);
344                 }
345             }
346             data.full = fullDiff();
347             data.type = getDiffType();
348         }
349         return data;
350     }
351 
352     /**
353      * Get the diff display type to use wrt. the request parameter
354      * {@code format}.
355      *
356      * @return {@link DiffType#SIDEBYSIDE} if the request contains no such
357      * parameter or one with an unknown value, the recognized diff type
358      * otherwise.
359      * @see DiffType#get(String)
360      * @see DiffType#getAbbrev()
361      * @see DiffType#toString()
362      */
getDiffType()363     public DiffType getDiffType() {
364         DiffType d = DiffType.get(req.getParameter(QueryParameters.FORMAT_PARAM));
365         return d == null ? DiffType.SIDEBYSIDE : d;
366     }
367 
368     /**
369      * Check, whether a full diff should be displayed.
370      *
371      * @return {@code true} if a request parameter {@code full} with the literal
372      * value {@code 1} was found.
373      */
fullDiff()374     public boolean fullDiff() {
375         String val = req.getParameter(QueryParameters.DIFF_LEVEL_PARAM);
376         return val != null && val.equals("1");
377     }
378 
379     /**
380      * Check, whether the request contains minimal information required to
381      * produce a valid page. If this method returns an empty string, the
382      * referred file or directory actually exists below the source root
383      * directory and is readable.
384      *
385      * @return {@code null} if the referred src file, directory or history is
386      * not available, an empty String if further processing is ok and a
387      * non-empty string which contains the URI encoded redirect path if the
388      * request should be redirected.
389      * @see #resourceNotAvailable()
390      * @see #getDirectoryRedirect()
391      */
canProcess()392     public String canProcess() {
393         if (resourceNotAvailable()) {
394             return null;
395         }
396         String redir = getDirectoryRedirect();
397         if (redir == null && getPrefix() == Prefix.HIST_L && !hasHistory()) {
398             return null;
399         }
400         // jel: outfactored from list.jsp - seems to be bogus
401         if (isDir()) {
402             if (getPrefix() == Prefix.XREF_P) {
403                 if (getResourceFileList().isEmpty()
404                         && !getRequestedRevision().isEmpty() && !hasHistory()) {
405                     return null;
406                 }
407             } else if ((getPrefix() == Prefix.RAW_P)
408                     || (getPrefix() == Prefix.DOWNLOAD_P)) {
409                 return null;
410             }
411         }
412         return redir == null ? "" : redir;
413     }
414 
415     /**
416      * Get a list of filenames in the requested path.
417      *
418      * @return an empty list, if the resource does not exist, is not a directory
419      * or an error occurred when reading it, otherwise a list of filenames in
420      * that directory, sorted alphabetically
421      *
422      * <p>
423      * For the root directory (/xref/) an authorization is performed for each
424      * project in case that projects are used.</p>
425      *
426      * @see #getResourceFile()
427      * @see #isDir()
428      */
getResourceFileList()429     public List<String> getResourceFileList() {
430         if (dirFileList == null) {
431             File[] files = null;
432             if (isDir() && getResourcePath().length() > 1) {
433                 files = getResourceFile().listFiles();
434             }
435             if (files == null) {
436                 dirFileList = Collections.emptyList();
437             } else {
438                 List<String> listOfFiles;
439                 if (env.getListDirsFirst()) {
440                     Arrays.sort(files, new Comparator<File>() {
441                             @Override
442                             public int compare(File f1, File f2) {
443                                 if (f1.isDirectory() && f2.isDirectory()) {
444                                     return f1.getName().compareTo(f2.getName());
445                                 } else if (f1.isFile() && f2.isFile()) {
446                                     return f1.getName().compareTo(f2.getName());
447                                 } else {
448                                     if (f1.isFile() && f2.isDirectory()) {
449                                         return 1;
450                                     } else {
451                                         return -1;
452                                     }
453                                 }
454                             }
455                         });
456                 } else {
457                     Arrays.sort(files,
458                             (File f1, File f2) -> f1.getName().compareTo(f2.getName()));
459                 }
460                 listOfFiles = Arrays.asList(files).stream().
461                             map(f -> f.getName()).collect(Collectors.toList());
462 
463                 if (env.hasProjects() && getPath().isEmpty()) {
464                     /**
465                      * This denotes the source root directory, we need to filter
466                      * projects which aren't allowed by the authorization
467                      * because otherwise the main xref page expose the names of
468                      * all projects in OpenGrok even those which aren't allowed
469                      * for the particular user. E. g. remove all which aren't
470                      * among the filtered set of projects.
471                      *
472                      * The authorization check is made in
473                      * {@link ProjectHelper#getAllProjects()} as a part of all
474                      * projects filtering.
475                      */
476                     List<String> modifiableListOfFiles = new ArrayList<>(listOfFiles);
477                     modifiableListOfFiles.removeIf((t) -> {
478                         return !getProjectHelper().getAllProjects().stream().anyMatch((p) -> {
479                             return p.getName().equalsIgnoreCase(t);
480                         });
481                     });
482                     return dirFileList = Collections.unmodifiableList(modifiableListOfFiles);
483                 }
484 
485                 dirFileList = Collections.unmodifiableList(listOfFiles);
486             }
487         }
488         return dirFileList;
489     }
490 
491     /**
492      * Get the time of last modification of the related file or directory.
493      *
494      * @return the last modification time of the related file or directory.
495      * @see File#lastModified()
496      */
getLastModified()497     public long getLastModified() {
498         return getResourceFile().lastModified();
499     }
500 
501     /**
502      * Get all RSS related directories from the request using its {@code also}
503      * parameter.
504      *
505      * @return an empty string if the requested resource is not a directory, a
506      * space (' ') separated list of unchecked directory names otherwise.
507      */
getHistoryDirs()508     public String getHistoryDirs() {
509         if (!isDir()) {
510             return "";
511         }
512         String[] val = req.getParameterValues("also");
513         if (val == null || val.length == 0) {
514             return path;
515         }
516         StringBuilder paths = new StringBuilder(path);
517         for (String val1 : val) {
518             paths.append(' ').append(val1);
519         }
520         return paths.toString();
521     }
522 
523     /**
524      * Get the int value of the given request parameter.
525      *
526      * @param name name of the parameter to lookup.
527      * @param defaultValue value to return, if the parameter is not set, is not
528      * a number, or is &lt; 0.
529      * @return the parsed int value on success, the given default value
530      * otherwise.
531      */
getIntParam(String name, int defaultValue)532     public int getIntParam(String name, int defaultValue) {
533         int ret = defaultValue;
534         String s = req.getParameter(name);
535         if (s != null && s.length() != 0) {
536             try {
537                 int x = Integer.parseInt(s, 10);
538                 if (x >= 0) {
539                     ret = x;
540                 }
541             } catch (NumberFormatException e) {
542                 LOGGER.log(Level.INFO, "Failed to parse " + name + " integer " + s, e);
543             }
544         }
545         return ret;
546     }
547 
548     /**
549      * Get the <b>start</b> index for a search result to return by looking up
550      * the {@code start} request parameter.
551      *
552      * @return 0 if the corresponding start parameter is not set or not a
553      * number, the number found otherwise.
554      */
getSearchStart()555     public int getSearchStart() {
556         return getIntParam(QueryParameters.START_PARAM, 0);
557     }
558 
559     /**
560      * Get the number of search results to max. return by looking up the
561      * {@code n} request parameter.
562      *
563      * @return the default number of hits if the corresponding start parameter
564      * is not set or not a number, the number found otherwise.
565      */
getSearchMaxItems()566     public int getSearchMaxItems() {
567         return getIntParam(QueryParameters.COUNT_PARAM, getEnv().getHitsPerPage());
568     }
569 
getRevisionMessageCollapseThreshold()570     public int getRevisionMessageCollapseThreshold() {
571         return getEnv().getRevisionMessageCollapseThreshold();
572     }
573 
getCurrentIndexedCollapseThreshold()574     public int getCurrentIndexedCollapseThreshold() {
575         return getEnv().getCurrentIndexedCollapseThreshold();
576     }
577 
getGroupsCollapseThreshold()578     public int getGroupsCollapseThreshold() {
579         return getEnv().getGroupsCollapseThreshold();
580     }
581 
582     /**
583      * Get sort orders from the request parameter {@code sort} and if this list
584      * would be empty from the cookie {@code OpenGrokorting}.
585      *
586      * @return a possible empty list which contains the sort order values in the
587      * same order supplied by the request parameter or cookie(s).
588      */
getSortOrder()589     public List<SortOrder> getSortOrder() {
590         List<SortOrder> sort = new ArrayList<>();
591         List<String> vals = getParamVals(QueryParameters.SORT_PARAM);
592         for (String s : vals) {
593             SortOrder so = SortOrder.get(s);
594             if (so != null) {
595                 sort.add(so);
596             }
597         }
598         if (sort.isEmpty()) {
599             vals = getCookieVals("OpenGrokSorting");
600             for (String s : vals) {
601                 SortOrder so = SortOrder.get(s);
602                 if (so != null) {
603                     sort.add(so);
604                 }
605             }
606         }
607         return sort;
608     }
609 
610     /**
611      * Get a reference to the {@code QueryBuilder} wrt. to the current request
612      * parameters: <dl> <dt>q</dt> <dd>freetext lookup rules</dd> <dt>defs</dt>
613      * <dd>definitions lookup rules</dd> <dt>path</dt> <dd>path related
614      * rules</dd> <dt>hist</dt> <dd>history related rules</dd> </dl>
615      *
616      * @return a query builder with all relevant fields populated.
617      */
getQueryBuilder()618     public QueryBuilder getQueryBuilder() {
619         if (queryBuilder == null) {
620             queryBuilder = new QueryBuilder().
621                     setFreetext(Laundromat.launderQuery(req.getParameter(QueryBuilder.FULL)))
622                     .setDefs(Laundromat.launderQuery(req.getParameter(QueryBuilder.DEFS)))
623                     .setRefs(Laundromat.launderQuery(req.getParameter(QueryBuilder.REFS)))
624                     .setPath(Laundromat.launderQuery(req.getParameter(QueryBuilder.PATH)))
625                     .setHist(Laundromat.launderQuery(req.getParameter(QueryBuilder.HIST)))
626                     .setType(Laundromat.launderQuery(req.getParameter(QueryBuilder.TYPE)));
627         }
628 
629         return queryBuilder;
630     }
631 
632     /**
633      * Get the eftar reader for the data directory. If it has been already
634      * opened and not closed, this instance gets returned. One should not close
635      * it once used: {@link #cleanup(ServletRequest)} takes care to close it.
636      *
637      * @return {@code null} if a reader can't be established, the reader
638      * otherwise.
639      */
getEftarReader()640     public EftarFileReader getEftarReader() {
641         if (eftarReader == null || eftarReader.isClosed()) {
642             File f = getEnv().getDtagsEftar();
643             if (f == null) {
644                 eftarReader = null;
645             } else {
646                 try {
647                     eftarReader = new EftarFileReader(f);
648                 } catch (FileNotFoundException e) {
649                     LOGGER.log(Level.FINE, "Failed to create EftarFileReader: ", e);
650                 }
651             }
652         }
653         return eftarReader;
654     }
655 
656     /**
657      * Get the definition tag for the request related file or directory.
658      *
659      * @return an empty string if not found, the tag otherwise.
660      */
getDefineTagsIndex()661     public String getDefineTagsIndex() {
662         if (dtag != null) {
663             return dtag;
664         }
665         getEftarReader();
666         if (eftarReader != null) {
667             try {
668                 dtag = eftarReader.get(getPath());
669                 // cfg.getPrefix() != Prefix.XREF_S) {
670             } catch (IOException e) {
671                 LOGGER.log(Level.INFO, "Failed to get entry from eftar reader: ", e);
672             }
673         }
674         if (dtag == null) {
675             dtag = "";
676         }
677         return dtag;
678     }
679 
680     /**
681      * Get the revision parameter {@code r} from the request.
682      *
683      * @return revision if found, an empty string otherwise.
684      */
getRequestedRevision()685     public String getRequestedRevision() {
686         if (rev == null) {
687             String tmp = Laundromat.launderInput(
688                     req.getParameter(QueryParameters.REVISION_PARAM));
689             rev = (tmp != null && tmp.length() > 0) ? tmp : "";
690         }
691         return rev;
692     }
693 
694     /**
695      * Check, whether the request related resource has history information.
696      *
697      * @return {@code true} if history is available.
698      * @see HistoryGuru#hasHistory(File)
699      */
hasHistory()700     public boolean hasHistory() {
701         if (hasHistory == null) {
702             hasHistory = HistoryGuru.getInstance().hasHistory(getResourceFile());
703         }
704         return hasHistory;
705     }
706 
707     /**
708      * Check, whether annotations are available for the related resource.
709      *
710      * @return {@code true} if annotations are available.
711      */
hasAnnotations()712     public boolean hasAnnotations() {
713         if (hasAnnotation == null) {
714             hasAnnotation = !isDir()
715                     && HistoryGuru.getInstance().hasHistory(getResourceFile());
716         }
717         return hasAnnotation;
718     }
719 
720     /**
721      * Check, whether the resource to show should be annotated.
722      *
723      * @return {@code true} if annotation is desired and available.
724      */
annotate()725     public boolean annotate() {
726         if (annotate == null) {
727             annotate = hasAnnotations()
728                     && Boolean.parseBoolean(req.getParameter(QueryParameters.ANNOTATION_PARAM));
729         }
730         return annotate;
731     }
732 
733     /**
734      * Get the annotation for the requested resource.
735      *
736      * @return {@code null} if not available or annotation was not requested,
737      * the cached annotation otherwise.
738      */
getAnnotation()739     public Annotation getAnnotation() {
740         if (isDir() || getResourcePath().equals("/") || !annotate()) {
741             return null;
742         }
743         if (annotation != null) {
744             return annotation;
745         }
746         getRequestedRevision();
747         try {
748             annotation = HistoryGuru.getInstance().annotate(resourceFile, rev.isEmpty() ? null : rev);
749         } catch (IOException e) {
750             LOGGER.log(Level.WARNING, "Failed to get annotations: ", e);
751             /* ignore */
752         }
753         return annotation;
754     }
755 
756     /**
757      * Get the {@code path} parameter and display value for "Search only in"
758      * option.
759      *
760      * @return always an array of 3 fields, whereby field[0] contains the path
761      * value to use (starts and ends always with a {@link org.opengrok.indexer.index.Indexer#PATH_SEPARATOR}). Field[1] the contains
762      * string to show in the UI. field[2] is set to {@code disabled=""} if the
763      * current path is the "/" directory, otherwise set to an empty string.
764      */
getSearchOnlyIn()765     public String[] getSearchOnlyIn() {
766         if (isDir()) {
767             return path.length() == 0
768                     ? new String[]{"/", "this directory", "disabled=\"\""}
769                     : new String[]{path, "this directory", ""};
770         }
771         String[] res = new String[3];
772         res[0] = path.substring(0, path.lastIndexOf(PATH_SEPARATOR) + 1);
773         res[1] = res[0];
774         res[2] = "";
775         return res;
776     }
777 
778     /**
779      * Get the project {@link #getPath()} refers to.
780      *
781      * @return {@code null} if not available, the project otherwise.
782      */
getProject()783     public Project getProject() {
784         return Project.getProject(getResourceFile());
785     }
786 
787     /**
788      * Same as {@link #getRequestedProjects()} but returns the project names as
789      * a coma separated String.
790      *
791      * @return a possible empty String but never {@code null}.
792      */
getRequestedProjectsAsString()793     public String getRequestedProjectsAsString() {
794         if (requestedProjectsString == null) {
795             Set<String> projects = getRequestedProjects();
796             if (projects.isEmpty()) {
797                 requestedProjectsString = "";
798             } else {
799                 StringBuilder buf = new StringBuilder();
800                 for (String name : projects) {
801                     buf.append(name).append(',');
802                 }
803                 buf.setLength(buf.length() - 1);
804                 requestedProjectsString = buf.toString();
805             }
806         }
807         return requestedProjectsString;
808     }
809 
810     /**
811      * Get a reference to a set of requested projects via request parameter
812      * {@code project} or cookies or defaults.
813      * <p>
814      * NOTE: This method assumes, that project names do <b>not</b> contain a
815      * comma (','), since this character is used as name separator!
816      * <p>
817      * It is determined as follows:
818      * <ol>
819      * <li>If there is no project in the configuration an empty set is returned. Otherwise:</li>
820      * <li>If there is only one project in the configuration,
821      * this one gets returned (no matter, what the request actually says). Otherwise</li>
822      * <li>If the request parameter {@code ALL_PROJECT_SEARCH} contains a true value,
823      * all projects are added to searching. Otherwise:</li>
824      * <li>If the request parameter {@code PROJECT_PARAM_NAME} contains any available project,
825      * the set with invalid projects removed gets returned. Otherwise:</li>
826      * <li>If the request parameter {@code GROUP_PARAM_NAME} contains any available group,
827      * then all projects from that group will be added to the result set. Otherwise:</li>
828      * <li>If the request has a cookie with the name {@code OPEN_GROK_PROJECT}
829      * and it contains any available project,
830      * the set with invalid projects removed gets returned. Otherwise:</li>
831      * <li>If a default project is set in the configuration,
832      * this project gets returned. Otherwise:</li>
833      * <li>an empty set</li>
834      * </ol>
835      *
836      * @return a possible empty set of project names but never {@code null}.
837      * @see #ALL_PROJECT_SEARCH
838      * @see #PROJECT_PARAM_NAME
839      * @see #GROUP_PARAM_NAME
840      * @see #OPEN_GROK_PROJECT
841      */
getRequestedProjects()842     public SortedSet<String> getRequestedProjects() {
843         if (requestedProjects == null) {
844             requestedProjects
845                     = getRequestedProjects(ALL_PROJECT_SEARCH, PROJECT_PARAM_NAME, GROUP_PARAM_NAME, OPEN_GROK_PROJECT);
846         }
847         return requestedProjects;
848     }
849 
850     private static final Pattern COMMA_PATTERN = Pattern.compile(",");
851 
splitByComma(String value, List<String> result)852     private static void splitByComma(String value, List<String> result) {
853         if (value == null || value.length() == 0) {
854             return;
855         }
856         String[] p = COMMA_PATTERN.split(value);
857         for (String p1 : p) {
858             if (p1.length() != 0) {
859                 result.add(p1);
860             }
861         }
862     }
863 
864     /**
865      * Get the cookie values for the given name. Splits comma separated values
866      * automatically into a list of Strings.
867      *
868      * @param cookieName name of the cookie.
869      * @return a possible empty list.
870      */
getCookieVals(String cookieName)871     public List<String> getCookieVals(String cookieName) {
872         Cookie[] cookies = req.getCookies();
873         ArrayList<String> res = new ArrayList<>();
874         if (cookies != null) {
875             for (int i = cookies.length - 1; i >= 0; i--) {
876                 if (cookies[i].getName().equals(cookieName)) {
877                     try {
878                         String value = URLDecoder.decode(cookies[i].getValue(), "utf-8");
879                         splitByComma(value, res);
880                     } catch (UnsupportedEncodingException ex) {
881                         LOGGER.log(Level.INFO, "decoding cookie failed", ex);
882                     }
883                 }
884             }
885         }
886         return res;
887     }
888 
889     /**
890      * Get the parameter values for the given name. Splits comma separated
891      * values automatically into a list of Strings.
892      *
893      * @param paramName name of the parameter.
894      * @return a possible empty list.
895      */
getParamVals(String paramName)896     private List<String> getParamVals(String paramName) {
897         String[] vals = req.getParameterValues(paramName);
898         List<String> res = new ArrayList<>();
899         if (vals != null) {
900             for (int i = vals.length - 1; i >= 0; i--) {
901                 splitByComma(vals[i], res);
902             }
903         }
904         return res;
905     }
906 
907     /**
908      * Same as {@link #getRequestedProjects()}, but with a variable cookieName
909      * and parameter name.
910      *
911      * @param searchAllParamName the name of the request parameter corresponding to search all projects.
912      * @param projectParamName   the name of the request parameter corresponding to a project name.
913      * @param groupParamName     the name of the request parameter corresponding to a group name
914      * @param cookieName         name of the cookie which possible contains project
915      *                           names used as fallback
916      * @return set of project names. Possibly empty set but never {@code null}.
917      */
getRequestedProjects( String searchAllParamName, String projectParamName, String groupParamName, String cookieName )918     protected SortedSet<String> getRequestedProjects(
919             String searchAllParamName,
920             String projectParamName,
921             String groupParamName,
922             String cookieName
923     ) {
924 
925         TreeSet<String> projectNames = new TreeSet<>();
926         List<Project> projects = getEnv().getProjectList();
927 
928         if (projects == null) {
929             return projectNames;
930         }
931 
932         if (Boolean.parseBoolean(req.getParameter(searchAllParamName))) {
933             return getProjectHelper()
934                     .getAllProjects()
935                     .stream()
936                     .map(Project::getName)
937                     .collect(Collectors.toCollection(TreeSet::new));
938         }
939 
940         // Use a project determined directly from the URL
941         if (getProject() != null && getProject().isIndexed()) {
942             projectNames.add(getProject().getName());
943             return projectNames;
944         }
945 
946         // Use a project if there is just a single project.
947         if (projects.size() == 1) {
948             Project p = projects.get(0);
949             if (p.isIndexed() && authFramework.isAllowed(req, p)) {
950                 projectNames.add(p.getName());
951             }
952             return projectNames;
953         }
954 
955         // Add all projects which match the project parameter name values/
956         List<String> names = getParamVals(projectParamName);
957         for (String projectName : names) {
958             Project project = Project.getByName(projectName);
959             if (project != null && project.isIndexed() && authFramework.isAllowed(req, project)) {
960                 projectNames.add(projectName);
961             }
962         }
963 
964         // Add all projects which are part of a group that matches the group parameter name.
965         names = getParamVals(groupParamName);
966         for (String groupName : names) {
967             Group group = Group.getByName(groupName);
968             if (group != null) {
969                 projectNames.addAll(getProjectHelper().getAllGrouped(group)
970                                                       .stream()
971                                                       .filter(project -> project.isIndexed())
972                                                       .map(Project::getName)
973                                                       .collect(Collectors.toSet()));
974             }
975         }
976 
977         // Add projects based on cookie.
978         if (projectNames.isEmpty()) {
979             List<String> cookies = getCookieVals(cookieName);
980             for (String s : cookies) {
981                 Project x = Project.getByName(s);
982                 if (x != null && x.isIndexed() && authFramework.isAllowed(req, x)) {
983                     projectNames.add(s);
984                 }
985             }
986         }
987 
988         // Add default projects.
989         if (projectNames.isEmpty()) {
990             Set<Project> defaultProjects = env.getDefaultProjects();
991             if (defaultProjects != null) {
992                 for (Project project : defaultProjects) {
993                     if (project.isIndexed() && authFramework.isAllowed(req, project)) {
994                         projectNames.add(project.getName());
995                     }
996                 }
997             }
998         }
999 
1000         return projectNames;
1001     }
1002 
getProjectHelper()1003     public ProjectHelper getProjectHelper() {
1004         return ProjectHelper.getInstance(this);
1005     }
1006 
1007     /**
1008      * Set the page title to use.
1009      *
1010      * @param title title to set (might be {@code null}).
1011      */
setTitle(String title)1012     public void setTitle(String title) {
1013         pageTitle = title;
1014     }
1015 
1016     /**
1017      * Get the page title to use.
1018      *
1019      * @return {@code null} if not set, the page title otherwise.
1020      */
getTitle()1021     public String getTitle() {
1022         return pageTitle;
1023     }
1024 
1025     /**
1026      * Get the base path to use to refer to CSS stylesheets and related
1027      * resources. Usually used to create links.
1028      *
1029      * @return the appropriate application directory prefixed with the
1030      * application's context path (e.g. "/source/default").
1031      * @see HttpServletRequest#getContextPath()
1032      * @see RuntimeEnvironment#getWebappLAF()
1033      */
getCssDir()1034     public String getCssDir() {
1035         return req.getContextPath() + PATH_SEPARATOR + getEnv().getWebappLAF();
1036     }
1037 
1038     /**
1039      * Get the current runtime environment.
1040      *
1041      * @return the runtime env.
1042      * @see RuntimeEnvironment#getInstance()
1043      */
getEnv()1044     public RuntimeEnvironment getEnv() {
1045         if (env == null) {
1046             env = RuntimeEnvironment.getInstance();
1047         }
1048         return env;
1049     }
1050 
1051     /**
1052      * Get the name patterns used to determine, whether a file should be
1053      * ignored.
1054      *
1055      * @return the corresponding value from the current runtime config..
1056      */
getIgnoredNames()1057     public IgnoredNames getIgnoredNames() {
1058         if (ignoredNames == null) {
1059             ignoredNames = getEnv().getIgnoredNames();
1060         }
1061         return ignoredNames;
1062     }
1063 
1064     /**
1065      * Get the canonical path to root of the source tree. File separators are
1066      * replaced with a {@link org.opengrok.indexer.index.Indexer#PATH_SEPARATOR}.
1067      *
1068      * @return The on disk source root directory.
1069      * @see RuntimeEnvironment#getSourceRootPath()
1070      */
getSourceRootPath()1071     public String getSourceRootPath() {
1072         if (sourceRootPath == null) {
1073             String srcpath = getEnv().getSourceRootPath();
1074             if (srcpath != null) {
1075                 sourceRootPath = srcpath.replace(File.separatorChar, PATH_SEPARATOR);
1076             }
1077         }
1078         return sourceRootPath;
1079     }
1080 
1081     /**
1082      * Get the prefix for the related request.
1083      *
1084      * @return {@link Prefix#UNKNOWN} if the servlet path matches any known
1085      * prefix, the prefix otherwise.
1086      */
getPrefix()1087     public Prefix getPrefix() {
1088         if (prefix == null) {
1089             prefix = Prefix.get(req.getServletPath());
1090         }
1091         return prefix;
1092     }
1093 
1094     /**
1095      * Get the canonical path of the related resource relative to the source
1096      * root directory (used file separators are all {@link org.opengrok.indexer.index.Indexer#PATH_SEPARATOR}). No check is made,
1097      * whether the obtained path is really an accessible resource on disk.
1098      *
1099      * @see HttpServletRequest#getPathInfo()
1100      * @return a possible empty String (denotes the source root directory) but
1101      * not {@code null}.
1102      */
getPath()1103     public String getPath() {
1104         if (path == null) {
1105             path = Util.getCanonicalPath(Laundromat.launderInput(
1106                     req.getPathInfo()), PATH_SEPARATOR);
1107             if (PATH_SEPARATOR_STRING.equals(path)) {
1108                 path = "";
1109             }
1110         }
1111         return path;
1112     }
1113 
1114     /**
1115      * Get the on disk file for the given path.
1116      *
1117      * NOTE: If a repository contains hard or symbolic links, the returned file
1118      * may finally point to a file outside of the source root directory.
1119      *
1120      * @param path the path to the file relatively to the source root
1121      * @return null if the related file or directory is not
1122      * available (can not be find below the source root directory), the readable
1123      * file or directory otherwise.
1124      * @see #getSourceRootPath()
1125      */
getResourceFile(String path)1126     public File getResourceFile(String path) {
1127         File f;
1128         f = new File(getSourceRootPath(), path);
1129         if (!f.canRead()) {
1130             return null;
1131         }
1132         return f;
1133     }
1134 
1135     /**
1136      * Get the on disk file to the request related file or directory.
1137      *
1138      * NOTE: If a repository contains hard or symbolic links, the returned file
1139      * may finally point to a file outside of the source root directory.
1140      *
1141      * @return {@code new File({@link org.opengrok.indexer.index.Indexer#PATH_SEPARATOR_STRING })} if the related file or directory is not
1142      * available (can not be find below the source root directory), the readable
1143      * file or directory otherwise.
1144      * @see #getSourceRootPath()
1145      * @see #getPath()
1146      */
getResourceFile()1147     public File getResourceFile() {
1148         if (resourceFile == null) {
1149             resourceFile = getResourceFile(getPath());
1150             if (resourceFile == null) {
1151                 resourceFile = new File(PATH_SEPARATOR_STRING);
1152             }
1153         }
1154         return resourceFile;
1155     }
1156 
1157     /**
1158      * Get the canonical on disk path to the request related file or directory
1159      * with all file separators replaced by a {@link org.opengrok.indexer.index.Indexer#PATH_SEPARATOR}.
1160      *
1161      * @return {@link org.opengrok.indexer.index.Indexer#PATH_SEPARATOR_STRING} if the evaluated path is invalid or outside the source root
1162      * directory), otherwise the path to the readable file or directory.
1163      * @see #getResourceFile()
1164      */
getResourcePath()1165     public String getResourcePath() {
1166         if (resourcePath == null) {
1167             resourcePath = Util.fixPathIfWindows(getResourceFile().getPath());
1168         }
1169         return resourcePath;
1170     }
1171 
1172     /**
1173      * Check, whether the related request resource matches a valid file or
1174      * directory below the source root directory and whether it matches an
1175      * ignored pattern.
1176      *
1177      * @return {@code true} if the related resource does not exists or should be
1178      * ignored.
1179      * @see #getIgnoredNames()
1180      * @see #getResourcePath()
1181      */
resourceNotAvailable()1182     public boolean resourceNotAvailable() {
1183         getIgnoredNames();
1184         return getResourcePath().equals(PATH_SEPARATOR_STRING) || ignoredNames.ignore(getPath())
1185                 || ignoredNames.ignore(resourceFile.getParentFile())
1186                 || ignoredNames.ignore(resourceFile);
1187     }
1188 
1189     /**
1190      * Check, whether the request related path represents a directory.
1191      *
1192      * @return {@code true} if directory related request
1193      */
isDir()1194     public boolean isDir() {
1195         if (isDir == null) {
1196             isDir = getResourceFile().isDirectory();
1197         }
1198         return isDir;
1199     }
1200 
trailingSlash(String path)1201     private static String trailingSlash(String path) {
1202         return path.length() == 0 || path.charAt(path.length() - 1) != PATH_SEPARATOR
1203                 ? PATH_SEPARATOR_STRING
1204                 : "";
1205     }
1206 
checkFile(File dir, String name, boolean compressed)1207     private File checkFile(File dir, String name, boolean compressed) {
1208         File f;
1209         if (compressed) {
1210             f = new File(dir, TandemPath.join(name, ".gz"));
1211             if (f.exists() && f.isFile()
1212                     && f.lastModified() >= resourceFile.lastModified()) {
1213                 return f;
1214             }
1215         }
1216         f = new File(dir, name);
1217         if (f.exists() && f.isFile()
1218                 && f.lastModified() >= resourceFile.lastModified()) {
1219             return f;
1220         }
1221         return null;
1222     }
1223 
checkFileResolve(File dir, String name, boolean compressed)1224     private File checkFileResolve(File dir, String name, boolean compressed) {
1225         File lresourceFile = new File(getSourceRootPath() + getPath(), name);
1226         if (!lresourceFile.canRead()) {
1227             lresourceFile = new File(PATH_SEPARATOR_STRING);
1228         }
1229         File f;
1230         if (compressed) {
1231             f = new File(dir, TandemPath.join(name, ".gz"));
1232             if (f.exists() && f.isFile()
1233                     && f.lastModified() >= lresourceFile.lastModified()) {
1234                 return f;
1235             }
1236         }
1237         f = new File(dir, name);
1238         if (f.exists() && f.isFile()
1239                 && f.lastModified() >= lresourceFile.lastModified()) {
1240             return f;
1241         }
1242         return null;
1243     }
1244 
1245     /**
1246      * Find the files with the given names in the {@link #getPath()} directory
1247      * relative to the crossfile directory of the opengrok data directory. It is
1248      * tried to find the compressed file first by appending the file extension
1249      * ".gz" to the filename. If that fails or an uncompressed version of the
1250      * file is younger than its compressed version, the uncompressed file gets
1251      * used.
1252      *
1253      * @param filenames filenames to lookup.
1254      * @return an empty array if the related directory does not exist or the
1255      * given list is {@code null} or empty, otherwise an array, which may
1256      * contain {@code null} entries (when the related file could not be found)
1257      * having the same order as the given list.
1258      */
findDataFiles(List<String> filenames)1259     public File[] findDataFiles(List<String> filenames) {
1260         if (filenames == null || filenames.isEmpty()) {
1261             return new File[0];
1262         }
1263         File[] res = new File[filenames.size()];
1264         File dir = new File(getEnv().getDataRootPath() + Prefix.XREF_P + path);
1265         if (dir.exists() && dir.isDirectory()) {
1266             getResourceFile();
1267             boolean compressed = getEnv().isCompressXref();
1268             for (int i = res.length - 1; i >= 0; i--) {
1269                 res[i] = checkFileResolve(dir, filenames.get(i), compressed);
1270             }
1271         }
1272         return res;
1273     }
1274 
1275     /**
1276      * Lookup the file {@link #getPath()} relative to the crossfile directory of
1277      * the opengrok data directory. It is tried to find the compressed file
1278      * first by appending the file extension ".gz" to the filename. If that
1279      * fails or an uncompressed version of the file is younger than its
1280      * compressed version, the uncompressed file gets used.
1281      *
1282      * @return {@code null} if not found, the file otherwise.
1283      */
findDataFile()1284     public File findDataFile() {
1285         return checkFile(new File(getEnv().getDataRootPath() + Prefix.XREF_P),
1286                 path, env.isCompressXref());
1287     }
1288 
getLatestRevision()1289     public String getLatestRevision() {
1290         if (!getEnv().isHistoryEnabled()) {
1291             return null;
1292         }
1293 
1294         History hist;
1295         try {
1296             hist = HistoryGuru.getInstance().
1297                     getHistory(new File(getEnv().getSourceRootFile(), getPath()), false, true);
1298         } catch (HistoryException ex) {
1299             return null;
1300         }
1301 
1302         if (hist == null) {
1303             return null;
1304         }
1305 
1306         List<HistoryEntry> hlist = hist.getHistoryEntries();
1307         if (hlist == null) {
1308             return null;
1309         }
1310 
1311         if (hlist.size() == 0) {
1312             return null;
1313         }
1314 
1315         HistoryEntry he = hlist.get(0);
1316         if (he == null) {
1317             return null;
1318         }
1319 
1320         return he.getRevision();
1321     }
1322 
1323     /**
1324      * Is revision the latest revision ?
1325      * @param rev revision string
1326      * @return true if latest revision, false otherwise
1327      */
isLatestRevision(String rev)1328     public boolean isLatestRevision(String rev) {
1329         return rev.equals(getLatestRevision());
1330     }
1331 
1332     /**
1333      * Get the location of cross reference for given file containing the given revision.
1334      * @param revStr defined revision string
1335      * @return location to redirect to
1336      */
getRevisionLocation(String revStr)1337     public String getRevisionLocation(String revStr) {
1338         StringBuilder sb = new StringBuilder();
1339 
1340         sb.append(req.getContextPath());
1341         sb.append(Prefix.XREF_P);
1342         sb.append(Util.URIEncodePath(path));
1343         sb.append("?");
1344         sb.append(QueryParameters.REVISION_PARAM_EQ);
1345         sb.append(Util.URIEncode(revStr));
1346 
1347         if (req.getQueryString() != null) {
1348             sb.append("&");
1349             sb.append(req.getQueryString());
1350         }
1351         if (fragmentIdentifier != null) {
1352             String anchor = Util.URIEncode(fragmentIdentifier);
1353 
1354             String reqFrag = req.getParameter(QueryParameters.FRAGMENT_IDENTIFIER_PARAM);
1355             if (reqFrag == null || reqFrag.isEmpty()) {
1356                 /*
1357                  * We've determined that the fragmentIdentifier field must have
1358                  * been set to augment request parameters. Now include it
1359                  * explicitly in the next request parameters.
1360                  */
1361                 sb.append("&");
1362                 sb.append(QueryParameters.FRAGMENT_IDENTIFIER_PARAM_EQ);
1363                 sb.append(anchor);
1364             }
1365             sb.append("#");
1366             sb.append(anchor);
1367         }
1368 
1369         return sb.toString();
1370     }
1371 
1372     /**
1373      * Get the path the request should be redirected (if any).
1374      *
1375      * @return {@code null} if there is no reason to redirect, the URI encoded
1376      * redirect path to use otherwise.
1377      */
getDirectoryRedirect()1378     public String getDirectoryRedirect() {
1379         if (isDir()) {
1380             getPrefix();
1381             /**
1382              * Redirect /xref -> /xref/
1383              */
1384             if (prefix == Prefix.XREF_P
1385                     && getUriEncodedPath().isEmpty()
1386                     && !req.getRequestURI().endsWith("/")) {
1387                 return req.getContextPath() + Prefix.XREF_P + '/';
1388             }
1389 
1390             if (path.length() == 0) {
1391                 // => /
1392                 return null;
1393             }
1394 
1395             if (prefix != Prefix.XREF_P && prefix != Prefix.HIST_L
1396                     && prefix != Prefix.RSS_P) {
1397                 // if it is an existing dir perhaps people wanted dir xref
1398                 return req.getContextPath() + Prefix.XREF_P
1399                         + getUriEncodedPath() + trailingSlash(path);
1400             }
1401             String ts = trailingSlash(path);
1402             if (ts.length() != 0) {
1403                 return req.getContextPath() + prefix + getUriEncodedPath() + ts;
1404             }
1405         }
1406         return null;
1407     }
1408 
1409     /**
1410      * Get the URI encoded canonical path to the related file or directory (the
1411      * URI part between the servlet path and the start of the query string).
1412      *
1413      * @return an URI encoded path which might be an empty string but not
1414      * {@code null}.
1415      * @see #getPath()
1416      */
getUriEncodedPath()1417     public String getUriEncodedPath() {
1418         if (uriEncodedPath == null) {
1419             uriEncodedPath = Util.URIEncodePath(getPath());
1420         }
1421         return uriEncodedPath;
1422     }
1423 
1424     /**
1425      * Add a new file script to the page by the name.
1426      *
1427      * @param name name of the script to search for
1428      * @return this
1429      *
1430      * @see Scripts#addScript(String, String, Scripts.Type)
1431      */
addScript(String name)1432     public PageConfig addScript(String name) {
1433         this.scripts.addScript(this.req.getContextPath(), name, isDebug() ? Scripts.Type.DEBUG : Scripts.Type.MINIFIED);
1434         return this;
1435     }
1436 
isDebug()1437     private boolean isDebug() {
1438         return Boolean.parseBoolean(req.getParameter(DEBUG_PARAM_NAME));
1439     }
1440 
1441     /**
1442      * Return the page scripts.
1443      *
1444      * @return the scripts
1445      *
1446      * @see Scripts
1447      */
getScripts()1448     public Scripts getScripts() {
1449         return this.scripts;
1450     }
1451 
1452     /**
1453      * Get opengrok's configured dataroot directory. It is verified, that the
1454      * used environment has a valid opengrok data root set and that it is an
1455      * accessible directory.
1456      *
1457      * @return the opengrok data directory.
1458      * @throws InvalidParameterException if inaccessible or not set.
1459      */
getDataRoot()1460     public File getDataRoot() {
1461         if (dataRoot == null) {
1462             String tmp = getEnv().getDataRootPath();
1463             if (tmp == null || tmp.length() == 0) {
1464                 throw new InvalidParameterException("dataRoot parameter is not "
1465                         + "set in configuration.xml!");
1466             }
1467             dataRoot = new File(tmp);
1468             if (!(dataRoot.isDirectory() && dataRoot.canRead())) {
1469                 throw new InvalidParameterException("The configured dataRoot '"
1470                         + tmp
1471                         + "' refers to a none-existing or unreadable directory!");
1472             }
1473         }
1474         return dataRoot;
1475     }
1476 
1477     /**
1478      * Prepare a search helper with all required information, ready to execute
1479      * the query implied by the related request parameters and cookies.
1480      * <p>
1481      * NOTE: One should check the {@link SearchHelper#errorMsg} as well as
1482      * {@link SearchHelper#redirect} and take the appropriate action before
1483      * executing the prepared query or continue processing.
1484      * <p>
1485      * This method stops populating fields as soon as an error occurs.
1486      *
1487      * @return a search helper.
1488      */
prepareSearch()1489     public SearchHelper prepareSearch() {
1490         SearchHelper sh = prepareInternalSearch();
1491 
1492         List<SortOrder> sortOrders = getSortOrder();
1493         sh.order = sortOrders.isEmpty() ? SortOrder.RELEVANCY : sortOrders.get(0);
1494 
1495         if (getRequestedProjects().isEmpty() && getEnv().hasProjects()) {
1496             sh.errorMsg = "You must select a project!";
1497             return sh;
1498         }
1499 
1500         if (sh.builder.getSize() == 0) {
1501             // Entry page show the map
1502             sh.redirect = req.getContextPath() + '/';
1503             return sh;
1504         }
1505 
1506         return sh;
1507     }
1508 
1509     /**
1510      * Prepare a search helper with required settings for an internal search.
1511      * <p>
1512      * NOTE: One should check the {@link SearchHelper#errorMsg} as well as
1513      * {@link SearchHelper#redirect} and take the appropriate action before
1514      * executing the prepared query or continue processing.
1515      * <p>
1516      * This method stops populating fields as soon as an error occurs.
1517      * @return a search helper.
1518      */
prepareInternalSearch()1519     public SearchHelper prepareInternalSearch() {
1520         SearchHelper sh = new SearchHelper();
1521         sh.dataRoot = getDataRoot(); // throws Exception if none-existent
1522         sh.order = SortOrder.RELEVANCY;
1523         sh.builder = getQueryBuilder();
1524         sh.start = getSearchStart();
1525         sh.maxItems = getSearchMaxItems();
1526         sh.contextPath = req.getContextPath();
1527         // jel: this should be IMHO a config param since not only core dependend
1528         sh.parallel = Runtime.getRuntime().availableProcessors() > 1;
1529         sh.isCrossRefSearch = getPrefix() == Prefix.SEARCH_R;
1530         sh.isGuiSearch = sh.isCrossRefSearch || getPrefix() == Prefix.SEARCH_P;
1531         sh.desc = getEftarReader();
1532         sh.sourceRoot = new File(getSourceRootPath());
1533         String xrValue = req.getParameter(QueryParameters.NO_REDIRECT_PARAM);
1534         sh.noRedirect = xrValue != null && !xrValue.isEmpty();
1535         return sh;
1536     }
1537 
1538     /**
1539      * Get the config w.r.t. the given request. If there is none yet, a new config
1540      * gets created, attached to the request and returned.
1541      * <p>
1542      *
1543      * @param request the request to use to initialize the config parameters.
1544      * @return always the same none-{@code null} config for a given request.
1545      * @throws NullPointerException if the given parameter is {@code null}.
1546      */
get(HttpServletRequest request)1547     public static PageConfig get(HttpServletRequest request) {
1548         Object cfg = request.getAttribute(ATTR_NAME);
1549         if (cfg != null) {
1550             return (PageConfig) cfg;
1551         }
1552         PageConfig pcfg = new PageConfig(request);
1553         request.setAttribute(ATTR_NAME, pcfg);
1554         return pcfg;
1555     }
1556 
PageConfig(HttpServletRequest req)1557     private PageConfig(HttpServletRequest req) {
1558         this.req = req;
1559         this.authFramework = RuntimeEnvironment.getInstance().getAuthorizationFramework();
1560         this.executor = RuntimeEnvironment.getInstance().getRevisionExecutor();
1561         this.fragmentIdentifier = req.getParameter(QueryParameters.FRAGMENT_IDENTIFIER_PARAM);
1562     }
1563 
1564     /**
1565      * Cleanup all allocated resources (if any) from the instance attached to
1566      * the given request.
1567      *
1568      * @param sr request to check, cleanup. Ignored if {@code null}.
1569      * @see PageConfig#get(HttpServletRequest)
1570      */
cleanup(ServletRequest sr)1571     public static void cleanup(ServletRequest sr) {
1572         if (sr == null) {
1573             return;
1574         }
1575         PageConfig cfg = (PageConfig) sr.getAttribute(ATTR_NAME);
1576         if (cfg == null) {
1577             return;
1578         }
1579         ProjectHelper.cleanup(cfg);
1580         sr.removeAttribute(ATTR_NAME);
1581         cfg.env = null;
1582         cfg.req = null;
1583         if (cfg.eftarReader != null) {
1584             cfg.eftarReader.close();
1585         }
1586     }
1587 
1588     /**
1589      * Checks if current request is allowed to access project.
1590      * @param t project
1591      * @return true if yes
1592      */
isAllowed(Project t)1593     public boolean isAllowed(Project t) {
1594         return this.authFramework.isAllowed(this.req, t);
1595     }
1596 
1597     /**
1598      * Checks if current request is allowed to access group.
1599      * @param g group
1600      * @return true if yes
1601      */
isAllowed(Group g)1602     public boolean isAllowed(Group g) {
1603         return this.authFramework.isAllowed(this.req, g);
1604     }
1605 
1606 
getMessages()1607     public SortedSet<AcceptedMessage> getMessages() {
1608         return env.getMessages();
1609     }
1610 
getMessages(String tag)1611     public SortedSet<AcceptedMessage> getMessages(String tag) {
1612         return env.getMessages(tag);
1613     }
1614 
1615     /**
1616      * Get basename of the path or "/" if the path is empty.
1617      * This is used for setting title of various pages.
1618      * @param path path
1619      * @return short version of the path
1620      */
getShortPath(String path)1621     public String getShortPath(String path) {
1622         File file = new File(path);
1623 
1624         if (path.isEmpty()) {
1625             return "/";
1626         }
1627 
1628         return file.getName();
1629     }
1630 
addTitleDelimiter(String title)1631     private String addTitleDelimiter(String title) {
1632         if (!title.isEmpty()) {
1633             return title + ", ";
1634         }
1635 
1636         return title;
1637     }
1638 
1639     /**
1640      * The search page title string should progressively reflect the search terms
1641      * so that if only small portion of the string is seen, it describes
1642      * the action as closely as possible while remaining readable.
1643      * @return string used for setting page title of search results page
1644      */
getSearchTitle()1645     public String getSearchTitle() {
1646         String title = "";
1647 
1648         if (req.getParameter(QueryBuilder.FULL) != null && !req.getParameter(QueryBuilder.FULL).isEmpty()) {
1649             title += req.getParameter(QueryBuilder.FULL) + " (full)";
1650         }
1651         if (req.getParameter(QueryBuilder.DEFS) != null && !req.getParameter(QueryBuilder.DEFS).isEmpty()) {
1652             title = addTitleDelimiter(title);
1653             title += req.getParameter(QueryBuilder.DEFS) + " (definition)";
1654         }
1655         if (req.getParameter(QueryBuilder.REFS) != null && !req.getParameter(QueryBuilder.REFS).isEmpty()) {
1656             title = addTitleDelimiter(title);
1657             title += req.getParameter(QueryBuilder.REFS) + " (reference)";
1658         }
1659         if (req.getParameter(QueryBuilder.PATH) != null && !req.getParameter(QueryBuilder.PATH).isEmpty()) {
1660             title = addTitleDelimiter(title);
1661             title += req.getParameter(QueryBuilder.PATH) + " (path)";
1662         }
1663         if (req.getParameter(QueryBuilder.HIST) != null && !req.getParameter(QueryBuilder.HIST).isEmpty()) {
1664             title = addTitleDelimiter(title);
1665             title += req.getParameter(QueryBuilder.HIST) + " (history)";
1666         }
1667 
1668         if (req.getParameterValues(QueryBuilder.PROJECT) != null && req.getParameterValues(QueryBuilder.PROJECT).length != 0) {
1669             if (!title.isEmpty()) {
1670                 title += " ";
1671             }
1672             title += "in projects: ";
1673             String[] projects = req.getParameterValues(QueryBuilder.PROJECT);
1674             title += String.join(",", projects);
1675         }
1676 
1677         return Util.htmlize(title + " - OpenGrok search results");
1678     }
1679 
1680     /**
1681      * Similar as {@link #getSearchTitle()}.
1682      * @return string used for setting page title of search view
1683      */
getHistoryTitle()1684     public String getHistoryTitle() {
1685         return Util.htmlize(getShortPath(path) +
1686                 " - OpenGrok history log for " + path);
1687     }
1688 
getPathTitle()1689     public String getPathTitle() {
1690         String title = getShortPath(path);
1691         if (getRequestedRevision() != null && !getRequestedRevision().isEmpty()) {
1692             title += " (revision " + getRequestedRevision() + ")";
1693         }
1694         title += " - OpenGrok cross reference for " + (path.isEmpty() ? "/" : path);
1695 
1696         return Util.htmlize(title);
1697     }
1698 
checkSourceRootExistence()1699     public void checkSourceRootExistence() throws IOException {
1700         if (getSourceRootPath() == null || getSourceRootPath().isEmpty()) {
1701             throw new FileNotFoundException("Unable to determine source root path. Missing configuration?");
1702         }
1703         File sourceRootPathFile = RuntimeEnvironment.getInstance().getSourceRootFile();
1704         if (!sourceRootPathFile.exists()) {
1705             throw new FileNotFoundException(String.format("Source root path \"%s\" does not exist", sourceRootPathFile.getAbsolutePath()));
1706         }
1707         if (!sourceRootPathFile.isDirectory()) {
1708             throw new FileNotFoundException(String.format("Source root path \"%s\" is not a directory", sourceRootPathFile.getAbsolutePath()));
1709         }
1710         if (!sourceRootPathFile.canRead()) {
1711             throw new IOException(String.format("Source root path \"%s\" is not readable", sourceRootPathFile.getAbsolutePath()));
1712         }
1713     }
1714 
1715     /**
1716      * Get all project related messages. These include
1717      * <ol>
1718      * <li>Main messages</li>
1719      * <li>Messages with tag = project name</li>
1720      * <li>Messages with tag = project's groups names</li>
1721      * </ol>
1722      *
1723      * @return the sorted set of messages according to the accept time
1724      * @see org.opengrok.indexer.web.messages.MessagesContainer#MESSAGES_MAIN_PAGE_TAG
1725      */
getProjectMessages()1726     private SortedSet<AcceptedMessage> getProjectMessages() {
1727         SortedSet<AcceptedMessage> messages = getMessages();
1728 
1729         if (getProject() != null) {
1730             messages.addAll(getMessages(getProject().getName()));
1731             getProject().getGroups().forEach(group -> {
1732                 messages.addAll(getMessages(group.getName()));
1733             });
1734         }
1735 
1736         return messages;
1737     }
1738 
1739     /**
1740      * Decide if this resource has been modified since the header value in the request.
1741      * <p>
1742      * The resource is modified since the weak ETag value in the request, the ETag is
1743      * computed using:
1744      * </p>
1745      * <ul>
1746      * <li>the source file modification</li>
1747      * <li>project messages</li>
1748      * <li>last timestamp for index</li>
1749      * <li>OpenGrok current deployed version</li>
1750      * </ul>
1751      *
1752      * <p>
1753      * If the resource was modified, appropriate headers in the response are filled.
1754      * </p>
1755      *
1756      * @param request the http request containing the headers
1757      * @param response the http response for setting the headers
1758      * @return true if resource was not modified; false otherwise
1759      * @see <a href="https://tools.ietf.org/html/rfc7232#section-2.3">HTTP ETag</a>
1760      * @see <a href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html">HTTP Caching</a>
1761      */
isNotModified(HttpServletRequest request, HttpServletResponse response)1762     public boolean isNotModified(HttpServletRequest request, HttpServletResponse response) {
1763         String currentEtag = String.format("W/\"%s\"",
1764                 Objects.hash(
1765                         // last modified time as UTC timestamp in millis
1766                         getLastModified(),
1767                         // all project related messages which changes the view
1768                         getProjectMessages(),
1769                         // last timestamp value
1770                         getEnv().getDateForLastIndexRun() != null ? getEnv().getDateForLastIndexRun().getTime() : 0,
1771                         // OpenGrok version has changed since the last time
1772                         Info.getVersion()
1773                 )
1774         );
1775 
1776         String headerEtag = request.getHeader(HttpHeaders.IF_NONE_MATCH);
1777 
1778         if (headerEtag != null && headerEtag.equals(currentEtag)) {
1779             // weak ETag has not changed, return 304 NOT MODIFIED
1780             response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
1781             return true;
1782         }
1783 
1784         // return 200 OK
1785         response.setHeader(HttpHeaders.ETAG, currentEtag);
1786         return false;
1787     }
1788 
1789     /**
1790      * @param root root path
1791      * @param path path
1792      * @return path relative to root
1793      */
getRelativePath(String root, String path)1794     public static String getRelativePath(String root, String path) {
1795         return Paths.get(root).relativize(Paths.get(path)).toString();
1796     }
1797 
evaluateMatchOffset()1798     public boolean evaluateMatchOffset() {
1799         if (fragmentIdentifier == null) {
1800             int matchOffset = getIntParam(QueryParameters.MATCH_OFFSET_PARAM, -1);
1801             if (matchOffset >= 0) {
1802                 File resourceFile = getResourceFile();
1803                 if (resourceFile.isFile()) {
1804                     LineBreaker breaker = new LineBreaker();
1805                     StreamSource streamSource = StreamSource.fromFile(resourceFile);
1806                     try {
1807                         breaker.reset(streamSource);
1808                         int matchLine = breaker.findLineIndex(matchOffset);
1809                         if (matchLine >= 0) {
1810                             // Convert to 1-based offset to accord with OpenGrok line number.
1811                             fragmentIdentifier = String.valueOf(matchLine + 1);
1812                             return true;
1813                         }
1814                     } catch (IOException e) {
1815                         LOGGER.log(Level.WARNING, "Failed to evaluate match offset for " +
1816                                 resourceFile, e);
1817                     }
1818                 }
1819             }
1820         }
1821         return false;
1822     }
1823 }
1824