@Component
@Named
public class ScooldConfig extends Config {

	private static final Logger logger = LoggerFactory.getLogger(ScooldConfig.class);

	@Override
	public com.typesafe.config.Config getFallbackConfig() {
		return Para.getConfig().getConfig(); // fall back to para.* config
	}

	@Override
	public Set<String> getKeysExcludedFromRendering() {
		return Set.of("scoold", "security.ignored", "security.protected.admin");
	}

	@Override
	public String getConfigRootPrefix() {
		return "scoold";
	}

	/* **************************************************************************************************************
	 * Core                                                                                                    Core *
	 ****************************************************************************************************************/

	@Documented(position = 10,
			identifier = "app_name",
			value = "Scoold",
			category = "Core",
			description = "The formal name of the web application.")
	public String appName() {
		return getConfigParam("app_name", "Scoold");
	}

	@Documented(position = 20,
			identifier = "para_access_key",
			value = "app:scoold",
			category = "Core",
			tags = {"requires restart"},
			description = "App identifier (access key) of the Para app used by Scoold.")
	public String paraAccessKey() {
		return getConfigParam("para_access_key", getConfigParam("access_key", "app:scoold"));
	}

	@Documented(position = 30,
			identifier = "para_secret_key",
			value = "x",
			category = "Core",
			tags = {"requires restart"},
			description = "Secret key of the Para app used by Scoold.")
	public String paraSecretKey() {
		return getConfigParam("para_secret_key", getConfigParam("secret_key", "x"));
	}

	@Documented(position = 40,
			identifier = "para_endpoint",
			value = "http://localhost:8080",
			category = "Core",
			tags = {"requires restart"},
			description = "The URL of the Para server for Scoold to connects to. For hosted Para, use `https://paraio.com`")
	public String paraEndpoint() {
		return getConfigParam("para_endpoint", getConfigParam("endpoint", "http://localhost:8080"));
	}

	@Documented(position = 50,
			identifier = "host_url",
			value = "http://localhost:8000",
			category = "Core",
			description = "The internet-facing (public) URL of this Scoold server.")
	public String serverUrl() {
		return StringUtils.removeEnd(getConfigParam("host_url", "http://localhost:" + serverPort()), "/");
	}

	@Documented(position = 60,
			identifier = "port",
			value = "8000",
			type = Integer.class,
			category = "Core",
			tags = {"requires restart"},
			description = "The network port of this Scoold server. Port number should be a number above `1024`.")
	public int serverPort() {
		return NumberUtils.toInt(System.getProperty("server.port"), getConfigInt("port", 8000));
	}

	@Documented(position = 70,
			identifier = "env",
			value = "development",
			category = "Core",
			tags = {"requires restart"},
			description = "The environment profile to be used - possible values are `production` or `development`")
	public String environment() {
		return getConfigParam("env", "development");
	}

	@Documented(position = 80,
			identifier = "app_secret_key",
			category = "Core",
			description = "A random secret string, min. 32 chars long. *Must be different from the secret key of "
					+ "the Para app*. Used for generating JWTs and passwordless authentication tokens.")
	public String appSecretKey() {
		return getConfigParam("app_secret_key", "");
	}

	@Documented(position = 90,
			identifier = "admins",
			category = "Core",
			description = "A comma-separated list of emails of people who will be promoted to administrators with "
					+ "full rights over the content on the site. This can also contain Para user identifiers.")
	public String admins() {
		return getConfigParam("admins", "");
	}

	@Documented(position = 100,
			identifier = "is_default_space_public",
			value = "true",
			type = Boolean.class,
			category = "Core",
			description = "When enabled, all content in the default space will be publicly visible, "
					+ "without authentication, incl. users and tags. Disable to make the site private.")
	public boolean isDefaultSpacePublic() {
		return getConfigBoolean("is_default_space_public", true);
	}

	@Documented(position = 110,
			identifier = "context_path",
			category = "Core",
			tags = {"requires restart"},
			description = "The context path (subpath) of the web application, defaults to the root path `/`.")
	public String serverContextPath() {
		String context = getConfigParam("context_path", "");
		return StringUtils.stripEnd((StringUtils.isBlank(context) ?
				System.getProperty("server.servlet.context-path", "") : context), "/");
	}

	@Documented(position = 120,
			identifier = "webhooks_enabled",
			value = "true",
			type = Boolean.class,
			category = "Core",
			description = "Enable/disable webhooks support for events like `question.create`, `user.signup`, etc.")
	public boolean webhooksEnabled() {
		return getConfigBoolean("webhooks_enabled", true);
	}

	@Documented(position = 130,
			identifier = "api_enabled",
			value = "false",
			type = Boolean.class,
			category = "Core",
			description = "Enable/disable the Scoold RESTful API. Disabled by default.")
	public boolean apiEnabled() {
		return getConfigBoolean("api_enabled", false);
	}

	@Documented(position = 140,
			identifier = "feedback_enabled",
			value = "false",
			type = Boolean.class,
			category = "Core",
			description = "Enable/disable the feedback page on the site. It is intended for internal discussion "
					+ "about the website itself.")
	public boolean feedbackEnabled() {
		return getConfigBoolean("feedback_enabled", false);
	}


	/* **************************************************************************************************************
	 * Emails                                                                                                Emails *
	 ****************************************************************************************************************/

	@Documented(position = 150,
			identifier = "support_email",
			value = "contact@scoold.com",
			category = "Emails",
			description = "The email address to use for sending transactional emails, like welcome/password reset emails.")
	public String supportEmail() {
		return getConfigParam("support_email", "contact@scoold.com");
	}

	@Documented(position = 160,
			identifier = "mail.host",
			category = "Emails",
			description = "The SMTP server host to use for sending emails.")
	public String mailHost() {
		return getConfigParam("mail.host", "");
	}

	@Documented(position = 170,
			identifier = "mail.port",
			value = "587",
			type = Integer.class,
			category = "Emails",
			description = "The SMTP server port to use for sending emails.")
	public int mailPort() {
		return getConfigInt("mail.port", 587);
	}

	@Documented(position = 180,
			identifier = "mail.username",
			category = "Emails",
			description = "The SMTP server username.")
	public String mailUsername() {
		return getConfigParam("mail.username", "");
	}

	@Documented(position = 190,
			identifier = "mail.password",
			category = "Emails",
			description = "The SMTP server password.")
	public String mailPassword() {
		return getConfigParam("mail.password", "");
	}

	@Documented(position = 200,
			identifier = "mail.tls",
			value = "true",
			type = Boolean.class,
			category = "Emails",
			description = "Enable/disable TLS for the SMTP connection.")
	public boolean mailTLSEnabled() {
		return getConfigBoolean("mail.tls", true);
	}

	@Documented(position = 210,
			identifier = "mail.ssl",
			value = "false",
			type = Boolean.class,
			category = "Emails",
			description = "Enable/disable SSL for the SMTP connection.")
	public boolean mailSSLEnabled() {
		return getConfigBoolean("mail.ssl", false);
	}

	@Documented(position = 220,
			identifier = "mail.debug",
			value = "false",
			type = Boolean.class,
			category = "Emails",
			description = "Enable/disable debug information when sending emails through SMTP.")
	public boolean mailDebugEnabled() {
		return getConfigBoolean("mail.debug", false);
	}

	@Documented(position = 230,
			identifier = "favtags_emails_enabled",
			value = "false",
			type = Boolean.class,
			category = "Emails",
			description = "Set the default toggle value for all users for receiving emails for new content "
					+ "with their favorite tags.")
	public boolean favoriteTagsEmailsEnabled() {
		return getConfigBoolean("favtags_emails_enabled", false);
	}

	@Documented(position = 240,
			identifier = "reply_emails_enabled",
			value = "false",
			type = Boolean.class,
			category = "Emails",
			description = "Set the default toggle value for all users for receiving emails for answers to their questions.")
	public boolean replyEmailsEnabled() {
		return getConfigBoolean("reply_emails_enabled", false);
	}

	@Documented(position = 250,
			identifier = "comment_emails_enabled",
			value = "false",
			type = Boolean.class,
			category = "Emails",
			description = "Set the default toggle value for all users for receiving emails for comments on their posts.")
	public boolean commentEmailsEnabled() {
		return getConfigBoolean("comment_emails_enabled", false);
	}

	@Documented(position = 260,
			identifier = "summary_email_period_days",
			value = "7",
			type = Integer.class,
			category = "Emails",
			tags = {"Pro"},
			description = "The time period between each content digest email, in days.")
	public int emailsSummaryIntervalDays() {
		return getConfigInt("summary_email_period_days", 7);
	}

	@Documented(position = 270,
			identifier = "summary_email_items",
			value = "25",
			type = Integer.class,
			category = "Emails",
			description = "The number of posts to include in the digest email (a summary of new posts).")
	public int emailsSummaryItems() {
		return getConfigInt("summary_email_items", 25);
	}

	@Documented(position = 280,
			identifier = "notification_emails_allowed",
			value = "true",
			type = Boolean.class,
			category = "Emails",
			description = "Enable/disable *all* notification emails.")
	public boolean notificationEmailsAllowed() {
		return getConfigBoolean("notification_emails_allowed", true);
	}

	@Documented(position = 290,
			identifier = "newpost_emails_allowed",
			value = "true",
			type = Boolean.class,
			category = "Emails",
			description = "Enable/disable *all* email notifications for every new question that is posted on the site.")
	public boolean emailsForNewPostsAllowed() {
		return getConfigBoolean("newpost_emails_allowed", true);
	}

	@Documented(position = 300,
			identifier = "favtags_emails_allowed",
			value = "true",
			type = Boolean.class,
			category = "Emails",
			description = "Enable/disable *all* email notifications for every new question tagged with a favorite tag.")
	public boolean emailsForFavtagsAllowed() {
		return getConfigBoolean("favtags_emails_allowed", true);
	}

	@Documented(position = 310,
			identifier = "reply_emails_allowed",
			value = "true",
			type = Boolean.class,
			category = "Emails",
			description = "Enable/disable *all* email notifications for every new answer that is posted on the site.")
	public boolean emailsForRepliesAllowed() {
		return getConfigBoolean("reply_emails_allowed", true);
	}

	@Documented(position = 320,
			identifier = "comment_emails_allowed",
			value = "true",
			type = Boolean.class,
			category = "Emails",
			description = "Enable/disable *all* email notifications for every new comment that is posted on the site.")
	public boolean emailsForCommentsAllowed() {
		return getConfigBoolean("comment_emails_allowed", true);
	}

	@Documented(position = 330,
			identifier = "mentions_emails_allowed",
			value = "true",
			type = Boolean.class,
			category = "Emails",
			tags = {"Pro"},
			description = "Enable/disable *all* email notifications every time a user is mentioned.")
	public boolean emailsForMentionsAllowed() {
		return getConfigBoolean("mentions_emails_allowed", true);
	}

	@Documented(position = 340,
			identifier = "summary_email_controlled_by_admins",
			value = "false",
			type = Boolean.class,
			category = "Emails",
			tags = {"Pro"},
			description = "Controls whether admins can enable/disable summary emails for everyone from the 'Settings' page")
	public boolean emailsForSummaryControlledByAdmins() {
		return getConfigBoolean("summary_email_controlled_by_admins", false);
	}

	@Documented(position = 350,
			identifier = "mention_emails_controlled_by_admins",
			value = "false",
			type = Boolean.class,
			category = "Emails",
			tags = {"Pro"},
			description = "Controls whether admins can enable/disable mention emails for everyone from the 'Settings' page")
	public boolean emailsForMentionsControlledByAdmins() {
		return getConfigBoolean("mention_emails_controlled_by_admins", false);
	}

	@Documented(position = 360,
			identifier = "emails.welcome_text1",
			value = "You are now part of {0} - a friendly Q&A community...",
			category = "Emails",
			description = "Allows for changing the default text (first paragraph) in the welcome email message.")
	public String emailsWelcomeText1(Map<String, String> lang) {
		return getConfigParam("emails.welcome_text1", lang.get("signin.welcome.body1") + "<br><br>");
	}

	@Documented(position = 370,
			identifier = "emails.welcome_text2",
			value = "To get started, simply navigate to the \"Ask question\" page and ask a question...",
			category = "Emails",
			description = "Allows for changing the default text (second paragraph) in the welcome email message.")
	public String emailsWelcomeText2(Map<String, String> lang) {
		return getConfigParam("emails.welcome_text2", lang.get("signin.welcome.body2") + "<br><br>");
	}

	@Documented(position = 380,
			identifier = "emails.welcome_text3",
			value = "Best, <br>The {0} team",
			category = "Emails",
			description = "Allows for changing the default text (signature at the end) in the welcome email message.")
	public String emailsWelcomeText3(Map<String, String> lang) {
		return getConfigParam("emails.welcome_text3", lang.get("notification.signature") + "<br><br>");
	}

	@Documented(position = 390,
			identifier = "emails.default_signature",
			value = "Best, <br>The {0} team",
			category = "Emails",
			description = "The default email signature for all transactional emails sent from Scoold.")
	public String emailsDefaultSignatureText(String defaultText) {
		return getConfigParam("emails.default_signature", defaultText);
	}

	/* **************************************************************************************************************
	 * Security                                                                                            Security *
	 ****************************************************************************************************************/

	@Documented(position = 400,
			identifier = "approved_domains_for_signups",
			category = "Security",
			description = "A comma-separated list of domain names, which will be used to restrict the people who "
					+ "are allowed to sign up on the site.")
	public String approvedDomainsForSignups() {
		return getConfigParam("approved_domains_for_signups", "");
	}

	@Documented(position = 410,
			identifier = "security.allow_unverified_emails",
			value = "false",
			type = Boolean.class,
			category = "Security",
			description = "Enable/disable email verification after the initial user registration. Users with unverified "
					+ "emails won't be able to sign in, unless they use a social login provider.")
	public boolean allowUnverifiedEmails() {
		return getConfigBoolean("security.allow_unverified_emails", StringUtils.isBlank(mailHost()));
	}

	@Documented(position = 420,
			identifier = "session_timeout",
			value = "86400",
			type = Integer.class,
			category = "Security",
			description = "The validity period of the authentication cookie, in seconds. Default is 24h.")
	public int sessionTimeoutSec() {
		return getConfigInt("session_timeout", Para.getConfig().sessionTimeoutSec());
	}

	@Documented(position = 430,
			identifier = "jwt_expires_after",
			value = "86400",
			type = Integer.class,
			category = "Security",
			description = "The validity period of the session token (JWT), in seconds. Default is 24h.")
	public int jwtExpiresAfterSec() {
		return getConfigInt("jwt_expires_after", Para.getConfig().jwtExpiresAfterSec());
	}

	@Documented(position = 440,
			identifier = "security.one_session_per_user",
			value = "true",
			type = Boolean.class,
			category = "Security",
			description = "If disabled, users can sign in from multiple locations and devices, keeping a few open "
					+ "sessions at once. Otherwise, only one session will be kept open, others will be closed.")
	public boolean oneSessionPerUser() {
		return getConfigBoolean("security.one_session_per_user", true);
	}

	@Documented(position = 450,
			identifier = "min_password_length",
			value = "8",
			type = Integer.class,
			category = "Security",
			description = "The minimum length of passwords.")
	public int minPasswordLength() {
		return getConfigInt("min_password_length", Para.getConfig().minPasswordLength());
	}

	@Documented(position = 460,
			identifier = "min_password_strength",
			value = "2",
			type = Integer.class,
			category = "Security",
			description = "The minimum password strength - one of 3 levels: `1` good enough, `2` strong, `3` very strong.")
	public int minPasswordStrength() {
		return getConfigInt("min_password_strength", 2);
	}

	@Documented(position = 470,
			identifier = "pass_reset_timeout",
			value = "1800",
			type = Integer.class,
			category = "Security",
			description = "The validity period of the password reset token sent via email for resetting users' "
					+ "passwords. Default is 30 min.")
	public int passwordResetTimeoutSec() {
		return getConfigInt("pass_reset_timeout", Para.getConfig().passwordResetTimeoutSec());
	}

	@Documented(position = 480,
			identifier = "profile_anonimity_enabled",
			value = "false",
			type = Boolean.class,
			category = "Security",
			description = "Enable/disable the option for users to anonimize their profiles on the site, "
					+ "hiding their name and picture.")
	public boolean profileAnonimityEnabled() {
		return getConfigBoolean("profile_anonimity_enabled", false);
	}

	@Documented(position = 490,
			identifier = "signup_captcha_site_key",
			category = "Security",
			description = "The reCAPTCHA v3 site key for protecting the signup and password reset pages.")
	public String captchaSiteKey() {
		return getConfigParam("signup_captcha_site_key", "");
	}

	@Documented(position = 500,
			identifier = "signup_captcha_secret_key",
			category = "Security",
			description = "The reCAPTCHA v3 secret.")
	public String captchaSecretKey() {
		return getConfigParam("signup_captcha_secret_key", "");
	}
	@Documented(position = 510,
			identifier = "csp_reports_enabled",
			value = "false",
			type = Boolean.class,
			category = "Security",
			description = "Enable/disable automatic reports each time the Content Security Policy is violated.")
	public boolean cspReportsEnabled() {
		return getConfigBoolean("csp_reports_enabled", false);
	}

	@Documented(position = 520,
			identifier = "csp_header_enabled",
			value = "true",
			type = Boolean.class,
			category = "Security",
			description = "Enable/disable the Content Security Policy (CSP) header.")
	public boolean cspHeaderEnabled() {
		return getConfigBoolean("csp_header_enabled", true);
	}

	@Documented(position = 530,
			identifier = "csp_header",
			value = "Dynamically generated, with nonces",
			category = "Security",
			description = "The CSP header value which will overwrite the default one. This can contain one or more "
					+ "`{{nonce}}` placeholders, which will be replaced with an actual nonce on each request.")
	public String cspHeader(String nonce) {
		return getConfigParam("csp_header", getDefaultContentSecurityPolicy()).replaceAll("\\{\\{nonce\\}\\}", nonce);
	}

	@Documented(position = 540,
			identifier = "hsts_header_enabled",
			value = "true",
			type = Boolean.class,
			category = "Security",
			description = "Enable/disable the `Strict-Transport-Security` security header.")
	public boolean hstsHeaderEnabled() {
		return getConfigBoolean("hsts_header_enabled", true);
	}

	@Documented(position = 550,
			identifier = "framing_header_enabled",
			value = "true",
			type = Boolean.class,
			category = "Security",
			description = "Enable/disable the `X-Frame-Options` security header.")
	public boolean framingHeaderEnabled() {
		return getConfigBoolean("framing_header_enabled", true);
	}

	@Documented(position = 560,
			identifier = "xss_header_enabled",
			value = "true",
			type = Boolean.class,
			category = "Security",
			description = "Enable/disable the `X-XSS-Protection` security header.")
	public boolean xssHeaderEnabled() {
		return getConfigBoolean("xss_header_enabled", true);
	}

	@Documented(position = 570,
			identifier = "contenttype_header_enabled",
			value = "true",
			type = Boolean.class,
			category = "Security",
			description = "Enable/disable the `X-Content-Type-Options` security header.")
	public boolean contentTypeHeaderEnabled() {
		return getConfigBoolean("contenttype_header_enabled", true);
	}

	@Documented(position = 580,
			identifier = "referrer_header_enabled",
			value = "true",
			type = Boolean.class,
			category = "Security",
			description = "Enable/disable the `Referrer-Policy` security header.")
	public boolean referrerHeaderEnabled() {
		return getConfigBoolean("referrer_header_enabled", true);
	}

	@Documented(position = 590,
			identifier = "permissions_header_enabled",
			value = "true",
			type = Boolean.class,
			category = "Security",
			description = "Enable/disable the `Permissions-Policy` security header.")
	public boolean permissionsHeaderEnabled() {
		return getConfigBoolean("permissions_header_enabled", true);
	}

	@Documented(position = 600,
			identifier = "csp_connect_sources",
			category = "Security",
			description = "Additional sources to add to the `connect-src` CSP directive. "
					+ "Used when adding external scripts to the site.")
	public String cspConnectSources() {
		return getConfigParam("csp_connect_sources", "");
	}

	@Documented(position = 610,
			identifier = "csp_frame_sources",
			category = "Security",
			description = "Additional sources to add to the `frame-src` CSP directive. "
					+ "Used when adding external scripts to the site.")
	public String cspFrameSources() {
		return getConfigParam("csp_frame_sources", "");
	}

	@Documented(position = 620,
			identifier = "csp_font_sources",
			category = "Security",
			description = "Additional sources to add to the `font-src` CSP directive. "
					+ "Used when adding external fonts to the site.")
	public String cspFontSources() {
		return getConfigParam("csp_font_sources", "");
	}

	@Documented(position = 630,
			identifier = "csp_style_sources",
			value = "",
			category = "Security",
			description = "Additional sources to add to the `style-src` CSP directive. "
					+ "Used when adding external fonts to the site.")
	public String cspStyleSources() {
		return getConfigParam("csp_style_sources", stylesheetUrl() + " " + externalStyles().replaceAll(",", ""));
	}

	/* **************************************************************************************************************
	 * Basic Authentication                                                                    Basic Authentication *
	 ****************************************************************************************************************/

	@Documented(position = 640,
			identifier = "password_auth_enabled",
			value = "true",
			type = Boolean.class,
			category = "Basic Authentication",
			description = "Enabled/disable the ability for users to sign in with an email and password.")
	public boolean passwordAuthEnabled() {
		return getConfigBoolean("password_auth_enabled", true);
	}

	@Documented(position = 650,
			identifier = "fb_app_id",
			category = "Basic Authentication",
			description = "Facebook OAuth2 app ID.")
	public String facebookAppId() {
		return getConfigParam("fb_app_id", "");
	}

	@Documented(position = 660,
			identifier = "fb_secret",
			category = "Basic Authentication",
			description = "Facebook app secret key.")
	public String facebookSecret() {
		return getConfigParam("fb_secret", "");
	}

	@Documented(position = 670,
			identifier = "gp_app_id",
			category = "Basic Authentication",
			description = "Google OAuth2 app ID.")
	public String googleAppId() {
		return getConfigParam("gp_app_id", "");
	}

	@Documented(position = 680,
			identifier = "gp_secret",
			category = "Basic Authentication",
			description = "Google app secret key.")
	public String googleSecret() {
		return getConfigParam("gp_secret", "");
	}

	@Documented(position = 690,
			identifier = "in_app_id",
			category = "Basic Authentication",
			description = "LinkedIn OAuth2 app ID.")
	public String linkedinAppId() {
		return getConfigParam("in_app_id", "");
	}

	@Documented(position = 700,
			identifier = "in_secret",
			category = "Basic Authentication",
			description = "LinkedIn app secret key.")
	public String linkedinSecret() {
		return getConfigParam("in_secret", "");
	}

	@Documented(position = 710,
			identifier = "tw_app_id",
			category = "Basic Authentication",
			description = "Twitter OAuth app ID.")
	public String twitterAppId() {
		return getConfigParam("tw_app_id", "");
	}

	@Documented(position = 720,
			identifier = "tw_secret",
			category = "Basic Authentication",
			description = "Twitter app secret key.")
	public String twitterSecret() {
		return getConfigParam("tw_secret", "");
	}

	@Documented(position = 730,
			identifier = "gh_app_id",
			category = "Basic Authentication",
			description = "GitHub OAuth2 app ID.")
	public String githubAppId() {
		return getConfigParam("gh_app_id", "");
	}

	@Documented(position = 740,
			identifier = "gh_secret",
			category = "Basic Authentication",
			description = "GitHub app secret key.")
	public String githubSecret() {
		return getConfigParam("gh_secret", "");
	}

	@Documented(position = 750,
			identifier = "ms_app_id",
			category = "Basic Authentication",
			description = "Microsoft OAuth2 app ID.")
	public String microsoftAppId() {
		return getConfigParam("ms_app_id", "");
	}

