001/*
002 * This library is part of OpenCms -
003 * the Open Source Content Management System
004 *
005 * Copyright (c) Alkacon Software GmbH & Co. KG (http://www.alkacon.com)
006 *
007 * This library is free software; you can redistribute it and/or
008 * modify it under the terms of the GNU Lesser General Public
009 * License as published by the Free Software Foundation; either
010 * version 2.1 of the License, or (at your option) any later version.
011 *
012 * This library is distributed in the hope that it will be useful,
013 * but WITHOUT ANY WARRANTY; without even the implied warranty of
014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
015 * Lesser General Public License for more details.
016 *
017 * For further information about Alkacon Software, please see the
018 * company website: http://www.alkacon.com
019 *
020 * For further information about OpenCms, please see the
021 * project website: http://www.opencms.org
022 *
023 * You should have received a copy of the GNU Lesser General Public
024 * License along with this library; if not, write to the Free Software
025 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
026 */
027
028package org.opencms.jsp.userdata;
029
030import org.opencms.ade.detailpage.CmsDetailPageInfo;
031import org.opencms.configuration.CmsParameterConfiguration;
032import org.opencms.configuration.I_CmsXmlConfiguration;
033import org.opencms.file.CmsObject;
034import org.opencms.file.CmsResource;
035import org.opencms.file.CmsUser;
036import org.opencms.file.CmsUserSearchParameters;
037import org.opencms.file.CmsUserSearchParameters.SortKey;
038import org.opencms.jsp.util.CmsJspStandardContextBean;
039import org.opencms.mail.CmsHtmlMail;
040import org.opencms.main.CmsException;
041import org.opencms.main.CmsLog;
042import org.opencms.main.OpenCms;
043import org.opencms.report.I_CmsReport;
044import org.opencms.security.CmsRole;
045import org.opencms.util.CmsStringUtil;
046import org.opencms.xml.content.CmsXmlContent;
047import org.opencms.xml.content.CmsXmlContentFactory;
048
049import java.util.ArrayList;
050import java.util.Arrays;
051import java.util.Collections;
052import java.util.List;
053import java.util.Optional;
054import java.util.ServiceLoader;
055import java.util.stream.Collectors;
056
057import javax.mail.internet.AddressException;
058import javax.mail.internet.InternetAddress;
059
060import org.apache.commons.digester3.Digester;
061import org.apache.commons.digester3.Rule;
062import org.apache.commons.logging.Log;
063import org.apache.commons.mail.EmailException;
064
065import org.dom4j.Element;
066import org.jsoup.Jsoup;
067import org.jsoup.nodes.Document;
068import org.xml.sax.Attributes;
069
070/**
071 * Manager class for user data requests.<p>
072 *
073 * Users can request their data either via username/password, in which case the user data will be collected for that user,
074 * or by email, in which case the data for all users with the given email address is collected.
075 * <p>
076 * User data is formatted as HTML by one or more configurable 'user data domain' classes, which implement the
077 * I_CmsUserDataDomain interface.
078 *
079 * <p>After the user requests their data, they are sent an email with a link (which is only valid for a certain time). When
080 * opening that link, they have to confirm the credentials they requested the data with, and if they successfully do so,
081 * are shown a page with their user data.
082 */
083public class CmsUserDataRequestManager {
084
085    /** The name that must be used for the function detail pages for user data requests. */
086    public static final String FUNCTION_NAME = "userdatarequest";
087
088    /** Tag name for the user data request manager. */
089    public static final String N_USERDATA = "userdata";
090
091    /** Attribute to enable/disable autoloading of plugins via service loader.*/
092    public static final String A_AUTOLOAD = "autoload";
093
094    /** Tag name for the user data domain. */
095    public static final String N_USERDATA_DOMAIN = "userdata-domain";
096
097    /** Logger for this class. */
098    private static final Log LOG = CmsLog.getLog(CmsUserDataRequestManager.class);
099
100    /** A CmsObject with admin privileges. */
101    private CmsObject m_adminCms;
102
103    /** The configured user data domains. */
104    private List<I_CmsUserDataDomain> m_configuredDomains = new ArrayList<>();
105
106    /** The store used to load/save user data requests. */
107    private CmsUserDataRequestStore m_requestStore = new CmsUserDataRequestStore();
108
109    /** If true, additional plugins should be loaded via service loader. */
110    private boolean m_autoload;
111
112    /** List of all domains, both the ones from the configuration and the ones loaded via service loader. */
113    private List<I_CmsUserDataDomain> m_allDomains = new ArrayList<>();
114
115    /**
116     * Adds digester rules for configuration.
117     *
118     * @param digester the digester
119     * @param basePath the base xpath for the configuration of user data requests
120     */
121    public static void addDigesterRules(Digester digester, String basePath) {
122
123        digester.addObjectCreate(basePath, CmsUserDataRequestManager.class);
124        digester.addRule(basePath, new Rule() {
125
126            private boolean m_autoload;
127
128            @Override
129            public void begin(String namespace, String name, Attributes attributes) throws Exception {
130
131                m_autoload = Boolean.parseBoolean(attributes.getValue(A_AUTOLOAD));
132            }
133
134            @Override
135            public void end(String namespace, String name) throws Exception {
136
137                CmsUserDataRequestManager manager = digester.peek();
138                manager.setAutoload(m_autoload);
139            }
140
141        });
142        String domainPath = basePath + "/" + N_USERDATA_DOMAIN;
143        digester.addObjectCreate(domainPath, null, I_CmsXmlConfiguration.A_CLASS);
144        digester.addSetNext(domainPath, "addUserDataDomain");
145        // use pre-existing rules for params in system configuration to set the parameters for the user domain
146    }
147
148    /**
149     * Gets all users with a given email address (maximum of 999).
150     *
151     * @param cms the CMS context
152     * @param email the email address
153     * @return the list of users
154     * @throws CmsException if something goes wrong
155     */
156    public static List<CmsUser> getUsersByEmail(CmsObject cms, String email) throws CmsException {
157
158        if (CmsStringUtil.isEmptyOrWhitespaceOnly(email)) {
159            // we don't want to find the users with no email address!
160            return new ArrayList<>();
161        }
162        CmsUserSearchParameters params = new CmsUserSearchParameters();
163        params.setPaging(9999, 1);
164        params.setFilterEmail(email);
165        params.setSorting(SortKey.email, true);
166        List<CmsUser> users = OpenCms.getOrgUnitManager().searchUsers(cms, params);
167        return users;
168    }
169
170    /**
171     * Adds a user data domain during configuration phase.
172     *
173     * @param domain the user data domain to add
174     */
175    public void addUserDataDomain(I_CmsUserDataDomain domain) {
176
177        checkNotInitialized();
178        m_configuredDomains.add(domain);
179    }
180
181    /**
182     * Writes the configuration back to XML.
183     *
184     * @param element the parent element
185     */
186    public void appendToXml(Element element) {
187
188        Element root = element.addElement(N_USERDATA);
189        if (isAutoload()) {
190            root.addAttribute(A_AUTOLOAD, "true");
191        }
192        for (I_CmsUserDataDomain domain : m_configuredDomains) {
193            String clsName = domain.getClass().getName();
194            Element domainElem = root.addElement(N_USERDATA_DOMAIN);
195            domainElem.addAttribute(I_CmsXmlConfiguration.A_CLASS, clsName);
196            CmsParameterConfiguration config = domain.getConfiguration();
197            config.appendToXml(domainElem);
198        }
199    }
200
201    /**
202     * Checks that the manager has not already been initialized, and throws an exception otherwise.
203     */
204    public void checkNotInitialized() {
205
206        if (m_adminCms != null) {
207            throw new IllegalStateException("CmsUserDataRequestManager already initialized.");
208        }
209    }
210
211    /**
212     * Gets the list of all user data domains, both those from the configuration and those loaded via service loader.
213     *
214     * @return the list of all user data domains
215     */
216    public List<I_CmsUserDataDomain> getAllDomains() {
217
218        return m_allDomains;
219    }
220
221    /**
222     * Gets the user data for an email address.
223     *
224     * <p>Only callable by root admin users.
225     *
226     * @param cms the CMS context
227     * @param mode the mode
228     * @param email the email address (may be null)
229     * @param searchStrings a list of additional search strings entered by the user
230     * @param root the root element to which the report should be added
231     * @param report the report to write to
232     * @return true if the HTML document was changed as a result of executing this method
233     *
234     * @throws CmsException if something goes wrong
235     */
236    public boolean getInfoForEmail(
237        CmsObject cms,
238        I_CmsUserDataDomain.Mode mode,
239        String email,
240        List<String> searchStrings,
241        org.jsoup.nodes.Element root,
242        I_CmsReport report)
243    throws CmsException {
244
245        OpenCms.getRoleManager().checkRole(cms, CmsRole.ROOT_ADMIN);
246        return internalGetInfoForEmail(cms, mode, email, searchStrings, root, report);
247    }
248
249    /**
250     * Gets the user data for a specific OpenCms user.
251     *
252     * <p>Only callable by root admin users.
253     *
254     * @param cms the CMS context
255     * @param mode the mode
256     * @param user the OpenCms user
257     * @param root the root element to which the report should be added
258     * @param report the report to write to
259     * @return true if the HTML document was changed as a result of executing this method
260     * @throws CmsException if something goes wrong
261     *
262     */
263    public boolean getInfoForUser(
264        CmsObject cms,
265        I_CmsUserDataDomain.Mode mode,
266        CmsUser user,
267        org.jsoup.nodes.Element root,
268        I_CmsReport report)
269    throws CmsException {
270
271        OpenCms.getRoleManager().checkRole(cms, CmsRole.ROOT_ADMIN);
272        return internalGetInfoForUser(cms, mode, user, root, report);
273    }
274
275    /**
276     * Gets the user data request store.
277     *
278     * @return the user data request store
279     */
280    public CmsUserDataRequestStore getRequestStore() {
281
282        return m_requestStore;
283    }
284
285    /**
286     * Initializes the manager.
287     *
288     * @param cms a CMS context with admin privileges
289     */
290    public void initialize(CmsObject cms) {
291
292        checkNotInitialized();
293        m_adminCms = cms;
294        m_allDomains.addAll(m_configuredDomains);
295        if (m_autoload) {
296            m_allDomains.addAll(loadUserDataDomainsFromClasses());
297        }
298        for (I_CmsUserDataDomain domain : m_allDomains) {
299            domain.initialize(cms);
300        }
301        m_requestStore.initialize(cms);
302    }
303
304    /**
305     * Loads the user data request configuration from the given file.
306     *
307     * <p>Returns null if no configuration is found.
308     *
309     * @param cms the CMS context
310     * @param path the site path of the configuration
311     * @return the configuration for the given path
312     */
313    public Optional<CmsUserDataRequestConfig> loadConfig(CmsObject cms, String path) {
314
315        LOG.debug("loading user data request config for path " + path);
316        if (path == null) {
317            LOG.info("path is null");
318            return Optional.empty();
319        }
320        try {
321            CmsResource resource = cms.readResource(path);
322            CmsXmlContent content = CmsXmlContentFactory.unmarshal(cms, cms.readFile(resource));
323            CmsUserDataRequestConfig result = new CmsUserDataRequestConfig(
324                cms,
325                content,
326                cms.getRequestContext().getLocale());
327            return Optional.of(result);
328        } catch (CmsException e) {
329            LOG.error(e.getLocalizedMessage(), e);
330            return Optional.empty();
331        }
332    }
333
334    /**
335     * Starts a user data request for the single user case (with user name and password).<p>
336     *
337     * @param cms the CMS context
338     * @param config the configuration
339     * @param user the user for which the data was requested
340     *
341     * @throws AddressException if parsing the email address fails
342     * @throws EmailException if sending the email fails
343     */
344    public void startUserDataRequest(CmsObject cms, CmsUserDataRequestConfig config, CmsUser user)
345    throws AddressException, EmailException {
346
347        Document doc = Jsoup.parseBodyFragment("");
348        org.jsoup.nodes.Element root = doc.body().appendElement("div");
349        CmsUserDataRequestInfo info = new CmsUserDataRequestInfo();
350        info.setUser(user.getName());
351        info.setType(CmsUserDataRequestType.singleUser);
352        info.setEmail(user.getEmail());
353        info.setExpiration(System.currentTimeMillis() + config.getRequestLifetime());
354        for (I_CmsUserDataDomain userDomain : getAllDomains()) {
355            if (userDomain.matchesUser(cms, CmsUserDataRequestType.singleUser, user)) {
356                userDomain.appendInfoHtml(
357                    cms,
358                    CmsUserDataRequestType.singleUser,
359                    Collections.singletonList(user),
360                    root);
361            }
362        }
363        info.setInfoHtml(root.toString());
364        m_requestStore.save(info);
365        sendMail(cms, config, user.getEmail(), info.getId());
366    }
367
368    /**
369     * Starts a user data request for the email case.
370     *
371     * @param cms the CMS context
372     * @param config the user data request configuration
373     * @param email the email address
374     * @throws CmsUserDataRequestException if something goes wrong
375     * @throws EmailException if sending the email fails
376     * @throws AddressException if parsing the email address fails
377     */
378    public void startUserDataRequest(CmsObject cms, CmsUserDataRequestConfig config, String email)
379    throws CmsUserDataRequestException, EmailException, AddressException {
380
381        if (CmsStringUtil.isEmptyOrWhitespaceOnly(email)) {
382            throw new IllegalArgumentException("Can not use empty email address for user data request by email.");
383        }
384        try {
385            List<CmsUser> users = getUsersByEmail(m_adminCms, email);
386            Document doc = Jsoup.parseBodyFragment("");
387            org.jsoup.nodes.Element root = doc.body().appendElement("div");
388
389            boolean foundDomain = false;
390            for (I_CmsUserDataDomain userDomain : getAllDomains()) {
391                List<CmsUser> usersForDomain = new ArrayList<>();
392                for (CmsUser user : users) {
393                    if (userDomain.matchesUser(cms, CmsUserDataRequestType.email, user)) {
394                        usersForDomain.add(user);
395                        foundDomain = true;
396                    }
397                }
398                if (!usersForDomain.isEmpty()) {
399                    userDomain.appendInfoHtml(cms, CmsUserDataRequestType.email, usersForDomain, root);
400                }
401            }
402            if (!foundDomain) {
403                throw new CmsUserDataRequestException("no users found with the given email address.");
404            }
405            CmsUserDataRequestInfo info = new CmsUserDataRequestInfo();
406            info.setType(CmsUserDataRequestType.email);
407            info.setEmail(email);
408            info.setInfoHtml(root.toString());
409            info.setExpiration(System.currentTimeMillis() + config.getRequestLifetime());
410            m_requestStore.save(info);
411            sendMail(cms, config, email, info.getId());
412        } catch (CmsException e) {
413            throw new CmsUserDataRequestException(e);
414        }
415
416    }
417
418    /**
419     * @see java.lang.Object#toString()
420     */
421    @Override
422    public String toString() {
423
424        String collectorsStr = getAllDomains().stream().map(domain -> domain.toString()).collect(
425            Collectors.joining(", "));
426        return getClass().getName() + "[" + collectorsStr + "]";
427    }
428
429    /**
430     * Sets the autoload flag, which enables automatic loading of plugins via service loader.
431     *
432     * @param autoload the value of the autoload flag
433     */
434    protected void setAutoload(boolean autoload) {
435
436        checkNotInitialized();
437        m_autoload = autoload;
438    }
439
440    /**
441     * Internal helper method for getting the user data for an email address.
442     *
443     * @param cms the CMS context
444     * @param mode the mode
445     * @param email the email address
446     * @param searchStrings an additional list of search strings entered by the user
447     * @param root the root element to which the report should be added
448     * @param report the report to write to
449     * @return true if the HTML document was changed as a result of executing this method
450     * @throws CmsException if something goes wrong
451     */
452    private boolean internalGetInfoForEmail(
453        CmsObject cms,
454        I_CmsUserDataDomain.Mode mode,
455        String email,
456        List<String> searchStrings,
457        org.jsoup.nodes.Element root,
458        I_CmsReport report)
459    throws CmsException {
460
461        Document doc = root.ownerDocument();
462        String oldHtml = doc.toString();
463
464        List<CmsUser> users = getUsersByEmail(m_adminCms, email);
465        boolean foundDomain = false;
466        int i = 0;
467        for (I_CmsUserDataDomain userDomain : getAllDomains()) {
468            i += 1;
469            report.print(
470                Messages.get().container(Messages.RPT_USERDATADOMAIN_COUNT_2, "" + i, "" + getAllDomains().size()));
471            report.print(
472                org.opencms.report.Messages.get().container(org.opencms.report.Messages.RPT_DOTS_0),
473                I_CmsReport.FORMAT_DEFAULT);
474            if (!userDomain.isAvailableForMode(mode)) {
475                report.println(
476                    org.opencms.report.Messages.get().container(org.opencms.report.Messages.RPT_SKIPPED_0),
477                    I_CmsReport.FORMAT_DEFAULT);
478                continue;
479            }
480            List<CmsUser> usersForDomain = new ArrayList<>();
481            for (CmsUser user : users) {
482                if (userDomain.matchesUser(cms, CmsUserDataRequestType.email, user)) {
483                    usersForDomain.add(user);
484                    foundDomain = true;
485                }
486            }
487            if (!usersForDomain.isEmpty()) {
488                userDomain.appendInfoHtml(cms, CmsUserDataRequestType.email, usersForDomain, root);
489            }
490            userDomain.appendlInfoForEmail(cms, email, searchStrings, root);
491            report.println(
492                org.opencms.report.Messages.get().container(org.opencms.report.Messages.RPT_OK_0),
493                I_CmsReport.FORMAT_OK);
494        }
495        String newHtml = doc.toString();
496        boolean changed = !(newHtml.equals(oldHtml));
497        return changed;
498    }
499
500    /**
501     * Internal helper method for getting the user data for a specific OpenCms user.
502     *
503     * @param cms the CMS context
504     * @param mode the mode
505     * @param user the OpenCms user
506     * @param root the root element to which the report should be added
507     * @param report the report
508     * @return true if the HTML document was changed as a result of executing this method
509     *
510     */
511    private boolean internalGetInfoForUser(
512        CmsObject cms,
513        I_CmsUserDataDomain.Mode mode,
514        CmsUser user,
515        org.jsoup.nodes.Element root,
516        I_CmsReport report) {
517
518        Document doc = root.ownerDocument();
519        String oldHtml = doc.toString();
520        int i = 0;
521        for (I_CmsUserDataDomain userDomain : getAllDomains()) {
522            i += 1;
523            report.print(
524                Messages.get().container(Messages.RPT_USERDATADOMAIN_COUNT_2, "" + i, "" + getAllDomains().size()));
525            report.print(
526                org.opencms.report.Messages.get().container(org.opencms.report.Messages.RPT_DOTS_0),
527                I_CmsReport.FORMAT_DEFAULT);
528            if (!userDomain.isAvailableForMode(mode)) {
529                report.println(
530                    org.opencms.report.Messages.get().container(org.opencms.report.Messages.RPT_SKIPPED_0),
531                    I_CmsReport.FORMAT_DEFAULT);
532                continue;
533            }
534            List<CmsUser> usersForDomain = new ArrayList<>();
535            if (userDomain.matchesUser(cms, CmsUserDataRequestType.email, user)) {
536                usersForDomain.add(user);
537            }
538            if (!usersForDomain.isEmpty()) {
539                userDomain.appendInfoHtml(cms, CmsUserDataRequestType.email, usersForDomain, root);
540            }
541            report.println(
542                org.opencms.report.Messages.get().container(org.opencms.report.Messages.RPT_OK_0),
543                I_CmsReport.FORMAT_OK);
544        }
545        String newHtml = doc.toString();
546        boolean changed = !(newHtml.equals(oldHtml));
547        return changed;
548    }
549
550    /**
551     * Returns true if the autoload flag is enabled.
552     *
553     * @return true if the autoload flag is enabled
554     */
555    private boolean isAutoload() {
556
557        return m_autoload;
558    }
559
560    /**
561     * Loads additional plugins via service loader.
562     *
563     * @return the additional plugins loaded
564     */
565    private List<I_CmsUserDataDomain> loadUserDataDomainsFromClasses() {
566
567        List<I_CmsUserDataDomain> result = new ArrayList<>();
568        ServiceLoader<I_CmsUserDataDomainProvider> loader = ServiceLoader.load(I_CmsUserDataDomainProvider.class);
569        for (I_CmsUserDataDomainProvider provider : loader) {
570            for (I_CmsUserDataDomain domain : provider.getUserDataDomains()) {
571                result.add(domain);
572            }
573        }
574        return result;
575    }
576
577    /**
578     * Sends the user data request mail to the given email address.
579     *
580     * @param cms the CMS context
581     * @param config the configuration
582     * @param email the email address
583     * @param id the user data request id
584     * @throws EmailException if sending the email fails
585     * @throws AddressException if parsing the address fails
586     */
587    private void sendMail(CmsObject cms, CmsUserDataRequestConfig config, String email, String id)
588    throws EmailException, AddressException {
589
590        CmsHtmlMail mail = new CmsHtmlMail();
591        mail.setCharset("UTF-8");
592
593        mail.setSubject(config.getMailSubject());
594        mail.setTo(Arrays.asList(InternetAddress.parse(email)));
595        String link = CmsJspStandardContextBean.getFunctionDetailLink(
596            cms,
597            CmsDetailPageInfo.FUNCTION_PREFIX,
598            FUNCTION_NAME,
599            true)
600            + "?"
601            + CmsJspUserDataRequestBean.PARAM_UDRID
602            + "="
603            + id
604            + "&"
605            + CmsJspUserDataRequestBean.PARAM_ACTION
606            + "="
607            + CmsJspUserDataRequestBean.ACTION_VIEW;
608        mail.setHtmlMsg(config.getMailText() + "<a href=\"" + link + "\">" + link + "</a>");
609        mail.send();
610
611    }
612}