/* * Copyright 2013-2020 Erudika. https://erudika.com * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * For issues and patches go to: https://github.com/erudika */ package com.erudika.scoold.utils; import com.erudika.para.Para; import com.erudika.para.client.ParaClient; import com.erudika.para.core.Address; import com.erudika.para.core.ParaObject; import com.erudika.para.core.Sysprop; import com.erudika.para.core.Tag; import com.erudika.para.core.User; import com.erudika.para.core.Vote; import com.erudika.para.core.Webhook; import com.erudika.para.core.utils.ParaObjectUtils; import com.erudika.para.email.Emailer; import com.erudika.para.utils.Config; import com.erudika.para.utils.Pager; import com.erudika.para.utils.Utils; import com.erudika.para.validation.ValidationUtils; import com.erudika.scoold.ScooldServer; import static com.erudika.scoold.ScooldServer.*; import com.erudika.scoold.core.Comment; import com.erudika.scoold.core.Feedback; import com.erudika.scoold.core.Post; import static com.erudika.scoold.core.Post.ALL_MY_SPACES; import static com.erudika.scoold.core.Post.DEFAULT_SPACE; import com.erudika.scoold.core.Profile; import static com.erudika.scoold.core.Profile.Badge.ENTHUSIAST; import static com.erudika.scoold.core.Profile.Badge.TEACHER; import com.erudika.scoold.core.Question; import com.erudika.scoold.core.Reply; import com.erudika.scoold.core.Report; import com.erudika.scoold.core.Revision; import com.erudika.scoold.core.UnapprovedQuestion; import com.erudika.scoold.core.UnapprovedReply; import static com.erudika.scoold.utils.HttpUtils.getCookieValue; import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.JWSHeader; import com.nimbusds.jose.JWSSigner; import com.nimbusds.jose.JWSVerifier; import com.nimbusds.jose.crypto.MACSigner; import com.nimbusds.jose.crypto.MACVerifier; import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.SignedJWT; import com.typesafe.config.ConfigObject; import java.io.IOException; import java.io.InputStream; import java.text.ParseException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.LinkedList; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Scanner; import java.util.Set; import java.util.TreeMap; import java.util.concurrent.Callable; import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; import javax.inject.Inject; import javax.inject.Named; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.validation.ConstraintViolation; import javax.ws.rs.WebApplicationException; import org.apache.commons.codec.binary.Base64; import org.apache.commons.lang3.RegExUtils; import org.apache.commons.lang3.math.NumberUtils; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Component; /** * * @author Alex Bogdanovski [[email protected]] */ @Component @Named public final class ScooldUtils { private static final Logger logger = LoggerFactory.getLogger(ScooldUtils.class); private static final Map<String, String> FILE_CACHE = new ConcurrentHashMap<String, String>(); private static final Set<String> APPROVED_DOMAINS = new HashSet<>(); private static final Set<String> ADMINS = new HashSet<>(); private static final String EMAIL_ALERTS_PREFIX = "email-alerts" + Config.SEPARATOR; private static final Profile API_USER; private static final Set<String> CORE_TYPES; private static final Set<String> HOOK_EVENTS; private static final Map<String, String> WHITELISTED_MACROS; private static final Map<String, Object> API_KEYS = new LinkedHashMap<>(); // jti => jwt static { API_USER = new Profile("1", "System"); API_USER.setVotes(1); API_USER.setCreatorid("1"); API_USER.setPicture(getGravatar(Config.SUPPORT_EMAIL)); API_USER.setGroups(User.Groups.ADMINS.toString()); CORE_TYPES = new HashSet<>(Arrays.asList(Utils.type(Comment.class), Utils.type(Feedback.class), Utils.type(Profile.class), Utils.type(Question.class), Utils.type(Reply.class), Utils.type(Report.class), Utils.type(Revision.class), Utils.type(UnapprovedQuestion.class), Utils.type(UnapprovedReply.class), // Para core types Utils.type(Address.class), Utils.type(Sysprop.class), Utils.type(Tag.class), Utils.type(User.class), Utils.type(Vote.class) )); HOOK_EVENTS = new HashSet<>(Arrays.asList( "question.create", "question.close", "answer.create", "answer.accept", "report.create", "comment.create", "user.signup", "revision.restore")); WHITELISTED_MACROS = new HashMap<String, String>(); WHITELISTED_MACROS.put("spaces", "#spacespage($spaces)"); WHITELISTED_MACROS.put("webhooks", "#webhookspage($webhooks)"); WHITELISTED_MACROS.put("comments", "#commentspage($commentslist)"); WHITELISTED_MACROS.put("postcomments", "#commentspage($showpost.comments)"); WHITELISTED_MACROS.put("replies", "#answerspage($answerslist $showPost)"); WHITELISTED_MACROS.put("feedback", "#questionspage($feedbacklist)"); WHITELISTED_MACROS.put("people", "#peoplepage($userlist)"); WHITELISTED_MACROS.put("questions", "#questionspage($questionslist)"); WHITELISTED_MACROS.put("compactanswers", "#compactanswerspage($answerslist)"); WHITELISTED_MACROS.put("answers", "#answerspage($answerslist)"); WHITELISTED_MACROS.put("reports", "#reportspage($reportslist)"); WHITELISTED_MACROS.put("revisions", "#revisionspage($revisionslist $showPost)"); WHITELISTED_MACROS.put("tags", "#tagspage($tagslist)"); } private ParaClient pc; private LanguageUtils langutils; private static ScooldUtils instance; @Inject private Emailer emailer; @Inject public ScooldUtils(ParaClient pc, LanguageUtils langutils) { this.pc = pc; this.langutils = langutils; } public ParaClient getParaClient() { return pc; } public LanguageUtils getLangutils() { return langutils; } public static ScooldUtils getInstance() { return instance; } static void setInstance(ScooldUtils instance) { ScooldUtils.instance = instance; } static { // multiple domains/admins are now allowed only in Scoold PRO String approvedDomains = Config.getConfigParam("approved_domains_for_signups", ""); if (!StringUtils.isBlank(approvedDomains)) { APPROVED_DOMAINS.add(approvedDomains); } // multiple admins are now allowed only in Scoold PRO String admins = Config.getConfigParam("admins", ""); if (!StringUtils.isBlank(admins)) { ADMINS.add(admins); } } public static void tryConnectToPara(Callable<Boolean> callable) { retryConnection(callable, 0); } private static void retryConnection(Callable<Boolean> callable, int retryCount) { try { if (!callable.call()) { throw new Exception(); } else if (retryCount > 0) { logger.info("Connected to Para backend."); } } catch (Exception e) { int maxRetries = Config.getConfigInt("connection_retries_max", 10); int retryInterval = Config.getConfigInt("connection_retry_interval_sec", 10); int count = ++retryCount; logger.error("No connection to Para backend. Retrying connection in {}s (attempt {} of {})...", retryInterval, count, maxRetries); if (maxRetries < 0 || retryCount < maxRetries) { Para.asyncExecute(new Runnable() { public void run() { try { Thread.sleep(retryInterval * 1000L); } catch (InterruptedException ex) { logger.error(null, ex); Thread.currentThread().interrupt(); } retryConnection(callable, count); } }); } } } public ParaObject checkAuth(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException { Profile authUser = null; boolean isApiRequest = isApiRequest(req); boolean isStaticRequest = StringUtils.endsWithAny(req.getRequestURI(), ".js", ".css", ".svg", ".png", ".jpg"); boolean isGlobalsJS = StringUtils.endsWithAny(req.getRequestURI(), "globals.js"); if (isApiRequest) { return checkApiAuth(req); } else if (HttpUtils.getStateParam(AUTH_COOKIE, req) != null && (!isStaticRequest || isGlobalsJS)) { User u = pc.me(HttpUtils.getStateParam(AUTH_COOKIE, req)); if (u != null && isEmailDomainApproved(u.getEmail())) { authUser = getOrCreateProfile(u, req); authUser.setUser(u); boolean updatedRank = promoteOrDemoteUser(authUser, u); boolean updatedProfile = updateProfilePictureAndName(authUser, u); if (updatedRank || updatedProfile) { authUser.update(); } } else { clearSession(req, res); if (u != null) { logger.warn("Attempted signin from an unknown domain: {}", u.getEmail()); } else { logger.info("Invalid JWT found in cookie {}.", AUTH_COOKIE); } res.sendRedirect(SIGNINLINK + "?code=3&error=true"); return null; } } return authUser; } private ParaObject checkApiAuth(HttpServletRequest req) { if (req.getRequestURI().equals(CONTEXT_PATH + "/api")) { return null; } String apiKeyJWT = StringUtils.removeStart(req.getHeader(HttpHeaders.AUTHORIZATION), "Bearer "); if (req.getRequestURI().equals(CONTEXT_PATH + "/api/stats") && isValidJWToken(apiKeyJWT)) { return API_USER; } else if (!isApiEnabled() || StringUtils.isBlank(apiKeyJWT) || !isValidJWToken(apiKeyJWT)) { throw new WebApplicationException(401); } return API_USER; } private boolean promoteOrDemoteUser(Profile authUser, User u) { if (authUser != null) { if (!isAdmin(authUser) && isRecognizedAsAdmin(u)) { logger.info("User '{}' with id={} promoted to admin.", u.getName(), authUser.getId()); authUser.setGroups(User.Groups.ADMINS.toString()); return true; } else if (isAdmin(authUser) && !isRecognizedAsAdmin(u)) { logger.info("User '{}' with id={} demoted to regular user.", u.getName(), authUser.getId()); authUser.setGroups(User.Groups.USERS.toString()); return true; } else if (!isMod(authUser) && u.isModerator()) { authUser.setGroups(User.Groups.MODS.toString()); return true; } } return false; } private Profile getOrCreateProfile(User u, HttpServletRequest req) { Profile authUser = pc.read(Profile.id(u.getId())); if (authUser == null) { authUser = Profile.fromUser(u); authUser.create(); if (!u.getIdentityProvider().equals("generic")) { sendWelcomeEmail(u, false, req); } Map<String, Object> payload = new LinkedHashMap<>(ParaObjectUtils.getAnnotatedFields(authUser, false)); payload.put("user", u); triggerHookEvent("user.signup", payload); logger.info("Created new user '{}' with id={}, groups={}, spaces={}.", u.getName(), authUser.getId(), authUser.getGroups(), authUser.getSpaces()); } return authUser; } private boolean updateProfilePictureAndName(Profile authUser, User u) { boolean update = false; if (!StringUtils.equals(u.getPicture(), authUser.getPicture()) && !StringUtils.contains(authUser.getPicture(), "gravatar.com") && !Config.getConfigBoolean("avatar_edits_enabled", true)) { authUser.setPicture(u.getPicture()); update = true; } if (!Config.getConfigBoolean("name_edits_enabled", true) && !StringUtils.equals(u.getName(), authUser.getName())) { authUser.setName(u.getName()); update = true; } if (!StringUtils.equals(u.getName(), authUser.getOriginalName())) { authUser.setOriginalName(u.getName()); update = true; } return update; } public void sendWelcomeEmail(User user, boolean verifyEmail, HttpServletRequest req) { // send welcome email notification if (user != null) { Map<String, Object> model = new HashMap<String, Object>(); Map<String, String> lang = getLang(req); String subject = Utils.formatMessage(lang.get("signin.welcome"), Config.APP_NAME); String body1 = Utils.formatMessage(Config.getConfigParam("emails.welcome_text1", lang.get("signin.welcome.body1") + "<br><br>"), Config.APP_NAME); String body2 = Config.getConfigParam("emails.welcome_text2", lang.get("signin.welcome.body2") + "<br><br>"); String body3 = Utils.formatMessage(Config.getConfigParam("emails.welcome_text3", "Best, <br>The {0} team<br><br>"), Config.APP_NAME); if (verifyEmail && !user.getActive() && !StringUtils.isBlank(user.getIdentifier())) { Sysprop s = pc.read(user.getIdentifier()); if (s != null) { String token = Utils.base64encURL(Utils.generateSecurityToken().getBytes()); s.addProperty(Config._EMAIL_TOKEN, token); pc.update(s); token = getServerURL() + CONTEXT_PATH + SIGNINLINK + "/register?id=" + user.getId() + "&token=" + token; body3 = "<b><a href=\"" + token + "\">" + lang.get("signin.welcome.verify") + "</a></b><br><br>" + body3; } } model.put("logourl", Config.getConfigParam("small_logo_url", "https://scoold.com/logo.png")); model.put("heading", Utils.formatMessage(lang.get("signin.welcome.title"), user.getName())); model.put("body", body1 + body2 + body3); emailer.sendEmail(Arrays.asList(user.getEmail()), subject, compileEmailTemplate(model)); } } public void sendPasswordResetEmail(String email, String token, HttpServletRequest req) { if (email != null && token != null) { Map<String, Object> model = new HashMap<String, Object>(); Map<String, String> lang = getLang(req); String url = getServerURL() + CONTEXT_PATH + SIGNINLINK + "/iforgot?email=" + email + "&token=" + token; String subject = lang.get("iforgot.title"); String body1 = "Open the link below to change your password:<br><br>"; String body2 = Utils.formatMessage("<b><a href=\"{0}\">RESET PASSWORD</a></b><br><br>", url); String body3 = "Best, <br>The " + Config.APP_NAME + " team<br><br>"; model.put("logourl", Config.getConfigParam("small_logo_url", "https://scoold.com/logo.png")); model.put("heading", lang.get("hello")); model.put("body", body1 + body2 + body3); emailer.sendEmail(Arrays.asList(email), subject, compileEmailTemplate(model)); } } @SuppressWarnings("unchecked") public void subscribeToNotifications(String email, String channelId) { if (!StringUtils.isBlank(email) && !StringUtils.isBlank(channelId)) { Sysprop s = pc.read(channelId); if (s == null || !s.hasProperty("emails")) { s = new Sysprop(channelId); s.addProperty("emails", new LinkedList<>()); } Set<String> emails = new HashSet<>((List<String>) s.getProperty("emails")); if (emails.add(email)) { s.addProperty("emails", emails); pc.create(s); } } } @SuppressWarnings("unchecked") public void unsubscribeFromNotifications(String email, String channelId) { if (!StringUtils.isBlank(email) && !StringUtils.isBlank(channelId)) { Sysprop s = pc.read(channelId); if (s == null || !s.hasProperty("emails")) { s = new Sysprop(channelId); s.addProperty("emails", new LinkedList<>()); } Set<String> emails = new HashSet<>((List<String>) s.getProperty("emails")); if (emails.remove(email)) { s.addProperty("emails", emails); pc.create(s); } } } @SuppressWarnings("unchecked") public Set<String> getNotificationSubscribers(String channelId) { return ((List<String>) Optional.ofNullable(((Sysprop) pc.read(channelId))). orElse(new Sysprop()).getProperties().getOrDefault("emails", Collections.emptyList())). stream().collect(Collectors.toSet()); } public void unsubscribeFromAllNotifications(Profile p) { User u = p.getUser(); if (u != null) { unsubscribeFromNewPosts(u); } } public boolean isEmailDomainApproved(String email) { if (StringUtils.isBlank(email)) { return false; } if (!APPROVED_DOMAINS.isEmpty()) { return APPROVED_DOMAINS.contains(StringUtils.substringAfter(email, "@")); } return true; } public Object isSubscribedToNewPosts(HttpServletRequest req) { Profile authUser = getAuthUser(req); if (authUser != null) { User u = authUser.getUser(); if (u != null) { return getNotificationSubscribers(EMAIL_ALERTS_PREFIX + "new_post_subscribers").contains(u.getEmail()); } } return false; } public void subscribeToNewPosts(User u) { if (u != null) { subscribeToNotifications(u.getEmail(), EMAIL_ALERTS_PREFIX + "new_post_subscribers"); } } public void unsubscribeFromNewPosts(User u) { if (u != null) { unsubscribeFromNotifications(u.getEmail(), EMAIL_ALERTS_PREFIX + "new_post_subscribers"); } } private Map<String, Profile> buildProfilesMap(List<User> users) { if (users != null && !users.isEmpty()) { Map<String, User> userz = users.stream().collect(Collectors.toMap(u -> u.getId(), u -> u)); List<Profile> profiles = pc.readAll(userz.keySet().stream(). map(uid -> Profile.id(uid)).collect(Collectors.toList())); Map<String, Profile> profilesMap = new HashMap<String, Profile>(users.size()); profiles.forEach(pr -> profilesMap.put(userz.get(pr.getCreatorid()).getEmail(), pr)); return profilesMap; } return Collections.emptyMap(); } private void sendEmailsToSubscribersInSpace(Set<String> emails, String space, String subject, String html) { int i = 0; int max = Config.MAX_ITEMS_PER_PAGE; List<String> terms = new ArrayList<>(max); for (String email : emails) { terms.add(email); if (++i == max) { emailer.sendEmail(buildProfilesMap(pc.findTermInList(Utils.type(User.class), Config._EMAIL, terms)). entrySet().stream().filter(e -> canAccessSpace(e.getValue(), space)). map(e -> e.getKey()).collect(Collectors.toList()), subject, html); i = 0; terms.clear(); } } if (!terms.isEmpty()) { emailer.sendEmail(buildProfilesMap(pc.findTermInList(Utils.type(User.class), Config._EMAIL, terms)). entrySet().stream().filter(e -> canAccessSpace(e.getValue(), space)). map(e -> e.getKey()).collect(Collectors.toList()), subject, html); } } private Set<String> getFavTagsSubscribers(List<String> tags) { if (!tags.isEmpty()) { Set<String> emails = new LinkedHashSet<>(); // find all user objects even if there are more than 10000 users in the system Pager pager = new Pager(1, "_docid", false, Config.MAX_ITEMS_PER_PAGE); List<Profile> profiles; do { profiles = pc.findQuery(Utils.type(Profile.class), "properties.favtags:(" + tags.stream(). map(t -> "\"".concat(t).concat("\"")).distinct(). collect(Collectors.joining(" ")) + ") AND properties.favtagsEmailsEnabled:true", pager); if (!profiles.isEmpty()) { List<User> users = pc.readAll(profiles.stream().map(p -> p.getCreatorid()). distinct().collect(Collectors.toList())); users.stream().forEach(u -> emails.add(u.getEmail())); } } while (!profiles.isEmpty()); return emails; } return Collections.emptySet(); } @SuppressWarnings("unchecked") public void sendUpdatedFavTagsNotifications(Post question, List<String> addedTags) { // sends a notification to subscibers of a tag if that tag was added to an existing question if (question != null && !question.isReply() && addedTags != null && !addedTags.isEmpty()) { Profile postAuthor = question.getAuthor(); // the current user - same as utils.getAuthUser(req) Map<String, Object> model = new HashMap<String, Object>(); String name = postAuthor.getName(); String body = Utils.markdownToHtml(question.getBody()); String picture = Utils.formatMessage("<img src='{0}' width='25'>", postAuthor.getPicture()); String postURL = getServerURL() + question.getPostLink(false, false); String tagsString = Optional.ofNullable(question.getTags()).orElse(Collections.emptyList()).stream(). map(t -> "<span class=\"tag\">" + (addedTags.contains(t) ? "<b>" + t + "<b>" : t) + "</span>"). collect(Collectors.joining(" ")); model.put("logourl", Config.getConfigParam("small_logo_url", "https://scoold.com/logo.png")); model.put("heading", Utils.formatMessage("{0} {1} edited:", picture, name)); model.put("body", Utils.formatMessage("<h2><a href='{0}'>{1}</a></h2><div>{2}</div><br>{3}", postURL, question.getTitle(), body, tagsString)); Set<String> emails = getFavTagsSubscribers(addedTags); sendEmailsToSubscribersInSpace(emails, question.getSpace(), name + " edited question '" + Utils.abbreviate(question.getTitle(), 255) + "'", compileEmailTemplate(model)); } } @SuppressWarnings("unchecked") public void sendNewPostNotifications(Post question) { if (question == null) { return; } // the current user - same as utils.getAuthUser(req) Profile postAuthor = question.getAuthor() != null ? question.getAuthor() : pc.read(question.getCreatorid()); if (!question.getType().equals(Utils.type(UnapprovedQuestion.class))) { Map<String, Object> model = new HashMap<String, Object>(); String name = postAuthor.getName(); String body = Utils.markdownToHtml(question.getBody()); String picture = Utils.formatMessage("<img src='{0}' width='25'>", postAuthor.getPicture()); String postURL = getServerURL() + question.getPostLink(false, false); String tagsString = Optional.ofNullable(question.getTags()).orElse(Collections.emptyList()).stream(). map(t -> "<span class=\"tag\">" + t + "</span>"). collect(Collectors.joining(" ")); model.put("logourl", Config.getConfigParam("small_logo_url", "https://scoold.com/logo.png")); model.put("heading", Utils.formatMessage("{0} {1} posted:", picture, name)); model.put("body", Utils.formatMessage("<h2><a href='{0}'>{1}</a></h2><div>{2}</div><br>{3}", postURL, question.getTitle(), body, tagsString)); Set<String> emails = new HashSet<String>(getNotificationSubscribers(EMAIL_ALERTS_PREFIX + "new_post_subscribers")); emails.addAll(getFavTagsSubscribers(question.getTags())); sendEmailsToSubscribersInSpace(emails, question.getSpace(), name + " posted the question '" + Utils.abbreviate(question.getTitle(), 255) + "'", compileEmailTemplate(model)); } else if (postsNeedApproval() && question instanceof UnapprovedQuestion) { Report rep = new Report(); rep.setDescription("New question awaiting approval"); rep.setSubType(Report.ReportType.OTHER); rep.setLink(question.getPostLink(false, false)); rep.setAuthorName(postAuthor.getName()); rep.create(); } } public void sendReplyNotifications(Post parentPost, Post reply) { // send email notification to author of post except when the reply is by the same person if (parentPost != null && reply != null && !StringUtils.equals(parentPost.getCreatorid(), reply.getCreatorid())) { Profile replyAuthor = reply.getAuthor(); // the current user - same as utils.getAuthUser(req) Map<String, Object> model = new HashMap<String, Object>(); String name = replyAuthor.getName(); String body = Utils.markdownToHtml(reply.getBody()); String picture = Utils.formatMessage("<img src='{0}' width='25'>", replyAuthor.getPicture()); String postURL = getServerURL() + parentPost.getPostLink(false, false); model.put("logourl", Config.getConfigParam("small_logo_url", "https://scoold.com/logo.png")); model.put("heading", Utils.formatMessage("New reply to <a href='{0}'>{1}</a>", postURL, parentPost.getTitle())); model.put("body", Utils.formatMessage("<h2>{0} {1}:</h2><div>{2}</div>", picture, name, body)); Profile authorProfile = pc.read(parentPost.getCreatorid()); if (authorProfile != null) { User author = authorProfile.getUser(); if (author != null) { if (authorProfile.getReplyEmailsEnabled()) { parentPost.addFollower(author); } } } if (postsNeedApproval() && reply instanceof UnapprovedReply) { Report rep = new Report(); rep.setDescription("New reply awaiting approval"); rep.setSubType(Report.ReportType.OTHER); rep.setLink(parentPost.getPostLink(false, false) + "#post-" + reply.getId()); rep.setAuthorName(reply.getAuthor().getName()); rep.create(); } if (parentPost.hasFollowers()) { emailer.sendEmail(new ArrayList<String>(parentPost.getFollowers().values()), name + " replied to '" + Utils.abbreviate(reply.getTitle(), 255) + "'", compileEmailTemplate(model)); } } } public Profile getAuthUser(HttpServletRequest req) { return (Profile) req.getAttribute(AUTH_USER_ATTRIBUTE); } public boolean isAuthenticated(HttpServletRequest req) { return getAuthUser(req) != null; } public boolean isNearMeFeatureEnabled() { return Config.getConfigBoolean("nearme_feature_enabled", !Config.getConfigParam("gmaps_api_key", "").isEmpty()); } public boolean isFeedbackEnabled() { return Config.getConfigBoolean("feedback_enabled", false); } public boolean isDefaultSpacePublic() { return Config.getConfigBoolean("is_default_space_public", true); } public boolean isWebhooksEnabled() { return Config.getConfigBoolean("webhooks_enabled", true); } public boolean isApiEnabled() { return Config.getConfigBoolean("api_enabled", false); } public boolean isFooterLinksEnabled() { return Config.getConfigBoolean("footer_links_enabled", true); } public String getFooterHTML() { return Config.getConfigParam("footer_html", ""); } public Set<String> getCoreScooldTypes() { return Collections.unmodifiableSet(CORE_TYPES); } public Set<String> getCustomHookEvents() { return Collections.unmodifiableSet(HOOK_EVENTS); } public Pager getPager(String pageParamName, HttpServletRequest req) { return pagerFromParams(pageParamName, req); } public Pager pagerFromParams(HttpServletRequest req) { return pagerFromParams("page", req); } public Pager pagerFromParams(String pageParamName, HttpServletRequest req) { Pager p = new Pager(); p.setPage(Math.min(NumberUtils.toLong(req.getParameter(pageParamName), 1), Config.MAX_PAGES)); p.setLimit(NumberUtils.toInt(req.getParameter("limit"), Config.MAX_ITEMS_PER_PAGE)); String lastKey = req.getParameter("lastKey"); String sort = req.getParameter("sortby"); String desc = req.getParameter("desc"); if (!StringUtils.isBlank(desc)) { p.setDesc(Boolean.parseBoolean(desc)); } if (!StringUtils.isBlank(lastKey)) { p.setLastKey(lastKey); } if (!StringUtils.isBlank(sort)) { p.setSortby(sort); } return p; } public String getLanguageCode(HttpServletRequest req) { String langCodeFromConfig = Config.getConfigParam("default_language_code", ""); String cookieLoc = getCookieValue(req, LOCALE_COOKIE); Locale requestLocale = langutils.getProperLocale(req.getLocale().toString()); return (cookieLoc != null) ? cookieLoc : (StringUtils.isBlank(langCodeFromConfig) ? requestLocale.getLanguage() : langutils.getProperLocale(langCodeFromConfig).getLanguage()); } public Locale getCurrentLocale(String langname) { Locale currentLocale = langutils.getProperLocale(langname); if (currentLocale == null) { currentLocale = langutils.getProperLocale(langutils.getDefaultLanguageCode()); } return currentLocale; } public Map<String, String> getLang(HttpServletRequest req) { return getLang(getCurrentLocale(getLanguageCode(req))); } public Map<String, String> getLang(Locale currentLocale) { Map<String, String> lang = langutils.readLanguage(currentLocale.toString()); if (lang == null || lang.isEmpty()) { lang = langutils.getDefaultLanguage(); } return lang; } public boolean isLanguageRTL(String langCode) { return StringUtils.equalsAnyIgnoreCase(langCode, "ar", "he", "dv", "iw", "fa", "ps", "sd", "ug", "ur", "yi"); } public void fetchProfiles(List<? extends ParaObject> objects) { if (objects == null || objects.isEmpty()) { return; } Map<String, String> authorids = new HashMap<String, String>(objects.size()); Map<String, Profile> authors = new HashMap<String, Profile>(objects.size()); for (ParaObject obj : objects) { if (obj.getCreatorid() != null) { authorids.put(obj.getId(), obj.getCreatorid()); } } List<String> ids = new ArrayList<String>(new HashSet<String>(authorids.values())); if (ids.isEmpty()) { return; } // read all post authors in batch for (ParaObject author : pc.readAll(ids)) { authors.put(author.getId(), (Profile) author); } // add system profile authors.put(API_USER.getId(), API_USER); // set author object for each post for (ParaObject obj : objects) { if (obj instanceof Post) { ((Post) obj).setAuthor(authors.get(authorids.get(obj.getId()))); } else if (obj instanceof Revision) { ((Revision) obj).setAuthor(authors.get(authorids.get(obj.getId()))); } } } //get the comments for each answer and the question public void getComments(List<Post> allPosts) { Map<String, List<Comment>> allComments = new HashMap<String, List<Comment>>(); List<String> allCommentIds = new ArrayList<String>(); List<Post> forUpdate = new ArrayList<Post>(allPosts.size()); // get the comment ids of the first 5 comments for each post for (Post post : allPosts) { // not set => read comments if any and embed ids in post object if (post.getCommentIds() == null) { forUpdate.add(reloadFirstPageOfComments(post)); allComments.put(post.getId(), post.getComments()); } else { // ids are set => add them to list for bulk read allCommentIds.addAll(post.getCommentIds()); } } if (!allCommentIds.isEmpty()) { // read all comments for all posts on page in bulk for (ParaObject comment : pc.readAll(allCommentIds)) { List<Comment> postComments = allComments.get(comment.getParentid()); if (postComments == null) { allComments.put(comment.getParentid(), new ArrayList<Comment>()); } allComments.get(comment.getParentid()).add((Comment) comment); } } // embed comments in each post for use within the view for (Post post : allPosts) { List<Comment> cl = allComments.get(post.getId()); long clSize = (cl == null) ? 0 : cl.size(); if (post.getCommentIds().size() != clSize) { forUpdate.add(reloadFirstPageOfComments(post)); clSize = post.getComments().size(); } else { post.setComments(cl); } post.getItemcount().setCount(clSize + 1L); // hack to show the "more" button } if (!forUpdate.isEmpty()) { pc.updateAll(allPosts); } } public Post reloadFirstPageOfComments(Post post) { List<Comment> commentz = pc.getChildren(post, Utils.type(Comment.class), post.getItemcount()); ArrayList<String> ids = new ArrayList<String>(commentz.size()); for (Comment comment : commentz) { ids.add(comment.getId()); } post.setCommentIds(ids); post.setComments(commentz); return post; } public void updateViewCount(Post showPost, HttpServletRequest req, HttpServletResponse res) { //do not count views from author if (showPost != null && !isMine(showPost, getAuthUser(req))) { String postviews = StringUtils.trimToEmpty(HttpUtils.getStateParam("postviews", req)); if (!StringUtils.contains(postviews, showPost.getId())) { long views = (showPost.getViewcount() == null) ? 0 : showPost.getViewcount(); showPost.setViewcount(views + 1); //increment count HttpUtils.setStateParam("postviews", (postviews.isEmpty() ? "" : postviews + ".") + showPost.getId(), req, res); pc.update(showPost); } } } public List<Post> getSimilarPosts(Post showPost, Pager pager) { List<Post> similarquestions = Collections.emptyList(); if (!showPost.isReply()) { String likeTxt = Utils.stripAndTrim((showPost.getTitle() + " " + showPost.getBody())); if (likeTxt.length() > 1000) { // read object on the server to prevent "URI too long" errors similarquestions = pc.findSimilar(showPost.getType(), showPost.getId(), new String[]{"properties.title", "properties.body", "properties.tags"}, "id:" + showPost.getId(), pager); } else if (!StringUtils.isBlank(likeTxt)) { similarquestions = pc.findSimilar(showPost.getType(), showPost.getId(), new String[]{"properties.title", "properties.body", "properties.tags"}, likeTxt, pager); } } return similarquestions; } public String getFirstLinkInPost(String postBody) { postBody = StringUtils.trimToEmpty(postBody); Pattern p = Pattern.compile("^!?\\[.*\\]\\((.+)\\)"); Matcher m = p.matcher(postBody); if (m.find()) { return m.group(1); } return ""; } public boolean param(HttpServletRequest req, String param) { return req.getParameter(param) != null; } public boolean isAjaxRequest(HttpServletRequest req) { return req.getHeader("X-Requested-With") != null || req.getParameter("X-Requested-With") != null; } public boolean isApiRequest(HttpServletRequest req) { return req.getRequestURI().startsWith(CONTEXT_PATH + "/api/") || req.getRequestURI().equals(CONTEXT_PATH + "/api"); } public boolean isAdmin(Profile authUser) { return authUser != null && User.Groups.ADMINS.toString().equals(authUser.getGroups()); } public boolean isMod(Profile authUser) { return authUser != null && (isAdmin(authUser) || User.Groups.MODS.toString().equals(authUser.getGroups())); } public boolean isRecognizedAsAdmin(User u) { return u.isAdmin() || ADMINS.contains(u.getIdentifier()) || ADMINS.stream().filter(s -> s.equalsIgnoreCase(u.getEmail())).findAny().isPresent(); } public boolean canComment(Profile authUser, HttpServletRequest req) { return isAuthenticated(req) && ((authUser.hasBadge(ENTHUSIAST) || Config.getConfigBoolean("new_users_can_comment", true) || isMod(authUser))); } public boolean postsNeedApproval() { return Config.getConfigBoolean("posts_need_approval", false); } public boolean postNeedsApproval(Profile authUser) { return postsNeedApproval() && authUser.getVotes() < Config.getConfigInt("posts_rep_threshold", ENTHUSIAST_IFHAS) && !isMod(authUser); } public boolean isDefaultSpace(String space) { return DEFAULT_SPACE.equalsIgnoreCase(getSpaceId(space)); } public boolean isAllSpaces(String space) { return ALL_MY_SPACES.equalsIgnoreCase(getSpaceId(space)); } public boolean canAccessSpace(Profile authUser, String targetSpaceId) { if (authUser == null) { return isDefaultSpacePublic(); } if (isMod(authUser) || isAllSpaces(targetSpaceId)) { return true; } if (StringUtils.isBlank(targetSpaceId) || targetSpaceId.length() < 2) { return false; } if (isDefaultSpace(targetSpaceId)) { // can user access the default space (blank) return isDefaultSpacePublic() || isMod(authUser) || !authUser.hasSpaces(); } boolean isMemberOfSpace = false; for (String space : authUser.getSpaces()) { if (StringUtils.startsWithIgnoreCase(space, getSpaceId(targetSpaceId) + Config.SEPARATOR)) { isMemberOfSpace = true; break; } } return isMemberOfSpace; } public String getSpaceIdFromCookie(Profile authUser, HttpServletRequest req) { if (isAdmin(authUser) && req.getParameter("space") != null) { Sysprop s = pc.read(getSpaceId(req.getParameter("space"))); // API override if (s != null) { return s.getId() + Config.SEPARATOR + s.getName(); } } String space = getValidSpaceId(authUser, Utils.base64dec(getCookieValue(req, SPACE_COOKIE))); return (isAllSpaces(space) && isMod(authUser)) ? DEFAULT_SPACE : verifyExistingSpace(authUser, space); } public void storeSpaceIdInCookie(String space, HttpServletRequest req, HttpServletResponse res) { HttpUtils.setRawCookie(SPACE_COOKIE, Utils.base64encURL(space.getBytes()), req, res, false, StringUtils.isBlank(space) ? 0 : 365 * 24 * 60 * 60); } public String verifyExistingSpace(Profile authUser, String space) { if (!isDefaultSpace(space) && !isAllSpaces(space) && pc.read(getSpaceId(space)) == null) { if (authUser != null) { authUser.removeSpace(space); pc.update(authUser); } return DEFAULT_SPACE; } return space; } public String getValidSpaceIdExcludingAll(Profile authUser, String space, HttpServletRequest req) { String s = StringUtils.isBlank(space) ? getSpaceIdFromCookie(authUser, req) : space; return isAllSpaces(s) ? getValidSpaceId(authUser, "x") : s; } private String getValidSpaceId(Profile authUser, String space) { if (authUser == null) { return DEFAULT_SPACE; } String defaultSpace = authUser.hasSpaces() ? authUser.getSpaces().iterator().next() : DEFAULT_SPACE; String s = canAccessSpace(authUser, space) ? space : defaultSpace; return StringUtils.isBlank(s) ? DEFAULT_SPACE : s; } public String getSpaceName(String space) { if (DEFAULT_SPACE.equalsIgnoreCase(space)) { return ""; } return RegExUtils.replaceAll(space, "^scooldspace:[^:]+:", ""); } public String getSpaceId(String space) { if (StringUtils.isBlank(space)) { return DEFAULT_SPACE; } String s = StringUtils.contains(space, Config.SEPARATOR) ? StringUtils.substring(space, 0, space.lastIndexOf(Config.SEPARATOR)) : "scooldspace:" + space; return "scooldspace".equals(s) ? space : s; } public String getSpaceFilteredQuery(Profile authUser, String currentSpace) { return isDefaultSpace(currentSpace) ? (canAccessSpace(authUser, currentSpace) ? "*" : "") : getSpaceFilter(authUser, currentSpace); } public String getSpaceFilteredQuery(HttpServletRequest req) { Profile authUser = getAuthUser(req); String currentSpace = getSpaceIdFromCookie(authUser, req); return getSpaceFilteredQuery(authUser, currentSpace); } public String getSpaceFilteredQuery(HttpServletRequest req, boolean isSpaceFiltered) { return getSpaceFilteredQuery(req, isSpaceFiltered, null, "*"); } public String getSpaceFilteredQuery(HttpServletRequest req, boolean isSpaceFiltered, String spaceFilter, String defaultQuery) { Profile authUser = getAuthUser(req); String currentSpace = getSpaceIdFromCookie(authUser, req); if (isSpaceFiltered) { return StringUtils.isBlank(spaceFilter) ? getSpaceFilter(authUser, currentSpace) : spaceFilter; } return canAccessSpace(authUser, currentSpace) ? defaultQuery : ""; } public String getSpaceFilter(Profile authUser, String spaceId) { if (isAllSpaces(spaceId)) { if (authUser.hasSpaces()) { return "(" + authUser.getSpaces().stream().map(s -> "properties.space:\"" + s + "\""). collect(Collectors.joining(" OR ")) + ")"; } else { return "properties.space:\"" + DEFAULT_SPACE + "\""; } } else { return "properties.space:\"" + spaceId + "\""; } } public Sysprop buildSpaceObject(String space) { space = Utils.abbreviate(space, 255); space = space.replaceAll(Config.SEPARATOR, ""); String spaceId = getSpaceId(Utils.noSpaces(Utils.stripAndTrim(space, " "), "-")); Sysprop s = new Sysprop(spaceId); s.setType("scooldspace"); s.setName(space); return s; } public String sanitizeQueryString(String query, HttpServletRequest req) { String qf = getSpaceFilteredQuery(req); String defaultQuery = "*"; String q = StringUtils.trimToEmpty(query); if (qf.isEmpty() || qf.length() > 1) { q = q.replaceAll("[\\*\\?]", "").trim(); q = RegExUtils.removeAll(q, "AND"); q = RegExUtils.removeAll(q, "OR"); q = RegExUtils.removeAll(q, "NOT"); q = q.trim(); defaultQuery = ""; } if (qf.isEmpty()) { return defaultQuery; } else if ("*".equals(qf)) { return q; } else { if (q.isEmpty()) { return qf; } else { return qf + " AND " + q; } } } public String getMacroCode(String key) { return WHITELISTED_MACROS.getOrDefault(key, ""); } public boolean isMine(Post showPost, Profile authUser) { // author can edit, mods can edit & ppl with rep > 100 can edit return showPost != null && authUser != null ? authUser.getId().equals(showPost.getCreatorid()) : false; } public boolean canEdit(Post showPost, Profile authUser) { return authUser != null ? (authUser.hasBadge(TEACHER) || isMod(authUser) || isMine(showPost, authUser)) : false; } @SuppressWarnings("unchecked") public <P extends ParaObject> P populate(HttpServletRequest req, P pobj, String... paramName) { if (pobj == null || paramName == null) { return pobj; } Map<String, Object> data = new LinkedHashMap<String, Object>(); if (isApiRequest(req)) { try { data = (Map<String, Object>) req.getAttribute(REST_ENTITY_ATTRIBUTE); if (data == null) { data = ParaObjectUtils.getJsonReader(Map.class).readValue(req.getInputStream()); } } catch (IOException ex) { logger.error(null, ex); data = Collections.emptyMap(); } } else { for (String param : paramName) { String[] values; if (param.matches(".+?\\|.$")) { // convert comma-separated value to list of strings String cleanParam = param.substring(0, param.length() - 2); values = req.getParameterValues(cleanParam); String firstValue = (values != null && values.length > 0) ? values[0] : null; String separator = param.substring(param.length() - 1); if (!StringUtils.isBlank(firstValue)) { data.put(cleanParam, Arrays.asList(firstValue.split(separator))); } } else { values = req.getParameterValues(param); if (values != null && values.length > 0) { data.put(param, values.length > 1 ? Arrays.asList(values) : Arrays.asList(values).iterator().next()); } } } } if (!data.isEmpty()) { ParaObjectUtils.setAnnotatedFields(pobj, data, null); } return pobj; } public <P extends ParaObject> Map<String, String> validate(P pobj) { HashMap<String, String> error = new HashMap<String, String>(); if (pobj != null) { Set<ConstraintViolation<P>> errors = ValidationUtils.getValidator().validate(pobj); for (ConstraintViolation<P> err : errors) { error.put(err.getPropertyPath().toString(), err.getMessage()); } } return error; } public static String getGravatar(String email) { if (StringUtils.isBlank(email)) { return "https://www.gravatar.com/avatar?d=retro&size=400"; } return "https://www.gravatar.com/avatar/" + Utils.md5(email.toLowerCase()) + "?size=400&d=retro"; } public static String getGravatar(Profile profile) { if (profile == null || profile.getUser() == null) { return "https://www.gravatar.com/avatar?d=retro&size=400"; } else { return getGravatar(profile.getUser().getEmail()); } } public void clearSession(HttpServletRequest req, HttpServletResponse res) { if (req != null) { HttpUtils.removeStateParam(AUTH_COOKIE, req, res); } } public boolean addBadgeOnce(Profile authUser, Profile.Badge b, boolean condition) { return addBadge(authUser, b, condition && !authUser.hasBadge(b), false); } public boolean addBadgeOnceAndUpdate(Profile authUser, Profile.Badge b, boolean condition) { return addBadgeAndUpdate(authUser, b, condition && authUser != null && !authUser.hasBadge(b)); } public boolean addBadgeAndUpdate(Profile authUser, Profile.Badge b, boolean condition) { return addBadge(authUser, b, condition, true); } public boolean addBadge(Profile user, Profile.Badge b, boolean condition, boolean update) { if (user != null && condition) { String newb = StringUtils.isBlank(user.getNewbadges()) ? "" : user.getNewbadges().concat(","); newb = newb.concat(b.toString()); user.addBadge(b); user.setNewbadges(newb); if (update) { user.update(); return true; } } return false; } public List<String> checkForBadges(Profile authUser, HttpServletRequest req) { List<String> badgelist = new ArrayList<String>(); if (authUser != null && !isAjaxRequest(req)) { long oneYear = authUser.getTimestamp() + (365 * 24 * 60 * 60 * 1000); addBadgeOnce(authUser, Profile.Badge.ENTHUSIAST, authUser.getVotes() >= ENTHUSIAST_IFHAS); addBadgeOnce(authUser, Profile.Badge.FRESHMAN, authUser.getVotes() >= FRESHMAN_IFHAS); addBadgeOnce(authUser, Profile.Badge.SCHOLAR, authUser.getVotes() >= SCHOLAR_IFHAS); addBadgeOnce(authUser, Profile.Badge.TEACHER, authUser.getVotes() >= TEACHER_IFHAS); addBadgeOnce(authUser, Profile.Badge.PROFESSOR, authUser.getVotes() >= PROFESSOR_IFHAS); addBadgeOnce(authUser, Profile.Badge.GEEK, authUser.getVotes() >= GEEK_IFHAS); addBadgeOnce(authUser, Profile.Badge.SENIOR, (System.currentTimeMillis() - authUser.getTimestamp()) >= oneYear); if (!StringUtils.isBlank(authUser.getNewbadges())) { badgelist.addAll(Arrays.asList(authUser.getNewbadges().split(","))); authUser.setNewbadges(null); authUser.update(); } } return badgelist; } private String loadEmailTemplate(String name) { return loadResource("emails/" + name + ".html"); } public String loadResource(String filePath) { if (filePath == null) { return ""; } if (FILE_CACHE.containsKey(filePath)) { return FILE_CACHE.get(filePath); } String template = ""; try (InputStream in = getClass().getClassLoader().getResourceAsStream(filePath)) { try (Scanner s = new Scanner(in).useDelimiter("\\A")) { template = s.hasNext() ? s.next() : ""; if (!StringUtils.isBlank(template)) { FILE_CACHE.put(filePath, template); } } } catch (Exception ex) { logger.info("Couldn't load resource '{}'.", filePath); } return template; } public String compileEmailTemplate(Map<String, Object> model) { model.put("footerhtml", Config.getConfigParam("emails_footer_html", "<a href=\"" + ScooldServer.getServerURL() + "\">" + Config.APP_NAME + "</a> • " + "<a href=\"https://scoold.com\">Powered by Scoold</a>")); return Utils.compileMustache(model, loadEmailTemplate("notify")); } public boolean isValidJWToken(String jwt) { try { String secret = Config.getConfigParam("app_secret_key", ""); if (secret != null && jwt != null) { JWSVerifier verifier = new MACVerifier(secret); SignedJWT sjwt = SignedJWT.parse(jwt); if (sjwt.verify(verifier)) { Date referenceTime = new Date(); JWTClaimsSet claims = sjwt.getJWTClaimsSet(); Date expirationTime = claims.getExpirationTime(); Date notBeforeTime = claims.getNotBeforeTime(); String jti = claims.getJWTID(); boolean expired = expirationTime != null && expirationTime.before(referenceTime); boolean notYetValid = notBeforeTime != null && notBeforeTime.after(referenceTime); boolean jtiRevoked = isApiKeyRevoked(jti, expired); return !(expired || notYetValid || jtiRevoked); } } } catch (JOSEException e) { logger.warn(null, e); } catch (ParseException ex) { logger.warn(null, ex); } return false; } public SignedJWT generateJWToken(Map<String, Object> claims) { return generateJWToken(claims, Config.JWT_EXPIRES_AFTER_SEC); } public SignedJWT generateJWToken(Map<String, Object> claims, long validitySeconds) { String secret = Config.getConfigParam("app_secret_key", ""); if (!StringUtils.isBlank(secret)) { try { Date now = new Date(); JWTClaimsSet.Builder claimsSet = new JWTClaimsSet.Builder(); claimsSet.issueTime(now); if (validitySeconds > 0) { claimsSet.expirationTime(new Date(now.getTime() + (validitySeconds * 1000))); } claimsSet.notBeforeTime(now); claimsSet.claim(Config._APPID, Config.getConfigParam("access_key", "x")); claims.entrySet().forEach((claim) -> claimsSet.claim(claim.getKey(), claim.getValue())); JWSSigner signer = new MACSigner(secret); SignedJWT signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claimsSet.build()); signedJWT.sign(signer); return signedJWT; } catch (JOSEException e) { logger.warn("Unable to sign JWT: {}.", e.getMessage()); } } logger.error("Failed to generate JWT token - app_secret_key is blank."); return null; } public boolean isApiKeyRevoked(String jti, boolean expired) { if (StringUtils.isBlank(jti)) { return false; } if (API_KEYS.isEmpty()) { Sysprop s = pc.read("api_keys"); if (s != null) { API_KEYS.putAll(s.getProperties()); } } if (API_KEYS.containsKey(jti) && expired) { revokeApiKey(jti); } return !API_KEYS.containsKey(jti); } public void registerApiKey(String jti, String jwt) { if (StringUtils.isBlank(jti) || StringUtils.isBlank(jwt)) { return; } API_KEYS.put(jti, jwt); saveApiKeysObject(); } public void revokeApiKey(String jti) { API_KEYS.remove(jti); saveApiKeysObject(); } public Map<String, Object> getApiKeys() { return Collections.unmodifiableMap(API_KEYS); } public Map<String, Long> getApiKeysExpirations() { return API_KEYS.keySet().stream().collect(Collectors.toMap(k -> k, k -> { try { Date exp = SignedJWT.parse((String) API_KEYS.get(k)).getJWTClaimsSet().getExpirationTime(); if (exp != null) { return exp.getTime(); } } catch (ParseException ex) { logger.error(null, ex); } return 0L; })); } private void saveApiKeysObject() { Sysprop s = new Sysprop("api_keys"); s.setProperties(API_KEYS); pc.create(s); } public void triggerHookEvent(String eventName, Object payload) { if (isWebhooksEnabled() && HOOK_EVENTS.contains(eventName)) { Para.asyncExecute(() -> { Webhook trigger = new Webhook(); trigger.setTriggeredEvent(eventName); trigger.setCustomPayload(payload); pc.create(trigger); }); } } public void setSecurityHeaders(String nonce, HttpServletRequest request, HttpServletResponse response) { // CSP Header if (Config.getConfigBoolean("csp_header_enabled", true)) { response.setHeader("Content-Security-Policy", Config.getConfigParam("csp_header", getDefaultContentSecurityPolicy(request.isSecure())). replaceAll("\\{\\{nonce\\}\\}", nonce)); } // HSTS Header if (Config.getConfigBoolean("hsts_header_enabled", true)) { response.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); } // Frame Options Header if (Config.getConfigBoolean("framing_header_enabled", true)) { response.setHeader("X-Frame-Options", "SAMEORIGIN"); } // XSS Header if (Config.getConfigBoolean("xss_header_enabled", true)) { response.setHeader("X-XSS-Protection", "1; mode=block"); } // Content Type Header if (Config.getConfigBoolean("contenttype_header_enabled", true)) { response.setHeader("X-Content-Type-Options", "nosniff"); } // Referrer Header if (Config.getConfigBoolean("referrer_header_enabled", true)) { response.setHeader("Referrer-Policy", "strict-origin"); } } public boolean cookieConsentGiven(HttpServletRequest request) { return !Config.getConfigBoolean("cookie_consent_required", false) || "allow".equals(HttpUtils.getCookieValue(request, "cookieconsent_status")); } public String base64DecodeScript(String encodedScript) { if (StringUtils.isBlank(encodedScript)) { return ""; } try { String decodedScript = Base64.isBase64(encodedScript) ? Utils.base64dec(encodedScript) : ""; return StringUtils.isBlank(decodedScript) ? encodedScript : decodedScript; } catch (Exception e) { return encodedScript; } } public Map<String, Object> getExternalScripts() { if (Config.getConfig().hasPath("external_scripts")) { ConfigObject extScripts = Config.getConfig().getObject("external_scripts"); if (extScripts != null && !extScripts.isEmpty()) { return new TreeMap<>(extScripts.unwrapped()); } } return Collections.emptyMap(); } public List<String> getExternalStyles() { String extStyles = Config.getConfigParam("external_styles", ""); if (!StringUtils.isBlank(extStyles)) { String[] styles = extStyles.split("\\s*,\\s*"); if (!StringUtils.isBlank(extStyles) && styles != null && styles.length > 0) { ArrayList<String> list = new ArrayList<String>(); for (String style : styles) { if (!StringUtils.isBlank(style)) { list.add(style); } } return list; } } return Collections.emptyList(); } public String getInlineCSS() { try { Sysprop custom = getCustomTheme(); String themeName = custom.getName(); String inline = Config.getConfigParam("inline_css", ""); String loadedTheme; if ("default".equalsIgnoreCase(themeName) || StringUtils.isBlank(themeName)) { return inline; } else if ("custom".equalsIgnoreCase(themeName)) { loadedTheme = (String) custom.getProperty("theme"); } else { loadedTheme = loadResource(getThemeKey(themeName)); if (StringUtils.isBlank(loadedTheme)) { FILE_CACHE.put("theme", "default"); custom.setName("default"); pc.update(custom); return inline; } else { FILE_CACHE.put("theme", themeName); } } loadedTheme = StringUtils.replaceEachRepeatedly(loadedTheme, new String[] {"<", "</", "<script", "<SCRIPT"}, new String[] {"", "", "", ""}); return loadedTheme + "\n/*** END OF THEME CSS ***/\n" + inline; } catch (Exception e) { logger.debug("Failed to load inline CSS."); } return ""; } public void setCustomTheme(String themeName, String themeCSS) { String id = "theme" + Config.SEPARATOR + "custom"; boolean isCustom = "custom".equalsIgnoreCase(themeName); String css = isCustom ? themeCSS : ""; Sysprop custom = new Sysprop(id); custom.setName(StringUtils.isBlank(css) && isCustom ? "default" : themeName); custom.addProperty("theme", css); pc.create(custom); FILE_CACHE.put("theme", themeName); FILE_CACHE.put(getThemeKey(themeName), isCustom ? css : loadResource(getThemeKey(themeName))); } public Sysprop getCustomTheme() { String id = "theme" + Config.SEPARATOR + "custom"; String selectedTheme = FILE_CACHE.get("theme"); if (selectedTheme != null && FILE_CACHE.containsKey(getThemeKey(selectedTheme))) { Sysprop s = new Sysprop(id); s.setName(selectedTheme); s.addProperty("theme", FILE_CACHE.get(getThemeKey(selectedTheme))); return s; } else { return (Sysprop) Optional.ofNullable(pc.read("theme" + Config.SEPARATOR + "custom")). orElseGet(() -> { String themeName = "default"; Sysprop s = new Sysprop(id); s.setName(themeName); s.addProperty("theme", ""); FILE_CACHE.put("theme", themeName); FILE_CACHE.put(getThemeKey(themeName), loadResource(getThemeKey(themeName))); return s; }); } } private String getThemeKey(String themeName) { return "themes/" + themeName + ".css"; } public String getDefaultTheme() { return loadResource("themes/default.css"); } public String getCSPNonce() { return Utils.generateSecurityToken(16); } public String getDefaultContentSecurityPolicy(boolean isSecure) { return (isSecure ? "upgrade-insecure-requests; " : "") + "default-src 'self'; " + "base-uri 'self'; " + "form-action 'self'; " + "connect-src 'self' " + (Config.IN_PRODUCTION ? getServerURL() : "") + " scoold.com www.google-analytics.com www.googletagmanager.com " + Config.getConfigParam("csp_connect_sources", "") + "; " + "frame-src 'self' accounts.google.com staticxx.facebook.com " + Config.getConfigParam("csp_frame_sources", "") + "; " + "font-src 'self' cdnjs.cloudflare.com fonts.gstatic.com fonts.googleapis.com " + Config.getConfigParam("csp_font_sources", "") + "; " + "style-src 'self' 'unsafe-inline' fonts.googleapis.com " // unsafe-inline required by MathJax and Google Maps! + (CDN_URL.startsWith("/") ? "" : CDN_URL) + " " + Config.getConfigParam("csp_style_sources", Config.getConfigParam("stylesheet_url", "") + " " + Config.getConfigParam("external_styles", "").replaceAll(",", "")) + "; " + "img-src 'self' https: data:; " + "object-src 'none'; " + "report-uri /reports/cspv; " + "script-src 'unsafe-inline' https: 'nonce-{{nonce}}' 'strict-dynamic';"; // CSP2 backward compatibility } }