	@Documented(position = 760,
			identifier = "ms_secret",
			category = "Basic Authentication",
			description = "Microsoft app secret key.")
	public String microsoftSecret() {
		return getConfigParam("ms_secret", "");
	}

	@Documented(position = 770,
			identifier = "ms_tenant_id",
			value = "common",
			category = "Basic Authentication",
			description = "Microsoft OAuth2 tenant ID")
	public String microsoftTenantId() {
		return getConfigParam("ms_tenant_id", "common");
	}

	@Documented(position = 780,
			identifier = "az_app_id",
			category = "Basic Authentication",
			description = "Amazon OAuth2 app ID.")
	public String amazonAppId() {
		return getConfigParam("az_app_id", "");
	}

	@Documented(position = 790,
			identifier = "az_secret",
			category = "Basic Authentication",
			description = "Amazon app secret key.")
	public String amazonSecret() {
		return getConfigParam("az_secret", "");
	}

	@Documented(position = 800,
			identifier = "sl_app_id",
			category = "Basic Authentication",
			tags = {"Pro"},
			description = "Slack OAuth2 app ID.")
	public String slackAppId() {
		return getConfigParam("sl_app_id", "");
	}

	@Documented(position = 810,
			identifier = "sl_secret",
			category = "Basic Authentication",
			tags = {"Pro"},
			description = "Slack app secret key.")
	public String slackSecret() {
		return getConfigParam("sl_secret", "");
	}

	@Documented(position = 820,
			identifier = "mm_app_id",
			category = "Basic Authentication",
			tags = {"Pro"},
			description = "Mattermost OAuth2 app ID.")
	public String mattermostAppId() {
		return getConfigParam("mm_app_id", "");
	}

	@Documented(position = 830,
			identifier = "mm_secret",
			category = "Basic Authentication",
			tags = {"Pro"},
			description = "Mattermost app secret key.")
	public String mattermostSecret() {
		return getConfigParam("mm_secret", "");
	}

	@Documented(position = 840,
			identifier = "security.custom.provider",
			value = "Continue with Acme Co.",
			category = "Basic Authentication",
			tags = {"Pro"},
			description = "The text on the button for signing in with the custom authentication scheme.")
	public String customLoginProvider() {
		return getConfigParam("security.custom.provider", "Continue with Acme Co.");
	}

	@Documented(position = 850,
			identifier = "security.custom.login_url",
			category = "Basic Authentication",
			tags = {"Pro"},
			description = "The URL address of an externally hosted, custom login page.")
	public String customLoginUrl() {
		return getConfigParam("security.custom.login_url", "");
	}

	/* **************************************************************************************************************
	 * LDAP Authentication                                                                      LDAP Authentication *
	 ****************************************************************************************************************/

	@Documented(position = 860,
			identifier = "security.ldap.server_url",
			category = "LDAP Authentication",
			description = "LDAP server URL. LDAP will be disabled if this is blank.")
	public String ldapServerUrl() {
		return getConfigParam("security.ldap.server_url", "");
	}

	@Documented(position = 870,
			identifier = "security.ldap.base_dn",
			category = "LDAP Authentication",
			description = "LDAP base DN.")
	public String ldapBaseDN() {
		return getConfigParam("security.ldap.base_dn", "");
	}

	@Documented(position = 880,
			identifier = "security.ldap.user_search_base",
			category = "LDAP Authentication",
			description = "LDAP search base, which will be used only if a direct bind is unsuccessfull.")
	public String ldapUserSearchBase() {
		return getConfigParam("security.ldap.user_search_base", "");
	}

	@Documented(position = 890,
			identifier = "security.ldap.user_search_filter",
			value = "(cn={0})",
			category = "LDAP Authentication",
			description = "LDAP search filter, for finding users if a direct bind is unsuccessful.")
	public String ldapUserSearchFilter() {
		return getConfigParam("security.ldap.user_search_filter", "(cn={0})");
	}

	@Documented(position = 900,
			identifier = "security.ldap.user_dn_pattern",
			value = "uid={0}",
			category = "LDAP Authentication",
			description = "LDAP user DN pattern, which will be comined with the base DN to form the full path to the"
					+ "user object, for a direct binding attempt.")
	public String ldapUserDNPattern() {
		return getConfigParam("security.ldap.user_dn_pattern", "uid={0}");
	}

	@Documented(position = 910,
			identifier = "security.ldap.active_directory_domain",
			category = "LDAP Authentication",
			description = "AD domain name. Add this *only* if you are connecting to an Active Directory server.")
	public String ldapActiveDirectoryDomain() {
		return getConfigParam("security.ldap.active_directory_domain", "");
	}

	@Documented(position = 920,
			identifier = "security.ldap.password_attribute",
			value = "userPassword",
			category = "LDAP Authentication",
			description = "LDAP password attribute name.")
	public String ldapPasswordAttributeName() {
		return getConfigParam("security.ldap.password_attribute", "userPassword");
	}

	@Documented(position = 930,
			identifier = "security.ldap.bind_dn",
			category = "LDAP Authentication",
			description = "LDAP bind DN")
	public String ldapBindDN() {
		return getConfigParam("security.ldap.bind_dn", "");
	}

	@Documented(position = 940,
			identifier = "security.ldap.bind_pass",
			category = "LDAP Authentication",
			description = "LDAP bind password.")
	public String ldapBindPassword() {
		return getConfigParam("security.ldap.bind_pass", "");
	}

	@Documented(position = 950,
			identifier = "security.ldap.username_as_name",
			value = "false",
			type = Boolean.class,
			category = "LDAP Authentication",
			description = "Enable/disable the use of usernames for names on Scoold.")
	public boolean ldapUsernameAsName() {
		return getConfigBoolean("security.ldap.username_as_name", false);
	}

	@Documented(position = 960,
			identifier = "security.ldap.provider",
			value = "Continue with LDAP",
			category = "LDAP Authentication",
			tags = {"Pro"},
			description = "The text on the LDAP sign in button.")
	public String ldapProvider() {
		return getConfigParam("security.ldap.provider", "Continue with LDAP");
	}

	@Documented(position = 970,
			identifier = "security.ldap.mods_group_node",
			category = "LDAP Authentication",
			description = "Moderators group mapping, mapping LDAP users with this node, to moderators on Scoold.")
	public String ldapModeratorsGroupNode() {
		return getConfigParam("security.ldap.mods_group_node", "");
	}

	@Documented(position = 980,
			identifier = "security.ldap.admins_group_node",
			category = "LDAP Authentication",
			description = "Administrators group mapping, mapping LDAP users with this node, to administrators on Scoold.")
	public String ldapAdministratorsGroupNode() {
		return getConfigParam("security.ldap.admins_group_node", "");
	}

	@Documented(position = 990,
			identifier = "security.ldap.compare_passwords",
			category = "LDAP Authentication",
			description = "LDAP compare passwords.")
	public String ldapComparePasswords() {
		return getConfigParam("security.ldap.compare_passwords", "");
	}

	@Documented(position = 1000,
			identifier = "security.ldap.password_param",
			value = "password",
			category = "LDAP Authentication",
			description = "LDAP password parameter name.")
	public String ldapPasswordParameter() {
		return getConfigParam("security.ldap.password_param", "password");
	}

	@Documented(position = 1010,
			identifier = "security.ldap.username_param",
			value = "username",
			category = "LDAP Authentication",
			description = "LDAP username parameter name.")
	public String ldapUsernameParameter() {
		return getConfigParam("security.ldap.username_param", "username");
	}

	@Documented(position = 1020,
			identifier = "security.ldap.is_local",
			value = "false",
			type = Boolean.class,
			category = "LDAP Authentication",
			tags = {"Pro"},
			description = "Enable/disable local handling of LDAP requests, instead of sending those to Para.")
	public boolean ldapIsLocal() {
		return getConfigBoolean("security.ldap.is_local", false);
	}

	/* **************************************************************************************************************
	 * SAML Authentication                                                                      SAML Authentication *
	 ****************************************************************************************************************/

	@Documented(position = 1030,
			identifier = "security.saml.idp.metadata_url",
			category = "SAML Authentication",
			tags = {"Pro"},
			description = "SAML metadata URL. Scoold will fetch most of the necessary information for the authentication"
					+ " request from that XML document. This will overwrite all other IDP settings.")
	public String samlIDPMetadataUrl() {
		return getConfigParam("security.saml.idp.metadata_url", "");
	}

	@Documented(position = 1040,
			identifier = "security.saml.sp.entityid",
			category = "SAML Authentication",
			tags = {"Pro"},
			description = "SAML SP endpoint address - e.g. `https://paraio.com/saml_auth/scoold`. The IDP will call "
					+ "this address for authentication.")
	public String samlSPEntityId() {
		if (samlIsLocal()) {
			return serverUrl() + serverContextPath() + "/saml_auth";
		}
		return getConfigParam("security.saml.sp.entityid", "");
	}

	@Documented(position = 1050,
			identifier = "security.saml.sp.x509cert",
			category = "SAML Authentication",
			tags = {"Pro"},
			description = "SAML client x509 certificate for the SP (public key). **Value must be Base64-encoded**.")
	public String samlSPX509Certificate() {
		return getConfigParam("security.saml.sp.x509cert", "");
	}

	@Documented(position = 1060,
			identifier = "security.saml.sp.privatekey",
			category = "SAML Authentication",
			tags = {"Pro"},
			description = "SAML client private key in PKCS#8 format for the SP. **Value must be Base64-encoded**.")
	public String samlSPX509PrivateKey() {
		return getConfigParam("security.saml.sp.privatekey", "");
	}

	@Documented(position = 1070,
			identifier = "security.saml.attributes.id",
			value = "UserID",
			category = "SAML Authentication",
			tags = {"Pro"},
			description = "SAML attribute name of the user `id`.")
	public String samlIdAttribute() {
		return getConfigParam("security.saml.attributes.id", "UserID");
	}

	@Documented(position = 1080,
			identifier = "security.saml.attributes.picture",
			value = "Picture",
			category = "SAML Authentication",
			tags = {"Pro"},
			description = "SAML attribute name of the user `picture`.")
	public String samlPictureAttribute() {
		return getConfigParam("security.saml.attributes.picture", "Picture");
	}

	@Documented(position = 1090,
			identifier = "security.saml.attributes.email",
			value = "EmailAddress",
			category = "SAML Authentication",
			tags = {"Pro"},
			description = "SAML attribute name of the user `email`.")
	public String samlEmailAttribute() {
		return getConfigParam("security.saml.attributes.email", "EmailAddress");
	}

	@Documented(position = 1100,
			identifier = "security.saml.attributes.name",
			value = "GivenName",
			category = "SAML Authentication",
			tags = {"Pro"},
			description = "SAML attribute name of the user `name`.")
	public String samlNameAttribute() {
		return getConfigParam("security.saml.attributes.name", "GivenName");
	}

	@Documented(position = 1110,
			identifier = "security.saml.attributes.firstname",
			value = "FirstName",
			category = "SAML Authentication",
			tags = {"Pro"},
			description = "SAML attribute name of the user `firstname`.")
	public String samlFirstNameAttribute() {
		return getConfigParam("security.saml.attributes.firstname", "FirstName");
	}

	@Documented(position = 1120,
			identifier = "security.saml.attributes.lastname",
			value = "LastName",
			category = "SAML Authentication",
			tags = {"Pro"},
			description = "SAML attribute name of the user `lastname`.")
	public String samlLastNameAttribute() {
		return getConfigParam("security.saml.attributes.lastname", "LastName");
	}

	@Documented(position = 1130,
			identifier = "security.saml.provider",
			value = "Continue with SAML",
			type = Boolean.class,
			category = "SAML Authentication",
			tags = {"Pro"},
			description = "The text on the button for signing in with SAML.")
	public String samlProvider() {
		return getConfigParam("security.saml.provider", "Continue with SAML");
	}

	@Documented(position = 1140,
			identifier = "security.saml.sp.assertion_consumer_service.url",
			category = "SAML Authentication",
			tags = {"Pro"},
			description = "SAML ACS URL.")
	public String samlSPAssertionConsumerServiceUrl() {
		return getConfigParam("security.saml.sp.assertion_consumer_service.url", samlIsLocal() ? samlSPEntityId() : "");
	}

	@Documented(position = 1150,
			identifier = "security.saml.sp.nameidformat",
			value = "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified",
			category = "SAML Authentication",
			tags = {"Pro"},
			description = "SAML name id format.")
	public String samlSPNameIdFormat() {
		return getConfigParam("security.saml.sp.nameidformat", "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified");
	}

	@Documented(position = 1160,
			identifier = "security.saml.idp.entityid",
			category = "SAML Authentication",
			tags = {"Pro"},
			description = "SAML IDP entity id for manually setting the endpoint address of the IDP, instead of getting "
					+ "it from the provided metadata URL.")
	public String samlIDPEntityId() {
		return getConfigParam("security.saml.idp.entityid", "");
	}

	@Documented(position = 1170,
			identifier = "security.saml.idp.single_sign_on_service.url",
			category = "SAML Authentication",
			tags = {"Pro"},
			description = "SAML SSO service URL of the IDP.")
	public String samlIDPSingleSignOnServiceUrl() {
		return getConfigParam("security.saml.idp.single_sign_on_service.url", "");
	}

	@Documented(position = 1180,
			identifier = "security.saml.idp.x509cert",
			category = "SAML Authentication",
			tags = {"Pro"},
			description = "SAML server x509 certificate for the IDP (public key). **Value must be Base64-encoded**.")
	public String samlIDPX509Certificate() {
		return getConfigParam("security.saml.idp.x509cert", "");
	}

	@Documented(position = 1190,
			identifier = "security.saml.security.authnrequest_signed",
			value = "false",
			type = Boolean.class,
			category = "SAML Authentication",
			tags = {"Pro"},
			description = "Enable/disable SAML authentication request signing.")
	public boolean samlAuthnRequestSigningEnabled() {
		return getConfigBoolean("security.saml.security.authnrequest_signed", false);
	}

	@Documented(position = 1200,
			identifier = "security.saml.security.want_messages_signed",
			value = "false",
			type = Boolean.class,
			category = "SAML Authentication",
			tags = {"Pro"},
			description = "Enable/disable SAML message signing.")
	public boolean samlMessageSigningEnabled() {
		return getConfigBoolean("security.saml.security.want_messages_signed", false);
	}

	@Documented(position = 1210,
			identifier = "security.saml.security.want_assertions_signed",
			value = "false",
			type = Boolean.class,
			category = "SAML Authentication",
			tags = {"Pro"},
			description = "Enable/disable SAML assertion signing.")
	public boolean samlAssertionSigningEnabled() {
		return getConfigBoolean("security.saml.security.want_assertions_signed", false);
	}

	@Documented(position = 1220,
			identifier = "security.saml.security.want_assertions_encrypted",
			value = "false",
			type = Boolean.class,
			category = "SAML Authentication",
			tags = {"Pro"},
			description = "Enable/disable SAML assertion encryption.")
	public boolean samlAssertionEncryptionEnabled() {
		return getConfigBoolean("security.saml.security.want_assertions_encrypted", false);
	}

	@Documented(position = 1230,
			identifier = "security.saml.security.want_nameid_encrypted",
			value = "false",
			type = Boolean.class,
			category = "SAML Authentication",
			tags = {"Pro"},
			description = "Enable/disable SAML name id encryption.")
	public boolean samlNameidEncryptionEnabled() {
		return getConfigBoolean("security.saml.security.want_nameid_encrypted", false);
	}

	@Documented(position = 1240,
			identifier = "security.saml.security.sign_metadata",
			value = "false",
			type = Boolean.class,
			category = "SAML Authentication",
			tags = {"Pro"},
			description = "Enable/disable SAML metadata signing.")
	public boolean samlMetadataSigningEnabled() {
		return getConfigBoolean("security.saml.security.sign_metadata", false);
	}

	@Documented(position = 1250,
			identifier = "security.saml.security.want_xml_validation",
			value = "true",
			type = Boolean.class,
			category = "SAML Authentication",
			tags = {"Pro"},
			description = "Enable/disable SAML XML validation.")
	public boolean samlXMLValidationEnabled() {
		return getConfigBoolean("security.saml.security.want_xml_validation", true);
	}

	@Documented(position = 1260,
			identifier = "security.saml.security.signature_algorithm",
			category = "SAML Authentication",
			tags = {"Pro"},
			description = "SAML signature algorithm.")
	public String samlSignatureAlgorithm() {
		return getConfigParam("security.saml.security.signature_algorithm", "");
	}

	@Documented(position = 1270,
			identifier = "security.saml.domain",
			value = "paraio.com",
			category = "SAML Authentication",
			tags = {"Pro"},
			description = "SAML domain name.")
	public String samlDomain() {
		return getConfigParam("security.saml.domain", "paraio.com");
	}

	@Documented(position = 1280,
			identifier = "security.saml.is_local",
			value = "false",
			type = Boolean.class,
			category = "SAML Authentication",
			tags = {"Pro"},
			description = "Enable/disable local handling of SAML requests, instead of sending those to Para.")
	public boolean samlIsLocal() {
		return getConfigBoolean("security.saml.is_local", false);
	}

	/* **************************************************************************************************************
	 * OAuth 2.0 authentication                                                            OAuth 2.0 authentication *
	 ****************************************************************************************************************/

	@Documented(position = 1290,
			identifier = "oa2_app_id",
			category = "OAuth 2.0 Authentication",
			description = "OAauth 2.0 client app identifier. Alternatives: `oa2second_app_id`, `oa2third_app_id`")
	public String oauthAppId(String a) {
		return getConfigParam("oa2" + a + "_app_id", "");
	}

	@Documented(position = 1300,
			identifier = "oa2_secret",
			category = "OAuth 2.0 Authentication",
			description = "OAauth 2.0 client app secret key. Alternatives: `oa2second_secret`, `oa2third_secret`")
	public String oauthSecret(String a) {
		return getConfigParam("oa2" + a + "_secret", "");
	}

	@Documented(position = 1310,
			identifier = "security.oauth.authz_url",
			category = "OAuth 2.0 Authentication",
			description = "OAauth 2.0 client app authorization URL (login page). Alternatives: "
					+ "`security.oauthsecond.authz_url`, `security.oauththird.authz_url`")
	public String oauthAuthorizationUrl(String a) {
		return getConfigParam("security.oauth" + a + ".authz_url", "");
	}

	@Documented(position = 1320,
			identifier = "security.oauth.token_url",
			category = "OAuth 2.0 Authentication",
			description = "OAauth 2.0 client app token endpoint URL. Alternatives: `security.oauthsecond.token_url`, "
					+ "`security.oauththird.token_url`")
	public String oauthTokenUrl(String a) {
		return getConfigParam("security.oauth" + a + ".token_url", "");
	}

	@Documented(position = 1330,
			identifier = "security.oauth.profile_url",
			category = "OAuth 2.0 Authentication",
			description = "OAauth 2.0 client app user info endpoint URL. Alternatives: `security.oauthsecond.profile_url`, "
					+ "`security.oauththird.profile_url`")
	public String oauthProfileUrl(String a) {
		return getConfigParam("security.oauth" + a + ".profile_url", "");
	}

	@Documented(position = 1340,
			identifier = "security.oauth.scope",
			value = "openid email profile",
			category = "OAuth 2.0 Authentication",
			description = "OAauth 2.0 client app scope. Alternatives: `security.oauthsecond.scope`, "
					+ "`security.oauththird.scope`")
	public String oauthScope(String a) {
		return getConfigParam("security.oauth" + a + ".scope", "openid email profile");
	}

	@Documented(position = 1350,
			identifier = "security.oauth.accept_header",
			category = "OAuth 2.0 Authentication",
			description = "OAauth 2.0 `Accept` header customization. Alternatives: `security.oauthsecond.accept_header`, "
					+ "`security.oauththird.accept_header`")
	public String oauthAcceptHeader(String a) {
		return getConfigParam("security.oauth" + a + ".accept_header", "");
	}

	@Documented(position = 1360,
			identifier = "security.oauth.parameters.id",
			value = "sub",
			category = "OAuth 2.0 Authentication",
			description = "OAauth 2.0 attribute mapping for `id`. Alternatives: `security.oauthsecond.parameters.id`, "
					+ "`security.oauththird.parameters.id`")
	public String oauthIdParameter(String a) {
		return getConfigParam("security.oauth" + a + ".parameters.id", null);
	}

	@Documented(position = 1370,
			identifier = "security.oauth.parameters.name",
			value = "name",
			category = "OAuth 2.0 Authentication",
			description = "OAauth 2.0 attribute mapping for `name`. Alternatives: `security.oauthsecond.parameters.name`, "
					+ "`security.oauththird.parameters.name`")
	public String oauthNameParameter(String a) {
		return getConfigParam("security.oauth" + a + ".parameters.name", null);
	}

	@Documented(position = 1380,
			identifier = "security.oauth.parameters.given_name",
			value = "given_name",
			category = "OAuth 2.0 Authentication",
			description = "OAauth 2.0 attribute mapping for `given_name`. Alternatives: "
					+ "`security.oauthsecond.parameters.given_name`, `security.oauththird.parameters.given_name`")
	public String oauthGivenNameParameter(String a) {
		return getConfigParam("security.oauth" + a + ".parameters.given_name", null);
	}

	@Documented(position = 1390,
			identifier = "security.oauth.parameters.family_name",
			value = "family_name",
			category = "OAuth 2.0 Authentication",
			description = "OAauth 2.0 attribute mapping for `family_name`. Alternatives: "
					+ "`security.oauthsecond.parameters.family_name`, `security.oauththird.parameters.family_name`")
	public String oauthFamiliNameParameter(String a) {
		return getConfigParam("security.oauth" + a + ".parameters.family_name", null);
	}

	@Documented(position = 1400,
			identifier = "security.oauth.parameters.email",
			value = "email",
			category = "OAuth 2.0 Authentication",
			description = "OAauth 2.0 attribute mapping for `email`. Alternatives: `security.oauthsecond.parameters.email`, "
					+ "`security.oauththird.parameters.email`")
	public String oauthEmailParameter(String a) {
		return getConfigParam("security.oauth" + a + ".parameters.email", null);
	}

	@Documented(position = 1410,
			identifier = "security.oauth.parameters.picture",
			value = "picture",
			category = "OAuth 2.0 Authentication",
			description = "OAauth 2.0 attribute mapping for `picture`. Alternatives: `security.oauthsecond.parameters.picture`, "
					+ "`security.oauththird.parameters.picture`")
	public String oauthPictureParameter(String a) {
		return getConfigParam("security.oauth" + a + ".parameters.picture", null);
	}

	@Documented(position = 1420,
			identifier = "security.oauth.download_avatars",
			value = "false",
			type = Boolean.class,
			category = "OAuth 2.0 Authentication",
			description = "Enable/disable OAauth 2.0 avatar downloading to local disk. Used when avatars are large in size. "
					+ "Alternatives: `security.oauthsecond.download_avatars`, `security.oauththird.download_avatars`")
	public boolean oauthAvatarDownloadingEnabled(String a) {
		return getConfigBoolean("security.oauth" + a + ".download_avatars", false);
	}

	@Documented(position = 1430,
			identifier = "security.oauth.token_delegation_enabled",
			value = "false",
			type = Boolean.class,
			category = "OAuth 2.0 Authentication",
			tags = {"Pro"},
			description = "Enable/disable OAauth 2.0 token delegation. The ID and access tokens will be saved and "
					+ "delegated to Scoold from Para. Alternatives: `security.oauthsecond.token_delegation_enabled`, "
					+ "`security.oauththird.token_delegation_enabled`")
	public boolean oauthTokenDelegationEnabled(String a) {
		return getConfigBoolean("security.oauth" + a + ".token_delegation_enabled", false);
	}

