diff --git a/ccm-core/src/main/java/com/arsdigita/ui/login/LoginConstants.java b/ccm-core/src/main/java/com/arsdigita/ui/login/LoginConstants.java index 8c179c2d5..cf2b69679 100644 --- a/ccm-core/src/main/java/com/arsdigita/ui/login/LoginConstants.java +++ b/ccm-core/src/main/java/com/arsdigita/ui/login/LoginConstants.java @@ -30,6 +30,9 @@ public interface LoginConstants { public static final String LOGIN_BUNDLE = "com.arsdigita.ui.login.LoginResources"; + + public static final String LOGIN_UI_BUNDLE + = "org.libreccm.ui.LoginBundle"; public static final GlobalizedMessage SUBMIT = LoginHelper.getMessage( "login.submit"); diff --git a/ccm-core/src/main/java/org/libreccm/mvc/freemarker/FreemarkerViewEngine.java b/ccm-core/src/main/java/org/libreccm/mvc/freemarker/FreemarkerViewEngine.java new file mode 100644 index 000000000..19d17d176 --- /dev/null +++ b/ccm-core/src/main/java/org/libreccm/mvc/freemarker/FreemarkerViewEngine.java @@ -0,0 +1,334 @@ +/* + * Copyright (C) 2020 LibreCCM Foundation. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301 USA + */ +package org.libreccm.mvc.freemarker; + +import freemarker.template.Configuration; +import freemarker.template.SimpleNumber; +import freemarker.template.Template; +import freemarker.template.TemplateException; +import freemarker.template.TemplateMethodModelEx; +import freemarker.template.TemplateModelException; +import freemarker.template.TemplateScalarModel; +import org.eclipse.krazo.engine.ViewEngineBase; +import org.eclipse.krazo.engine.ViewEngineConfig; +import org.libreccm.theming.ThemeInfo; +import org.libreccm.theming.ThemeProvider; +import org.libreccm.theming.utils.L10NUtils; +import org.libreccm.theming.utils.SettingsUtils; +import org.libreccm.theming.utils.TextUtils; + +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.Charset; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import javax.annotation.Priority; +import javax.enterprise.context.ApplicationScoped; +import javax.enterprise.context.spi.Context; +import javax.enterprise.context.spi.CreationalContext; +import javax.enterprise.inject.spi.Bean; +import javax.enterprise.inject.spi.BeanManager; +import javax.inject.Inject; +import javax.mvc.MvcContext; +import javax.mvc.engine.ViewEngine; +import javax.mvc.engine.ViewEngineContext; +import javax.mvc.engine.ViewEngineException; +import javax.servlet.http.HttpServletRequest; + +/** + * Customized version of the Freemarker View Engine. This class is based of the + * View Engine from the Krazo project, but has been extended: + * + * + * @author Jens Pelzetter + */ +@ApplicationScoped +@Priority(ViewEngine.PRIORITY_APPLICATION) +public class FreemarkerViewEngine extends ViewEngineBase { + + @Inject + private BeanManager beanManager; + + @Inject + @ViewEngineConfig + private Configuration configuration; + + @Inject + private MvcContext mvc; + + @Inject + private L10NUtils l10nUtils; + + @Inject + private SettingsUtils settingsUtils; + + @Inject + private TextUtils textUtils; + + @Inject + private ThemeTemplateUtil themeTemplateUtil; + + @Override + public boolean supports(String view) { + return view.endsWith(".ftl"); + } + + @Override + public void processView(final ViewEngineContext context) + throws ViewEngineException { + + final Charset charset = resolveCharsetAndSetContentType(context); + + try (final Writer writer = new OutputStreamWriter( + context.getOutputStream(), charset + )) { + final Template template = configuration.getTemplate( + resolveView(context) + ); + + final Map model = new HashMap<>(); + model.put("mvc", mvc); + model.put("request", context.getRequest(HttpServletRequest.class)); + + final Optional templateInfo = themeTemplateUtil + .getTemplateInfo(context.getView()); + final ThemeProvider themeProvider = themeTemplateUtil + .findThemeProvider(templateInfo.get().getThemeInfo()); + if (templateInfo.isPresent()) { + final ThemeInfo themeInfo = templateInfo.get().getThemeInfo(); + model.put("getSetting", + new GetSettingMethod(themeInfo, themeProvider) + ); + model.put("localize", + new LocalizeMethod(themeInfo, themeProvider) + ); + } + model.put("truncateText", new TruncateTextMethod()); + + final Map namedBeans = beanManager + .getBeans(Object.class) + .stream() + .filter(bean -> bean.getName() != null) + .map(this::findBeanInstance) + .filter(Optional::isPresent) + .map(Optional::get) + .collect( + Collectors.toMap( + NamedBeanInstance::getName, + NamedBeanInstance::getBeanInstance + ) + ); + + model.putAll(namedBeans); + model.putAll(context.getModels().asMap()); + + template.process(model, writer); + } catch (TemplateException | IOException e) { + throw new ViewEngineException(e); + } + } + + /** + * Helper method for retrieving a an instance of a named bean using CDI. + * + * @param bean The bean to retrieve. + * + * @return An instance of the bean. + */ + @SuppressWarnings("rawtypes") + private Optional findBeanInstance(final Bean bean) { + final Context context = beanManager.getContext(bean.getScope()); + final CreationalContext creationalContext = beanManager + .createCreationalContext(bean); + @SuppressWarnings("unchecked") + final Object instance = context.get(bean, creationalContext); + + if (instance == null) { + return Optional.empty(); + } else { + return Optional.of( + new NamedBeanInstance(bean.getName(), instance) + ); + } + } + + /** + * Helper class encapsulating the information about a named bean. + */ + private class NamedBeanInstance { + + /** + * The name of the bean. + */ + private final String name; + + /** + * The bean instance. + */ + private final Object beanInstance; + + public NamedBeanInstance(String name, Object beanInstance) { + this.name = name; + this.beanInstance = beanInstance; + } + + public String getName() { + return name; + } + + public Object getBeanInstance() { + return beanInstance; + } + + } + + /** + * Retrieves a setting from the theme using the {@link SettingsUtils}. + */ + private class GetSettingMethod implements TemplateMethodModelEx { + + private final ThemeInfo fromTheme; + + private final ThemeProvider themeProvider; + + public GetSettingMethod(final ThemeInfo fromTheme, + final ThemeProvider themeProvider) { + this.fromTheme = fromTheme; + this.themeProvider = themeProvider; + } + + @Override + public Object exec(final List arguments) throws TemplateModelException { + + switch (arguments.size()) { + case 2: { + final String filePath = ((TemplateScalarModel) arguments + .get(0)) + .getAsString(); + final String settingName = ((TemplateScalarModel) arguments + .get(0)) + .getAsString(); + + return settingsUtils.getSetting(fromTheme, + themeProvider, + filePath, + settingName); + } + case 3: { + final String filePath + = ((TemplateScalarModel) arguments.get(0)) + .getAsString(); + final String settingName + = ((TemplateScalarModel) arguments.get(1)) + .getAsString(); + final String defaultValue + = ((TemplateScalarModel) arguments.get(2)) + .getAsString(); + + return settingsUtils.getSetting(fromTheme, + themeProvider, + filePath, + settingName, + defaultValue); + } + default: + throw new TemplateModelException( + "Illegal number of arguments."); + } + } + + } + + /** + * Retrieves a localized value from the theme using the {@link L10NUtils}. + */ + private class LocalizeMethod implements TemplateMethodModelEx { + + private final ThemeInfo fromTheme; + + private final ThemeProvider themeProvider; + + public LocalizeMethod(final ThemeInfo fromTheme, + final ThemeProvider themeProvider) { + this.fromTheme = fromTheme; + this.themeProvider = themeProvider; + } + + @Override + public Object exec(final List arguments) throws TemplateModelException { + if (arguments.isEmpty()) { + throw new TemplateModelException("No string to localize."); + } + + final String bundle; + if (arguments.size() > 1) { + bundle = ((TemplateScalarModel) arguments.get(1)).getAsString(); + } else { + bundle = "theme-bundle"; + } + + final String key = ((TemplateScalarModel) arguments.get(0)) + .getAsString(); + + return l10nUtils.getText(fromTheme, themeProvider, bundle, key); + } + + } + + /** + * Truncates text to a specific length. + */ + private class TruncateTextMethod implements TemplateMethodModelEx { + + @Override + public Object exec(final List arguments) throws TemplateModelException { + if (arguments.size() == 2) { + final String text = ((TemplateScalarModel) arguments.get(0)) + .getAsString(); + final int length = ((SimpleNumber) arguments.get(1)) + .getAsNumber() + .intValue(); + + return textUtils.truncateText(text, length); + } else { + throw new TemplateModelException("Illegal number of arguments."); + } + } + + } + +} diff --git a/ccm-core/src/main/java/org/libreccm/mvc/freemarker/KrazoTemplateLoader.java b/ccm-core/src/main/java/org/libreccm/mvc/freemarker/KrazoTemplateLoader.java index 6b31bbc11..d10c970f8 100644 --- a/ccm-core/src/main/java/org/libreccm/mvc/freemarker/KrazoTemplateLoader.java +++ b/ccm-core/src/main/java/org/libreccm/mvc/freemarker/KrazoTemplateLoader.java @@ -53,7 +53,9 @@ class KrazoTemplateLoader implements TemplateLoader { @Override public Object findTemplateSource(final String name) throws IOException { - if (name.startsWith("@themes") || name.startsWith("/@themes")) { + if (name.startsWith("@themes") + || name.startsWith("/@themes") + || name.startsWith("WEB-INF/views/@themes")) { return null; } else { // Freemarker drops "/" diff --git a/ccm-core/src/main/java/org/libreccm/mvc/freemarker/MvcFreemarkerConfigurationProducer.java b/ccm-core/src/main/java/org/libreccm/mvc/freemarker/MvcFreemarkerConfigurationProducer.java index a209a462a..83c5c6fd9 100644 --- a/ccm-core/src/main/java/org/libreccm/mvc/freemarker/MvcFreemarkerConfigurationProducer.java +++ b/ccm-core/src/main/java/org/libreccm/mvc/freemarker/MvcFreemarkerConfigurationProducer.java @@ -28,28 +28,33 @@ import org.eclipse.krazo.engine.ViewEngineConfig; import org.eclipse.krazo.ext.freemarker.DefaultConfigurationProducer; import org.libreccm.theming.Themes; -import javax.enterprise.context.ApplicationScoped; +import javax.enterprise.inject.Produces; import javax.enterprise.inject.Specializes; import javax.inject.Inject; +import javax.mvc.Models; import javax.servlet.ServletContext; -import javax.ws.rs.Produces; /** - * Extends the default configuration for Freemarker of Eclipse Krazo to - * support Freemarker templates in CCM themes. - * + * Extends the default configuration for Freemarker of Eclipse Krazo to support + * Freemarker templates in CCM themes. + * * @author Jens Pelzetter */ -@ApplicationScoped -public class MvcFreemarkerConfigurationProducer +public class MvcFreemarkerConfigurationProducer extends DefaultConfigurationProducer { - + + @Inject + private Models models; + @Inject private ServletContext servletContext; - + @Inject private Themes themes; + @Inject + private ThemeTemplateUtil themeTemplateUtil; + @Produces @ViewEngineConfig @Specializes @@ -58,7 +63,7 @@ public class MvcFreemarkerConfigurationProducer final Configuration configuration = new Configuration( Configuration.VERSION_2_3_30 ); - + configuration.setDefaultEncoding("UTF-8"); configuration.setTemplateExceptionHandler( TemplateExceptionHandler.RETHROW_HANDLER @@ -70,7 +75,7 @@ public class MvcFreemarkerConfigurationProducer new MultiTemplateLoader( new TemplateLoader[]{ new KrazoTemplateLoader(servletContext), - new ThemesTemplateLoader(themes), + new ThemesTemplateLoader(themes, themeTemplateUtil), // For loading Freemarker macro libraries from WEB-INF // resources new WebappTemplateLoader( @@ -82,8 +87,8 @@ public class MvcFreemarkerConfigurationProducer } ) ); - + return configuration; } - + } diff --git a/ccm-core/src/main/java/org/libreccm/mvc/freemarker/TemplateInfo.java b/ccm-core/src/main/java/org/libreccm/mvc/freemarker/TemplateInfo.java new file mode 100644 index 000000000..55d6fe3d2 --- /dev/null +++ b/ccm-core/src/main/java/org/libreccm/mvc/freemarker/TemplateInfo.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2021 LibreCCM Foundation. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301 USA + */ +package org.libreccm.mvc.freemarker; + +import org.libreccm.theming.ThemeInfo; + +/** + * Encapulates the data of a template. + * + * @author Jens Pelzetter + */ +class TemplateInfo { + + /** + * Info about the theme providing the template. + */ + private final ThemeInfo themeInfo; + + /** + * The path of the template, + */ + private final String filePath; + + public TemplateInfo(ThemeInfo themeInfo, String filePath) { + this.themeInfo = themeInfo; + this.filePath = filePath; + } + + public ThemeInfo getThemeInfo() { + return themeInfo; + } + + public String getFilePath() { + return filePath; + } + +} diff --git a/ccm-core/src/main/java/org/libreccm/mvc/freemarker/ThemeTemplateUtil.java b/ccm-core/src/main/java/org/libreccm/mvc/freemarker/ThemeTemplateUtil.java new file mode 100644 index 000000000..dc9721245 --- /dev/null +++ b/ccm-core/src/main/java/org/libreccm/mvc/freemarker/ThemeTemplateUtil.java @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2021 LibreCCM Foundation. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301 USA + */ +package org.libreccm.mvc.freemarker; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.libreccm.core.UnexpectedErrorException; +import org.libreccm.theming.ThemeInfo; +import org.libreccm.theming.ThemeProvider; +import org.libreccm.theming.ThemeVersion; +import org.libreccm.theming.Themes; + +import java.util.Arrays; +import java.util.Optional; + +import javax.enterprise.context.RequestScoped; +import javax.enterprise.inject.Instance; +import javax.inject.Inject; + +/** + * Utility class for retreving a {@link TemplateInfo} instance for a template. + * + * @author Jens Pelzetter + */ +@RequestScoped +class ThemeTemplateUtil { + + private static final Logger LOGGER = LogManager.getLogger( + ThemeTemplateUtil.class); + + @Inject + private Instance themeProviders; + + @Inject + private Themes themes; + + /** + * Checks if the provided path points to a template. + * + * @param templatePath The path of the template. + * + * @return {@code true} if the path points to a template, {@code false} + * otherwise. + */ + public boolean isValidTemplatePath(final String templatePath) { + return templatePath.startsWith("@themes") + || templatePath.startsWith("/@themes"); + } + + /** + * Get the {@link TemplateInfo} for the template. + * + * @param templatePath The path of the template. + * + * @return An {@link Optional} with a {@link TemplateInfo} for the template. + * If the template is not available, an empty {@link Optional} is + * returned. + */ + public Optional getTemplateInfo(final String templatePath) { + if (!isValidTemplatePath(templatePath)) { + throw new IllegalArgumentException( + String.format( + "Provided template \"%s\" path does not start with " + + "\"@theme\" or \"/@theme\".", + templatePath + ) + ); + } + + final String[] tokens; + if (templatePath.startsWith("/")) { + tokens = templatePath.substring(1).split("/"); + } else { + tokens = templatePath.split("/"); + } + + return getTemplateInfo(tokens); + } + + /** + * Find the {@link ThemeProvider} for a theme. + * + * @param forTheme The theme + * + * @return The {@link ThemeProvider} for the theme. + */ + public ThemeProvider findThemeProvider(final ThemeInfo forTheme) { + final Instance provider = themeProviders + .select(forTheme.getProvider()); + + if (provider.isUnsatisfied()) { + LOGGER.error("ThemeProvider \"{}\" not found.", + forTheme.getProvider().getName()); + throw new UnexpectedErrorException( + String.format( + "ThemeProvider \"%s\" not found.", + forTheme.getProvider().getName() + ) + ); + } + + return provider.get(); + } + + /** + * Retrieves the {@link TemplateInfo} for a template. + * + * @param tokens The tokens of the template path. + * + * @return An {@link Optional} with a {@link TemplateInfo} for the template. + * If the template is not available, an empty {@link Optional} is + * returned. + */ + private Optional getTemplateInfo(final String[] tokens) { + if (tokens.length >= 4) { + final String themeName = tokens[1]; + final ThemeVersion themeVersion = ThemeVersion.valueOf( + tokens[2] + ); + final String filePath = String.join( + "/", + Arrays.copyOfRange( + tokens, 3, tokens.length, String[].class + ) + ); + + final Optional themeInfo = themes.getTheme( + themeName, themeVersion + ); + + if (themeInfo.isPresent()) { + return Optional.of(new TemplateInfo(themeInfo.get(), filePath)); + } else { + return Optional.empty(); + } + } else { + throw new IllegalArgumentException( + String.format( + "Template path has wrong format. Expected at least " + + "four tokens separated by slashes, but found only %d", + tokens.length + ) + ); + } + } + +} diff --git a/ccm-core/src/main/java/org/libreccm/mvc/freemarker/ThemesTemplateLoader.java b/ccm-core/src/main/java/org/libreccm/mvc/freemarker/ThemesTemplateLoader.java index 478a505a5..eaf9ede78 100644 --- a/ccm-core/src/main/java/org/libreccm/mvc/freemarker/ThemesTemplateLoader.java +++ b/ccm-core/src/main/java/org/libreccm/mvc/freemarker/ThemesTemplateLoader.java @@ -19,7 +19,6 @@ package org.libreccm.mvc.freemarker; import freemarker.cache.TemplateLoader; -import org.libreccm.theming.ThemeInfo; import org.libreccm.theming.ThemeVersion; import org.libreccm.theming.Themes; @@ -27,7 +26,6 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.Reader; -import java.util.Arrays; import java.util.Optional; /** @@ -39,8 +37,13 @@ class ThemesTemplateLoader implements TemplateLoader { private final Themes themes; - public ThemesTemplateLoader(final Themes themes) { + private final ThemeTemplateUtil themeTemplateUtil; + + public ThemesTemplateLoader( + final Themes themes, final ThemeTemplateUtil themeTemplateUtil + ) { this.themes = themes; + this.themeTemplateUtil = themeTemplateUtil; } /** @@ -53,73 +56,37 @@ class ThemesTemplateLoader implements TemplateLoader { * of the theme from which the template is loaded. {@code $version} is the * version of the theme to use. This token is converted to * {@link ThemeVersion}. Valid values are therefore {@code DRAFT} and - * {@code LIVE}. The remainder of the path is the path to the file inside the - * theme. + * {@code LIVE}. The remainder of the path is the path to the file inside + * the theme. * * @param path The path of the file. The path must include the theme and its * version. * * @return An {@link InputStream} for the template if the template was found - * in the theme. Otherwise {@code null} is returned. + * in the theme. Otherwise {@code null} is returned. * * @throws IOException */ @Override public Object findTemplateSource(final String path) throws IOException { - if (path.startsWith("@themes") || path.startsWith("/@themes")) { - final String[] tokens; - if (path.startsWith("/")) { - tokens = path.substring(1).split("/"); + if (themeTemplateUtil.isValidTemplatePath(path)) { + final Optional templateInfo = themeTemplateUtil + .getTemplateInfo(path); + + if (templateInfo.isPresent()) { + final Optional source = themes.getFileFromTheme( + templateInfo.get().getThemeInfo(), + templateInfo.get().getFilePath() + ); + + if (source.isPresent()) { + return source.get(); + } else { + return null; + } } else { - tokens = path.split("/"); + return null; } - return findTemplateSource(tokens); - } else { - return null; - } - } - - private InputStream findTemplateSource(final String[] tokens) { - if (tokens.length >= 4) { - final String themeName = tokens[1]; - final ThemeVersion themeVersion = ThemeVersion - .valueOf(tokens[2]); - final String filePath = String.join( - "/", - Arrays.copyOfRange( - tokens, 3, tokens.length, String[].class - ) - ); - - return findTemplateSource(themeName, themeVersion, filePath); - } else { - return null; - } - } - - private InputStream findTemplateSource( - final String themeName, - final ThemeVersion themeVersion, - final String filePath - ) { - final Optional themeInfo = themes.getTheme( - themeName, themeVersion - ); - if (themeInfo.isPresent()) { - return findTemplateSource(themeInfo.get(), filePath); - } else { - return null; - } - } - - private InputStream findTemplateSource( - final ThemeInfo themeInfo, final String filePath - ) { - final Optional source = themes.getFileFromTheme( - themeInfo, filePath - ); - if (source.isPresent()) { - return source.get(); } else { return null; } diff --git a/ccm-core/src/main/java/org/libreccm/theming/StaticThemeProvider.java b/ccm-core/src/main/java/org/libreccm/theming/StaticThemeProvider.java index ed89e5afd..0c03cec1f 100644 --- a/ccm-core/src/main/java/org/libreccm/theming/StaticThemeProvider.java +++ b/ccm-core/src/main/java/org/libreccm/theming/StaticThemeProvider.java @@ -39,6 +39,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.NoSuchElementException; import java.util.Objects; import java.util.Optional; import java.util.Set; @@ -53,25 +54,31 @@ import javax.json.JsonObject; import javax.json.JsonReader; /** - * Implementation of {@link ThemeProvider} for serves themes stored in the + * Implementation of {@link ThemeProvider} for serves themes stored in the * classpath. - * + * * @author Jens Pelzetter */ @RequestScoped public class StaticThemeProvider implements ThemeProvider { private static final long serialVersionUID = 1L; + private static final Logger LOGGER = LogManager.getLogger( StaticThemeProvider.class); private static final String THEMES_DIR = "/themes"; + private static final String THEMES_PACKAGE = "themes"; + private static final String THEME_MANIFEST_JSON_PATH = THEMES_DIR + "/%s/theme.json"; + private static final String THEME_MANIFEST_XML_PATH = THEMES_DIR + "/%s/theme.xml"; + private static final String THEME_MANIFEST_JSON = "theme.json"; + private static final String THEME_MANIFEST_XML = "theme.xml"; @Inject @@ -84,7 +91,7 @@ public class StaticThemeProvider implements ThemeProvider { public String getName() { return "StaticThemeProvider"; } - + @Override public List getThemes() { @@ -472,18 +479,26 @@ public class StaticThemeProvider implements ThemeProvider { final String fileName = path.get(0); - final Optional fileData = currentDirectory + final Optional fileDataResult = currentDirectory .stream() .map(value -> (JsonObject) value) .filter(value -> filterFileData(value, fileName)) .findAny(); if (path.size() == 1) { - return fileData; + return fileDataResult; } else { - - if (fileData.get().getBoolean("isDirectory")) { - return findFile(path.subList(1, path.size()), - fileData.get().getJsonArray("files")); + final JsonObject fileData = fileDataResult + .orElseThrow( + () -> new NoSuchElementException( + String.format( + "File %s not found.", path + ) + ) + ); + if (fileData.getBoolean("isDirectory")) { + return findFile( + path.subList(1, path.size()), fileData.getJsonArray("files") + ); } else { return Optional.empty(); } diff --git a/ccm-core/src/main/java/org/libreccm/theming/manifest/ThemeManifest.java b/ccm-core/src/main/java/org/libreccm/theming/manifest/ThemeManifest.java index 76133ed7e..27f1a5b76 100644 --- a/ccm-core/src/main/java/org/libreccm/theming/manifest/ThemeManifest.java +++ b/ccm-core/src/main/java/org/libreccm/theming/manifest/ThemeManifest.java @@ -34,6 +34,9 @@ import javax.xml.bind.annotation.XmlRootElement; import static org.libreccm.theming.ThemeConstants.*; import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; /** * Each theme contains a Manifest (either in XML or JSON format) which provides @@ -88,8 +91,16 @@ public class ThemeManifest implements Serializable { @XmlElement(name = "default-template", namespace = THEMES_XML_NS) private String defaultTemplate; + @XmlElement(name = "mvc-templates", namespace = THEMES_XML_NS) + private Map mvcTemplates; + + @XmlElement(name = "views", namespace = THEMES_XML_NS) + private Map> views; + public ThemeManifest() { templates = new ArrayList<>(); + mvcTemplates = new HashMap<>(); + views = new HashMap<>(); } public String getName() { @@ -156,6 +167,62 @@ public class ThemeManifest implements Serializable { this.defaultTemplate = defaultTemplate; } + public Map getMvcTemplates() { + return Collections.unmodifiableMap(mvcTemplates); + } + + public Optional getMvcTemplate(final String name) { + return Optional.ofNullable(mvcTemplates.get(name)); + } + + public void addMvcTemplate( + final String name, final ThemeTemplate template + ) { + mvcTemplates.put(name, template); + } + + protected void setMvcTemplates( + final Map mvcTemplates + ) { + this.mvcTemplates = mvcTemplates; + } + + public Map> getViews() { + return Collections.unmodifiableMap(views); + } + + public Map getViewsOfApplication(final String application) { + if (views.containsKey(application)) { + return views.get(application); + } else { + return Collections.emptyMap(); + } + } + + public void addViewsOfApplication( + final String application, final Map viewsOfApplication + ) { + views.put(application, viewsOfApplication); + } + + public void addViewToApplication( + final String application, final String view, final String templateName + ) { + final Map applicationViews; + if (views.containsKey(application)) { + applicationViews = views.get(application); + } else { + applicationViews = new HashMap<>(); + views.put(application, applicationViews); + } + + applicationViews.put(view, templateName); + } + + protected void setViews(final Map> views) { + this.views = new HashMap<>(views); + } + @Override public int hashCode() { int hash = 7; @@ -166,6 +233,8 @@ public class ThemeManifest implements Serializable { hash = 83 * hash + Objects.hashCode(description); hash = 83 * hash + Objects.hashCode(templates); hash = 83 * hash + Objects.hashCode(defaultTemplate); + hash = 83 * hash + Objects.hashCode(mvcTemplates); + hash = 83 * hash + Objects.hashCode(views); return hash; } @@ -202,7 +271,14 @@ public class ThemeManifest implements Serializable { if (!Objects.equals(templates, other.getTemplates())) { return false; } - return Objects.equals(defaultTemplate, other.getDefaultTemplate()); + if (!Objects.equals(defaultTemplate, other.getDefaultTemplate())) { + return false; + } + if (!Objects.equals(mvcTemplates, other.getMvcTemplates())) { + return false; + } + + return Objects.equals(views, other.getViews()); } public boolean canEqual(final Object obj) { @@ -216,24 +292,30 @@ public class ThemeManifest implements Serializable { public String toString(final String data) { - return String.format("%s{ " - + "name = \"%s\", " - + "type = \"%s\", " - + "masterTheme = \"%s\", " - + "title = \"%s\", " - + "description = \"%s\", " - + "templates = %s, " - + "defaultTemplate%s" - + " }", - super.toString(), - name, - type, - masterTheme, - Objects.toString(title), - Objects.toString(description), - Objects.toString(templates), - defaultTemplate, - data); + return String.format( + "%s{ " + + "name = \"%s\", " + + "type = \"%s\", " + + "masterTheme = \"%s\", " + + "title = \"%s\", " + + "description = \"%s\", " + + "templates = %s, " + + "defaultTemplate, " + + "mvcTemplates = %s," + + "views = %s%s" + + " }", + super.toString(), + name, + type, + masterTheme, + Objects.toString(title), + Objects.toString(description), + Objects.toString(templates), + defaultTemplate, + Objects.toString(mvcTemplates), + Objects.toString(views), + data + ); } diff --git a/ccm-core/src/main/java/org/libreccm/theming/manifest/ThemeManifestUtil.java b/ccm-core/src/main/java/org/libreccm/theming/manifest/ThemeManifestUtil.java index 9a3ef5c2a..aada91252 100644 --- a/ccm-core/src/main/java/org/libreccm/theming/manifest/ThemeManifestUtil.java +++ b/ccm-core/src/main/java/org/libreccm/theming/manifest/ThemeManifestUtil.java @@ -18,7 +18,8 @@ */ package org.libreccm.theming.manifest; -import static org.libreccm.theming.ThemeConstants.*; +import static org.libreccm.theming.ThemeConstants.THEME_MANIFEST_JSON; +import static org.libreccm.theming.ThemeConstants.THEME_MANIFEST_XML; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; @@ -60,8 +61,6 @@ public class ThemeManifestUtil implements Serializable { * @return The parsed manifest file. */ public ThemeManifest loadManifest(final Path path) { - -// final String pathStr = path.toString().toLowerCase(Locale.ROOT); final BufferedReader reader; try { reader = Files.newBufferedReader(path, Charset.forName("UTF-8")); @@ -70,34 +69,11 @@ public class ThemeManifestUtil implements Serializable { } return parseManifest(reader, path.toString()); - -// final ObjectMapper mapper; -// if (pathStr.endsWith(THEME_MANIFEST_JSON)) { -// mapper = new ObjectMapper(); -// } else if (pathStr.endsWith(THEME_MANIFEST_XML)) { -// final JacksonXmlModule xmlModule = new JacksonXmlModule(); -// mapper = new XmlMapper(xmlModule); -// } else { -// throw new IllegalArgumentException(String -// .format("The provided path \"%s\" does not point to a theme " -// + "manifest file.", -// path.toString())); -// } -// -// mapper.registerModule(new JaxbAnnotationModule()); -// -// final ThemeManifest manifest; -// try { -// manifest = mapper.readValue(reader, ThemeManifest.class); -// } catch (IOException ex) { -// throw new UnexpectedErrorException(ex); -// } -// return manifest; } - public ThemeManifest loadManifest(final InputStream inputStream, - final String fileName) { - + public ThemeManifest loadManifest( + final InputStream inputStream, final String fileName + ) { final InputStreamReader reader; try { reader = new InputStreamReader(inputStream, "UTF-8"); @@ -106,34 +82,11 @@ public class ThemeManifestUtil implements Serializable { } return parseManifest(reader, fileName); - -// final ObjectMapper mapper; -// if (fileName.endsWith(THEME_MANIFEST_JSON)) { -// mapper = new ObjectMapper(); -// } else if (fileName.endsWith(THEME_MANIFEST_XML)) { -// final JacksonXmlModule xmlModule = new JacksonXmlModule(); -// mapper = new XmlMapper(xmlModule); -// } else { -// throw new IllegalArgumentException(String -// .format("The provided path \"%s\" does not point to a theme " -// + "manifest file.", -// fileName)); -// } -// -// mapper.registerModule(new JaxbAnnotationModule()); -// -// final ThemeManifest manifest; -// try { -// manifest = mapper.readValue(reader, ThemeManifest.class); -// } catch (IOException ex) { -// throw new UnexpectedErrorException(ex); -// } -// return manifest; } - public String serializeManifest(final ThemeManifest manifest, - final String format) { - + public String serializeManifest( + final ThemeManifest manifest, final String format + ) { final ObjectMapper mapper; switch (format) { diff --git a/ccm-core/src/main/java/org/libreccm/theming/mvc/ThemeResourceProvider.java b/ccm-core/src/main/java/org/libreccm/theming/mvc/ThemeResourceProvider.java new file mode 100644 index 000000000..18315fdd9 --- /dev/null +++ b/ccm-core/src/main/java/org/libreccm/theming/mvc/ThemeResourceProvider.java @@ -0,0 +1,165 @@ +/* + * Copyright (C) 2021 LibreCCM Foundation. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301 USA + */ +package org.libreccm.theming.mvc; + +import org.libreccm.theming.ThemeFileInfo; +import org.libreccm.theming.ThemeProvider; +import org.libreccm.theming.ThemeVersion; + +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import javax.enterprise.context.RequestScoped; +import javax.enterprise.inject.Any; +import javax.enterprise.inject.Instance; +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.core.Response; + +/** + * Provides access to the resources/assets of themes. + * + * @see ThemeResources + * + * @author Jens Pelzetter + */ +@RequestScoped +@Path("/") +public class ThemeResourceProvider { + + /** + * Injection point for the available {@link ThemeProvider}s. + */ + @Inject + @Any + private Instance providers; + + /** + * Serves a resources from a theme. This endpoint is mounted at + * {@code /@themes/{theme}/{themeVersion}/{path:.+}}. If the provided theme + * is not found, the provided theme is not available in the requested + * version, or if the theme does not provide the requested resource the + * endpoint will response with a 404 response. If the response is found, a + * response with the asset and the correct mime type is returned. + * + * @param themeName The name of the theme providing the resource. + * @param themeVersionParam The version of the theme to use (either + * {@code LIVE} or {@code DRAFT}. + * @param pathParam The path of the resource to serve. + * + * @return A response with the resource and the correct mime type, or a 404 + * response if the resource, the theme version or the theme is not + * available. + */ + @GET + @Path("/{theme}/{themeVersion}/{path:.+}") + public Response getThemeFile( + @PathParam("theme") final String themeName, + @PathParam("themeVersion") final String themeVersionParam, + @PathParam("path") final String pathParam + ) { + final Optional provider = findProvider(themeName); + final ThemeVersion themeVersion = ThemeVersion.valueOf( + themeVersionParam + ); + + if (provider.isPresent()) { + final Optional fileInfo = provider + .get() + .getThemeFileInfo(themeName, themeVersion, pathParam); + + if (fileInfo.isPresent()) { + final ThemeFileInfo themeFileInfo = fileInfo.get(); + if (themeFileInfo.isDirectory()) { + return Response.status(Response.Status.FORBIDDEN).build(); + } else { + final Optional inputStream = provider + .get() + .getThemeFileAsStream( + themeName, themeVersion, pathParam + ); + if (inputStream.isPresent()) { + final InputStream inStream = inputStream.get(); + return Response + .ok(inStream) + .type(themeFileInfo.getMimeType()) + .build(); + } else { + return Response + .status(Response.Status.NOT_FOUND) + .entity( + String.format( + "File \"%s\" does not exist in version of " + + "theme %s.", + pathParam, + themeVersion, + themeName + ) + ) + .build(); + } + } + } else { + return Response + .status(Response.Status.NOT_FOUND) + .entity( + String.format( + "File \"%s\" does not exist in the %s " + + "version of theme %s.", + pathParam, + themeVersion, + themeName + ) + ) + .build(); + } + } else { + return Response + .status(Response.Status.NOT_FOUND) + .entity(String.format("Theme \"%s\" does not exist.", + themeName)) + .build(); + } + } + + /** + * Helper method for finding the provider of a theme. + * + * @param forTheme The theme. + * + * @return An {@link Optional} with the provider of the theme. If there is + * no matching provider, an empty {@link Optional} is returned. + */ + private Optional findProvider(final String forTheme) { + final List providersList = new ArrayList<>(); + providers + .forEach(provider -> providersList.add(provider)); + + return providersList + .stream() + .filter(current -> current.providesTheme(forTheme, + ThemeVersion.DRAFT)) + .findAny(); + } + +} diff --git a/ccm-core/src/main/java/org/libreccm/theming/mvc/ThemeResources.java b/ccm-core/src/main/java/org/libreccm/theming/mvc/ThemeResources.java new file mode 100644 index 000000000..246bf19c6 --- /dev/null +++ b/ccm-core/src/main/java/org/libreccm/theming/mvc/ThemeResources.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2021 LibreCCM Foundation. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301 USA + */ +package org.libreccm.theming.mvc; + +import java.util.HashSet; +import java.util.Set; + +import javax.ws.rs.ApplicationPath; +import javax.ws.rs.core.Application; + +/** + * JAX-RS application providing the resources/assets of a theme (images, CSS + * files, etc) under the {@code /@themes} URL. + * + * @author Jens Pelzetter + */ +@ApplicationPath("/@themes") +public class ThemeResources extends Application { + + @Override + public Set> getClasses() { + final Set> classes = new HashSet<>(); + classes.add(ThemeResourceProvider.class); + return classes; + } + +} diff --git a/ccm-core/src/main/java/org/libreccm/theming/mvc/ThemesMvc.java b/ccm-core/src/main/java/org/libreccm/theming/mvc/ThemesMvc.java new file mode 100644 index 000000000..70809320e --- /dev/null +++ b/ccm-core/src/main/java/org/libreccm/theming/mvc/ThemesMvc.java @@ -0,0 +1,260 @@ +/* + * Copyright (C) 2020 LibreCCM Foundation. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301 USA + */ +package org.libreccm.theming.mvc; + +import org.libreccm.sites.Site; +import org.libreccm.sites.SiteRepository; +import org.libreccm.theming.ThemeInfo; +import org.libreccm.theming.ThemeVersion; +import org.libreccm.theming.Themes; +import org.libreccm.theming.manifest.ThemeManifest; +import org.libreccm.theming.manifest.ThemeTemplate; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import javax.enterprise.context.RequestScoped; +import javax.inject.Inject; +import javax.mvc.Models; +import javax.servlet.ServletContext; +import javax.ws.rs.NotFoundException; +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; + +/** + * Main integration point for MVC application with the theme system. + * + * @author Jens Pelzetter + */ +@RequestScoped +public class ThemesMvc { + + @Inject + private Models models; + + @Inject + private ServletContext servletContext; + + @Inject + private SiteRepository siteRepo; + + @Inject + private Themes themes; + + /** + * Get the template for a specific application and view from the current + * theme. + * + * @param uriInfo URI is required for some tasks in inside the method. + * @param application The application for which the template is requested. + * @param view The view for which the template is requested. + * + * @return The path of the template to use for the view of the application. + */ + public String getMvcTemplate( + final UriInfo uriInfo, + final String application, + final String view + ) { + final Site site = getSite(uriInfo); + final String theme = parseThemeParam(uriInfo); + final ThemeVersion themeVersion = parsePreviewParam(uriInfo); + final ThemeInfo themeInfo = getTheme( + site, + theme, + themeVersion + ); + final ThemeManifest manifest = themeInfo.getManifest(); + final Map views = manifest.getViewsOfApplication( + application + ); + final String viewTemplateName; + if (views.containsKey(view)) { + viewTemplateName = views.get(view); + } else { + final Map defaultAppViews = manifest + .getViewsOfApplication(application); + if (defaultAppViews.containsKey("default")) { + viewTemplateName = defaultAppViews.get("default"); + } else { + throw new WebApplicationException( + String.format( + "Theme \"%s\" does not provide a template for view " + + "\"%s\" of application \"%s\", and there is no " + + "default template configured.", + themeInfo.getName(), + view, + application + ) + ); + } + } + + final ThemeTemplate themeTemplate = manifest + .getMvcTemplate(viewTemplateName) + .orElseThrow( + () -> new WebApplicationException( + String.format( + "Theme \"%s\" maps view \"%s\" of application \"%s\" " + + "to template \"%s\" but not template with this " + + "name was found in the theme.", + themeInfo.getName(), + view, + application, + viewTemplateName + ) + ) + ); + + models.put("contextPath", servletContext.getContextPath()); + models.put("themeName", themeInfo.getName()); + models.put("themeVersion", themeInfo.getVersion()); + models.put( + "themeUrl", + String.format( + "%s/@themes/%s/%s", + servletContext.getContextPath(), + themeInfo.getName(), + themeInfo.getVersion() + ) + ); + + return String.format( + "/@themes/%s/%s/%s", + themeInfo.getName(), + Objects.toString(themeVersion), + themeTemplate.getPath() + ); + } + + /** + * Helper method of retrieving the current site. + * + * @param uriInfo Used to extract the current site. + * + * @return The current site. + */ + private Site getSite(final UriInfo uriInfo) { + Objects.requireNonNull(uriInfo); + + final String domain = uriInfo.getBaseUri().getHost(); + + final Site site; + if (siteRepo.hasSiteForDomain(domain)) { + site = siteRepo.findByDomain(domain).get(); + } else { + site = siteRepo + .findDefaultSite() + .orElseThrow(() -> new NotFoundException( + "No matching Site and no default Site.")); + } + + return site; + } + + /** + * Helper method for retrieving a the theme to use. + * + * @param site The current site. + * @param theme The theme to retrieve. + * @param themeVersion The version of the theme to retrieve. + * + * @return A {@link ThemeInfo} object providing access to to the theme and + * its resources. + */ + private ThemeInfo getTheme( + final Site site, + final String theme, + final ThemeVersion themeVersion) { + if ("--DEFAULT--".equals(theme)) { + return themes + .getTheme(site.getDefaultTheme(), themeVersion) + .orElseThrow( + () -> new WebApplicationException( + String.format( + "The configured default theme \"%s\" for " + + "site \"%s\" is not available.", + site.getDefaultTheme(), + site.getDomainOfSite() + ), + Response.Status.INTERNAL_SERVER_ERROR + ) + ); + } else { + return themes + .getTheme(theme, themeVersion) + .orElseThrow( + () -> new WebApplicationException( + String.format( + "The theme \"%s\" is not available.", + theme + ), + Response.Status.BAD_REQUEST + ) + ); + } + } + + /** + * Helper method for parsing the {@code theme} query parameter which can be + * used to override the default theme of a site. + * + * @param uriInfo Information about the current URI. + * + * @return The value of the {@link theme} query parameter if present, or + * {@code --DEFAULT--} if the query parameter is not present. + */ + private String parseThemeParam(final UriInfo uriInfo) { + if (uriInfo.getQueryParameters().containsKey("theme")) { + return uriInfo.getQueryParameters().getFirst("theme"); + } else { + return "--DEFAULT--"; + } + } + + /** + * Helper method for parsing the {@code preview} query parameter. The + * {@code preview} query parameter allows it to test the draft version of a + * theme. + * + * @param uriInfo Information about the current URI. + * + * @return If the value of the parameter is {@code theme} or {@code all} + * {@link ThemeVersion#DRAFT} is returned. If the query parameter is + * not present or has another value, {@link ThemeVersion#LIVE} is + * returned. + */ + private ThemeVersion parsePreviewParam(final UriInfo uriInfo) { + if (uriInfo.getQueryParameters().containsKey("preview")) { + final List values = uriInfo + .getQueryParameters() + .get("preview"); + if (values.contains("theme") || values.contains("all")) { + return ThemeVersion.DRAFT; + } else { + return ThemeVersion.LIVE; + } + } else { + return ThemeVersion.LIVE; + } + } + +} diff --git a/ccm-core/src/main/java/org/libreccm/theming/mvc/package-info.java b/ccm-core/src/main/java/org/libreccm/theming/mvc/package-info.java new file mode 100644 index 000000000..cc845360e --- /dev/null +++ b/ccm-core/src/main/java/org/libreccm/theming/mvc/package-info.java @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2021 LibreCCM Foundation. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301 USA + */ +/** + * Integration of the Theming System with Jakarta EE MVC. + */ +package org.libreccm.theming.mvc; diff --git a/ccm-core/src/main/java/org/libreccm/ui/IsAuthenticatedFilter.java b/ccm-core/src/main/java/org/libreccm/ui/IsAuthenticatedFilter.java index dea89b408..da877e4ba 100644 --- a/ccm-core/src/main/java/org/libreccm/ui/IsAuthenticatedFilter.java +++ b/ccm-core/src/main/java/org/libreccm/ui/IsAuthenticatedFilter.java @@ -58,7 +58,7 @@ public class IsAuthenticatedFilter implements ContainerRequestFilter { Response.temporaryRedirect( URI.create( String.format( - "/%s/ccm/register?return_url=%s", + "/%s/@login?returnUrl=%s", contextPath, returnUrl ) diff --git a/ccm-core/src/main/java/org/libreccm/ui/login/LoginApplication.java b/ccm-core/src/main/java/org/libreccm/ui/login/LoginApplication.java new file mode 100644 index 000000000..10d821a8f --- /dev/null +++ b/ccm-core/src/main/java/org/libreccm/ui/login/LoginApplication.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2020 LibreCCM Foundation. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301 USA + */ +package org.libreccm.ui.login; + +import java.util.HashSet; +import java.util.Set; + +import javax.ws.rs.ApplicationPath; +import javax.ws.rs.core.Application; + +/** + * + * @author Jens Pelzetter + */ +@ApplicationPath("/@login") +public class LoginApplication extends Application { + + @Override + public Set> getClasses() { + final Set> classes = new HashSet<>(); + classes.add(LoginController.class); + + return classes; + } + + + +} diff --git a/ccm-core/src/main/java/org/libreccm/ui/login/LoginController.java b/ccm-core/src/main/java/org/libreccm/ui/login/LoginController.java new file mode 100644 index 000000000..f006369e5 --- /dev/null +++ b/ccm-core/src/main/java/org/libreccm/ui/login/LoginController.java @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2020 LibreCCM Foundation. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301 USA + */ +package org.libreccm.ui.login; + +import com.arsdigita.kernel.KernelConfig; + +import org.apache.shiro.authc.AuthenticationException; +import org.apache.shiro.authc.UsernamePasswordToken; +import org.apache.shiro.subject.Subject; +import org.libreccm.configuration.ConfigurationManager; +import org.libreccm.security.ChallengeManager; +import org.libreccm.security.User; +import org.libreccm.security.UserRepository; +import org.libreccm.theming.mvc.ThemesMvc; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Optional; + +import javax.enterprise.context.RequestScoped; +import javax.inject.Inject; +import javax.mail.MessagingException; +import javax.mvc.Controller; +import javax.mvc.Models; +import javax.servlet.ServletContext; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.DefaultValue; +import javax.ws.rs.FormParam; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.QueryParam; +import javax.ws.rs.RedirectionException; +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriBuilder; +import javax.ws.rs.core.UriInfo; + +/** + * + * @author Jens Pelzetter + */ +@Controller +@Path("/") +@RequestScoped +public class LoginController { + + @Inject + private ChallengeManager challengeManager; + + @Inject + private ConfigurationManager confManager; + + @Inject + private Models models; + + @Inject + private HttpServletRequest request; + + @Inject + private Subject subject; + + @Inject + private ThemesMvc themesMvc; + + @Inject + private UserRepository userRepository; + + @GET + @Path("/") + public String getLoginForm( + @Context final UriInfo uriInfo, + @QueryParam("returnUrl") @DefaultValue("") final String returnUrl + ) { + models.put( + "emailIsPrimaryIdentifier", isEmailPrimaryIdentifier() + ); + if (models.get("loginFailed") == null) { + models.put("loginFailed", false); + } + models.put("returnUrl", returnUrl); + return themesMvc.getMvcTemplate(uriInfo, "login", "loginForm"); + } + + @POST + @Path("/") + public Object processLogin( + @Context final UriInfo uriInfo, + @FormParam("login") final String login, + @FormParam("password") final String password, + @FormParam("rememberMe") final String rememberMeValue, + @FormParam("returnUrl") @DefaultValue("") final String returnUrl + ) { + final UsernamePasswordToken token = new UsernamePasswordToken( + login, password + ); + token.setRememberMe("on".equals(rememberMeValue)); + try { + subject.login(token); + } catch (AuthenticationException ex) { + models.put("loginFailed", true); + return getLoginForm(uriInfo, returnUrl); + } + + try { + return Response.seeOther( + new URI( + request.getScheme(), + "", + request.getServerName(), + request.getServerPort(), + String.join(request.getContextPath(), returnUrl), + "", + "" + ) + ).build(); + } catch (URISyntaxException ex) { + throw new WebApplicationException( + Response.Status.INTERNAL_SERVER_ERROR + ); + } + } + + @GET + @Path("/recover-password") + public String getRecoverPasswordForm(@Context final UriInfo uriInfo) { + return themesMvc.getMvcTemplate(uriInfo, "login", "recoverPassword"); + } + + @POST + @Path("/recover-password") + public String recoverPassword( + @Context final UriInfo uriInfo, + @FormParam("email") final String email + ) { + final Optional user = userRepository.findByEmailAddress(email); + if (user.isPresent()) { + try { + challengeManager.sendPasswordRecover(user.get()); + } catch (MessagingException ex) { + models.put("failedToSendRecoverMessage", true); + return getRecoverPasswordForm(uriInfo); + } + } + + return themesMvc.getMvcTemplate(uriInfo, "login", "passwordRecovered"); + } + + private boolean isEmailPrimaryIdentifier() { + final KernelConfig kernelConfig = confManager.findConfiguration( + KernelConfig.class + ); + return kernelConfig.emailIsPrimaryIdentifier(); + } + +} diff --git a/ccm-core/src/main/java/org/libreccm/ui/login/LoginMessages.java b/ccm-core/src/main/java/org/libreccm/ui/login/LoginMessages.java new file mode 100644 index 000000000..5ae2a2795 --- /dev/null +++ b/ccm-core/src/main/java/org/libreccm/ui/login/LoginMessages.java @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2020 LibreCCM Foundation. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301 USA + */ +package org.libreccm.ui.login; + +import com.arsdigita.ui.login.LoginConstants; + +import org.libreccm.l10n.GlobalizationHelper; + +import java.text.MessageFormat; +import java.util.AbstractMap; +import java.util.List; +import java.util.Map; +import java.util.ResourceBundle; +import java.util.Set; +import java.util.stream.Collectors; + +import javax.annotation.PostConstruct; +import javax.enterprise.context.RequestScoped; +import javax.inject.Inject; +import javax.inject.Named; + +/** + * + * @author Jens Pelzetter + */ +@RequestScoped +@Named("LoginMessages") +public class LoginMessages extends AbstractMap{ + + /** + * Provides access to the locale negoiated by LibreCCM. + */ + @Inject + private GlobalizationHelper globalizationHelper; + + /** + * The {@link ResourceBundle} to use. + */ + private ResourceBundle messages; + + /** + * Loads the resource bundle. + */ + @PostConstruct + private void init() { + messages = ResourceBundle.getBundle( + LoginConstants.LOGIN_UI_BUNDLE, + globalizationHelper.getNegotiatedLocale() + ); + } + + /** + * Retrieves a message from the resource bundle. + * + * @param key The key of the message. + * @return The translated message or {@code ???message???} if the the key is + * not found in the resource bundle (message is replaced with the key). + */ + public String getMessage(final String key) { + if (messages.containsKey(key)) { + return messages.getString(key); + } else { + return String.format("???%s???", key); + } + } + + /** + * Retrieves a message with placeholders. + * + * @param key The key of the message. + * @param parameters The parameters for the placeholders. + * @return The translated message or {@code ???message???} if the the key is + * not found in the resource bundle (message is replaced with the key). + */ + public String getMessage( + final String key, final List parameters + ) { + return getMessage(key, parameters.toArray()); + } + + /** + * The translated message or {@code ???message???} if the the key is + * not found in the resource bundle (message is replaced with the key). + * + @param key The key of the message. + * @param parameters The parameters for the placeholders. + * @return The translated message or {@code ???message???} if the the key is + * not found in the resource bundle (message is replaced with the key). + */ + public String getMessage( + final String key, final Object[] parameters + ) { + if (messages.containsKey(key)) { + return MessageFormat.format(messages.getString(key), parameters); + } else { + return String.format("???%s???", key); + } + } + + @Override + public String get(final Object key) { + return get((String) key); + } + + public String get(final String key) { + return getMessage(key); + } + + @Override + public Set> entrySet() { + return messages + .keySet() + .stream() + .collect( + Collectors.toMap(key -> key, key -> messages.getString(key)) + ) + .entrySet(); + } + + +} diff --git a/ccm-core/src/main/java/org/libreccm/ui/login/LogoutApplication.java b/ccm-core/src/main/java/org/libreccm/ui/login/LogoutApplication.java new file mode 100644 index 000000000..6d942471a --- /dev/null +++ b/ccm-core/src/main/java/org/libreccm/ui/login/LogoutApplication.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2021 LibreCCM Foundation. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301 USA + */ +package org.libreccm.ui.login; + +import java.util.HashSet; +import java.util.Set; + +import javax.ws.rs.ApplicationPath; +import javax.ws.rs.core.Application; + +/** + * + * @author Jens Pelzetter + */ +@ApplicationPath("/@logout") +public class LogoutApplication extends Application { + + @Override + public Set> getClasses() { + final Set> classes = new HashSet<>(); + classes.add(LogoutController.class); + return classes; + } + +} diff --git a/ccm-core/src/main/java/org/libreccm/ui/login/LogoutController.java b/ccm-core/src/main/java/org/libreccm/ui/login/LogoutController.java new file mode 100644 index 000000000..52b5406a7 --- /dev/null +++ b/ccm-core/src/main/java/org/libreccm/ui/login/LogoutController.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2021 LibreCCM Foundation. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301 USA + */ +package org.libreccm.ui.login; + +import org.apache.shiro.subject.Subject; +import org.libreccm.theming.mvc.ThemesMvc; + +import javax.enterprise.context.RequestScoped; +import javax.inject.Inject; +import javax.mvc.Controller; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.UriInfo; + +/** + * + * @author Jens Pelzetter + */ +@Controller +@Path("/") +@RequestScoped +public class LogoutController { + + @Inject + private Subject subject; + + @Inject + private ThemesMvc themesMvc; + + @GET + @Path("/") + public String logout(@Context final UriInfo uriInfo) { + subject.logout(); + + return themesMvc.getMvcTemplate(uriInfo, "logout", "loggedout"); + } + +} diff --git a/ccm-core/src/main/resources/META-INF/beans.xml b/ccm-core/src/main/resources/META-INF/beans.xml index 295fa9e6c..eecc609e3 100644 --- a/ccm-core/src/main/resources/META-INF/beans.xml +++ b/ccm-core/src/main/resources/META-INF/beans.xml @@ -5,6 +5,11 @@ http://xmlns.jcp.org/xml/ns/javaee/beans_1_1.xsd" bean-discovery-mode="all"> + + org.libreccm.security.AuthorizationInterceptor diff --git a/ccm-core/src/main/resources/org/libreccm/ui/LoginBundle.properties b/ccm-core/src/main/resources/org/libreccm/ui/LoginBundle.properties new file mode 100644 index 000000000..8959c3abb --- /dev/null +++ b/ccm-core/src/main/resources/org/libreccm/ui/LoginBundle.properties @@ -0,0 +1,14 @@ + +login.submit=Login +login.primaryEmail=Email Address +login.screenname.label=User name +login.password.label=Password +login.screenname.help=Your user name +login.password.help=Your password +login.errors.failed=Login failed. Wrong username or password. +login.errors.failedToSendRecoverMessage=Failed to send password recover message. +login.title=Login +login.recover_password.title=Recover password +login.recover_password.submit=Recover password +login.password_recovered.title=Recover mail send +login.password_recovered.message=An email with instructions how to reset your password has been sent. diff --git a/ccm-core/src/main/resources/org/libreccm/ui/LoginBundle_de.properties b/ccm-core/src/main/resources/org/libreccm/ui/LoginBundle_de.properties new file mode 100644 index 000000000..6956e262f --- /dev/null +++ b/ccm-core/src/main/resources/org/libreccm/ui/LoginBundle_de.properties @@ -0,0 +1,14 @@ + +login.submit=Anmelden +login.primaryEmail=E-Mail-Addresse +login.screenname.label=Benutzername +login.password.label=Passwort +login.screenname.help=Ihr Benutzername +login.password.help=Ihr Passwort +login.errors.failed=Anmeldung fehlgeschlagen. Falscher Benutzername oder falsches Passwort. +login.errors.failedToSendRecoverMessage=Senden der Wiederherstellungsmail fehlgeschlagen. +login.title=Login +login.recover_password.title=Passwort zur\u00fccksetzen +login.recover_password.submit=Passwort zur\u00fccksetzen +login.password_recovered.title=Mail gesendet +login.password_recovered.message=Eine E-Mail mit Anweisungen zum Zur\u00fccksetzen Ihres Passworts wurde an Ihre E-Mail-Adresse geschickt. diff --git a/ccm-core/src/main/resources/themes/ccm-freemarker/header.html.ftl b/ccm-core/src/main/resources/themes/ccm-freemarker/header.html.ftl new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/ccm-core/src/main/resources/themes/ccm-freemarker/header.html.ftl @@ -0,0 +1 @@ + diff --git a/ccm-core/src/main/resources/themes/ccm-freemarker/images/libreccm.png b/ccm-core/src/main/resources/themes/ccm-freemarker/images/libreccm.png new file mode 100644 index 000000000..676ec2137 Binary files /dev/null and b/ccm-core/src/main/resources/themes/ccm-freemarker/images/libreccm.png differ diff --git a/ccm-core/src/main/resources/themes/ccm-freemarker/login/login-form.html.ftl b/ccm-core/src/main/resources/themes/ccm-freemarker/login/login-form.html.ftl new file mode 100644 index 000000000..40cc805ab --- /dev/null +++ b/ccm-core/src/main/resources/themes/ccm-freemarker/login/login-form.html.ftl @@ -0,0 +1,34 @@ +<#import "../main.html.ftl" as main> + +<@main.ccm_main> +

${LoginMessages['login.title']}

+ <#if (loginFailed)> +
+ ${LoginMessages['login.errors.failed']} +
+ + + diff --git a/ccm-core/src/main/resources/themes/ccm-freemarker/login/login-form.html_1.ftl b/ccm-core/src/main/resources/themes/ccm-freemarker/login/login-form.html_1.ftl new file mode 100644 index 000000000..d4052f387 --- /dev/null +++ b/ccm-core/src/main/resources/themes/ccm-freemarker/login/login-form.html_1.ftl @@ -0,0 +1,39 @@ + + + + Category page + + + +
+

${LoginMessages['login.title']}

+ <#if (loginFailed)> +
+ ${LoginMessages['login.errors.failed']} +
+ +
+ + + + + + + + + +
+
+ <#include "../footer.html.ftl"> + + diff --git a/ccm-core/src/main/resources/themes/ccm-freemarker/login/login-password-recovered.html.ftl b/ccm-core/src/main/resources/themes/ccm-freemarker/login/login-password-recovered.html.ftl new file mode 100644 index 000000000..ccee63371 --- /dev/null +++ b/ccm-core/src/main/resources/themes/ccm-freemarker/login/login-password-recovered.html.ftl @@ -0,0 +1,15 @@ + + + + Category page + + + +
+

${LoginMessages['login.password_recovered.title']}

+

${LoginMessages['login.password_recovered.message']}

+
+ <#include "footer.html.ftl"> + + + diff --git a/ccm-core/src/main/resources/themes/ccm-freemarker/login/login-recover-password.html.ftl b/ccm-core/src/main/resources/themes/ccm-freemarker/login/login-recover-password.html.ftl new file mode 100644 index 000000000..b39124964 --- /dev/null +++ b/ccm-core/src/main/resources/themes/ccm-freemarker/login/login-recover-password.html.ftl @@ -0,0 +1,27 @@ + + + + Category page + + + +
+

${LoginMessages['login.recover_password.title']}

+ <# if (failedToSendRecoverMessage)> +
+ ${LoginMessages['login.errors.failedToSendRecoverMessage']} +
+ +
+ + + + +
+
+ <#include "footer.html.ftl"> + + diff --git a/ccm-core/src/main/resources/themes/ccm-freemarker/logout/loggedout.html.ftl b/ccm-core/src/main/resources/themes/ccm-freemarker/logout/loggedout.html.ftl new file mode 100644 index 000000000..c126f5956 --- /dev/null +++ b/ccm-core/src/main/resources/themes/ccm-freemarker/logout/loggedout.html.ftl @@ -0,0 +1,13 @@ + + + + Category page + + + +
+

Logout successful

+

Logout successful

+
+ + diff --git a/ccm-core/src/main/resources/themes/ccm-freemarker/main.html.ftl b/ccm-core/src/main/resources/themes/ccm-freemarker/main.html.ftl new file mode 100644 index 000000000..45e8ae70a --- /dev/null +++ b/ccm-core/src/main/resources/themes/ccm-freemarker/main.html.ftl @@ -0,0 +1,25 @@ +<#macro ccm_main scripts=[]> + + + Category page + + <#list scripts as script> +