/**
* This file is part of lavagna.
*
* lavagna is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* lavagna is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with lavagna. If not, see <http://www.gnu.org/licenses/>.
*/
package io.lavagna.web.support;
import com.samskivert.mustache.Mustache;
import com.samskivert.mustache.Template;
import io.lavagna.common.Json;
import io.lavagna.common.LavagnaEnvironment;
import io.lavagna.common.Version;
import io.lavagna.model.Permission;
import io.lavagna.web.helper.ExpectPermission;
import io.lavagna.web.security.CSRFToken;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.core.io.support.ResourcePatternResolver;
import org.springframework.stereotype.Controller;
import org.springframework.util.StreamUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.apache.commons.lang3.ArrayUtils.contains;
@Controller
public class ResourceController {
private static final String PROJ_SHORT_NAME = "{projectShortName:[A-Z0-9_]+}";
private static final String BOARD_SHORT_NAME = "{shortName:[A-Z0-9_]+}";
private static final String CARD_SEQ = "{cardId:[0-9]+}";
private final LavagnaEnvironment env;
// we don't care if the values are set more than one time
private final AtomicReference<Template> indexTopTemplate = new AtomicReference<>();
private final AtomicReference<byte[]> indexCache = new AtomicReference<>();
private final AtomicReference<byte[]> jsCache = new AtomicReference<>();
private final AtomicReference<byte[]> jsLoginCache = new AtomicReference<>();
private final AtomicReference<byte[]> cssCache = new AtomicReference<>();
private final String version;
public ResourceController(LavagnaEnvironment env) {
this.env = env;
this.version = Version.version();
}
private static List<String> prepareTemplates(ServletContext context, String initialPath) throws IOException {
List<String> r = new ArrayList<>();
BeforeAfter ba = new AngularTemplate();
for (String file : allFilesWithExtension(context, initialPath, ".html")) {
ByteArrayOutputStream os = new ByteArrayOutputStream();
output(file, context, os, ba);
r.add(os.toString(StandardCharsets.UTF_8.displayName()));
}
return r;
}
private static Set<String> nullable(Set<String> s) {
return s == null ? Collections.<String>emptySet() : s;
}
private static Set<String> allFilesWithExtension(ServletContext context, String initialPath, String extension) {
Set<String> res = new TreeSet<>();
extractFilesWithExtensionRec(context, initialPath, extension, res);
return res;
}
private static void extractFilesWithExtensionRec(ServletContext context, String initialPath, String extension,
Set<String> res) {
for (String s : nullable(context.getResourcePaths(initialPath))) {
if (s.endsWith("/")) {
extractFilesWithExtensionRec(context, s, extension, res);
} else if (s.endsWith(extension)) {
res.add(s);
}
}
}
private static void concatenateResourcesWithExtension(ServletContext context, String initialPath, String extension,
OutputStream os, BeforeAfter ba) throws IOException {
for (String s : new TreeSet<>(nullable(context.getResourcePaths(initialPath)))) {
if (s.endsWith(extension)) {
output(s, context, os, ba);
} else if (s.endsWith("/")) {
concatenateResourcesWithExtension(context, s, extension, os, ba);
}
}
}
private static void output(String file, ServletContext context, OutputStream os, BeforeAfter ba)
throws IOException {
ba.before(file, context, os);
try (InputStream is = context.getResourceAsStream(file)) {
StreamUtils.copy(is, os);
}
ba.after(file, context, os);
os.flush();
}
@ExpectPermission(Permission.ADMINISTRATION)
@RequestMapping(value = { "admin", "admin/login", "admin/users", "admin/roles",
"admin/export-import", "admin/endpoint-info", "admin/parameters",
"admin/smtp", "admin/integrations" }, method = RequestMethod.GET)
public void handleIndexForAdmin(HttpServletRequest request, HttpServletResponse response) throws IOException {
handleIndex(request, response);
}
@ExpectPermission(Permission.PROJECT_ADMINISTRATION)
@RequestMapping(value = { PROJ_SHORT_NAME + "/manage",//
PROJ_SHORT_NAME + "/manage/project",//
PROJ_SHORT_NAME + "/manage/boards",//
PROJ_SHORT_NAME + "/manage/roles",//
PROJ_SHORT_NAME + "/manage/labels",//
PROJ_SHORT_NAME + "/manage/import",//
PROJ_SHORT_NAME + "/manage/milestones",//
PROJ_SHORT_NAME + "/manage/mail-ticket",//
PROJ_SHORT_NAME + "/manage/access",//
PROJ_SHORT_NAME + "/manage/status" }, method = RequestMethod.GET)
public void handleIndexForProjectAdmin(HttpServletRequest request, HttpServletResponse response)
throws IOException {
handleIndex(request, response);
}
@ExpectPermission(Permission.UPDATE_PROFILE)
@RequestMapping(value = "me", method = RequestMethod.GET)
public void handleIndexForMe(HttpServletRequest request, HttpServletResponse response) throws IOException {
handleIndex(request, response);
}
@RequestMapping(value = { "/",//
"user/{provider}/{username}", "user/{provider}/{username}/projects/", "user/{provider}/{username}/activity/",//
"about", "about/third-party",
"calendar",
"calendar/" + PROJ_SHORT_NAME + "/" + BOARD_SHORT_NAME + "-" + CARD_SEQ,
"search",//
"search/" + PROJ_SHORT_NAME + "/" + BOARD_SHORT_NAME + "-" + CARD_SEQ,//
PROJ_SHORT_NAME + "",//
PROJ_SHORT_NAME + "/search",//
PROJ_SHORT_NAME + "/search/" + BOARD_SHORT_NAME + "-" + CARD_SEQ,
PROJ_SHORT_NAME + "/" + BOARD_SHORT_NAME,//
PROJ_SHORT_NAME + "/calendar",
PROJ_SHORT_NAME + "/calendar/" + BOARD_SHORT_NAME + "-" + CARD_SEQ,
PROJ_SHORT_NAME + "/statistics",//
PROJ_SHORT_NAME + "/milestones",//
PROJ_SHORT_NAME + "/milestones/{id}/",//
PROJ_SHORT_NAME + "/milestones/{id}/" + BOARD_SHORT_NAME + "-" + CARD_SEQ,//
PROJ_SHORT_NAME + "/" + BOARD_SHORT_NAME + "-" + CARD_SEQ }, method = RequestMethod.GET)
public void handleIndex(HttpServletRequest request, HttpServletResponse response) throws IOException {
ServletContext context = request.getServletContext();
if (contains(env.getActiveProfiles(), "dev") || indexTopTemplate.get() == null) {
ByteArrayOutputStream indexTop = new ByteArrayOutputStream();
try (InputStream is = context.getResourceAsStream("/WEB-INF/views/index-top.html")) {
StreamUtils.copy(is, indexTop);
}
indexTopTemplate
.set(Mustache.compiler().escapeHTML(false).compile(indexTop.toString(StandardCharsets.UTF_8.name())));
}
if (contains(env.getActiveProfiles(), "dev") || indexCache.get() == null) {
ByteArrayOutputStream index = new ByteArrayOutputStream();
output("/WEB-INF/views/index.html", context, index, new BeforeAfter());
Map<String, Object> data = new HashMap<>();
data.put("contextPath", request.getServletContext().getContextPath() + "/");
data.put("version", version);
List<String> inlineTemplates = prepareTemplates(context, "/app/");
data.put("inlineTemplates", inlineTemplates);
indexCache.set(Mustache.compiler().escapeHTML(false)
.compile(index.toString(StandardCharsets.UTF_8.name())).execute(data)
.getBytes(StandardCharsets.UTF_8));
}
try (OutputStream os = response.getOutputStream()) {
response.setContentType("text/html; charset=UTF-8");
Map<String, Object> info = new HashMap<>();
Locale currentLocale = ObjectUtils.firstNonNull(request.getLocale(), Locale.ENGLISH);
info.put("firstDayOfWeek", Calendar.getInstance(currentLocale).getFirstDayOfWeek() - 1);
info.put("csrf", CSRFToken.getToken(request));
StreamUtils.copy(indexTopTemplate.get().execute(info).getBytes(StandardCharsets.UTF_8), os);
StreamUtils.copy(indexCache.get(), os);
}
}
@RequestMapping(value = "/resource-login/app-login-{version:.+}.js", method = RequestMethod.GET)
public void handleJsLogin(HttpServletRequest request, HttpServletResponse response) throws IOException {
if (contains(env.getActiveProfiles(), "dev") || jsLoginCache.get() == null) {
ServletContext context = request.getServletContext();
BeforeAfter ba = new JS();
ByteArrayOutputStream allJs = new ByteArrayOutputStream();
for (String res : Arrays.asList(
"/js/angular.min.js", "/js/angular-sanitize.min.js",//
//
"/js/angular-animate.min.js", "/js/angular-aria.min.js",
"/js/angular-messages.min.js", "/js/angular-material.min.js",
"/js/angular-translate.min.js")) {
output(res, context, allJs, ba);
}
addMessages(context, allJs, ba);
concatenateResourcesWithExtension(context, "/app-login/", ".js", allJs, ba);
//
jsLoginCache.set(allJs.toByteArray());
}
try (OutputStream os = response.getOutputStream()) {
response.setContentType("text/javascript");
StreamUtils.copy(jsLoginCache.get(), os);
}
}
/**
* Dynamically load and concatenate the js present in the configured directories
*
* @param request
* @param response
* @throws IOException
*/
@RequestMapping(value = "/resource/app-{version:.+}.js", method = RequestMethod.GET)
public void handleJs(HttpServletRequest request, HttpServletResponse response) throws IOException {
if (contains(env.getActiveProfiles(), "dev") || jsCache.get() == null) {
ServletContext context = request.getServletContext();
BeforeAfter ba = new JS();
ByteArrayOutputStream allJs = new ByteArrayOutputStream();
//
for (String res : Arrays.asList("/js/angular-file-upload-html5-shim.js",//
"/js/angular.min.js", "/js/angular-sanitize.min.js",//
//
"/js/angular-animate.min.js", "/js/angular-aria.min.js",
"/js/angular-messages.min.js", "/js/angular-material.min.js",
//
"/js/angular-ui-router.min.js",//
"/js/angular-file-upload.min.js",//
"/js/angular-translate.min.js",//
"/js/angular-material-calendar.min.js",//
//
"/js/highlight.pack.js",//
"/js/marked.js",//
"/js/Sortable.js",//
"/js/sockjs.min.js", "/js/stomp.min.js",//
//
"/js/search-parser.js",//
"/js/moment.min.js",//
"/js/Chart.min.js")) {
output(res, context, allJs, ba);
}
//
//
addMessages(context, allJs, ba);
//
//
//concatenateResourcesWithExtension(context, "/app/app.js", ".js", allJs, ba);
output("/app/app.js", context, allJs, ba);
concatenateResourcesWithExtension(context, "/app/controllers/", ".js", allJs, ba);
concatenateResourcesWithExtension(context, "/app/components/", ".js", allJs, ba);
concatenateResourcesWithExtension(context, "/app/directives/", ".js", allJs, ba);
concatenateResourcesWithExtension(context, "/app/filters/", ".js", allJs, ba);
concatenateResourcesWithExtension(context, "/app/services/", ".js", allJs, ba);
//
jsCache.set(allJs.toByteArray());
}
try (OutputStream os = response.getOutputStream()) {
response.setContentType("text/javascript");
StreamUtils.copy(jsCache.get(), os);
}
}
private void addMessages(ServletContext context, OutputStream os, BeforeAfter ba) throws IOException {
ba.before("i18n", context, os);
//
ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
Resource[] resources = resolver.getResources("classpath:io/lavagna/i18n/messages_*.properties");
//
os.write(("window.io_lavagna=window.io_lavagna||{};window.io_lavagna.i18n=" + Json.GSON
.toJson(fromResources(resources))).getBytes(StandardCharsets.UTF_8));
ba.after("i18n", context, os);
}
private static Map<String, Map<Object, Object>> fromResources(Resource[] resources) throws IOException {
Pattern extractLanguage = Pattern.compile("^messages_(.*)\\.properties$");
Map<String, Map<Object, Object>> langs = new HashMap<>();
String version = Version.version();
for (Resource res : resources) {
Matcher matcher = extractLanguage.matcher(res.getFilename());
matcher.find();
String lang = matcher.group(1);
Properties p = new Properties();
try (InputStream is = res.getInputStream()) {
p.load(is);
}
langs.put(lang, new HashMap<>(p));
langs.get(lang).put("build.version", version);
}
return langs;
}
@RequestMapping(value = "/css/all-{version:.+}.css", method = RequestMethod.GET)
public void handleCss(HttpServletRequest request, HttpServletResponse response) throws IOException {
if (contains(env.getActiveProfiles(), "dev") || cssCache.get() == null) {
ByteArrayOutputStream cssOs = new ByteArrayOutputStream();
ServletContext context = request.getServletContext();
BeforeAfter ba = new BeforeAfter();
//make sure we add the css in the right order
concatenateResourcesWithExtension(context, "/css/", ".css", cssOs, ba);
concatenateResourcesWithExtension(context, "/app/ui/", ".css", cssOs, ba);
concatenateResourcesWithExtension(context, "/app/components/", ".css", cssOs, ba);
cssCache.set(cssOs.toByteArray());
}
try (OutputStream os = response.getOutputStream()) {
response.setContentType("text/css");
StreamUtils.copy(cssCache.get(), os);
}
}
private static class BeforeAfter {
void before(String file, ServletContext context, OutputStream os) throws IOException {
}
void after(String file, ServletContext context, OutputStream os) throws IOException {
}
}
private static class JS extends BeforeAfter {
@Override
public void before(String file, ServletContext context, OutputStream os) throws IOException {
os.write((";\n\n /* begin " + file + " */ \n\n").getBytes(StandardCharsets.UTF_8));
}
@Override
public void after(String file, ServletContext context, OutputStream os) throws IOException {
os.write((";\n\n /* end " + file + " */ \n\n").getBytes(StandardCharsets.UTF_8));
}
}
private static class AngularTemplate extends BeforeAfter {
@Override
void before(String file, ServletContext context, OutputStream os) throws IOException {
os.write(("<script type=\"text/ng-template\" id=\"" + StringUtils.stripStart(file, "/") + "\">")
.getBytes(StandardCharsets.UTF_8));
}
@Override
void after(String file, ServletContext context, OutputStream os) throws IOException {
os.write("</script>".getBytes(StandardCharsets.UTF_8));
}
}
}