	@Documented(position = 1440,
			identifier = "security.oauth.spaces_attribute_name",
			value = "spaces",
			category = "OAuth 2.0 Authentication",
			tags = {"Pro"},
			description = "OAauth 2.0 attribute mapping for users' `spaces`. The spaces can be comma-separated. "
					+ "Alternatives: `security.oauthsecond.spaces_attribute_name`, "
					+ "`security.oauththird.spaces_attribute_name`")
	public String oauthSpacesAttributeName(String a) {
		return getConfigParam("security.oauth" + a + ".spaces_attribute_name", "spaces");
	}

	@Documented(position = 1450,
			identifier = "security.oauth.groups_attribute_name",
			value = "roles",
			category = "OAuth 2.0 Authentication",
			tags = {"Pro"},
			description = "OAauth 2.0 attribute mapping for users' `groups`. "
					+ "Alternatives: `security.oauthsecond.groups_attribute_name`, "
					+ "`security.oauththird.groups_attribute_name`")
	public String oauthGroupsAttributeName(String a) {
		return getConfigParam("security.oauth" + a + ".groups_attribute_name", "roles");
	}

	@Documented(position = 1460,
			identifier = "security.oauth.mods_equivalent_claim_value",
			value = "mod",
			category = "OAuth 2.0 Authentication",
			tags = {"Pro"},
			description = "OAauth 2.0 claim used for mapping OAuth2 users having it, "
					+ "to moderators on Scoold. Alternatives: `security.oauthsecond.mods_equivalent_claim_value`, "
					+ "`security.oauththird.mods_equivalent_claim_value`")
	public String oauthModeratorsEquivalentClaim(String a) {
		return getConfigParam("security.oauth" + a + ".mods_equivalent_claim_value", "mod");
	}

	@Documented(position = 1470,
			identifier = "security.oauth.admins_equivalent_claim_value",
			value = "admin",
			category = "OAuth 2.0 Authentication",
			tags = {"Pro"},
			description = "OAauth 2.0 claim used for mapping OAuth2 users having it, "
					+ "to administrators on Scoold. Alternatives: `security.oauthsecond.admins_equivalent_claim_value`, "
					+ "`security.oauththird.admins_equivalent_claim_value`")
	public String oauthAdministratorsEquivalentClaim(String a) {
		return getConfigParam("security.oauth" + a + ".admins_equivalent_claim_value", "admin");
	}

	@Documented(position = 1480,
			identifier = "security.oauth.users_equivalent_claim_value",
			category = "OAuth 2.0 Authentication",
			tags = {"Pro"},
			description = "OAauth 2.0 claim used for **denying access** to OAuth2 users **not** having it. "
					+ "Alternatives: `security.oauthsecond.users_equivalent_claim_value`, "
					+ "`security.oauththird.users_equivalent_claim_value`")
	public String oauthUsersEquivalentClaim(String a) {
		return getConfigParam("security.oauth" + a + ".users_equivalent_claim_value", "");
	}

	@Documented(position = 1490,
			identifier = "security.oauth.domain",
			category = "OAuth 2.0 Authentication",
			description = "OAauth 2.0 domain name for constructing user email addresses in case they are missing. "
					+ "Alternatives: `security.oauthsecond.domain`, `security.oauththird.domain`")
	public String oauthDomain(String a) {
		return getConfigParam("security.oauth" + a + ".domain", null);
	}

	@Documented(position = 1500,
			identifier = "security.oauth.provider",
			value = "Continue with OpenID Connect",
			category = "OAuth 2.0 Authentication",
			description = "The text on the button for signing in with OAuth2 or OIDC.")
	public String oauthProvider(String a) {
		return getConfigParam("security.oauth" + a + ".provider", "Continue with " + a + "OpenID Connect");
	}

	/* **************************************************************************************************************
	 * Posts                                                                                                  Posts *
	 ****************************************************************************************************************/

	@Documented(position = 1510,
			identifier = "new_users_can_comment",
			value = "true",
			type = Boolean.class,
			category = "Posts",
			description = "Enable/disable the ability for users with reputation below 100 to comments on posts.")
	public boolean newUsersCanComment() {
		return getConfigBoolean("new_users_can_comment", true);
	}

	@Documented(position = 1520,
			identifier = "posts_need_approval",
			value = "false",
			type = Boolean.class,
			category = "Posts",
			description = "Enable/disable the need for approval of new posts by a moderator. ")
	public boolean postsNeedApproval() {
		return getConfigBoolean("posts_need_approval", false);
	}

	@Documented(position = 1530,
			identifier = "wiki_answers_enabled",
			value = "true",
			type = Boolean.class,
			category = "Posts",
			tags = {"Pro"},
			description = "Enable/disable the ability for users to create wiki-style answers, editable by everyone.")
	public boolean wikiAnswersEnabled() {
		return getConfigBoolean("wiki_answers_enabled", true);
	}

	@Documented(position = 1540,
			identifier = "media_recording_allowed",
			value = "true",
			type = Boolean.class,
			category = "Posts",
			tags = {"Pro"},
			description = "Enable/disable support for attaching recorded videos and voice messages to posts.")
	public boolean mediaRecordingAllowed() {
		return getConfigBoolean("media_recording_allowed", true);
	}

	@Documented(position = 1550,
			identifier = "delete_protection_enabled",
			value = "true",
			type = Boolean.class,
			category = "Posts",
			description = "Enable/disable the ability for authors to delete their own question, when it already has "
					+ "answers and activity.")
	public boolean deleteProtectionEnabled() {
		return getConfigBoolean("delete_protection_enabled", true);
	}

	@Documented(position = 1560,
			identifier = "max_text_length",
			value = "20000",
			type = Integer.class,
			category = "Posts",
			description = "The maximum text length of each post (question or answer). Longer content will be truncated.")
	public int maxPostLength() {
		return getConfigInt("max_post_length", 20000);
	}

	@Documented(position = 1570,
			identifier = "max_tags_per_post",
			value = "5",
			type = Integer.class,
			category = "Posts",
			description = "The maximum number of tags a question can have. The minimum is 0 - then the default tag is used.")
	public int maxTagsPerPost() {
		return getConfigInt("max_tags_per_post", 5);
	}

	@Documented(position = 1580,
			identifier = "max_replies_per_post",
			value = "500",
			type = Integer.class,
			category = "Posts",
			description = "The maximum number of answers a question can have.")
	public int maxRepliesPerPost() {
		return getConfigInt("max_replies_per_post", 500);
	}

	@Documented(position = 1590,
			identifier = "max_comments_per_id",
			value = "1000",
			type = Integer.class,
			category = "Posts",
			description = "The maximum number of comments a post can have.")
	public int maxCommentsPerPost() {
		return getConfigInt("max_comments_per_id", 1000);
	}

	@Documented(position = 1600,
			identifier = "max_comment_length",
			value = "600",
			type = Integer.class,
			category = "Posts",
			description = "The maximum length of each comment.")
	public int maxCommentLength() {
		return getConfigInt("max_comment_length", 600);
	}

	@Documented(position = 1610,
			identifier = "max_mentions_in_posts",
			value = "10",
			type = Integer.class,
			category = "Posts",
			tags = {"Pro"},
			description = "The maximum number of mentioned users a post can have.")
	public int maxMentionsInPosts() {
		return getConfigInt("max_mentions_in_posts", 10);
	}

	@Documented(position = 1620,
			identifier = "anonymous_posts_enabled",
			value = "false",
			type = Boolean.class,
			category = "Posts",
			tags = {"Pro"},
			description = "Enable/disable the ability for unathenticated users to create new questions.")
	public boolean anonymousPostsEnabled() {
		return getConfigBoolean("anonymous_posts_enabled", false);
	}

	@Documented(position = 1630,
			identifier = "nearme_feature_enabled",
			value = "false",
			type = Boolean.class,
			category = "Posts",
			description = "Enable/disable the ability for users to attach geolocation data to questions and "
					+ "location-based filtering of questions.")
	public boolean postsNearMeEnabled() {
		return getConfigBoolean("nearme_feature_enabled", !googleMapsApiKey().isEmpty());
	}

	@Documented(position = 1640,
			identifier = "merge_question_bodies",
			value = "true",
			type = Boolean.class,
			category = "Posts",
			description = "Enable/disable the merging of question bodies when two questions are merged into one.")
	public boolean mergeQuestionBodies() {
		return getConfigBoolean("merge_question_bodies", true);
	}

	@Documented(position = 1650,
			identifier = "max_similar_posts",
			value = "7",
			type = Integer.class,
			category = "Posts",
			description = "The maximum number of similar posts which will be displayed on the side.")
	public int maxSimilarPosts() {
		return getConfigInt("max_similar_posts", 7);
	}

	@Documented(position = 1660,
			identifier = "default_question_tag",
			value = "question",
			category = "Posts",
			description = "The default question tag, used when no other tags are provided by its author.")
	public String defaultQuestionTag() {
		return getConfigParam("default_question_tag", "");
	}

	@Documented(position = 1670,
			identifier = "posts_rep_threshold",
			value = "100",
			type = Integer.class,
			category = "Posts",
			description = "The minimum reputation an author needs to create a post without approval by moderators. "
					+ "This is only required if new posts need apporval.")
	public int postsReputationThreshold() {
		return getConfigInt("posts_rep_threshold", enthusiastIfHasRep());
	}

	/* **************************************************************************************************************
	 * Spaces                                                                                                Spaces *
	 ****************************************************************************************************************/

	@Documented(position = 1680,
			identifier = "auto_assign_spaces",
			category = "Spaces",
			description = "A comma-separated list of spaces to assign to all new users.")
	public String autoAssignSpaces() {
		return getConfigParam("auto_assign_spaces", "");
	}

	@Documented(position = 1690,
			identifier = "reset_spaces_on_new_assignment",
			value = "true",
			type = Boolean.class,
			category = "Spaces",
			description = "Spaces delegated from identity providers will overwrite the existing ones for users.")
	public boolean resetSpacesOnNewAssignment(boolean def) {
		return getConfigBoolean("reset_spaces_on_new_assignment", def);
	}

	/* **************************************************************************************************************
	 * Reputation and Rewards                                                                Reputation and Rewards *
	 ****************************************************************************************************************/

	@Documented(position = 1700,
			identifier = "answer_voteup_reward_author",
			value = "10",
			type = Integer.class,
			category = "Reputation and Rewards",
			description = "Reputation points given to author of answer as reward when a user upvotes it.")
	public int answerVoteupRewardAuthor() {
		return getConfigInt("answer_voteup_reward_author", 10);
	}

	@Documented(position = 1710,
			identifier = "question_voteup_reward_author",
			value = "5",
			type = Integer.class,
			category = "Reputation and Rewards",
			description = "Reputation points given to author of question as reward when a user upvotes it.")
	public int questionVoteupRewardAuthor() {
		return getConfigInt("question_voteup_reward_author", 5);
	}

	@Documented(position = 1720,
			identifier = "voteup_reward_author",
			value = "2",
			type = Integer.class,
			category = "Reputation and Rewards",
			description = "Reputation points given to author of comment or other post as reward when a user upvotes it.")
	public int voteupRewardAuthor() {
		return getConfigInt("voteup_reward_author", 2);
	}

	@Documented(position = 1730,
			identifier = "answer_approve_reward_author",
			value = "10",
			type = Integer.class,
			category = "Reputation and Rewards",
			description = "Reputation points given to author of answer as reward when the question's author accepts it.")
	public int answerApprovedRewardAuthor() {
		return getConfigInt("answer_approve_reward_author", 10);
	}

	@Documented(position = 1740,
			identifier = "answer_approve_reward_voter",
			value = "3",
			type = Integer.class,
			category = "Reputation and Rewards",
			description = "Reputation points given to author of question who accepted an answer.")
	public int answerApprovedRewardVoter() {
		return getConfigInt("answer_approve_reward_voter", 3);
	}

	@Documented(position = 1750,
			identifier = "post_votedown_penalty_author",
			value = "3",
			type = Integer.class,
			category = "Reputation and Rewards",
			description = "Reputation points taken from author of post as penalty when their post was downvoted.")
	public int postVotedownPenaltyAuthor() {
		return getConfigInt("post_votedown_penalty_author", 3);
	}

	@Documented(position = 1760,
			identifier = "post_votedown_penalty_voter",
			value = "1",
			type = Integer.class,
			category = "Reputation and Rewards",
			description = "Reputation points taken from the user who downvotes any content. Discourages downvoting slightly.")
	public int postVotedownPenaltyVoter() {
		return getConfigInt("post_votedown_penalty_voter", 1);
	}

	@Documented(position = 1770,
			identifier = "voter_ifhas",
			value = "100",
			type = Integer.class,
			category = "Reputation and Rewards",
			description = "Number of votes (up or down) needed from a user for earning the `voter` badge.")
	public int voterIfHasRep() {
		return getConfigInt("voter_ifhas", 100);
	}

	@Documented(position = 1780,
			identifier = "commentator_ifhas",
			value = "100",
			type = Integer.class,
			category = "Reputation and Rewards",
			description = "Number of comments a user needs to have posted for earning the `commentator` badge.")
	public int commentatorIfHasRep() {
		return getConfigInt("commentator_ifhas", 100);
	}

	@Documented(position = 1790,
			identifier = "critic_ifhas",
			value = "10",
			type = Integer.class,
			category = "Reputation and Rewards",
			description = "Number of cast downvotes needed from a user for earning the `critic` badge.")
	public int criticIfHasRep() {
		return getConfigInt("critic_ifhas", 10);
	}

	@Documented(position = 1800,
			identifier = "supporter_ifhas",
			value = "50",
			type = Integer.class,
			category = "Reputation and Rewards",
			description = "Number of cast upvotes needed from a user for earning the `supporter` badge`.")
	public int supporterIfHasRep() {
		return getConfigInt("supporter_ifhas", 50);
	}

	@Documented(position = 1810,
			identifier = "goodquestion_ifhas",
			value = "20",
			type = Integer.class,
			category = "Reputation and Rewards",
			description = "Votes needed on a question before its author gets to earn the `good question` badge.")
	public int goodQuestionIfHasRep() {
		return getConfigInt("goodquestion_ifhas", 20);
	}

	@Documented(position = 1820,
			identifier = "goodanswer_ifhas",
			value = "10",
			type = Integer.class,
			category = "Reputation and Rewards",
			description = "Votes needed on an answer before its author gets to earn the `good answer` badge.")
	public int goodAnswerIfHasRep() {
		return getConfigInt("goodanswer_ifhas", 10);
	}

	@Documented(position = 1830,
			identifier = "enthusiast_ifhas",
			value = "100",
			type = Integer.class,
			category = "Reputation and Rewards",
			description = "Reputation points needed for earning the `enthusiast` badge.")
	public int enthusiastIfHasRep() {
		return getConfigInt("enthusiast_ifhas", 100);
	}

	@Documented(position = 1840,
			identifier = "freshman_ifhas",
			value = "300",
			type = Integer.class,
			category = "Reputation and Rewards",
			description = "Reputation points needed for earning the `freshman` badge.")
	public int freshmanIfHasRep() {
		return getConfigInt("freshman_ifhas", 300);
	}

	@Documented(position = 1850,
			identifier = "scholar_ifhas",
			value = "500",
			type = Boolean.class,
			category = "Reputation and Rewards",
			description = "Reputation points needed for earning the `scholar` badge.")
	public int scholarIfHasRep() {
		return getConfigInt("scholar_ifhas", 500);
	}

	@Documented(position = 1860,
			identifier = "teacher_ifhas",
			value = "1000",
			type = Integer.class,
			category = "Reputation and Rewards",
			description = "Reputation points needed for earning the `teacher` badge.")
	public int teacherIfHasRep() {
		return getConfigInt("teacher_ifhas", 1000);
	}

	@Documented(position = 1870,
			identifier = "",
			value = "5000",
			type = Integer.class,
			category = "Reputation and Rewards",
			description = "Reputation points needed for earning the `professor` badge.")
	public int professorIfHasRep() {
		return getConfigInt("professor_ifhas", 5000);
	}

	@Documented(position = 1880,
			identifier = "geek_ifhas",
			value = "9000",
			type = Integer.class,
			category = "Reputation and Rewards",
			description = "Reputation points needed for earning the `geek` badge.")
	public int geekIfHasRep() {
		return getConfigInt("geek_ifhas", 9000);
	}

	/* **************************************************************************************************************
	 * File Storage                                                                                    File Storage *
	 ****************************************************************************************************************/

	@Documented(position = 1890,
			identifier = "uploads_enabled",
			value = "true",
			type = Boolean.class,
			category = "File Storage",
			tags = {"Pro"},
			description = "Enable/disable file uploads.")
	public boolean uploadsEnabled() {
		return getConfigBoolean("uploads_enabled", true);
	}

	@Documented(position = 1900,
			identifier = "file_uploads_dir",
			value = "uploads",
			category = "File Storage",
			tags = {"Pro"},
			description = "The directory (local or in the cloud) where files will be stored.")
	public String fileUploadsDirectory() {
		return getConfigParam("file_uploads_dir", "uploads");
	}

	@Documented(position = 1910,
			identifier = "uploads_require_auth",
			value = "false",
			type = Boolean.class,
			category = "File Storage",
			tags = {"Pro"},
			description = "Enable/disable the requirement that uploaded files can only be accessed by authenticated users.")
	public boolean uploadsRequireAuthentication() {
		return getConfigBoolean("uploads_require_auth", !isDefaultSpacePublic());
	}

	@Documented(position = 1920,
			identifier = "allowed_upload_formats",
			category = "File Storage",
			tags = {"Pro"},
			description = "A comma-separated list of allowed MIME types in the format `extension:mime_type`, e.g."
					+ "`py:text/plain` or just the extensions `py,yml`")
	public String allowedUploadFormats() {
		return getConfigParam("allowed_upload_formats", "");
	}

	@Documented(position = 1930,
			identifier = "s3_bucket",
			category = "File Storage",
			tags = {"Pro"},
			description = "AWS S3 bucket name as target for storing files.")
	public String s3Bucket() {
		return getConfigParam("s3_bucket", "");
	}

	@Documented(position = 1940,
			identifier = "s3_path",
			category = "File Storage",
			tags = {"Pro"},
			description = "AWS S3 object prefix (directory) inside the bucket.")
	public String s3Path() {
		return getConfigParam("s3_path", "uploads");
	}

	@Documented(position = 1950,
			identifier = "s3_region",
			category = "File Storage",
			tags = {"Pro"},
			description = "AWS S3 region.")
	public String s3Region() {
		return getConfigParam("s3_region", "");
	}

	@Documented(position = 1960,
			identifier = "s3_access_key",
			category = "File Storage",
			tags = {"Pro"},
			description = "AWS S3 access key.")
	public String s3AccessKey() {
		return getConfigParam("s3_access_key", "");
	}

	@Documented(position = 1970,
			identifier = "s3_secret_key",
			category = "File Storage",
			tags = {"Pro"},
			description = "AWS S3 secret key.")
	public String s3SecretKey() {
		return getConfigParam("s3_secret_key", "");
	}

	@Documented(position = 1980,
			identifier = "blob_storage_account",
			category = "File Storage",
			tags = {"Pro"},
			description = "Azure Blob Storage account ID.")
	public String azureStorageAccount() {
		return getConfigParam("blob_storage_account", "");
	}

	@Documented(position = 1990,
			identifier = "blob_storage_token",
			category = "File Storage",
			tags = {"Pro"},
			description = "Azure Blob Storage token.")
	public String azureStorageToken() {
		return getConfigParam("blob_storage_token", "");
	}

	@Documented(position = 2000,
			identifier = "blob_storage_container",
			category = "File Storage",
			tags = {"Pro"},
			description = "Azure Blob Storage container.")
	public String azureStorageContainer() {
		return getConfigParam("blob_storage_container", "");
	}

	@Documented(position = 2010,
			identifier = "blob_storage_path",
			category = "File Storage",
			tags = {"Pro"},
			description = "Azure Blob Storage path prefix (subfolder) within a container.")
	public String azureStoragePath() {
		return getConfigParam("blob_storage_path", "uploads");
	}

	/* **************************************************************************************************************
	 * Customization                                                                                  Customization *
	 ****************************************************************************************************************/

	@Documented(position = 2020,
			identifier = "default_language_code",
			category = "Customization",
			description = "The default language code to use for the site. Set this to make the site load a "
					+ "different language from English.")
	public String defaultLanguageCode() {
		return getConfigParam("default_language_code", "");
	}

	@Documented(position = 2030,
			identifier = "welcome_message",
			category = "Customization",
			description = "Adds a brief intro text inside a banner at the top of the main page for new visitors to see.")
	public String welcomeMessage() {
		return getConfigParam("welcome_message", "");
	}

	@Documented(position = 2040,
			identifier = "welcome_message_onlogin",
			category = "Customization",
			description = "Adds a brief intro text inside a banner at the top of the 'Sign in' page only.")
	public String welcomeMessageOnLogin() {
		return getConfigParam("welcome_message_onlogin", "");
	}

	@Documented(position = 2050,
			identifier = "dark_mode_enabled",
			value = "true",
			type = Boolean.class,
			category = "Customization",
			description = "Enable/disable the option for users to switch to the dark theme.")
	public boolean darkModeEnabled() {
		return getConfigBoolean("dark_mode_enabled", true);
	}

	@Documented(position = 2060,
			identifier = "meta_description",
			category = "Customization",
			description = "The content inside the description `<meta>` tag.")
	public String metaDescription() {
		return getConfigParam("meta_description", "");
	}

	@Documented(position = 2070,
			identifier = "meta_keywords",
			category = "Customization",
			description = "The content inside the keywords `<meta>` tag.")
	public String metaKeywords() {
		return getConfigParam("meta_keywords", "");
	}

	@Documented(position = 2080,
			identifier = "show_branding",
			value = "true",
			type = Boolean.class,
			category = "Customization",
			description = "Enable/disable the 'Powered by Scoold' branding in the footer.")
	public boolean scooldBrandingEnabled() {
		return getConfigBoolean("show_branding", true);
	}

	@Documented(position = 2090,
			identifier = "mathjax_enabled",
			value = "false",
			type = Boolean.class,
			category = "Customization",
			tags = {"Pro"},
			description = "Enable/disable support for MathJax and LaTeX for scientific expressions in Markdown.")
	public boolean mathjaxEnabled() {
		return getConfigBoolean("mathjax_enabled", false);
	}

	@Documented(position = 2100,
			identifier = "gravatars_enabled",
			value = "true",
			type = Boolean.class,
			category = "Customization",
			description = "Enable/disable support for Gravatars.")
	public boolean gravatarsEnabled() {
		return getConfigBoolean("gravatars_enabled", true);
	}

	@Documented(position = 2110,
			identifier = "gravatars_pattern",
			value = "retro",
			category = "Customization",
			description = "The pattern to use when displaying empty/anonymous gravatar pictures.")
	public String gravatarsPattern() {
		return getConfigParam("gravatars_pattern", "retro");
	}

	@Documented(position = 2120,
			identifier = "avatar_repository",
			category = "Customization",
			tags = {"preview"},
			description = "The avatar repository - one of `imgur`, `cloudinary`.")
	public String avatarRepository() {
		return getConfigParam("avatar_repository", "");
	}

	@Documented(position = 2130,
			identifier = "footer_html",
			category = "Customization",
			description = "Some custom HTML content to be added to the website footer.")
	public String footerHtml() {
		return getConfigParam("footer_html", "");
	}

	@Documented(position = 2140,
			identifier = "navbar_link1_url",
			category = "Customization",
			description = "The URL of an extra custom link which will be added to the top navbar.")
	public String navbarCustomLink1Url() {
		return getConfigParam("navbar_link1_url", "");
	}

	@Documented(position = 2150,
			identifier = "navbar_link1_text",
			value = "Link1",
			category = "Customization",
			description = "The title of an extra custom link which will be added to the top navbar.")
	public String navbarCustomLink1Text() {
		return getConfigParam("navbar_link1_text", "Link1");
	}

	@Documented(position = 2160,
			identifier = "navbar_link2_url",
			category = "Customization",
			description = "The URL of an extra custom link which will be added to the top navbar.")
	public String navbarCustomLink2Url() {
		return getConfigParam("navbar_link2_url", "");
	}

