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