	@Documented(position = 2170,
			identifier = "navbar_link2_text",
			value = "Link2",
			category = "Customization",
			description = "The title of an extra custom link which will be added to the top navbar.")
	public String navbarCustomLink2Text() {
		return getConfigParam("navbar_link2_text", "Link2");
	}

	@Documented(position = 2180,
			identifier = "navbar_menu_link1_url",
			category = "Customization",
			description = "The URL of an extra custom link which will be added to user's dropdown menu."
					+ " Only shown to authenticated users.")
	public String navbarCustomMenuLink1Url() {
		return getConfigParam("navbar_menu_link1_url", "");
	}

	@Documented(position = 2190,
			identifier = "navbar_menu_link1_text",
			value = "Menu Link1",
			category = "Customization",
			description = "The title of an extra custom link which will be added to the user's dropdown menu.")
	public String navbarCustomMenuLink1Text() {
		return getConfigParam("navbar_menu_link1_text", "Menu Link1");
	}

	@Documented(position = 2200,
			identifier = "navbar_menu_link2_url",
			category = "Customization",
			description = "The URL of an extra custom link which will be added to user's dropdown menu."
					+ " Only shown to authenticated users.")
	public String navbarCustomMenuLink2Url() {
		return getConfigParam("navbar_menu_link2_url", "");
	}

	@Documented(position = 2210,
			identifier = "navbar_menu_link2_text",
			value = "Menu Link2",
			category = "Customization",
			description = "The title of an extra custom link which will be added to the user's dropdown menu.")
	public String navbarCustomMenuLink2Text() {
		return getConfigParam("navbar_menu_link2_text", "Menu Link2");
	}

	@Documented(position = 2220,
			identifier = "always_hide_comment_forms",
			value = "true",
			type = Boolean.class,
			category = "Customization",
			description = "Enable/disable a visual tweak which keeps all comment text editors closed at all times.")
	public boolean alwaysHideCommentForms() {
		return getConfigBoolean("always_hide_comment_forms", true);
	}

	@Documented(position = 2230,
			identifier = "footer_links_enabled",
			value = "true",
			type = Boolean.class,
			category = "Customization",
			description = "Enable/disable all links in the website footer.")
	public boolean footerLinksEnabled() {
		return getConfigBoolean("footer_links_enabled", true);
	}

	@Documented(position = 2240,
			identifier = "emails_footer_html",
			value = "<a href=\"{host_url}\">{app_name}</a> &bull; <a href=\"https://scoold.com\">Powered by Scoold</a>",
			category = "Customization",
			description = "The HTML code snippet to embed at the end of each transactional email message.")
	public String emailsFooterHtml() {
		return getConfigParam("emails_footer_html", "<a href=\"" + serverUrl() + "\">" + appName() + "</a> &bull; " +
				"<a href=\"https://scoold.com\">Powered by Scoold</a>");
	}

	@Documented(position = 2250,
			identifier = "cookie_consent_required",
			value = "false",
			type = Boolean.class,
			category = "Customization",
			description = "Enable/disable the cookie consent popup box and blocks all external JS scripts from loading. "
					+ "Used for compliance with GDPR/CCPA.")
	public boolean cookieConsentRequired() {
		return getConfigBoolean("cookie_consent_required", false);
	}

	@Documented(position = 2260,
			identifier = "fixed_nav",
			value = "false",
			type = Boolean.class,
			category = "Customization",
			description = "Enable/disable a fixed navigation bar.")
	public boolean fixedNavEnabled() {
		return getConfigBoolean("fixed_nav", false);
	}

	@Documented(position = 2270,
			identifier = "logo_width",
			value = "100",
			type = Integer.class,
			category = "Customization",
			description = "The width of the logo image in the nav bar, in pixels. Used for fine adjustments to the logo size.")
	public int logoWidth() {
		return getConfigInt("logo_width", 100);
	}

	@Documented(position = 2280,
			identifier = "code_highlighting_enabled",
			value = "true",
			type = Boolean.class,
			category = "Customization",
			description = "Enable/disable support for syntax highlighting in code blocks.")
	public boolean codeHighlightingEnabled() {
		return getConfigBoolean("code_highlighting_enabled", true);
	}

	@Documented(position = 2290,
			identifier = "max_pages",
			value = "1000",
			type = Integer.class,
			category = "Customization",
			description = "Maximum number of pages to return as results.")
	public int maxPages() {
		return getConfigInt("max_pages", 1000);
	}

	@Documented(position = 2300,
			identifier = "numeric_pagination_enabled",
			value = "false",
			type = Boolean.class,
			category = "Customization",
			description = "Enable/disable the numeric pagination style `(< 1 2 3...N >)`.")
	public boolean numericPaginationEnabled() {
		return getConfigBoolean("numeric_pagination_enabled", false);
	}

	@Documented(position = 2310,
			identifier = "html_in_markdown_enabled",
			value = "false",
			type = Boolean.class,
			category = "Customization",
			description = "Enable/disable the ability for users to insert basic HTML tags inside Markdown content.")
	public boolean htmlInMarkdownEnabled() {
		return getConfigBoolean("html_in_markdown_enabled", false);
	}

	@Documented(position = 2320,
			identifier = "max_items_per_page",
			value = "30",
			type = Integer.class,
			category = "Customization",
			description = "Maximum number of results to return in a single page of results.")
	public int maxItemsPerPage() {
		return getConfigInt("max_items_per_page", Para.getConfig().maxItemsPerPage());
	}

	@Documented(position = 2330,
			identifier = "avatar_edits_enabled",
			value = "true",
			type = Boolean.class,
			category = "Customization",
			description = "Enable/disable the ability for users to edit their profile pictures.")
	public boolean avatarEditsEnabled() {
		return getConfigBoolean("avatar_edits_enabled", true);
	}

	@Documented(position = 2340,
			identifier = "name_edits_enabled",
			value = "true",
			type = Boolean.class,
			category = "Customization",
			description = "Enable/disable the ability for users to edit their name.")
	public boolean nameEditsEnabled() {
		return getConfigBoolean("name_edits_enabled", true);
	}

	/* **************************************************************************************************************
	 * Frontend Assets                                                                              Frontend Assets *
	 ****************************************************************************************************************/

	@Documented(position = 2350,
			identifier = "logo_url",
			value = "/images/logo.svg",
			category = "Frontend Assets",
			description = "The URL of the logo in the nav bar.")
	public String logoUrl() {
		return getConfigParam("logo_url", imagesLink() + "/logo.svg");
	}

	@Documented(position = 2360,
			identifier = "small_logo_url",
			value = "/images/logowhite.png",
			category = "Frontend Assets",
			description = "The URL of a smaller logo. Mainly used in transactional emails.")
	public String logoSmallUrl() {
		return getConfigParam("small_logo_url", serverUrl() + imagesLink() + "/logowhite.png");
	}

	@Documented(position = 2370,
			identifier = "cdn_url",
			category = "miscellaneous",
			description = "A CDN URL where all static assets might be stored.")
	public String cdnUrl() {
		return StringUtils.stripEnd(getConfigParam("cdn_url", serverContextPath()), "/");
	}

	@Documented(position = 2380,
			identifier = "stylesheet_url",
			value = "/styles/style.css",
			category = "Frontend Assets",
			description = "A stylesheet URL of a CSS file which will be used as the main stylesheet. *This will overwrite"
					+ " all existing CSS styles!*")
	public String stylesheetUrl() {
		return getConfigParam("stylesheet_url", stylesLink() + "/style.css");
	}

	@Documented(position = 2390,
			identifier = "external_styles",
			category = "Frontend Assets",
			description = "A comma-separated list of external CSS files. These will be loaded *after* the main stylesheet.")
	public String externalStyles() {
		return getConfigParam("external_styles", "");
	}

	@Documented(position = 2400,
			identifier = "external_scripts._id_",
			type = Map.class,
			category = "Frontend Assets",
			description = "A map of external JS scripts. These will be loaded after the main JS script. For example: "
					+ "`scoold.external_scripts.script1 = \"alert('Hi')\"`")
	public Map<String, Object> externalScripts() {
		if (getConfig().hasPath("external_scripts")) {
			ConfigObject extScripts = getConfig().getObject("external_scripts");
			if (extScripts != null && !extScripts.isEmpty()) {
				return new LinkedHashMap<>(extScripts.unwrapped());
			}
		}
		return Collections.emptyMap();
	}

	@Documented(position = 2410,
			identifier = "inline_css",
			category = "Frontend Assets",
			description = "Some short, custom CSS snippet to embed inside the `<head>` element.")
	public String inlineCSS() {
		return getConfigParam("inline_css", "");
	}

	@Documented(position = 2420,
			identifier = "favicon_url",
			value = "/images/favicon.ico",
			category = "Frontend Assets",
			description = "The URL of the favicon image.")
	public String faviconUrl() {
		return getConfigParam("favicon_url", imagesLink() + "/favicon.ico");
	}

	@Documented(position = 2430,
			identifier = "meta_app_icon",
			value = "/images/logowhite.png",
			category = "Frontend Assets",
			description = "The URL of the app icon image in the `<meta property='og:image'>` tag.")
	public String metaAppIconUrl() {
		return getConfigParam("meta_app_icon", imagesLink() + "/logowhite.png");
	}

	/* **************************************************************************************************************
	 * Mattermost Integration                                                                Mattermost Integration *
	 ****************************************************************************************************************/

	@Documented(position = 2440,
			identifier = "mattermost.server_url",
			category = "Mattermost Integration",
			tags = {"Pro"},
			description = "Mattermost server URL.")
	public String mattermostServerUrl() {
		return getConfigParam("mattermost.server_url", "");
	}

	@Documented(position = 2450,
			identifier = "mattermost.bot_username",
			value = "scoold",
			category = "Mattermost Integration",
			tags = {"Pro"},
			description = "Mattermost bot username.")
	public String mattermostBotUsername() {
		return getConfigParam("mattermost.bot_username", "scoold");
	}

	@Documented(position = 2460,
			identifier = "mattermost.bot_icon_url",
			value = "/images/logowhite.png",
			category = "Mattermost Integration",
			tags = {"Pro"},
			description = "Mattermost bot avatar URL.")
	public String mattermostBotIconUrl() {
		return getConfigParam("mattermost.bot_icon_url", serverUrl() + imagesLink() + "/logowhite.png");
	}

	@Documented(position = 2470,
			identifier = "mattermost.post_to_space",
			category = "Mattermost Integration",
			tags = {"Pro"},
			description = "Default space on Scoold where questions created on Mattermost will be published. Set it to "
					+ "`workspace` for using the team's name.")
	public String mattermostPostToSpace() {
		return getConfigParam("mattermost.post_to_space", "");
	}

	@Documented(position = 2480,
			identifier = "mattermost.map_channels_to_spaces",
			value = "false",
			type = Boolean.class,
			category = "Mattermost Integration",
			tags = {"Pro"},
			description = "Enable/disable mapping of Mattermost channels to Scoold spaces. When enabled, will create a "
					+ "Scoold space for each Mattermost channel.")
	public boolean mattermostMapChannelsToSpaces() {
		return getConfigBoolean("mattermost.map_channels_to_spaces", false);
	}

	@Documented(position = 2490,
			identifier = "mattermost.map_workspaces_to_spaces",
			value = "true",
			type = Boolean.class,
			category = "Mattermost Integration",
			tags = {"Pro"},
			description = "Enable/disable mapping of Mattermost teams to Scoold spaces. When enabled, will create a "
					+ "Scoold space for each Mattermost team.")
	public boolean mattermostMapWorkspacesToSpaces() {
		return getConfigBoolean("mattermost.map_workspaces_to_spaces", true);
	}

	@Documented(position = 2500,
			identifier = "mattermost.max_notification_webhooks",
			value = "10",
			type = Integer.class,
			category = "Mattermost Integration",
			tags = {"Pro"},
			description = "The maximum number of incoming webhooks which can be created on Scoold. Each webhook links a"
					+ " Mattermost channel to Scoold.")
	public int mattermostMaxNotificationWebhooks() {
		return getConfigInt("mattermost.max_notification_webhooks", 10);
	}

	@Documented(position = 2510,
			identifier = "mattermost.notify_on_new_answer",
			value = "true",
			type = Boolean.class,
			category = "Mattermost Integration",
			tags = {"Pro"},
			description = "Enable/disable the ability for Scoold to send notifications to Mattermost for new answers.")
	public boolean mattermostNotifyOnNewAnswer() {
		return getConfigBoolean("mattermost.notify_on_new_answer", true);
	}

	@Documented(position = 2520,
			identifier = "mattermost.notify_on_new_question",
			value = "true",
			type = Boolean.class,
			category = "Mattermost Integration",
			tags = {"Pro"},
			description = "Enable/disable the ability for Scoold to send notifications to Mattermost for new questions.")
	public boolean mattermostNotifyOnNewQuestion() {
		return getConfigBoolean("mattermost.notify_on_new_question", true);
	}

	@Documented(position = 2530,
			identifier = "mattermost.notify_on_new_comment",
			value = "true",
			type = Boolean.class,
			category = "Mattermost Integration",
			tags = {"Pro"},
			description = "Enable/disable the ability for Scoold to send notifications to Mattermost for new comments.")
	public boolean mattermostNotifyOnNewComment() {
		return getConfigBoolean("mattermost.notify_on_new_comment", true);
	}

	@Documented(position = 2540,
			identifier = "mattermost.dm_on_new_comment",
			value = "false",
			type = Boolean.class,
			category = "Mattermost Integration",
			tags = {"Pro"},
			description = "Enable/disable the ability for Scoold to send direct messages to Mattermost users for new comments.")
	public boolean mattermostDmOnNewComment() {
		return getConfigBoolean("mattermost.dm_on_new_comment", false);
	}

	@Documented(position = 2550,
			identifier = "mattermost.default_question_tags",
			value = "via-mattermost",
			category = "Mattermost Integration",
			tags = {"Pro"},
			description = "Default question tags for questions created on Mattermost (comma-separated list).")
	public String mattermostDefaultQuestionTags() {
		return getConfigParam("mattermost.default_question_tags", "via-mattermost");
	}

	/* **************************************************************************************************************
	 * Slack Integration                                                                          Slack Integration *
	 ****************************************************************************************************************/

	@Documented(position = 2560,
			identifier = "slack.auth_enabled",
			value = "false",
			type = Boolean.class,
			category = "Slack Integration",
			tags = {"Pro"},
			description = "Enable/disable authentication with Slack.")
	public boolean slackAuthEnabled() {
		return getConfigBoolean("slack.auth_enabled", !slackAppId().isEmpty());
	}

	@Documented(position = 2570,
			identifier = "slack.app_id",
			category = "Slack Integration",
			tags = {"Pro"},
			description = "The Slack app ID (first ID from the app's credentials, not the OAuth2 Client ID).")
	public String slackIntegrationAppId() {
		return getConfigParam("slack.app_id", "");
	}

	@Documented(position = 2580,
			identifier = "slack.signing_secret",
			value = "x",
			category = "Slack Integration",
			tags = {"Pro"},
			description = "Slack signing secret key for verifying request signatures.")
	public String slackSigningSecret() {
		return getConfigParam("slack.signing_secret", "x");
	}

	@Documented(position = 2590,
			identifier = "slack.max_notification_webhooks",
			value = "10",
			type = Integer.class,
			category = "Slack Integration",
			tags = {"Pro"},
			description = "The maximum number of incoming webhooks which can be created on Scoold. Each webhook links a"
					+ " Slack channel to Scoold.")
	public int slackMaxNotificationWebhooks() {
		return getConfigInt("slack.max_notification_webhooks", 10);
	}

	@Documented(position = 2600,
			identifier = "slack.map_channels_to_spaces",
			value = "false",
			type = Boolean.class,
			category = "Slack Integration",
			tags = {"Pro"},
			description = "Enable/disable mapping of Slack channels to Scoold spaces. When enabled, will create a "
					+ "Scoold space for each Slack channel.")
	public boolean slackMapChannelsToSpaces() {
		return getConfigBoolean("slack.map_channels_to_spaces", false);
	}

	@Documented(position = 2610,
			identifier = "slack.map_workspaces_to_spaces",
			value = "true",
			type = Boolean.class,
			category = "Slack Integration",
			tags = {"Pro"},
			description = "Enable/disable mapping of Slack teams to Scoold spaces. When enabled, will create a "
					+ "Scoold space for each Slack team.")
	public boolean slackMapWorkspacesToSpaces() {
		return getConfigBoolean("slack.map_workspaces_to_spaces", true);
	}

	@Documented(position = 2620,
			identifier = "slack.post_to_space",
			category = "Slack Integration",
			tags = {"Pro"},
			description = "Default space on Scoold where questions created on Slack will be published. Set it to "
					+ "`workspace` for using the team's name.")
	public String slackPostToSpace() {
		return getConfigParam("slack.post_to_space", "");
	}

	@Documented(position = 2630,
			identifier = "slack.default_title",
			value = "A question from Slack",
			category = "Slack Integration",
			tags = {"Pro"},
			description = "Default question title for questions created on Slack.")
	public String slackDefaultQuestionTitle() {
		return getConfigParam("slack.default_title", "A question from Slack");
	}

	@Documented(position = 2640,
			identifier = "slack.notify_on_new_answer",
			value = "true",
			type = Boolean.class,
			category = "Slack Integration",
			tags = {"Pro"},
			description = "Enable/disable the ability for Scoold to send notifications to Slack for new answers.")
	public boolean slackNotifyOnNewAnswer() {
		return getConfigBoolean("slack.notify_on_new_answer", true);
	}

	@Documented(position = 2650,
			identifier = "slack.notify_on_new_question",
			value = "true",
			type = Boolean.class,
			category = "Slack Integration",
			tags = {"Pro"},
			description = "Enable/disable the ability for Scoold to send notifications to Slack for new questions.")
	public boolean slackNotifyOnNewQuestion() {
		return getConfigBoolean("slack.notify_on_new_question", true);
	}

	@Documented(position = 2660,
			identifier = "slack.notify_on_new_comment",
			value = "true",
			type = Boolean.class,
			category = "Slack Integration",
			tags = {"Pro"},
			description = "Enable/disable the ability for Scoold to send notifications to Slack for new comments.")
	public boolean slackNotifyOnNewComment() {
		return getConfigBoolean("slack.notify_on_new_comment", true);
	}

	@Documented(position = 2670,
			identifier = "slack.dm_on_new_comment",
			value = "false",
			type = Boolean.class,
			category = "Slack Integration",
			tags = {"Pro"},
			description = "Enable/disable the ability for Scoold to send direct messages to Slack users for new comments.")
	public boolean slacDmOnNewComment() {
		return getConfigBoolean("slack.dm_on_new_comment", false);
	}

	@Documented(position = 2680,
			identifier = "slack.default_question_tags",
			value = "via-slack",
			category = "Slack Integration",
			tags = {"Pro"},
			description = "Default question tags for questions created on Slack (comma-separated list).")
	public String slackDefaultQuestionTags() {
		return getConfigParam("slack.default_question_tags", "via-slack");
	}

	/* **************************************************************************************************************
	 * Microsoft Teams Integration                                                      Microsoft Teams Integration *
	 ****************************************************************************************************************/

	@Documented(position = 2690,
			identifier = "teams.bot_id",
			category = "Microsoft Teams Integration",
			tags = {"Pro"},
			description = "Teams bot ID.")
	public String teamsBotId() {
		return getConfigParam("teams.bot_id", "");
	}

	@Documented(position = 2700,
			identifier = "teams.bot_secret",
			category = "Microsoft Teams Integration",
			tags = {"Pro"},
			description = "Teams bot secret key.")
	public String teamsBotSecret() {
		return getConfigParam("teams.bot_secret", "");
	}

	@Documented(position = 2710,
			identifier = "teams.bot_service_url",
			value = "https://smba.trafficmanager.net/emea/",
			category = "Microsoft Teams Integration",
			tags = {"Pro"},
			description = "Teams bot service URL.")
	public String teamsBotServiceUrl() {
		return getConfigParam("teams.bot_service_url", "https://smba.trafficmanager.net/emea/");
	}

	@Documented(position = 2720,
			identifier = "teams.notify_on_new_answer",
			value = "true",
			type = Boolean.class,
			category = "Microsoft Teams Integration",
			tags = {"Pro"},
			description = "Enable/disable the ability for Scoold to send notifications to Teams for new answers.")
	public boolean teamsNotifyOnNewAnswer() {
		return getConfigBoolean("teams.notify_on_new_answer", true);
	}

	@Documented(position = 2730,
			identifier = "teams.notify_on_new_question",
			value = "true",
			type = Boolean.class,
			category = "Microsoft Teams Integration",
			tags = {"Pro"},
			description = "Enable/disable the ability for Scoold to send notifications to Teams for new questions.")
	public boolean teamsNotifyOnNewQuestion() {
		return getConfigBoolean("teams.notify_on_new_question", true);
	}

	@Documented(position = 2740,
			identifier = "teams.notify_on_new_comment",
			value = "true",
			type = Boolean.class,
			category = "Microsoft Teams Integration",
			tags = {"Pro"},
			description = "Enable/disable the ability for Scoold to send notifications to Teams for new comments.")
	public boolean teamsNotifyOnNewComment() {
		return getConfigBoolean("teams.notify_on_new_comment", true);
	}

	@Documented(position = 2750,
			identifier = "teams.dm_on_new_comment",
			value = "false",
			type = Boolean.class,
			category = "Microsoft Teams Integration",
			tags = {"Pro"},
			description = "Enable/disable the ability for Scoold to send direct messages to Teams users for new comments.")
	public boolean teamsDmOnNewComment() {
		return getConfigBoolean("teams.dm_on_new_comment", false);
	}

	@Documented(position = 2760,
			identifier = "teams.post_to_space",
			category = "Microsoft Teams Integration",
			tags = {"Pro"},
			description = "Default space on Scoold where questions created on Teams will be published. Set it to "
					+ "`workspace` for using the team's name.")
	public String teamsPostToSpace() {
		return getConfigParam("teams.post_to_space", "");
	}

	@Documented(position = 2770,
			identifier = "teams.map_channels_to_spaces",
			value = "false",
			type = Boolean.class,
			category = "Microsoft Teams Integration",
			tags = {"Pro"},
			description = "Enable/disable mapping of Teams channels to Scoold spaces. When enabled, will create a "
					+ "Scoold space for each Teams channel.")
	public boolean teamsMapChannelsToSpaces() {
		return getConfigBoolean("teams.map_channels_to_spaces", false);
	}

	@Documented(position = 2780,
			identifier = "teams.map_workspaces_to_spaces",
			value = "true",
			type = Boolean.class,
			category = "Microsoft Teams Integration",
			tags = {"Pro"},
			description = "Enable/disable mapping of Teams teams to Scoold spaces. When enabled, will create a "
					+ "Scoold space for each Teams team.")
	public boolean teamsMapWorkspacesToSpaces() {
		return getConfigBoolean("teams.map_workspaces_to_spaces", true);
	}

	@Documented(position = 2790,
			identifier = "teams.max_notification_webhooks",
			value = "10",
			type = Integer.class,
			category = "Microsoft Teams Integration",
			tags = {"Pro"},
			description = "The maximum number of incoming webhooks which can be created on Scoold. Each webhook links a"
					+ " Teams channel to Scoold.")
	public int teamsMaxNotificationWebhooks() {
		return getConfigInt("teams.max_notification_webhooks", 10);
	}

	@Documented(position = 2800,
			identifier = "teams.default_title",
			value = "A question from Microsoft Teams",
			category = "Microsoft Teams Integration",
			tags = {"Pro"},
			description = "Default question title for questions created on Teams.")
	public String teamsDefaultQuestionTitle() {
		return getConfigParam("teams.default_title", "A question from Microsoft Teams");
	}

	@Documented(position = 2810,
			identifier = "teams.default_question_tags",
			value = "via-teams",
			category = "Microsoft Teams Integration",
			tags = {"Pro"},
			description = "Default question tags for questions created on Teams (comma-separated list).")
	public String teamsDefaultQuestionTags() {
		return getConfigParam("teams.default_question_tags", "via-teams");
	}

	/* **************************************************************************************************************
	 * SCIM                                                                                                    SCIM *
	 ****************************************************************************************************************/

	@Documented(position = 2820,
			identifier = "scim_enabled",
			value = "false",
			type = Boolean.class,
			category = "SCIM",
			tags = {"Pro", "preview"},
			description = "Enable/disable support for SCIM user provisioning.")
	public boolean scimEnabled() {
		return getConfigBoolean("scim_enabled", false);
	}

	@Documented(position = 2830,
			identifier = "scim_secret_token",
			category = "SCIM",
			tags = {"Pro", "preview"},
			description = "SCIM secret token.")
	public String scimSecretToken() {
		return getConfigParam("scim_secret_token", "");
	}

	@Documented(position = 2840,
			identifier = "scim_allow_provisioned_users_only",
			value = "false",
			type = Boolean.class,
			category = "SCIM",
			tags = {"Pro", "preview"},
			description = "Enable/disable the restriction that only SCIM-provisioned users can sign in.")
	public boolean scimAllowProvisionedUsersOnly() {
		return getConfigBoolean("scim_allow_provisioned_users_only", false);
	}

	@Documented(position = 2850,
			identifier = "scim_map_groups_to_spaces",
			value = "true",
			type = Boolean.class,
			category = "SCIM",
			tags = {"Pro", "preview"},
			description = "Enable/disable mapping of SCIM groups to Scoold spaces.")
	public boolean scimMapGroupsToSpaces() {
		return getConfigBoolean("scim_map_groups_to_spaces", true);
	}

	@Documented(position = 2860,
			identifier = "security.scim.admins_group_equivalent_to",
			value = "admins",
			category = "SCIM",
			tags = {"Pro", "preview"},
			description = "SCIM group whose members will be promoted to administrators on Scoold.")
	public String scimAdminsGroupEquivalentTo() {
		return getConfigParam("security.scim.admins_group_equivalent_to", "admins");
	}

	@Documented(position = 2870,
			identifier = "security.scim.mods_group_equivalent_to",
			value = "mods",
			category = "SCIM",
			tags = {"Pro", "preview"},
			description = "SCIM group whose members will be promoted to moderators on Scoold.")
	public String scimModeratorsGroupEquivalentTo() {
		return getConfigParam("security.scim.mods_group_equivalent_to", "mods");
	}

	/* **************************************************************************************************************
	 * Miscellaneous                                                                                  Miscellaneous *
	 ****************************************************************************************************************/

	@Documented(position = 2880,
			identifier = "security.redirect_uri",
			value = "http://localhost:8080",
			category = "Miscellaneous",
			description = "Publicly accessible, internet-facing URL of the Para endpoint where authenticated users "
					+ "will be redirected to, from the identity provider. Used when Para is hosted behind a proxy.")
	public String redirectUri() {
		return getConfigParam("security.redirect_uri", paraEndpoint());
	}

	@Documented(position = 2890,
			identifier = "redirect_signin_to_idp",
			value = "false",
			type = Boolean.class,
			category = "Miscellaneous",
			description = "Enable/disable the redirection of users from the signin page, directly to the IDP login page.")
	public boolean redirectSigninToIdp() {
		return getConfigBoolean("redirect_signin_to_idp", false);
	}

	@Documented(position = 2900,
			identifier = "gmaps_api_key",
			category = "Miscellaneous",
			description = "The Google Maps API key. Used for geolocation functionality, (e.g. 'posts near me', location).")
	public String googleMapsApiKey() {
		return getConfigParam("gmaps_api_key", "");
	}

	@Documented(position = 2910,
			identifier = "imgur_client_id",
			category = "Miscellaneous",
			tags = {"preview"},
			description = "Imgur API client id. Used for uploading avatars to Imgur. **Note:** Imgur have some breaking "
					+ "restrictions going on in their API and this might not work.")
	public String imgurClientId() {
		return getConfigParam("imgur_client_id", "");
	}

	@Documented(position = 2920,
			identifier = "max_fav_tags",
			value = "50",
			type = Integer.class,
			category = "Posts",
			description = "Maximum number of favorite tags.")
	public int maxFavoriteTags() {
		return getConfigInt("max_fav_tags", 50);
	}

	@Documented(position = 2930,
			identifier = "batch_request_size",
			value = "0",
			type = Integer.class,
			category = "Miscellaneous",
			description = "Maximum batch size for the Para client pagination requests.")
	public int batchRequestSize() {
		return getConfigInt("batch_request_size", 0);
	}

	@Documented(position = 2940,
			identifier = "signout_url",
			value = "/signin?code=5&success=true",
			category = "Miscellaneous",
			description = "The URL which users will be redirected to after they click 'Sign out'. Can be a page hosted"
					+ " externally.")
	public String signoutUrl(int... code) {
		if (code == null || code.length < 1) {
			code = new int[]{5};
		}
		return getConfigParam("signout_url", SIGNINLINK + "?code=" + code[0] + "&success=true");
	}

	@Documented(position = 2950,
			identifier = "vote_expires_after_sec",
			value = "2592000",
			type = Integer.class,
			category = "Miscellaneous",
			description = "Vote expiration timeout, in seconds. Users can vote again on the same content after "
					+ "this period has elapsed. Default is 30 days.")
	public int voteExpiresAfterSec() {
		return getConfigInt("vote_expires_after_sec", Para.getConfig().voteExpiresAfterSec());
	}

	@Documented(position = 2960,
			identifier = "vote_locked_after_sec",
			value = "30",
			type = Integer.class,
			category = "Miscellaneous",
			description = "Vote locking period, in seconds. Vote cannot be changed after this period has elapsed. "
					+ "Default is 30 sec.")
	public int voteLockedAfterSec() {
		return getConfigInt("vote_locked_after_sec", Para.getConfig().voteLockedAfterSec());
	}

	@Documented(position = 2970,
			identifier = "import_batch_size",
			value = "100",
			type = Integer.class,
			category = "Miscellaneous",
			description = "Maximum number objects to read and send to Para when importing data from a backup.")
	public int importBatchSize() {
		return getConfigInt("import_batch_size", 100);
	}

	@Documented(position = 2980,
			identifier = "connection_retries_max",
			value = "10",
			type = Integer.class,
			category = "Miscellaneous",
			description = "Maximum number of connection retries to Para.")
	public int paraConnectionRetryAttempts() {
		return getConfigInt("connection_retries_max", 10);
	}

	@Documented(position = 2990,
			identifier = "connection_retry_interval_sec",
			value = "10",
			type = Integer.class,
			category = "Miscellaneous",
			description = "Para connection retry interval, in seconds.")
	public int paraConnectionRetryIntervalSec() {
		return getConfigInt("connection_retry_interval_sec", 10);
	}

	@Documented(position = 3000,
			identifier = "rewrite_inbound_links_with_fqdn",
			category = "Miscellaneous",
			description = "If set, links to Scoold in emails will be replaced with a public-facing FQDN.")
	public String rewriteInboundLinksWithFQDN() {
		return getConfigParam("rewrite_inbound_links_with_fqdn", "");
	}

	@Documented(position = 3010,
			identifier = "cluster_nodes",
			value = "1",
			type = Integer.class,
			category = "Miscellaneous",
			description = "Total number of nodes present in the cluster when Scoold is deployed behind a reverse proxy.")
	public int clusterNodes() {
		return getConfigInt("cluster_nodes", 1);
	}

	@Documented(position = 3020,
			identifier = "autoinit.root_app_secret_key",
			category = "Miscellaneous",
			description = "If configured, Scoold will try to automatically initialize itself with Para and create its "
					+ "own Para app, called `app:scoold`. The keys for that new app will be saved in the configuration file.")
	public String autoInitWithRootAppSecretKey() {
		return getConfigParam("autoinit.root_app_secret_key", "");
	}

	@Documented(position = 3030,
			identifier = "autoinit.para_config_file",
			category = "Miscellaneous",
			description = "Does the same as `scoold.autoinit.root_app_secret_key` but tries to read the secret key for"
					+ " the root Para app from the Para configuration file, wherever that may be.")
	public String autoInitWithParaConfigFile() {
		return getConfigParam("autoinit.para_config_file", "");
	}

	/* **********************************************************************************************************/

	public boolean inDevelopment() {
		return environment().equals("development");
	}

	public boolean inProduction() {
		return environment().equals("production");
	}

	public boolean hasValue(String key) {
		return !StringUtils.isBlank(getConfigParam(key, ""));
	}

	private String getAppId() {
		return App.identifier(paraAccessKey());
	}

	public String localeCookie() {
		return getAppId() + "-locale";
	}

	public String spaceCookie() {
		return getAppId() + "-space";
	}

	public String authCookie() {
		return getAppId() + "-auth";
	}

	public String imagesLink() {
		return (inProduction() ? cdnUrl() : serverContextPath()) + "/images";
	}

	public String scriptsLink() {
		return (inProduction() ? cdnUrl() : serverContextPath()) + "/scripts";
	}

	public String stylesLink() {
		return (inProduction() ? cdnUrl() : serverContextPath()) + "/styles";
	}

	public Map<String, Object> oauthSettings(String alias) {
		String a = StringUtils.trimToEmpty(alias);
		Map<String, Object> settings = new LinkedHashMap<>();
		settings.put("oa2" + a + "_app_id", oauthAppId(a));
		settings.put("oa2" + a + "_secret", oauthSecret(a));
		settings.put("security.oauth" + a + ".token_url", oauthTokenUrl(a));
		settings.put("security.oauth" + a + ".profile_url", oauthProfileUrl(a));
		settings.put("security.oauth" + a + ".scope", oauthScope(a));
		settings.put("security.oauth" + a + ".accept_header", oauthAcceptHeader(a));
		settings.put("security.oauth" + a + ".parameters.id", oauthIdParameter(a));
		settings.put("security.oauth" + a + ".parameters.name", oauthNameParameter(a));
		settings.put("security.oauth" + a + ".parameters.given_name", oauthGivenNameParameter(a));
		settings.put("security.oauth" + a + ".parameters.family_name", oauthFamiliNameParameter(a));
		settings.put("security.oauth" + a + ".parameters.email", oauthEmailParameter(a));
		settings.put("security.oauth" + a + ".parameters.picture", oauthPictureParameter(a));
		settings.put("security.oauth" + a + ".download_avatars", oauthAvatarDownloadingEnabled(a));
		settings.put("security.oauth" + a + ".domain", oauthDomain(a));
		settings.put("security.oauth" + a + ".token_delegation_enabled", oauthTokenDelegationEnabled(a));
		return settings;
	}

	public Map<String, Object> ldapSettings() {
		Map<String, Object> settings = new LinkedHashMap<>();
		settings.put("security.ldap.server_url", ldapServerUrl());
		settings.put("security.ldap.base_dn", ldapBaseDN());
		settings.put("security.ldap.bind_dn", ldapBindDN());
		settings.put("security.ldap.bind_pass", ldapBindPassword());
		settings.put("security.ldap.user_search_base", ldapUserSearchBase());
		settings.put("security.ldap.user_search_filter", ldapUserSearchFilter());
		settings.put("security.ldap.user_dn_pattern", ldapUserDNPattern());
		settings.put("security.ldap.password_attribute", ldapPasswordAttributeName());
		settings.put("security.ldap.username_as_name", ldapUsernameAsName());
		settings.put("security.ldap.active_directory_domain", ldapActiveDirectoryDomain());
		settings.put("security.ldap.mods_group_node", ldapModeratorsGroupNode());
		settings.put("security.ldap.admins_group_node", ldapAdministratorsGroupNode());
		if (!ldapComparePasswords().isEmpty()) {
			settings.put("security.ldap.compare_passwords", ldapComparePasswords());
		}
		return settings;
	}

	public Map<String, Object> samlSettings() {
		Map<String, Object> settings = new LinkedHashMap<>();
		settings.put("security.saml.sp.entityid", samlSPEntityId());
		settings.put("security.saml.sp.assertion_consumer_service.url", samlSPAssertionConsumerServiceUrl());
		settings.put("security.saml.sp.nameidformat", samlSPNameIdFormat());
		settings.put("security.saml.sp.x509cert", samlSPX509Certificate());
		settings.put("security.saml.sp.privatekey", samlSPX509PrivateKey());

		settings.put("security.saml.idp.entityid", samlIDPEntityId());
		settings.put("security.saml.idp.single_sign_on_service.url", samlIDPSingleSignOnServiceUrl());
		settings.put("security.saml.idp.x509cert", samlIDPX509Certificate());
		settings.put("security.saml.idp.metadata_url", samlIDPMetadataUrl());

		settings.put("security.saml.security.authnrequest_signed", samlAuthnRequestSigningEnabled());
		settings.put("security.saml.security.want_messages_signed", samlMessageSigningEnabled());
		settings.put("security.saml.security.want_assertions_signed", samlAssertionSigningEnabled());
		settings.put("security.saml.security.want_assertions_encrypted", samlAssertionEncryptionEnabled());
		settings.put("security.saml.security.want_nameid_encrypted", samlNameidEncryptionEnabled());
		settings.put("security.saml.security.sign_metadata", samlMetadataSigningEnabled());
		settings.put("security.saml.security.want_xml_validation", samlXMLValidationEnabled());
		settings.put("security.saml.security.signature_algorithm", samlSignatureAlgorithm());

		settings.put("security.saml.attributes.id", samlIdAttribute());
		settings.put("security.saml.attributes.picture", samlPictureAttribute());
		settings.put("security.saml.attributes.email", samlEmailAttribute());
		settings.put("security.saml.attributes.name", samlNameAttribute());
		settings.put("security.saml.attributes.firstname", samlFirstNameAttribute());
		settings.put("security.saml.attributes.lastname", samlLastNameAttribute());
		settings.put("security.saml.domain", samlDomain());
		return settings;
	}

	public Map<String, Object> getParaAppSettings() {
		Map<String, Object> settings = new LinkedHashMap<String, Object>();
		settings.put("gp_app_id", googleAppId());
		settings.put("gp_secret", googleSecret());
		settings.put("fb_app_id", facebookAppId());
		settings.put("fb_secret", facebookSecret());
		settings.put("gh_app_id", githubAppId());
		settings.put("gh_secret", githubSecret());
		settings.put("in_app_id", linkedinAppId());
		settings.put("in_secret", linkedinSecret());
		settings.put("tw_app_id", twitterAppId());
		settings.put("tw_secret", twitterSecret());
		settings.put("ms_app_id", microsoftAppId());
		settings.put("ms_secret", microsoftSecret());
		settings.put("sl_app_id", slackAppId());
		settings.put("sl_secret", slackSecret());
		settings.put("az_app_id", amazonAppId());
		settings.put("az_secret", amazonSecret());
		// Microsoft tenant id support - https://github.com/Erudika/scoold/issues/208
		settings.put("ms_tenant_id", microsoftTenantId());
		// OAuth 2 settings
		settings.putAll(oauthSettings(""));
		settings.putAll(oauthSettings("second"));
		settings.putAll(oauthSettings("third"));
		// LDAP settings
		settings.putAll(ldapSettings());
		// SAML settings
		settings.putAll(samlSettings());
		// secret key
		settings.put("app_secret_key", appSecretKey());
		// email verification
		settings.put("security.allow_unverified_emails", allowUnverifiedEmails());
		// sessions
		settings.put("security.one_session_per_user", oneSessionPerUser());
		settings.put("session_timeout", sessionTimeoutSec());

		// URLs for success and failure
		settings.put("signin_success", serverUrl() + serverContextPath() + SIGNINLINK + "/success?jwt=id");
		settings.put("signin_failure", serverUrl() + serverContextPath() + SIGNINLINK + "?code=3&error=true");
		return settings;
	}

	String getDefaultContentSecurityPolicy() {
		return "default-src 'self'; "
				+ "base-uri 'self'; "
				+ "media-src 'self' blob:; "
				+ "form-action 'self' " + signoutUrl() + "; "
				+ "connect-src 'self' " + (inProduction() ? serverUrl() : "")
				+ " maps.googleapis.com api.imgur.com accounts.google.com " + cspConnectSources() + "; "
				+ "frame-src 'self' *.google.com staticxx.facebook.com " + cspFrameSources() + "; "
				+ "font-src 'self' cdnjs.cloudflare.com fonts.gstatic.com fonts.googleapis.com " + cspFontSources() + "; "
				// unsafe-inline required by MathJax and Google Maps!
				+ "style-src 'self' 'unsafe-inline' fonts.googleapis.com accounts.google.com "
				+ (cdnUrl().startsWith("/") ? "" : cdnUrl()) + " " + cspStyleSources() + "; "
				+ "img-src 'self' https: data:; "
				+ "object-src 'none'; "
				+ "report-uri /reports/cspv; "
				+ "script-src 'unsafe-inline' https: 'nonce-{{nonce}}' 'strict-dynamic';"; // CSP2 backward compatibility
	}

} public static final String QUESTIONSLINK = HOMEPAGE + "questions"; public static final String SIGNINLINK = HOMEPAGE + "signin"; public abstract class Post extends Sysprop {

	private static final long serialVersionUID = 1L;
	private static final ScooldConfig CONF = ScooldUtils.getConfig();

	public static final String DEFAULT_SPACE = "scooldspace:default";
	public static final String ALL_MY_SPACES = "scooldspace:*";

	@Stored
	private String body;
	@Stored @NotBlank @Size(min = 2, max = 255)
	private String title;
	@Stored @NotEmpty @Size(min = 1)
	private List<String> tags;

	@Stored private Long viewcount;
	@Stored private String answerid;
	@Stored private String revisionid;
	@Stored private String closerid;
	@Stored private Long answercount;
	@Stored private Long lastactivity;
	@Stored private Long lastedited;
	@Stored private String lasteditby;
	@Stored private String deletereportid;
	@Stored private String location;
	@Stored private String address;
	@Stored private String latlng;
	@Stored private List<String> commentIds;
	@Stored private String space;
	@Stored private Map<String, String> followers;
	@Stored private Boolean deprecated;

	private transient Profile author;
	private transient Profile lastEditor;
	private transient List<Comment> comments;
	private transient Pager itemcount;

	public Post() {
		this.answercount = 0L;
		this.viewcount = 0L;
	}

	private ParaClient client() {
		return ScooldUtils.getInstance().getParaClient();
	}

	public Boolean getDeprecated() {
		if (deprecated == null || isReply()) {
			deprecated = false;
		}
		return deprecated;
	}

	public void setDeprecated(Boolean deprecated) {
		this.deprecated = deprecated;
	}

	public Long getLastactivity() {
		if (lastactivity == null || lastactivity <= 0) {
			lastactivity = getUpdated();
		}
		return lastactivity;
	}

	public void setLastactivity(Long lastactivity) {
		this.lastactivity = lastactivity;
	}

	public Long getLastedited() {
		if (lastedited == null || lastedited <= 0) {
			lastedited = getUpdated();
		}
		return lastedited;
	}

	public void setLastedited(Long lastedited) {
		this.lastedited = lastedited;
	}

	@JsonIgnore
	public Pager getItemcount() {
		if (itemcount == null) {
			itemcount = new Pager(5);
			itemcount.setDesc(false);
		}
		return itemcount;
	}

	public void setItemcount(Pager itemcount) {
		this.itemcount = itemcount;
	}

	public Map<String, String> getFollowers() {
		return followers;
	}

	public void setFollowers(Map<String, String> followers) {
		this.followers = followers;
	}

	public String getDeletereportid() {
		return deletereportid;
	}

	public void setDeletereportid(String deletereportid) {
		this.deletereportid = deletereportid;
	}

	public String getLocation() {
		return location;
	}

	public void setLocation(String location) {
		this.location = location;
	}

	public String getAddress() {
		return address;
	}

	public void setAddress(String address) {
		this.address = address;
	}

	public String getLatlng() {
		return latlng;
	}

	public void setLatlng(String latlng) {
		this.latlng = latlng;
	}

	public List<String> getTags() {
		return tags;
	}

	public void setTags(List<String> tags) {
		this.tags = tags;
	}

	public String getLasteditby() {
		return lasteditby;
	}

	public void setLasteditby(String lasteditby) {
		this.lasteditby = lasteditby;
	}

	public Long getAnswercount() {
		return answercount;
	}

	public void setAnswercount(Long answercount) {
		this.answercount = answercount;
	}

	public String getCloserid() {
		return closerid;
	}

	public void setCloserid(String closed) {
		this.closerid = closed;
	}

	public String getRevisionid() {
		return revisionid;
	}

	public void setRevisionid(String revisionid) {
		this.revisionid = revisionid;
	}

	public String getAnswerid() {
		return answerid;
	}

	public void setAnswerid(String answerid) {
		this.answerid = answerid;
	}

	public Long getViewcount() {
		return viewcount;
	}

	public void setViewcount(Long viewcount) {
		this.viewcount = viewcount;
	}

	public String getTitle() {
		if (StringUtils.isBlank(title)) {
			return getId();
		}
		return title;
	}

	public void setTitle(String title) {
		if (!StringUtils.isBlank(title)) {
			this.title = StringUtils.trimToEmpty(title);
			setName(title);
		}
	}

	public String getBody() {
		return body;
	}

	public void setBody(String body) {
		this.body = body;
	}

	public boolean isClosed() {
		return !StringUtils.isBlank(this.closerid);
	}

	public String getTagsString() {
		if (getTags() == null || getTags().isEmpty()) {
			return "";
		}
		Collections.sort(getTags());
		return StringUtils.join(getTags(), ",");
	}

	public String create() {
		updateTags(null, getTags());
		this.body = Utils.abbreviate(this.body, ScooldUtils.getConfig().maxPostLength());
		Post p = client().create(this);
		if (p != null) {
			Revision.createRevisionFromPost(p, true);
			setId(p.getId());
			setTimestamp(p.getTimestamp());
			return p.getId();
		}
		return null;
	}

	public void update() {
		client().update(this);
	}

	public void delete() {
		// delete post
		ArrayList<ParaObject> children = new ArrayList<ParaObject>();
		ArrayList<String> ids = new ArrayList<String>();
		// delete Comments
		children.addAll(client().getChildren(this, Utils.type(Comment.class)));
		// delete Revisions
		children.addAll(client().getChildren(this, Utils.type(Revision.class)));

		for (ParaObject reply : client().getChildren(this, Utils.type(Reply.class))) {
			// delete answer
			children.add(reply);
			// delete Comments
			children.addAll(client().getChildren(reply, Utils.type(Comment.class)));
			// delete Revisions
			children.addAll(client().getChildren(reply, Utils.type(Revision.class)));
		}
		for (ParaObject child : children) {
			ids.add(child.getId());
		}
		updateTags(getTags(), null);
		client().deleteAll(ids);
		client().delete(this);
	}

	public static String getTagString(String tag) {
		if (StringUtils.isBlank(tag)) {
			return "";
		}
		String s = tag.replaceAll("[\\p{S}\\p{P}\\p{C}&&[^+\\.]]", " ").replaceAll("\\p{Z}+", " ").trim();
		return StringUtils.truncate(Utils.noSpaces(s, "-"), 35);
	}

	public void updateTags(List<String> oldTags, List<String> newTags) {
		List<String> deleteUs = new LinkedList<>();
		List<Tag> updateUs = new LinkedList<>();
		Map<String, Tag> oldTagz = Optional.ofNullable(oldTags).orElse(Collections.emptyList()).stream().
				map(t -> new Tag(getTagString(t))).distinct().collect(Collectors.toMap(t -> t.getId(), t -> t));
		Map<String, Tag> newTagz = Optional.ofNullable(newTags).orElse(Collections.emptyList()).stream().
				map(t -> new Tag(getTagString(t))).distinct().collect(Collectors.toMap(t -> t.getId(), t -> t));
		Map<String, Tag> existingTagz = client().readAll(Stream.concat(oldTagz.keySet().stream(), newTagz.keySet().
				stream()).distinct().collect(Collectors.toList())).
				stream().collect(Collectors.toMap(t -> t.getId(), t -> (Tag) t));
		// add newly created tags
		client().createAll(newTagz.values().stream().filter(t -> {
			t.setCount(1);
			return !existingTagz.containsKey(t.getId());
		}).collect(Collectors.toList()));
		// increment or decrement the count of the rest
		existingTagz.values().forEach(t -> {
			if (!oldTagz.containsKey(t.getId()) && newTagz.containsKey(t.getId())) {
				t.setCount(t.getCount() + 1);
				updateUs.add(t);
			} else if (oldTagz.containsKey(t.getId()) && (newTags == null || !newTagz.containsKey(t.getId()))) {
				t.setCount(t.getCount() - 1);
				if (t.getCount() <= 0) {
					// check if actual count is different
					int c = client().getCount(Utils.type(Question.class),
							Collections.singletonMap(Config._TAGS, t.getTag())).intValue();
					if (c <= 1) {
						deleteUs.add(t.getId());
					} else {
						t.setCount(c);
					}
				} else {
					updateUs.add(t);
				}
			} // else: count remains unchanged
		});
		client().updateAll(updateUs);
		client().deleteAll(deleteUs);
		int tagsLimit = Math.min(CONF.maxTagsPerPost(), 100);
		setTags(newTagz.values().stream().limit(tagsLimit).map(t -> t.getTag()).collect(Collectors.toList()));
	}

	@JsonIgnore
	public Profile getAuthor() {
		return author;
	}

	public void setAuthor(Profile author) {
		this.author = author;
	}

	@JsonIgnore
	public Profile getLastEditor() {
		return lastEditor;
	}

	public void setLastEditor(Profile lastEditor) {
		this.lastEditor = lastEditor;
	}

	public void setComments(List<Comment> comments) {
		this.comments = comments;
	}

	@JsonIgnore // DO NOT REMOVE! clashes with User.getComments() field in index
	public List<Comment> getComments() {
		return this.comments;
	}

	public List<String> getCommentIds() {
		return commentIds;
	}

	public String getSpace() {
		if (StringUtils.isBlank(space)) {
			space = DEFAULT_SPACE;
		}
		return space;
	}

	public void setSpace(String space) {
		this.space = space;
	}

	public void setCommentIds(List<String> commentIds) {
		this.commentIds = commentIds;
	}

	public boolean addCommentId(String id) {
		if (getCommentIds() != null && getCommentIds().size() < getItemcount().getLimit()) {
			return getCommentIds().add(id);
		}
		return false;
	}

	@JsonIgnore
	public List<Reply> getAnswers(Pager pager) {
		return getAnswers(Reply.class, pager);
	}

	@JsonIgnore
	public List<Reply> getUnapprovedAnswers(Pager pager) {
		if (isReply()) {
			return Collections.emptyList();
		}
		return client().getChildren(this, Utils.type(UnapprovedReply.class), pager);
	}

	private List<Reply> getAnswers(Class<? extends Reply> type, Pager pager) {
		if (isReply()) {
			return Collections.emptyList();
		}

		List<Reply> answers = client().getChildren(this, Utils.type(type), pager);
		// we try to find the accepted answer inside the answers list, in not there, read it from db
		if (pager.getPage() < 2 && !StringUtils.isBlank(getAnswerid())) {
			Reply acceptedAnswer = null;
			for (Iterator<Reply> iterator = answers.iterator(); iterator.hasNext();) {
				Reply answer = iterator.next();
				if (getAnswerid().equals(answer.getId())) {
					acceptedAnswer = answer;
					iterator.remove();
					break;
				}
			}
			if (acceptedAnswer == null) {
				acceptedAnswer = client().read(getAnswerid());
			}
			if (acceptedAnswer != null) {
				ArrayList<Reply> sortedAnswers = new ArrayList<Reply>(answers.size() + 1);
				if (pager.isDesc()) {
					sortedAnswers.add(acceptedAnswer);
					sortedAnswers.addAll(answers);
				} else {
					sortedAnswers.addAll(answers);
					sortedAnswers.add(acceptedAnswer);
				}
				return sortedAnswers;
			}
		}
		return answers;
	}

	@JsonIgnore
	public List<Revision> getRevisions(Pager pager) {
		return client().getChildren(this, Utils.type(Revision.class), pager);
	}

	@JsonIgnore
	public boolean isReply() {
		return this instanceof Reply;
	}

	@JsonIgnore
	public boolean isQuestion() {
		return this instanceof Question;
	}

	@JsonIgnore
	public boolean isFeedback() {
		return this instanceof Feedback;
	}

	public String getPostLink(boolean plural, boolean noid) {
		Post p = this;
		String ptitle = Utils.noSpaces(Utils.stripAndTrim(p.getTitle()), "-");
		String pid = (noid ? "" : "/" + p.getId() + "/" + ptitle);
		String ctx = CONF.serverContextPath();
		if (p.isQuestion()) {
			return ctx + (plural ? ScooldServer.QUESTIONSLINK : ScooldServer.QUESTIONLINK + pid);
		} else if (p.isFeedback()) {
			return ctx + ScooldServer.FEEDBACKLINK + (plural ? "" : pid);
		} else if (p.isReply()) {
			return ctx + ScooldServer.QUESTIONLINK + (noid ? "" : "/" + p.getParentid());
		}
		return "";
	}

	public void restoreRevisionAndUpdate(String revisionid) {
		Revision rev = client().read(revisionid);
		if (rev != null) {
			//copy rev data to post
			setTitle(rev.getTitle());
			setBody(rev.getBody());
			setTags(rev.getTags());
			setRevisionid(rev.getId());
			setLastactivity(System.currentTimeMillis());
			//update post without creating a new revision
			client().update(this);
			ScooldUtils.getInstance().triggerHookEvent("revision.restore", rev);
		}
	}

	public void addFollower(User user) {
		if (followers == null) {
			followers = new LinkedHashMap<String, String>();
		}
		if (user != null && !StringUtils.isBlank(user.getEmail())) {
			followers.put(user.getId(), user.getEmail());
		}
	}

	public void removeFollower(User user) {
		if (followers != null && user != null) {
			followers.remove(user.getId());
		}
	}

	public boolean hasFollowers() {
		return (followers != null && !followers.isEmpty());
	}

	public boolean hasUpdatedContent(Post beforeUpdate) {
		if (beforeUpdate == null) {
			return false;
		}
		return !StringUtils.equals(getTitle(), beforeUpdate.getTitle())
				|| !StringUtils.equals(getBody(), beforeUpdate.getBody())
				|| !Objects.equals(getTags(), beforeUpdate.getTags());
	}

	public boolean equals(Object obj) {
		if (obj == null || getClass() != obj.getClass()) {
			return false;
		}
		return Objects.equals(getTitle(), ((Post) obj).getTitle())
				&& Objects.equals(getBody(), ((Post) obj).getBody())
				&& Objects.equals(getLocation(), ((Post) obj).getLocation())
				&& Objects.equals(getSpace(), ((Post) obj).getSpace())
				&& Objects.equals(getTags(), ((Post) obj).getTags());
	}

	public int hashCode() {
		return Objects.hashCode(getTitle()) + Objects.hashCode(getBody()) + Objects.hashCode(getTags());
	}
} public class Profile extends Sysprop {

	private static final long serialVersionUID = 1L;

	@Stored private String originalName;
	@Stored private String originalPicture;
	@Stored private Long lastseen;
	@Stored private String location;
	@Stored private String latlng;
	@Stored private String status;
	@Stored private String aboutme;
	@Stored private String badges;
	@Stored private String groups;
	@Stored private Long upvotes;
	@Stored private Long downvotes;
	@Stored private Long comments;
	@Stored @URL private String picture;
	@Stored @URL private String website;
	@Stored private List<String> favtags;
	@Stored private Set<String> favspaces;
	@Stored private Set<String> spaces;
	@Stored private Boolean replyEmailsEnabled;
	@Stored private Boolean commentEmailsEnabled;
	@Stored private Boolean favtagsEmailsEnabled;
	@Stored private Boolean anonymityEnabled;
	@Stored private Boolean darkmodeEnabled;
	@Stored private Integer yearlyVotes;
	@Stored private Integer quarterlyVotes;
	@Stored private Integer monthlyVotes;
	@Stored private Integer weeklyVotes;

	private transient String newbadges;
	private transient Integer newreports;
	private transient User user;

	public enum Badge {
		VETERAN(10),		//regular visitor		//NOT IMPLEMENTED

		NICEPROFILE(10),	//100% profile completed
		REPORTER(0),		//for every report
		VOTER(0),			//100 total votes
		COMMENTATOR(0),		//100+ comments
		CRITIC(0),			//10+ downvotes
		SUPPORTER(10),		//50+ upvotes
		EDITOR(0),			//first edit of post
		BACKINTIME(0),		//for each rollback of post
		NOOB(10),			//first question + first approved answer
		ENTHUSIAST(0),		//100+ rep  [//			 ]
		FRESHMAN(0),		//300+ rep	[////		 ]
		SCHOLAR(0),			//500+ rep	[//////		 ]
		TEACHER(0),			//1000+ rep	[////////	 ]
		PROFESSOR(0),		//5000+ rep	[//////////	 ]
		GEEK(0),			//9000+ rep	[////////////]
		GOODQUESTION(10),	//20+ votes
		GOODANSWER(10),		//10+ votes
		EUREKA(0),			//for every answer to own question
		SENIOR(0),			//one year + member
		DISCIPLINED(0);		//each time user deletes own comment

		private final int reward;

		Badge(int reward) {
			this.reward = reward;
		}

		public String toString() {
			return super.toString().toLowerCase();
		}

		public Integer getReward() {
			return this.reward;
		}
	}

	public Profile() {
		this(null, null);
	}

	public Profile(String id) {
		this(id, null);
	}

	public Profile(String userid, String name) {
		setId(id(userid));
		setName(name);
		this.status = "";
		this.aboutme = "";
		this.location = "";
		this.website = "";
		this.badges = "";
		this.upvotes = 0L;
		this.downvotes = 0L;
		this.comments = 0L;
		this.yearlyVotes = 0;
		this.quarterlyVotes = 0;
		this.monthlyVotes = 0;
		this.weeklyVotes = 0;
		this.anonymityEnabled = false;
		this.darkmodeEnabled = false;
		this.favtagsEmailsEnabled = ScooldUtils.getConfig().favoriteTagsEmailsEnabled();
		this.replyEmailsEnabled = ScooldUtils.getConfig().replyEmailsEnabled();
		this.commentEmailsEnabled = ScooldUtils.getConfig().commentEmailsEnabled();
	}

	public static final String id(String userid) {
		if (StringUtils.endsWith(userid, Para.getConfig().separator() + "profile")) {
			return userid;
		} else {
			return userid != null ? userid + Para.getConfig().separator() + "profile" : null;
		}
	}

	public static Profile fromUser(User u) {
		Profile p = new Profile(u.getId(), u.getName());
		p.setUser(u);
		p.setOriginalName(u.getName());
		p.setPicture(u.getPicture());
		p.setAppid(u.getAppid());
		p.setCreatorid(u.getId());
		p.setTimestamp(u.getTimestamp());
		p.setGroups(ScooldUtils.getInstance().isRecognizedAsAdmin(u)
				? User.Groups.ADMINS.toString() : u.getGroups());
		// auto-assign spaces to new users
		String space = StringUtils.substringBefore(ScooldUtils.getConfig().autoAssignSpaces(), ",");
		if (!StringUtils.isBlank(space) && !ScooldUtils.getInstance().isDefaultSpace(space)) {
			Sysprop s = client().read(ScooldUtils.getInstance().getSpaceId(space));
			if (s == null) {
				s = ScooldUtils.getInstance().buildSpaceObject(space);
				client().create(s); // create the space it it's missing
			}
			if (ScooldUtils.getConfig().resetSpacesOnNewAssignment(u.isOAuth2User() || u.isLDAPUser() || u.isSAMLUser())) {
				p.setSpaces(Collections.singleton(s.getId() + Para.getConfig().separator() + s.getName()));
			} else {
				p.getSpaces().add(s.getId() + Para.getConfig().separator() + s.getName());
			}
		}
		return p;
	}

	private static ParaClient client() {
		return ScooldUtils.getInstance().getParaClient();
	}

	@JsonIgnore
	public User getUser() {
		if (user == null) {
			user = client().read(getCreatorid() == null
					? StringUtils.removeEnd(getId(), Para.getConfig().separator() + "profile") : getCreatorid());
		}
		return user;
	}

	public Integer getYearlyVotes() {
		if (yearlyVotes < 0) {
			yearlyVotes = 0;
		}
		return yearlyVotes;
	}

	public void setYearlyVotes(Integer yearlyVotes) {
		this.yearlyVotes = yearlyVotes;
	}

	public Integer getQuarterlyVotes() {
		if (quarterlyVotes < 0) {
			quarterlyVotes = 0;
		}
		return quarterlyVotes;
	}

	public void setQuarterlyVotes(Integer quarterlyVotes) {
		this.quarterlyVotes = quarterlyVotes;
	}

	public Integer getMonthlyVotes() {
		if (monthlyVotes < 0) {
			monthlyVotes = 0;
		}
		return monthlyVotes;
	}

	public void setMonthlyVotes(Integer monthlyVotes) {
		this.monthlyVotes = monthlyVotes;
	}

	public Integer getWeeklyVotes() {
		if (weeklyVotes < 0) {
			weeklyVotes = 0;
		}
		return weeklyVotes;
	}

	public void setWeeklyVotes(Integer weeklyVotes) {
		this.weeklyVotes = weeklyVotes;
	}

	public Boolean getReplyEmailsEnabled() {
		return replyEmailsEnabled;
	}

	public void setReplyEmailsEnabled(Boolean replyEmailsEnabled) {
		this.replyEmailsEnabled = replyEmailsEnabled;
	}

	public Boolean getCommentEmailsEnabled() {
		return commentEmailsEnabled;
	}

	public void setCommentEmailsEnabled(Boolean commentEmailsEnabled) {
		this.commentEmailsEnabled = commentEmailsEnabled;
	}

	public Boolean getFavtagsEmailsEnabled() {
		return favtagsEmailsEnabled;
	}

	public void setFavtagsEmailsEnabled(Boolean favtagsEmailsEnabled) {
		this.favtagsEmailsEnabled = favtagsEmailsEnabled;
	}

	public Boolean getAnonymityEnabled() {
		return anonymityEnabled;
	}

	public void setAnonymityEnabled(Boolean anonymityEnabled) {
		this.anonymityEnabled = anonymityEnabled;
	}

	public Boolean getDarkmodeEnabled() {
		return darkmodeEnabled;
	}

	public void setDarkmodeEnabled(Boolean darkmodeEnabled) {
		this.darkmodeEnabled = darkmodeEnabled;
	}

	public String getGroups() {
		return groups;
	}

	public void setGroups(String groups) {
		this.groups = groups;
	}

	public String getPicture() {
		return picture;
	}

	public void setPicture(String picture) {
		this.picture = picture;
	}

	public void setUser(User user) {
		this.user = user;
	}

	public String getLatlng() {
		return latlng;
	}

	public void setLatlng(String latlng) {
		this.latlng = latlng;
	}

	public String getNewbadges() {
		return newbadges;
	}

	public void setNewbadges(String newbadges) {
		this.newbadges = newbadges;
	}

	public List<String> getFavtags() {
		if (favtags == null) {
			favtags = new LinkedList<String>();
		}
		return favtags;
	}

	public void setFavtags(List<String> favtags) {
		this.favtags = favtags;
	}

	public Set<String> getFavspaces() {
		if (favspaces == null) {
			favspaces = new LinkedHashSet<String>();
		}
		return favspaces;
	}

	public void setFavspaces(Set<String> favspaces) {
		this.favspaces = favspaces;
	}

	public Set<String> getSpaces() {
		if (ScooldUtils.getInstance().isMod(this)) {
			spaces = ScooldUtils.getInstance().getAllSpaces().stream().
					map(s -> s.getId() + Para.getConfig().separator() + s.getName()).collect(Collectors.toSet());
		}
		if (spaces == null) {
			spaces = new LinkedHashSet<String>();
		}
		if (spaces.isEmpty()) {
			spaces.add(Post.DEFAULT_SPACE);
		}
		// this is confusing - let admins control who is in the default space
		//if (spaces.size() > 1 && spaces.contains(Post.DEFAULT_SPACE)) {
		//	spaces.remove(Post.DEFAULT_SPACE);
		//}
		return spaces;
	}

	public void setSpaces(Set<String> spaces) {
		this.spaces = spaces;
	}

	@JsonIgnore
	public Set<String> getAllSpaces() {
		return getSpaces().stream().filter(s -> !s.equalsIgnoreCase(Post.DEFAULT_SPACE)).collect(Collectors.toSet());
	}

	public Long getLastseen() {
		return lastseen;
	}

	public void setLastseen(Long val) {
		this.lastseen = val;
	}

	public String getWebsite() {
		return website;
	}

	public void setWebsite(String website) {
		this.website = website;
	}

	public Long getComments() {
		return comments;
	}

	public void setComments(Long comments) {
		this.comments = comments;
	}

	public Long getDownvotes() {
		return downvotes;
	}

	public void setDownvotes(Long downvotes) {
		this.downvotes = downvotes;
	}

	public Long getUpvotes() {
		return upvotes;
	}

	public void setUpvotes(Long upvotes) {
		this.upvotes = upvotes;
	}

	public String getBadges() {
		return badges;
	}

	public void setBadges(String badges) {
		this.badges = badges;
	}

	public String getLocation() {
		return location;
	}

	public void setLocation(String location) {
		this.location = location;
	}

	public String getStatus() {
		return status;
	}

	public void setStatus(String status) {
		this.status = status;
	}

	public String getAboutme() {
		return this.aboutme;
	}

	public void setAboutme(String aboutme) {
		this.aboutme = aboutme;
	}

	public String getOriginalName() {
		return originalName;
	}

	public void setOriginalName(String originalName) {
		this.originalName = originalName;
	}

	public String getOriginalPicture() {
		return originalPicture;
	}

	public void setOriginalPicture(String originalPicture) {
		this.originalPicture = originalPicture;
	}

	@SuppressWarnings("unchecked")
	public List<Question> getAllQuestions(Pager pager) {
		if (getId() == null) {
			return new ArrayList<Question>();
		}
		return (List<Question>) getPostsForUser(Utils.type(Question.class), pager);
	}

	@SuppressWarnings("unchecked")
	public List<Reply> getAllAnswers(Pager pager) {
		if (getId() == null) {
			return new ArrayList<Reply>();
		}
		return (List<Reply>) getPostsForUser(Utils.type(Reply.class), pager);
	}

	@SuppressWarnings("unchecked")
	public List<Question> getAllUnapprovedQuestions(Pager pager) {
		if (getId() == null) {
			return new ArrayList<Question>();
		}
		return (List<Question>) getPostsForUser(Utils.type(UnapprovedQuestion.class), pager);
	}

	@SuppressWarnings("unchecked")
	public List<Reply> getAllUnapprovedAnswers(Pager pager) {
		if (getId() == null) {
			return new ArrayList<Reply>();
		}
		return (List<Reply>) getPostsForUser(Utils.type(UnapprovedReply.class), pager);
	}

	private List<? extends Post> getPostsForUser(String type, Pager pager) {
		pager.setSortby("votes");
		return client().findTerms(type, Collections.singletonMap(Config._CREATORID, getId()), true, pager);
	}

	public String getFavtagsString() {
		if (getFavtags().isEmpty()) {
			return "";
		}
		return StringUtils.join(getFavtags(), ", ");
	}

	public boolean hasFavtags() {
		return !getFavtags().isEmpty();
	}

	public boolean hasSpaces() {
		return !(getSpaces().size() <= 1 && getSpaces().contains(Post.DEFAULT_SPACE));
	}

	public void removeSpace(String space) {
		String sid = ScooldUtils.getInstance().getSpaceId(space);
		Iterator<String> it = getSpaces().iterator();
		while (it.hasNext()) {
			if (it.next().startsWith(sid + Para.getConfig().separator())) {
				it.remove();
			}
		}
	}

	public long getTotalVotes() {
		if (upvotes == null) {
			upvotes = 0L;
		}
		if (downvotes == null) {
			downvotes = 0L;
		}

		return upvotes + downvotes;
	}

	public void addRep(int rep) {
		if (getVotes() == null) {
			setVotes(0);
		}
		setVotes(getVotes() + rep);
		updateVoteGains(rep);
	}

	public void removeRep(int rep) {
		if (getVotes() == null) {
			setVotes(0);
		}
		setVotes(getVotes() - rep);
		updateVoteGains(-rep);
		if (getVotes() < 0) {
			setVotes(0);
		}
	}

	public void incrementUpvotes() {
		if (this.upvotes == null) {
			this.upvotes = 1L;
		} else {
			this.upvotes = this.upvotes + 1L;
		}
	}

	public void incrementDownvotes() {
		if (this.downvotes == null) {
			this.downvotes = 1L;
		} else {
			this.downvotes = this.downvotes + 1L;
		}
	}

	private void updateVoteGains(int rep) {
		Long updated = Optional.ofNullable(getUpdated()).orElse(getTimestamp());
		LocalDateTime lastUpdate = LocalDateTime.ofInstant(Instant.ofEpochMilli(updated), ZoneId.systemDefault());
		LocalDate now = LocalDate.now();
		if (now.getYear() != lastUpdate.getYear()) {
			yearlyVotes = rep;
		} else {
			yearlyVotes += rep;
		}
		if (now.get(IsoFields.QUARTER_OF_YEAR) != lastUpdate.get(IsoFields.QUARTER_OF_YEAR)) {
			quarterlyVotes = rep;
		} else {
			quarterlyVotes += rep;
		}
		if (now.getMonthValue() != lastUpdate.getMonthValue()) {
			monthlyVotes = rep;
		} else {
			monthlyVotes += rep;
		}
		if (now.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR) != lastUpdate.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR)) {
			weeklyVotes = rep;
		} else {
			weeklyVotes += rep;
		}
		setUpdated(Utils.timestamp());
	}

	public boolean hasBadge(Badge b) {
		return StringUtils.containsIgnoreCase(badges, ",".concat(b.toString()).concat(","));
	}

	public void addBadge(Badge b) {
		String badge = b.toString();
		if (StringUtils.isBlank(badges)) {
			badges = ",";
		}
		badges = badges.concat(badge).concat(",");
		addRep(b.getReward());
	}

	public void addBadges(Badge[] larr) {
		for (Badge badge : larr) {
			addBadge(badge);
			addRep(badge.getReward());
		}
	}

	public void removeBadge(Badge b) {
		String badge = b.toString();
		if (StringUtils.isBlank(badges)) {
			return;
		}
		badge = ",".concat(badge).concat(",");

		if (badges.contains(badge)) {
			badges = badges.replaceFirst(badge, ",");
			removeRep(b.getReward());
		}
		if (StringUtils.isBlank(badges.replaceAll(",", ""))) {
			badges = "";
		}
	}

	public HashMap<String, Integer> getBadgesMap() {
		HashMap<String, Integer> badgeMap = new HashMap<String, Integer>(0);
		if (StringUtils.isBlank(badges)) {
			return badgeMap;
		}

		for (String badge : badges.split(",")) {
			Integer val = badgeMap.get(badge);
			int count = (val == null) ? 0 : val.intValue();
			badgeMap.put(badge, ++count);
		}

		badgeMap.remove("");
		return badgeMap;
	}

	public boolean isComplete() {
		return (!StringUtils.isBlank(location)
				&& !StringUtils.isBlank(aboutme)
				&& !StringUtils.isBlank(website));
	}

	public String create() {
		setLastseen(System.currentTimeMillis());
		client().create(this);
		return getId();
	}

	public void update() {
		setLastseen(System.currentTimeMillis());
		updateVoteGains(0); // reset vote gains if they we're past the time frame
		client().update(this);
	}

	public void delete() {
		client().delete(this);
		client().delete(getUser());
		ScooldUtils.getInstance().unsubscribeFromAllNotifications(this);
	}

	public int countNewReports() {
		if (newreports == null) {
			newreports = client().getCount(Utils.type(Report.class),
					Collections.singletonMap("properties.closed", false)).intValue();
		}
		return newreports;
	}

	public boolean equals(Object obj) {
		if (obj == null || getClass() != obj.getClass()) {
			return false;
		}
		return Objects.equals(getName(), ((Profile) obj).getName())
				&& Objects.equals(getLocation(), ((Profile) obj).getLocation())
				&& Objects.equals(getId(), ((Profile) obj).getId());
	}

	public int hashCode() {
		return Objects.hashCode(getName()) + Objects.hashCode(getId());
	}
} public enum Badge {
	VETERAN(10),		//regular visitor		//NOT IMPLEMENTED

	NICEPROFILE(10),	//100% profile completed
	REPORTER(0),		//for every report
	VOTER(0),			//100 total votes
	COMMENTATOR(0),		//100+ comments
	CRITIC(0),			//10+ downvotes
	SUPPORTER(10),		//50+ upvotes
	EDITOR(0),			//first edit of post
	BACKINTIME(0),		//for each rollback of post
	NOOB(10),			//first question + first approved answer
	ENTHUSIAST(0),		//100+ rep  [//			 ]
	FRESHMAN(0),		//300+ rep	[////		 ]
	SCHOLAR(0),			//500+ rep	[//////		 ]
	TEACHER(0),			//1000+ rep	[////////	 ]
	PROFESSOR(0),		//5000+ rep	[//////////	 ]
	GEEK(0),			//9000+ rep	[////////////]
	GOODQUESTION(10),	//20+ votes
	GOODANSWER(10),		//10+ votes
	EUREKA(0),			//for every answer to own question
	SENIOR(0),			//one year + member
	DISCIPLINED(0);		//each time user deletes own comment

	private final int reward;

	Badge(int reward) {
		this.reward = reward;
	}

	public String toString() {
		return super.toString().toLowerCase();
	}

	public Integer getReward() {
		return this.reward;
	}
} public class Question extends Post {
	private static final long serialVersionUID = 1L;

	public Question() {
		super();
		setTags(new LinkedList<String>());
	}
} public class Reply extends Post {
	private static final long serialVersionUID = 1L;

	public Reply() {
		super();
	}
} public class Revision extends Sysprop {
	private static final long serialVersionUID = 1L;

	@Stored private String body;
	@Stored private String description;
	@Stored private String title;
	@Stored private Boolean original;

	private transient Profile author;

	public Revision() {
		this(null);
	}

	public Revision(String id) {
		setId(id);
	}

	private ParaClient client() {
		return ScooldUtils.getInstance().getParaClient();
	}

	public Boolean getOriginal() {
		return original;
	}

	public void setOriginal(Boolean original) {
		this.original = original;
	}

	public String getTitle() {
		return title;
	}

	public void setTitle(String title) {
		setName(title);
		this.title = title;
	}

	public String getDescription() {
		return description;
	}

	public void setDescription(String description) {
		this.description = description;
	}

	public String getBody() {
		return body;
	}

	public void setBody(String body) {
		this.body = body;
	}

	@JsonIgnore
	public Profile getAuthor() {
		return author;
	}

	public void setAuthor(Profile author) {
		this.author = author;
	}

	public static void createRevisionFromPost(Post post, boolean orig) {
		if (post != null && post.getId() != null) {
			String revUserid = post.getLasteditby();
			if (revUserid == null) {
				revUserid = post.getCreatorid();
			}
			Revision postrev = new Revision();
			postrev.setCreatorid(revUserid);
			postrev.setParentid(post.getId());
			postrev.setTitle(post.getTitle());
			postrev.setBody(post.getBody());
			postrev.setTags(post.getTags());
			postrev.setOriginal(orig);
			String rid = postrev.create();
			if (rid != null) {
				post.setRevisionid(rid);
			}
		}
	}

	public void delete() {
		client().delete(this);
	}

	public void update() {
		client().update(this);
	}

	public String create() {
		Revision r = client().create(this);
		if (r != null) {
			setId(r.getId());
			setTimestamp(r.getTimestamp());
			return r.getId();
		}
		return null;
	}

	public boolean equals(Object obj) {
		if (obj == null || getClass() != obj.getClass()) {
			return false;
		}
		return Objects.equals(getBody(), ((Revision) obj).getBody()) &&
				Objects.equals(getDescription(), ((Revision) obj).getDescription());
	}

	public int hashCode() {
		return Objects.hashCode(getBody()) + Objects.hashCode(getDescription()) + Objects.hashCode(getTimestamp());
	}

} public class UnapprovedQuestion extends Question {
	private static final long serialVersionUID = 1L;
} public class UnapprovedReply extends Reply {
	private static final long serialVersionUID = 1L;
} @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" + Para.getConfig().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

	private List<Sysprop> allSpaces;

	private static final ScooldConfig CONF = new ScooldConfig();

	static {
		API_USER = new Profile("1", "System");
		API_USER.setVotes(1);
		API_USER.setCreatorid("1");
		API_USER.setTimestamp(Utils.timestamp());
		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("simplecomments", "#simplecommentspage($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 final ParaClient pc;
	private final LanguageUtils langutils;
	private final AvatarRepository avatarRepository;
	private final GravatarAvatarGenerator gravatarAvatarGenerator;
	private static ScooldUtils instance;
	private Sysprop customTheme;
	@Inject private Emailer emailer;

	@Inject
	public ScooldUtils(ParaClient pc, LanguageUtils langutils, AvatarRepositoryProxy avatarRepository,
			GravatarAvatarGenerator gravatarAvatarGenerator) {
		this.pc = pc;
		this.langutils = langutils;
		this.avatarRepository = avatarRepository;
		this.gravatarAvatarGenerator = gravatarAvatarGenerator;
		API_USER.setPicture(avatarRepository.getAnonymizedLink(CONF.supportEmail()));
	}

	public ParaClient getParaClient() {
		return pc;
	}

	public LanguageUtils getLangutils() {
		return langutils;
	}

	public static ScooldUtils getInstance() {
		return instance;
	}

	static void setInstance(ScooldUtils instance) {
		ScooldUtils.instance = instance;
	}

	public static ScooldConfig getConfig() {
		return CONF;
	}

	static {
		// multiple domains/admins are allowed only in Scoold PRO
		String approvedDomain = StringUtils.substringBefore(CONF.approvedDomainsForSignups(), ",");
		if (!StringUtils.isBlank(approvedDomain)) {
			APPROVED_DOMAINS.add(approvedDomain);
		}
		// multiple admins are allowed only in Scoold PRO
		String admin = StringUtils.substringBefore(CONF.admins(), ",");
		if (!StringUtils.isBlank(admin)) {
			ADMINS.add(admin);
		}
	}

	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 = CONF.paraConnectionRetryAttempts();
			int retryInterval = CONF.paraConnectionRetryIntervalSec();
			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;
		String jwt = HttpUtils.getStateParam(CONF.authCookie(), req);
		if (isApiRequest(req)) {
			return checkApiAuth(req);
		} else if (jwt != null && !StringUtils.endsWithAny(req.getRequestURI(),
				".js", ".css", ".svg", ".png", ".jpg", ".ico", ".gif", ".woff2", ".woff", "people/avatar")) {
			User u = pc.me(jwt);
			if (u != null && isEmailDomainApproved(u.getEmail())) {
				authUser = getOrCreateProfile(u, req);
				authUser.setUser(u);
				authUser.setOriginalPicture(u.getPicture());
				boolean updatedRank = promoteOrDemoteUser(authUser, u);
				boolean updatedProfile = updateProfilePictureAndName(authUser, u);
				if (updatedRank || updatedProfile) {
					authUser.update();
				}
			} else {
				clearSession(req, res);
				logger.info("Invalid JWT found in cookie {}.", CONF.authCookie());
				res.sendRedirect(CONF.serverUrl() + CONF.serverContextPath() + SIGNINLINK + "?code=3&error=true");
				return null;
			}
		}
		return authUser;
	}

	private ParaObject checkApiAuth(HttpServletRequest req) {
		if (req.getRequestURI().equals(CONF.serverContextPath() + "/api")) {
			return null;
		}
		String apiKeyJWT = StringUtils.removeStart(req.getHeader(HttpHeaders.AUTHORIZATION), "Bearer ");
		if (req.getRequestURI().equals(CONF.serverContextPath() + "/api/ping")) {
			return API_USER;
		} else if (req.getRequestURI().equals(CONF.serverContextPath() + "/api/stats") && isValidJWToken(apiKeyJWT)) {
			return API_USER;
		} else if (!isApiEnabled() || StringUtils.isBlank(apiKeyJWT) || !isValidJWToken(apiKeyJWT)) {
			throw new UnauthorizedException();
		}
		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())
				&& !gravatarAvatarGenerator.isLink(authUser.getPicture())
				&& !CONF.avatarEditsEnabled()) {
			authUser.setPicture(u.getPicture());
			update = true;
		}
		if (!CONF.nameEditsEnabled() &&	!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 boolean isDarkModeEnabled(Profile authUser, HttpServletRequest req) {
		return (authUser != null && authUser.getDarkmodeEnabled()) ||
				"1".equals(HttpUtils.getCookieValue(req, "dark-mode"));
	}

	private String getDefaultEmailSignature(String defaultText) {
		String template = CONF.emailsDefaultSignatureText(defaultText);
		return Utils.formatMessage(template, CONF.appName());
	}

	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"), CONF.appName());
			String body1 = Utils.formatMessage(CONF.emailsWelcomeText1(lang), CONF.appName());
			String body2 = CONF.emailsWelcomeText2(lang);
			String body3 = getDefaultEmailSignature(CONF.emailsWelcomeText3(lang));

			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 = CONF.serverUrl() + CONF.serverContextPath() + SIGNINLINK + "/register?id=" + user.getId() + "&token=" + token;
					body3 = "<b><a href=\"" + token + "\">" + lang.get("signin.welcome.verify") + "</a></b><br><br>" + body3;
				}
			}

			model.put("subject", escapeHtml(subject));
			model.put("logourl", getSmallLogoUrl());
			model.put("heading", Utils.formatMessage(lang.get("signin.welcome.title"), escapeHtml(user.getName())));
			model.put("body", body1 + body2 + body3);
			emailer.sendEmail(Arrays.asList(user.getEmail()), subject, compileEmailTemplate(model));
		}
	}

	public void sendVerificationEmail(Sysprop identifier, HttpServletRequest req) {
		if (identifier != null) {
			Map<String, Object> model = new HashMap<String, Object>();
			Map<String, String> lang = getLang(req);
			String subject = Utils.formatMessage(lang.get("signin.welcome"), CONF.appName());
			String body = getDefaultEmailSignature(CONF.emailsWelcomeText3(lang));

			String token = Utils.base64encURL(Utils.generateSecurityToken().getBytes());
			identifier.addProperty(Config._EMAIL_TOKEN, token);
			identifier.addProperty("confirmationTimestamp", Utils.timestamp());
			pc.update(identifier);
			token = CONF.serverUrl() + CONF.serverContextPath() + SIGNINLINK + "/register?id=" +
					identifier.getCreatorid() + "&token=" + token;
			body = "<b><a href=\"" + token + "\">" + lang.get("signin.welcome.verify") + "</a></b><br><br>" + body;

			model.put("subject", escapeHtml(subject));
			model.put("logourl", getSmallLogoUrl());
			model.put("heading", lang.get("hello"));
			model.put("body", body);
			emailer.sendEmail(Arrays.asList(identifier.getId()), 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 = CONF.serverUrl() + CONF.serverContextPath() + SIGNINLINK + "/iforgot?email=" + email + "&token=" + token;
			String subject = lang.get("iforgot.title");
			String body1 = lang.get("notification.iforgot.body1") + "<br><br>";
			String body2 = Utils.formatMessage("<b><a href=\"{0}\">" + lang.get("notification.iforgot.body2") +
					"</a></b><br><br>", url);
			String body3 = getDefaultEmailSignature(lang.get("notification.signature") + "<br><br>");

			model.put("subject", escapeHtml(subject));
			model.put("logourl", getSmallLogoUrl());
			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() && !APPROVED_DOMAINS.contains(StringUtils.substringAfter(email, "@"))) {
			logger.warn("Attempted signin from an unknown domain - email {} is part of an unapproved domain.", email);
			return false;
		}
		return true;
	}

	public Object isSubscribedToNewPosts(HttpServletRequest req) {
		if (!isNewPostNotificationAllowed()) {
			return false;
		}

		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 = CONF.maxItemsPerPage();
		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) &&
								!isIgnoredSpaceForNotifications(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) &&
							!isIgnoredSpaceForNotifications(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, CONF.maxItemsPerPage());
			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, HttpServletRequest req) {
		if (!isFavTagsNotificationAllowed()) {
			return;
		}
		// 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>();
			Map<String, String> lang = getLang(req);
			String name = postAuthor.getName();
			String body = Utils.markdownToHtml(question.getBody());
			String picture = Utils.formatMessage("<img src='{0}' width='25'>", escapeHtmlAttribute(avatarRepository.
					getLink(postAuthor, AvatarFormat.Square25)));
			String postURL = CONF.serverUrl() + question.getPostLink(false, false);
			String tagsString = Optional.ofNullable(question.getTags()).orElse(Collections.emptyList()).stream().
					map(t -> "<span class=\"tag\">" +
							(addedTags.contains(t) ? "<b>" + escapeHtml(t) + "<b>" : escapeHtml(t)) + "</span>").
					collect(Collectors.joining("&nbsp;"));
			String subject = Utils.formatMessage(lang.get("notification.favtags.subject"), name,
					Utils.abbreviate(question.getTitle(), 255));
			model.put("subject", escapeHtml(subject));
			model.put("logourl", getSmallLogoUrl());
			model.put("heading", Utils.formatMessage(lang.get("notification.favtags.heading"), picture, escapeHtml(name)));
			model.put("body", Utils.formatMessage("<h2><a href='{0}'>{1}</a></h2><div>{2}</div><br>{3}",
					postURL, escapeHtml(question.getTitle()), body, tagsString));

			Set<String> emails = getFavTagsSubscribers(addedTags);
			sendEmailsToSubscribersInSpace(emails, question.getSpace(), subject, compileEmailTemplate(model));
		}
	}

	@SuppressWarnings("unchecked")
	public void sendNewPostNotifications(Post question, HttpServletRequest req) {
		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))) {
			if (!isNewPostNotificationAllowed()) {
				return;
			}

			Map<String, Object> model = new HashMap<String, Object>();
			Map<String, String> lang = getLang(req);
			String name = postAuthor.getName();
			String body = Utils.markdownToHtml(question.getBody());
			String picture = Utils.formatMessage("<img src='{0}' width='25'>", escapeHtmlAttribute(avatarRepository.
					getLink(postAuthor, AvatarFormat.Square25)));
			String postURL = CONF.serverUrl() + question.getPostLink(false, false);
			String tagsString = Optional.ofNullable(question.getTags()).orElse(Collections.emptyList()).stream().
					map(t -> "<span class=\"tag\">" + escapeHtml(t) + "</span>").
					collect(Collectors.joining("&nbsp;"));
			String subject = Utils.formatMessage(lang.get("notification.newposts.subject"), name,
					Utils.abbreviate(question.getTitle(), 255));
			model.put("subject", escapeHtml(subject));
			model.put("logourl", getSmallLogoUrl());
			model.put("heading", Utils.formatMessage(lang.get("notification.newposts.heading"), picture, escapeHtml(name)));
			model.put("body", Utils.formatMessage("<h2><a href='{0}'>{1}</a></h2><div>{2}</div><br>{3}",
					postURL, escapeHtml(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(), subject, 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, HttpServletRequest req) {
		// 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>();
			Map<String, String> lang = getLang(req);
			String name = replyAuthor.getName();
			String body = Utils.markdownToHtml(reply.getBody());
			String picture = Utils.formatMessage("<img src='{0}' width='25'>", escapeHtmlAttribute(avatarRepository.
					getLink(replyAuthor, AvatarFormat.Square25)));
			String postURL = CONF.serverUrl() + parentPost.getPostLink(false, false);
			String subject = Utils.formatMessage(lang.get("notification.reply.subject"), name,
					Utils.abbreviate(reply.getTitle(), 255));
			model.put("subject", escapeHtml(subject));
			model.put("logourl", getSmallLogoUrl());
			model.put("heading", Utils.formatMessage(lang.get("notification.reply.heading"),
					Utils.formatMessage("<a href='{0}'>{1}</a>", postURL, escapeHtml(parentPost.getTitle()))));
			model.put("body", Utils.formatMessage("<h2>{0} {1}:</h2><div>{2}</div>", picture, escapeHtml(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 (isReplyNotificationAllowed() && parentPost.hasFollowers()) {
				emailer.sendEmail(new ArrayList<String>(parentPost.getFollowers().values()),
						subject,
						compileEmailTemplate(model));
			}
		}
	}

	public void sendCommentNotifications(Post parentPost, Comment comment, Profile commentAuthor, HttpServletRequest req) {
		// send email notification to author of post except when the comment is by the same person
		if (parentPost != null && comment != null) {
			parentPost.setAuthor(pc.read(Profile.id(parentPost.getCreatorid()))); // parent author is not current user (authUser)
			Map<String, Object> payload = new LinkedHashMap<>(ParaObjectUtils.getAnnotatedFields(comment, false));
			payload.put("parent", parentPost);
			payload.put("author", commentAuthor);
			triggerHookEvent("comment.create", payload);
			// get the last 5-6 commentators who want to be notified - https://github.com/Erudika/scoold/issues/201
			Pager p = new Pager(1, Config._TIMESTAMP, false, 5);
			boolean isCommentatorThePostAuthor = StringUtils.equals(parentPost.getCreatorid(), comment.getCreatorid());
			Set<String> last5ids = pc.findChildren(parentPost, Utils.type(Comment.class),
					"!(" + Config._CREATORID + ":\"" + comment.getCreatorid() + "\")", p).
					stream().map(c -> c.getCreatorid()).distinct().collect(Collectors.toSet());
			if (!isCommentatorThePostAuthor && !last5ids.contains(parentPost.getCreatorid())) {
				last5ids = new HashSet<>(last5ids);
				last5ids.add(parentPost.getCreatorid());
			}
			Map<String, String> lang = getLang(req);
			List<Profile> last5commentators = pc.readAll(new ArrayList<>(last5ids));
			last5commentators = last5commentators.stream().filter(u -> u.getCommentEmailsEnabled()).collect(Collectors.toList());
			pc.readAll(last5commentators.stream().map(u -> u.getCreatorid()).collect(Collectors.toList())).forEach(author -> {
				if (isCommentNotificationAllowed()) {
					Map<String, Object> model = new HashMap<String, Object>();
					String name = commentAuthor.getName();
					String body = Utils.markdownToHtml(comment.getComment());
					String pic = Utils.formatMessage("<img src='{0}' width='25'>",
						escapeHtmlAttribute(avatarRepository.getLink(commentAuthor, AvatarFormat.Square25)));
					String postURL = CONF.serverUrl() + parentPost.getPostLink(false, false);
					String subject = Utils.formatMessage(lang.get("notification.comment.subject"), name, parentPost.getTitle());
					model.put("subject", escapeHtml(subject));
					model.put("logourl", getSmallLogoUrl());
					model.put("heading", Utils.formatMessage(lang.get("notification.comment.heading"),
							Utils.formatMessage("<a href='{0}'>{1}</a>", postURL, escapeHtml(parentPost.getTitle()))));
					model.put("body", Utils.formatMessage("<h2>{0} {1}:</h2><div class='panel'>{2}</div>", pic, escapeHtml(name), body));
					emailer.sendEmail(Arrays.asList(((User) author).getEmail()), subject, compileEmailTemplate(model));
				}
			});
		}
	}

	private String escapeHtmlAttribute(String value) {
		return StringUtils.trimToEmpty(value)
				.replaceAll("'", "%27")
				.replaceAll("\"", "%22")
				.replaceAll("\\\\", "");
	}

	private String escapeHtml(String value) {
		return StringEscapeUtils.escapeHtml4(value);
	}

	public Profile readAuthUser(HttpServletRequest req) {
		Profile authUser = null;
		User u = pc.me(HttpUtils.getStateParam(CONF.authCookie(), req));
		if (u != null && isEmailDomainApproved(u.getEmail())) {
			return getOrCreateProfile(u, req);
		}
		return authUser;
	}

	public Profile getAuthUser(HttpServletRequest req) {
		return (Profile) req.getAttribute(AUTH_USER_ATTRIBUTE);
	}

	public boolean isAuthenticated(HttpServletRequest req) {
		return getAuthUser(req) != null;
	}

	public boolean isFeedbackEnabled() {
		return CONF.feedbackEnabled();
	}

	public boolean isNearMeFeatureEnabled() {
		return CONF.postsNearMeEnabled();
	}

	public boolean isDefaultSpacePublic() {
		return CONF.isDefaultSpacePublic();
	}

	public boolean isWebhooksEnabled() {
		return CONF.webhooksEnabled();
	}

	public boolean isAnonymityEnabled() {
		return CONF.profileAnonimityEnabled();
	}

	public boolean isApiEnabled() {
		return CONF.apiEnabled();
	}

	public boolean isFooterLinksEnabled() {
		return CONF.footerLinksEnabled();
	}

	public boolean isNotificationsAllowed() {
		return CONF.notificationEmailsAllowed();
	}

	public boolean isNewPostNotificationAllowed() {
		return isNotificationsAllowed() && CONF.emailsForNewPostsAllowed();
	}

	public boolean isFavTagsNotificationAllowed() {
		return isNotificationsAllowed() && CONF.emailsForFavtagsAllowed();
	}

	public boolean isReplyNotificationAllowed() {
		return isNotificationsAllowed() && CONF.emailsForRepliesAllowed();
	}

	public boolean isCommentNotificationAllowed() {
		return isNotificationsAllowed() && CONF.emailsForCommentsAllowed();
	}

	public boolean isDarkModeEnabled() {
		return CONF.darkModeEnabled();
	}

	public boolean isSlackAuthEnabled() {
		return CONF.slackAuthEnabled();
	}

	public static boolean isGravatarEnabled() {
		return CONF.gravatarsEnabled();
	}

	public static String gravatarPattern() {
		return CONF.gravatarsPattern();
	}

	public static String getDefaultAvatar() {
		return CONF.imagesLink() + "/anon.svg";
	}

	public static boolean isAvatarUploadsEnabled() {
		return isImgurAvatarRepositoryEnabled();
	}

	public static boolean isImgurAvatarRepositoryEnabled() {
		return !StringUtils.isBlank(CONF.imgurClientId()) && "imgur".equalsIgnoreCase(CONF.avatarRepository());
	}

	public String getFooterHTML() {
		return CONF.footerHtml();
	}

	public boolean isNavbarLink1Enabled() {
		return !StringUtils.isBlank(getNavbarLink1URL());
	}

	public String getNavbarLink1URL() {
		return CONF.navbarCustomLink1Url();
	}

	public String getNavbarLink1Text() {
		return CONF.navbarCustomLink1Text();
	}

	public boolean isNavbarLink2Enabled() {
		return !StringUtils.isBlank(getNavbarLink2URL());
	}

	public String getNavbarLink2URL() {
		return CONF.navbarCustomLink2Url();
	}

	public String getNavbarLink2Text() {
		return CONF.navbarCustomLink2Text();
	}

	public boolean isNavbarMenuLink1Enabled() {
		return !StringUtils.isBlank(getNavbarMenuLink1URL());
	}

	public String getNavbarMenuLink1URL() {
		return CONF.navbarCustomMenuLink1Url();
	}

	public String getNavbarMenuLink1Text() {
		return CONF.navbarCustomMenuLink1Text();
	}

	public boolean isNavbarMenuLink2Enabled() {
		return !StringUtils.isBlank(getNavbarMenuLink2URL());
	}

	public String getNavbarMenuLink2URL() {
		return CONF.navbarCustomMenuLink2Url();
	}

	public String getNavbarMenuLink2Text() {
		return CONF.navbarCustomMenuLink2Text();
	}

	public boolean alwaysHideCommentForms() {
		return CONF.alwaysHideCommentForms();
	}

	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(CONF.maxItemsPerPage());
		p.setPage(Math.min(NumberUtils.toLong(req.getParameter(pageParamName), 1), CONF.maxPages()));
		p.setLimit(NumberUtils.toInt(req.getParameter("limit"), CONF.maxItemsPerPage()));
		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 = CONF.defaultLanguageCode();
		String cookieLoc = getCookieValue(req, CONF.localeCookie());
		Locale fromReq = (req == null) ? Locale.getDefault() : req.getLocale();
		Locale requestLocale = langutils.getProperLocale(fromReq.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);
				if (clSize == post.getItemcount().getLimit() && pc.getCount(Utils.type(Comment.class),
						Collections.singletonMap("parentid", post.getId())) > clSize) {
					clSize++; // hack to show the "more" button
				}
			}
			post.getItemcount().setCount(clSize);
		}
		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(CONF.serverContextPath() + "/api/") || req.getRequestURI().equals(CONF.serverContextPath() + "/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) || CONF.newUsersCanComment() || isMod(authUser)));
	}

	public boolean postsNeedApproval() {
		return CONF.postsNeedApproval();
	}

	public boolean postNeedsApproval(Profile authUser) {
		return postsNeedApproval() && authUser.getVotes() < CONF.postsReputationThreshold() && !isMod(authUser);
	}

	public String getWelcomeMessage(Profile authUser) {
		return authUser == null ? CONF.welcomeMessage() : "";
	}

	public String getWelcomeMessageOnLogin(Profile authUser) {
		if (authUser == null) {
			return "";
		}
		String welcomeMsgOnlogin = CONF.welcomeMessageOnLogin();
		if (StringUtils.contains(welcomeMsgOnlogin, "{{")) {
			welcomeMsgOnlogin = Utils.compileMustache(Collections.singletonMap("user",
					ParaObjectUtils.getAnnotatedFields(authUser, false)), welcomeMsgOnlogin);
		}
		return welcomeMsgOnlogin;
	}

	public boolean isDefaultSpace(String space) {
		return DEFAULT_SPACE.equalsIgnoreCase(getSpaceId(space));
	}

	public String getDefaultSpace() {
		return DEFAULT_SPACE;
	}

	public boolean isAllSpaces(String space) {
		return ALL_MY_SPACES.equalsIgnoreCase(getSpaceId(space));
	}

	public List<Sysprop> getAllSpaces() {
		if (allSpaces == null) {
			allSpaces = new LinkedList<>(pc.findQuery("scooldspace", "*", new Pager(Config.DEFAULT_LIMIT)));
		}
		return allSpaces;
	}

	public boolean canAccessSpace(Profile authUser, String targetSpaceId) {
		if (authUser == null) {
			return isDefaultSpacePublic() && isDefaultSpace(targetSpaceId);
		}
		if (isMod(authUser) || isAllSpaces(targetSpaceId)) {
			return true;
		}
		if (StringUtils.isBlank(targetSpaceId) || targetSpaceId.length() < 2) {
			return false;
		}
		// this is confusing - let admins control who is in the default space
		//if (isDefaultSpace(targetSpaceId)) {
		//	// can user access the default space (blank)
		//	return isDefaultSpacePublic() || isMod(authUser) || !authUser.hasSpaces();
		//}
		boolean isMemberOfSpace = false;
		for (String space : authUser.getSpaces()) {
			String spaceId = getSpaceId(targetSpaceId);
			if (StringUtils.startsWithIgnoreCase(space, spaceId + Para.getConfig().separator()) || space.equalsIgnoreCase(spaceId)) {
				isMemberOfSpace = true;
				break;
			}
		}
		return isMemberOfSpace;
	}

	private boolean isIgnoredSpaceForNotifications(Profile profile, String space) {
		return profile != null && !profile.getFavspaces().isEmpty() && !profile.getFavspaces().contains(getSpaceId(space));
	}

	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() + Para.getConfig().separator() + s.getName();
			}
		}
		String spaceAttr = (String) req.getAttribute(CONF.spaceCookie());
		String spaceValue = StringUtils.isBlank(spaceAttr) ? Utils.base64dec(getCookieValue(req, CONF.spaceCookie())) : spaceAttr;
		String space = getValidSpaceId(authUser, spaceValue);
		return (isAllSpaces(space) && isMod(authUser)) ? DEFAULT_SPACE : verifyExistingSpace(authUser, space);
	}

	public void storeSpaceIdInCookie(String space, HttpServletRequest req, HttpServletResponse res) {
		// directly set the space on the requests, overriding the cookie value
		// used for setting the space from a direct URL to a particular space
		req.setAttribute(CONF.spaceCookie(), space);
		HttpUtils.setRawCookie(CONF.spaceCookie(), Utils.base64encURL(space.getBytes()),
				req, res, true, "Strict", StringUtils.isBlank(space) ? 0 : 365 * 24 * 60 * 60);
	}

	public String verifyExistingSpace(Profile authUser, String space) {
		if (!isDefaultSpace(space) && !isAllSpaces(space)) {
			Sysprop s = pc.read(getSpaceId(space));
			if (s == null) {
				if (authUser != null) {
					authUser.removeSpace(space);
					pc.update(authUser);
				}
				return DEFAULT_SPACE;
			} else {
				return s.getId() + Para.getConfig().separator() + s.getName(); // updates current space name in case it was renamed
			}
		}
		return space;
	}

	public String getValidSpaceIdExcludingAll(Profile authUser, String space, HttpServletRequest req) {
		String s = StringUtils.isBlank(space) ? getSpaceIdFromCookie(authUser, req) : space;
		return isAllSpaces(s) ? DEFAULT_SPACE : s;
	}

	private String getValidSpaceId(Profile authUser, String space) {
		if (authUser == null) {
			return DEFAULT_SPACE;
		}
		String defaultSpace = authUser.hasSpaces() ? ALL_MY_SPACES : 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, Para.getConfig().separator()) ?
				StringUtils.substring(space, 0, space.lastIndexOf(Para.getConfig().separator())) : "scooldspace:" + space;
		return "scooldspace".equals(s) ? space : s;
	}

	public String getSpaceFilteredQuery(Profile authUser, String currentSpace) {
		return 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, 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 != null && authUser.hasSpaces()) {
				return "(" + authUser.getSpaces().stream().map(s -> "properties.space:\"" + s + "\"").
						collect(Collectors.joining(" OR ")) + ")";
			} else {
				return "properties.space:\"" + DEFAULT_SPACE + "\"";
			}
		} else if (isDefaultSpace(spaceId) && isMod(authUser)) { // DO NOT MODIFY!
			return "*";
		} else {
			return "properties.space:\"" + spaceId + "\"";
		}
	}

	public Sysprop buildSpaceObject(String space) {
		space = Utils.abbreviate(space, 255);
		space = space.replaceAll(Para.getConfig().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 = q.replaceAll("$[\\*]*", "");
			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 ("*".equals(q)) {
			return qf;
		} else {
			if (q.isEmpty()) {
				return qf;
			} else {
				return qf + " AND " + q;
			}
		}
	}

	public String getUsersSearchQuery(String qs, String spaceFilter) {
		qs = Utils.stripAndTrim(qs).toLowerCase();
		if (!StringUtils.isBlank(qs)) {
			String wildcardLower = qs.matches("[\\p{IsAlphabetic}]*") ? qs + "*" : qs;
			String wildcardUpper = StringUtils.capitalize(wildcardLower);
			String template = "(name:({1}) OR name:({2} OR {3}) OR properties.location:({0}) OR "
					+ "properties.aboutme:({0}) OR properties.groups:({0}))";
			qs = (StringUtils.isBlank(spaceFilter) ? "" : spaceFilter + " AND ") +
					Utils.formatMessage(template, qs, StringUtils.capitalize(qs), wildcardLower, wildcardUpper);
		} else {
			qs = StringUtils.isBlank(spaceFilter) ? "*" : spaceFilter;
		}
		return qs;
	}

	public List<Post> fullQuestionsSearch(String query, Pager... pager) {
		String typeFilter = Config._TYPE + ":(" + String.join(" OR ",
						Utils.type(Question.class), Utils.type(Reply.class), Utils.type(Comment.class)) + ")";
		String qs = StringUtils.isBlank(query) || query.startsWith("*") ? typeFilter : query + " AND " + typeFilter;
		List<ParaObject> mixedResults = pc.findQuery("", qs, pager);
		Predicate<ParaObject> isQuestion =  obj -> obj.getType().equals(Utils.type(Question.class));

		Map<String, ParaObject> idsToQuestions = new HashMap<>(mixedResults.stream().filter(isQuestion).
				collect(Collectors.toMap(q -> q.getId(), q -> q)));
		Set<String> toRead = new LinkedHashSet<>();
		mixedResults.stream().filter(isQuestion.negate()).forEach(obj -> {
			if (!idsToQuestions.containsKey(obj.getParentid())) {
				toRead.add(obj.getParentid());
			}
		});
		// find all parent posts but this excludes parents of parents - i.e. won't work for comments in answers
		List<Post> parentPostsLevel1 = pc.readAll(new ArrayList<>(toRead));
		parentPostsLevel1.stream().filter(isQuestion).forEach(q -> idsToQuestions.put(q.getId(), q));

		toRead.clear();

		// read parents of parents if any
		parentPostsLevel1.stream().filter(isQuestion.negate()).forEach(obj -> {
			if (!idsToQuestions.containsKey(obj.getParentid())) {
				toRead.add(obj.getParentid());
			}
		});
		List<Post> parentPostsLevel2 = pc.readAll(new ArrayList<>(toRead));
		parentPostsLevel2.stream().forEach(q -> idsToQuestions.put(q.getId(), q));

		ArrayList<Post> results = new ArrayList<Post>(idsToQuestions.size());
		for (ParaObject result : idsToQuestions.values()) {
			if (result instanceof Post) {
				results.add((Post) result);
			}
		}
		return results;
	}

	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;
	}

	public boolean canDelete(Post showPost, Profile authUser) {
		return canDelete(showPost, authUser, null);
	}

	public boolean canDelete(Post showPost, Profile authUser, String approvedAnswerId) {
		if (authUser == null) {
			return false;
		}
		if (CONF.deleteProtectionEnabled()) {
			if (showPost.isReply()) {
				return isMine(showPost, authUser) && !StringUtils.equals(approvedAnswerId, showPost.getId());
			} else {
				return isMine(showPost, authUser) && showPost.getAnswercount() == 0;
			}
		}
		return isMine(showPost, authUser);
	}

	@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 String getFullAvatarURL(Profile profile, AvatarFormat format) {
		return avatarRepository.getLink(profile, format);
	}

	public void clearSession(HttpServletRequest req, HttpServletResponse res) {
		if (req != null) {
			String jwt = HttpUtils.getStateParam(CONF.authCookie(), req);
			if (!StringUtils.isBlank(jwt)) {
				if (CONF.oneSessionPerUser()) {
					synchronized (pc) {
						pc.setAccessToken(jwt);
						pc.revokeAllTokens();
						pc.signOut();
					}
				}
				HttpUtils.removeStateParam(CONF.authCookie(), req, res);
			}
			HttpUtils.removeStateParam("dark-mode", 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() >= CONF.enthusiastIfHasRep());
			addBadgeOnce(authUser, Profile.Badge.FRESHMAN, authUser.getVotes() >= CONF.freshmanIfHasRep());
			addBadgeOnce(authUser, Profile.Badge.SCHOLAR, authUser.getVotes() >= CONF.scholarIfHasRep());
			addBadgeOnce(authUser, Profile.Badge.TEACHER, authUser.getVotes() >= CONF.teacherIfHasRep());
			addBadgeOnce(authUser, Profile.Badge.PROFESSOR, authUser.getVotes() >= CONF.professorIfHasRep());
			addBadgeOnce(authUser, Profile.Badge.GEEK, authUser.getVotes() >= CONF.geekIfHasRep());
			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", CONF.emailsFooterHtml());
		String fqdn = CONF.rewriteInboundLinksWithFQDN();
		if (!StringUtils.isBlank(fqdn)) {
			model.entrySet().stream().filter(e -> (e.getValue() instanceof String)).forEachOrdered(e -> {
				model.put(e.getKey(), StringUtils.replace((String) e.getValue(), CONF.serverUrl(), fqdn));
			});
		}
		return Utils.compileMustache(model, loadEmailTemplate("notify"));
	}

	public boolean isValidJWToken(String jwt) {
		String appSecretKey = CONF.appSecretKey();
		String masterSecretKey = CONF.paraSecretKey();
		return isValidJWToken(appSecretKey, jwt) || isValidJWToken(masterSecretKey, jwt);
	}

	boolean isValidJWToken(String secret, String jwt) {
		try {
			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, CONF.jwtExpiresAfterSec());
	}

	public SignedJWT generateJWToken(Map<String, Object> claims, long validitySeconds) {
		String secret = CONF.appSecretKey();
		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, CONF.paraAccessKey());
				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 Profile getSystemUser() {
		return API_USER;
	}

	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 (CONF.cspHeaderEnabled()) {
			response.setHeader("Content-Security-Policy",
					(request.isSecure() ? "upgrade-insecure-requests; " : "") + CONF.cspHeader(nonce));
		}
		// HSTS Header
		if (CONF.hstsHeaderEnabled()) {
			response.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
		}
		// Frame Options Header
		if (CONF.framingHeaderEnabled()) {
			response.setHeader("X-Frame-Options", "SAMEORIGIN");
		}
		// XSS Header
		if (CONF.xssHeaderEnabled()) {
			response.setHeader("X-XSS-Protection", "1; mode=block");
		}
		// Content Type Header
		if (CONF.contentTypeHeaderEnabled()) {
			response.setHeader("X-Content-Type-Options", "nosniff");
		}
		// Referrer Header
		if (CONF.referrerHeaderEnabled()) {
			response.setHeader("Referrer-Policy", "strict-origin");
		}
		// Permissions Policy Header
		if (CONF.permissionsHeaderEnabled()) {
			response.setHeader("Permissions-Policy", "geolocation=()");
		}
	}

	public boolean cookieConsentGiven(HttpServletRequest request) {
		return !CONF.cookieConsentRequired() || "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() {
		return CONF.externalScripts();
	}

	public List<String> getExternalStyles() {
		String extStyles = CONF.externalStyles();
		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 = CONF.inlineCSS();
			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");
					customTheme = 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" + Para.getConfig().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);
		customTheme = pc.create(custom);
		FILE_CACHE.put("theme", themeName);
		FILE_CACHE.put(getThemeKey(themeName), isCustom ? css : loadResource(getThemeKey(themeName)));
	}

	public Sysprop getCustomTheme() {
		String id = "theme" + Para.getConfig().separator() + "custom";
		if (customTheme == null) {
			customTheme = (Sysprop) Optional.ofNullable(pc.read(id)).orElseGet(this::getDefaultThemeObject);
		}
		return customTheme;
	}

	private Sysprop getDefaultThemeObject() {
		String themeName = "default";
		Sysprop s = new Sysprop("theme" + Para.getConfig().separator() + "custom");
		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 getSmallLogoUrl() {
		String defaultLogo = CONF.serverUrl() + CONF.imagesLink() + "/logowhite.png";
		String logoUrl = CONF.logoSmallUrl();
		String defaultMainLogoUrl = CONF.imagesLink() + "/logo.svg";
		String mainLogoUrl = CONF.logoUrl();
		if (!defaultLogo.equals(logoUrl)) {
			return logoUrl;
		} else if (!mainLogoUrl.equals(defaultMainLogoUrl)) {
			return mainLogoUrl;
		}
		return logoUrl;
	}

	public String getCSPNonce() {
		return Utils.generateSecurityToken(16);
	}

	public String getFacebookLoginURL() {
		return "https://www.facebook.com/dialog/oauth?client_id=" + CONF.facebookAppId() +
				"&response_type=code&scope=email&redirect_uri=" + getParaEndpoint() +
				"/facebook_auth&state=" + getParaAppId();
	}

	public String getGoogleLoginURL() {
		return "https://accounts.google.com/o/oauth2/v2/auth?" +
				"client_id=" + CONF.googleAppId() + "&response_type=code&scope=openid%20profile%20email&redirect_uri="
				+ getParaEndpoint() + "/google_auth&state=" + getParaAppId();
	}

	public String getGitHubLoginURL() {
		return "https://github.com/login/oauth/authorize?response_type=code&client_id=" + CONF.githubAppId() +
				"&scope=user%3Aemail&state=" + getParaAppId() +
				"&redirect_uri=" + getParaEndpoint() + "/github_auth";
	}

	public String getLinkedInLoginURL() {
		return "https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=" + CONF.linkedinAppId() +
				"&scope=r_liteprofile%20r_emailaddress&state=" + getParaAppId() +
				"&redirect_uri=" + getParaEndpoint() + "/linkedin_auth";
	}

	public String getTwitterLoginURL() {
		return getParaEndpoint() + "/twitter_auth?state=" + getParaAppId();
	}

	public String getMicrosoftLoginURL() {
		return "https://login.microsoftonline.com/" + CONF.microsoftTenantId() +
				"/oauth2/v2.0/authorize?response_type=code&client_id=" + CONF.microsoftAppId() +
				"&scope=https%3A%2F%2Fgraph.microsoft.com%2Fuser.read&state=" + getParaAppId() +
				"&redirect_uri=" + getParaEndpoint() + "/microsoft_auth";
	}

	public String getSlackLoginURL() {
		return "https://slack.com/oauth/v2/authorize?response_type=code&client_id=" + CONF.slackAppId() +
				"&user_scope=identity.basic%20identity.email%20identity.team%20identity.avatar&state=" + getParaAppId() +
				"&redirect_uri=" + getParaEndpoint() + "/slack_auth";
	}

	public String getAmazonLoginURL() {
		return "https://www.amazon.com/ap/oa?response_type=code&client_id=" + CONF.amazonAppId() +
				"&scope=profile&state=" + getParaAppId() +
				"&redirect_uri=" + getParaEndpoint() + "/amazon_auth";
	}

	public String getOAuth2LoginURL() {
		return CONF.oauthAuthorizationUrl("") + "?" +
				"response_type=code&client_id=" + CONF.oauthAppId("") +
				"&scope=" + CONF.oauthScope("") + "&state=" + getParaAppId() +
				"&redirect_uri=" + getParaEndpoint() + "/oauth2_auth";
	}

	public String getOAuth2SecondLoginURL() {
		return CONF.oauthAuthorizationUrl("second") + "?" +
				"response_type=code&client_id=" + CONF.oauthAppId("second") +
				"&scope=" +  CONF.oauthScope("second") + "&state=" + getParaAppId() +
				"&redirect_uri=" + getParaEndpoint() + "/oauth2_auth";
	}

	public String getOAuth2ThirdLoginURL() {
		return CONF.oauthAuthorizationUrl("third") + "?" +
				"response_type=code&client_id=" + CONF.oauthAppId("third") +
				"&scope=" +  CONF.oauthScope("third") + "&state=" + getParaAppId() +
				"&redirect_uri=" + getParaEndpoint() + "/oauth2_auth";
	}

	public String getParaEndpoint() {
		return CONF.redirectUri();
	}

	public String getParaAppId() {
		return StringUtils.removeStart(CONF.paraAccessKey(), "app:");
	}

	public String getFirstConfiguredLoginURL() {
		if (!CONF.facebookAppId().isEmpty()) {
			return getFacebookLoginURL();
		}
		if (!CONF.googleAppId().isEmpty()) {
			return getGoogleLoginURL();
		}
		if (!CONF.githubAppId().isEmpty()) {
			return getGitHubLoginURL();
		}
		if (!CONF.linkedinAppId().isEmpty()) {
			return getLinkedInLoginURL();
		}
		if (!CONF.twitterAppId().isEmpty()) {
			return getTwitterLoginURL();
		}
		if (!CONF.microsoftAppId().isEmpty()) {
			return getMicrosoftLoginURL();
		}
		if (isSlackAuthEnabled()) {
			return getSlackLoginURL();
		}
		if (!CONF.amazonAppId().isEmpty()) {
			return getAmazonLoginURL();
		}
		if (!CONF.oauthAppId("").isEmpty()) {
			return getOAuth2LoginURL();
		}
		if (!CONF.oauthAppId("second").isEmpty()) {
			return getOAuth2SecondLoginURL();
		}
		if (!CONF.oauthAppId("third").isEmpty()) {
			return getOAuth2ThirdLoginURL();
		}
		return SIGNINLINK + "?code=3&error=true";
	}
}
import com.erudika.para.client.ParaClient;
import com.erudika.para.core.Address;
import com.erudika.para.core.ParaObject;
import com.erudika.para.core.User;
import com.erudika.para.core.utils.Pager;
import com.erudika.para.core.utils.Para;
import com.erudika.para.core.utils.ParaObjectUtils;
import com.erudika.para.core.utils.Utils;
import com.erudika.scoold.ScooldConfig;
import static com.erudika.scoold.ScooldServer.QUESTIONSLINK;
import static com.erudika.scoold.ScooldServer.SIGNINLINK;
import com.erudika.scoold.core.Post;
import com.erudika.scoold.core.Profile;
import com.erudika.scoold.core.Profile.Badge;
import com.erudika.scoold.core.Question;
import com.erudika.scoold.core.Reply;
import com.erudika.scoold.core.Revision;
import com.erudika.scoold.core.UnapprovedQuestion;
import com.erudika.scoold.core.UnapprovedReply;
import com.erudika.scoold.utils.ScooldUtils;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import javax.inject.Inject;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.Produces;
import org.apache.commons.beanutils.BeanUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
/*
 * Copyright 2013-2022 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.controllers;


/**
 *
 * @author Alex Bogdanovski [alex@erudika.com]
 */
@Controller
@RequestMapping("/question")
public class QuestionController {

	public static final Logger logger = LoggerFactory.getLogger(QuestionController.class);
	private static final ScooldConfig CONF = ScooldUtils.getConfig();

	private final ScooldUtils utils;
	private final ParaClient pc;

	@Inject
	public QuestionController(ScooldUtils utils) {
		this.utils = utils;
		this.pc = utils.getParaClient();
	}

	@GetMapping({"/{id}", "/{id}/{title}", "/{id}/{title}/*"})
	public String get(@PathVariable String id, @PathVariable(required = false) String title,
			@RequestParam(required = false) String sortby, HttpServletRequest req, HttpServletResponse res, Model model) {

		Post showPost = pc.read(id);
		if (showPost == null || !ParaObjectUtils.typesMatch(showPost)) {
			return "redirect:" + QUESTIONSLINK;
		}
		Profile authUser = utils.getAuthUser(req);
		if (!utils.canAccessSpace(authUser, showPost.getSpace())) {
			return "redirect:" + (utils.isDefaultSpacePublic() || utils.isAuthenticated(req) ?
					QUESTIONSLINK : SIGNINLINK + "?returnto=" + req.getRequestURI());
		} else if (!utils.isDefaultSpace(showPost.getSpace()) && pc.read(utils.getSpaceId(showPost.getSpace())) == null) {
			showPost.setSpace(Post.DEFAULT_SPACE);
			pc.update(showPost);
		}

		if (showPost instanceof UnapprovedQuestion && !(utils.isMine(showPost, authUser) || utils.isMod(authUser))) {