001/*
002 * This library is part of OpenCms -
003 * the Open Source Content Management System
004 *
005 * Copyright (c) Alkacon Software GmbH & Co. KG (https://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 GmbH & Co. KG, please see the
018 * company website: https://www.alkacon.com
019 *
020 * For further information about OpenCms, please see the
021 * project website: https://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.relations;
029
030import org.opencms.db.CmsDbContext;
031import org.opencms.db.CmsDriverManager;
032import org.opencms.db.CmsPublishList;
033import org.opencms.file.CmsProject;
034import org.opencms.file.CmsResource;
035import org.opencms.file.CmsResourceFilter;
036import org.opencms.file.CmsVfsResourceNotFoundException;
037import org.opencms.file.types.I_CmsResourceType;
038import org.opencms.main.CmsException;
039import org.opencms.main.CmsIllegalStateException;
040import org.opencms.main.CmsLog;
041import org.opencms.main.OpenCms;
042import org.opencms.report.I_CmsReport;
043import org.opencms.util.CmsStringUtil;
044import org.opencms.workplace.threads.A_CmsProgressThread;
045
046import java.util.ArrayList;
047import java.util.HashMap;
048import java.util.Iterator;
049import java.util.List;
050import java.util.Map;
051
052import org.apache.commons.logging.Log;
053
054/**
055 * Validates relations of resources in the OpenCms VFS.<p>
056 *
057 * Relations are, for instance, href attribs in anchor tags and src attribs in
058 * image tags, as well as OpenCmsVfsFile values in Xml Content.<p>
059 *
060 * External links to targets outside the OpenCms VFS don't get validated.<p>
061 *
062 * Objects using this class are responsible to handle detected broken links.<p>
063 *
064 * @since 6.3.0
065 */
066public class CmsRelationSystemValidator {
067
068    /** The log object for this class. */
069    private static final Log LOG = CmsLog.getLog(CmsRelationSystemValidator.class);
070
071    /** The driver manager. */
072    protected CmsDriverManager m_driverManager;
073
074    /**
075     * Default constructor.<p>
076     *
077     * @param driverManager The Cms driver manager
078     */
079    public CmsRelationSystemValidator(CmsDriverManager driverManager) {
080
081        m_driverManager = driverManager;
082    }
083
084    /**
085     * Validates the relations against the online project.<p>
086     *
087     * The result is printed to the given report.<p>
088     *
089     * Validating references means to answer the question, whether
090     * we would have broken links in the online project if the given
091     * publish list would get published.<p>
092     *
093     * @param dbc the database context
094     * @param publishList the publish list to validate
095     * @param report a report to print messages
096     *
097     * @return a map with lists of invalid links
098     *          (<code>{@link org.opencms.relations.CmsRelation}}</code> objects)
099     *          keyed by root paths
100     *
101     * @throws Exception if something goes wrong
102     */
103    public Map<String, List<CmsRelation>> validateResources(
104        CmsDbContext dbc,
105        CmsPublishList publishList,
106        I_CmsReport report)
107    throws Exception {
108
109        // check if progress should be set in the thread
110        A_CmsProgressThread thread = null;
111        if (Thread.currentThread() instanceof A_CmsProgressThread) {
112            thread = (A_CmsProgressThread)Thread.currentThread();
113        }
114
115        Map<String, List<CmsRelation>> invalidResources = new HashMap<String, List<CmsRelation>>();
116        boolean interProject = (publishList != null);
117        if (report != null) {
118            report.println(
119                Messages.get().container(Messages.RPT_HTMLLINK_VALIDATOR_BEGIN_0),
120                I_CmsReport.FORMAT_HEADLINE);
121        }
122        List<CmsResource> resources = new ArrayList<CmsResource>();
123        if (publishList == null) {
124            CmsResourceFilter filter = CmsResourceFilter.IGNORE_EXPIRATION;
125            List<I_CmsResourceType> resTypes = OpenCms.getResourceManager().getResourceTypes();
126            Iterator<I_CmsResourceType> itTypes = resTypes.iterator();
127            int count = 0;
128            while (itTypes.hasNext()) {
129
130                // set progress in thread (first 10 percent)
131                count++;
132                if (thread != null) {
133
134                    if (thread.isInterrupted()) {
135                        throw new CmsIllegalStateException(
136                            org.opencms.workplace.commons.Messages.get().container(
137                                org.opencms.workplace.commons.Messages.ERR_PROGRESS_INTERRUPTED_0));
138                    }
139                    thread.setProgress((count * 10) / resTypes.size());
140                }
141
142                I_CmsResourceType type = itTypes.next();
143                if (type instanceof I_CmsLinkParseable) {
144                    filter = filter.addRequireType(type.getTypeId());
145                    try {
146                        resources.addAll(
147                            m_driverManager.readResources(
148                                dbc,
149                                m_driverManager.readResource(dbc, "/", filter),
150                                filter,
151                                true));
152                    } catch (CmsException e) {
153                        LOG.error(
154                            Messages.get().getBundle().key(Messages.LOG_RETRIEVAL_RESOURCES_1, type.getTypeName()),
155                            e);
156                    }
157                }
158            }
159        } else {
160            resources.addAll(publishList.getAllResources());
161        }
162
163        // populate a lookup map with the project resources that
164        // actually get published keyed by their resource names.
165        // second, resources that don't get validated are ignored.
166        Map<String, CmsResource> offlineFilesLookup = new HashMap<String, CmsResource>();
167        Iterator<CmsResource> itResources = resources.iterator();
168        int count = 0;
169        while (itResources.hasNext()) {
170
171            // set progress in thread (next 10 percent)
172            count++;
173            if (thread != null) {
174
175                if (thread.isInterrupted()) {
176                    throw new CmsIllegalStateException(
177                        org.opencms.workplace.commons.Messages.get().container(
178                            org.opencms.workplace.commons.Messages.ERR_PROGRESS_INTERRUPTED_0));
179                }
180                thread.setProgress(((count * 10) / resources.size()) + 10);
181            }
182
183            CmsResource resource = itResources.next();
184            offlineFilesLookup.put(resource.getRootPath(), resource);
185            offlineFilesLookup.put(resource.getStructureId().toString(), resource);
186        }
187        CmsProject project = dbc.currentProject();
188        if (interProject) {
189            try {
190                project = m_driverManager.readProject(dbc, CmsProject.ONLINE_PROJECT_ID);
191            } catch (CmsException e) {
192                // should never happen
193                LOG.error(e.getLocalizedMessage(), e);
194            }
195        }
196
197        boolean foundBrokenLinks = false;
198        for (int index = 0, size = resources.size(); index < size; index++) {
199
200            // set progress in thread (next 20 percent; leave rest for creating the list and the html)
201            if (thread != null) {
202
203                if (thread.isInterrupted()) {
204                    throw new CmsIllegalStateException(
205                        org.opencms.workplace.commons.Messages.get().container(
206                            org.opencms.workplace.commons.Messages.ERR_PROGRESS_INTERRUPTED_0));
207                }
208                thread.setProgress(((index * 20) / resources.size()) + 20);
209            }
210
211            CmsResource resource = resources.get(index);
212            String resourceName = resource.getRootPath();
213
214            if (report != null) {
215                report.print(
216                    org.opencms.report.Messages.get().container(
217                        org.opencms.report.Messages.RPT_SUCCESSION_2,
218                        Integer.valueOf(index + 1),
219                        Integer.valueOf(size)),
220                    I_CmsReport.FORMAT_NOTE);
221                report.print(Messages.get().container(Messages.RPT_HTMLLINK_VALIDATING_0), I_CmsReport.FORMAT_NOTE);
222                report.print(
223                    org.opencms.report.Messages.get().container(
224                        org.opencms.report.Messages.RPT_ARGUMENT_1,
225                        dbc.removeSiteRoot(resourceName)));
226                report.print(org.opencms.report.Messages.get().container(org.opencms.report.Messages.RPT_DOTS_0));
227            }
228            List<CmsRelation> brokenLinks = validateLinks(dbc, resource, offlineFilesLookup, project, report);
229            if (brokenLinks.size() > 0) {
230                // the resource contains broken links
231                invalidResources.put(resourceName, brokenLinks);
232                foundBrokenLinks = true;
233            } else {
234                // the resource contains *NO* broken links
235                if (report != null) {
236                    report.println(
237                        org.opencms.report.Messages.get().container(org.opencms.report.Messages.RPT_OK_0),
238                        I_CmsReport.FORMAT_OK);
239                }
240            }
241        }
242
243        if (foundBrokenLinks) {
244            // print a summary if we found broken links in the validated resources
245            if (report != null) {
246                report.println(
247                    Messages.get().container(Messages.RPT_HTMLLINK_VALIDATOR_ERROR_0),
248                    I_CmsReport.FORMAT_ERROR);
249            }
250        }
251        if (report != null) {
252            report.println(
253                Messages.get().container(Messages.RPT_HTMLLINK_VALIDATOR_END_0),
254                I_CmsReport.FORMAT_HEADLINE);
255        }
256        return invalidResources;
257    }
258
259    /**
260     * Checks a link to a resource which has been deleted.<p>
261     * @param relation the relation
262     *
263     * @param link the URI of the resource which has a link to the deleted resource
264     * @param fileLookup a lookup table of files to be published
265     * @return true if the resource which has a link to the deleted resource is also going to be deleted
266     */
267    protected boolean checkLinkForDeletedLinkTarget(
268        CmsRelation relation,
269        String link,
270        Map<String, CmsResource> fileLookup) {
271
272        boolean isValidLink = false;
273        // since we are going to delete the resource
274        // check if the linked resource is also to be deleted
275        if (fileLookup.containsKey(link) || fileLookup.containsKey(relation.getSourceId().toString())) {
276            // Technically, if the relation source is going to be published too and is not deleted, the link is not valid. But in that case, validateLinks will also be called for that resource and detect broken the broken link there.
277            isValidLink = true;
278        }
279        return isValidLink;
280    }
281
282    /**
283     * Checks a link from a resource which has changed.<p>
284     *
285     * @param dbc the current dbc
286     * @param resource the link source
287     * @param relation the relation
288     * @param link the link target
289     * @param project the current project
290     * @param fileLookup a lookup table which contains the files which are going to be published
291     *
292     * @return true if the link will be valid after publishing
293     */
294    protected boolean checkLinkForNewOrChangedLinkSource(
295        CmsDbContext dbc,
296        CmsResource resource,
297        CmsRelation relation,
298        String link,
299        CmsProject project,
300        Map<String, CmsResource> fileLookup) {
301
302        boolean isValidLink = true;
303        // the link is valid...
304        try {
305            // ... if the linked resource exists in the online project
306            // search the target of link in the online project
307            try {
308                link = m_driverManager.getVfsDriver(
309                    dbc).readResource(dbc, project.getUuid(), relation.getTargetId(), true).getRootPath();
310            } catch (CmsVfsResourceNotFoundException e) {
311                // reading by id failed, this means that the link variable still equals relation.getTargetPath()
312                if (LOG.isDebugEnabled()) {
313                    LOG.debug(
314                        Messages.get().getBundle().key(
315                            Messages.LOG_LINK_VALIDATION_READBYID_FAILED_2,
316                            relation.getTargetId().toString(),
317                            project.getName()),
318                        e);
319                }
320                m_driverManager.getVfsDriver(dbc).readResource(dbc, project.getUuid(), relation.getTargetPath(), true);
321            }
322        } catch (CmsException e) {
323            // ... or if the linked resource is a resource that gets actually published
324            if (LOG.isDebugEnabled()) {
325                LOG.debug(
326                    Messages.get().getBundle().key(
327                        Messages.LOG_LINK_VALIDATION_READBYPATH_FAILED_2,
328                        relation.getTargetPath(),
329                        project.getName()),
330                    e);
331            }
332            if (!fileLookup.containsKey(link)) {
333                isValidLink = false;
334            }
335        } finally {
336            // ... and if the linked resource to be published get deleted
337            if (fileLookup.containsKey(link)) {
338                CmsResource offlineResource = fileLookup.get(link);
339                if (offlineResource.getState().isDeleted()) {
340                    if (LOG.isDebugEnabled()) {
341                        LOG.debug(Messages.get().getBundle().key(Messages.LOG_LINK_VALIDATION_RESOURCEDELETED_1, link));
342                    }
343                    isValidLink = false;
344                }
345            }
346        }
347        return isValidLink;
348    }
349
350    /**
351     * Validates the links for the specified resource.<p>
352     *
353     * @param dbc the database context
354     * @param resource the resource that will be validated
355     * @param fileLookup a map for faster lookup with all resources keyed by their rootpath
356     * @param project the project to validate
357     * @param report the report to write to
358     *
359     * @return a list with the broken links as {@link CmsRelation} objects for the specified resource,
360     *          or an empty list if no broken links were found
361     */
362    protected List<CmsRelation> validateLinks(
363        CmsDbContext dbc,
364        CmsResource resource,
365        Map<String, CmsResource> fileLookup,
366        CmsProject project,
367        I_CmsReport report) {
368
369        List<CmsRelation> brokenRelations = new ArrayList<CmsRelation>();
370        Map<String, Boolean> validatedLinks = new HashMap<String, Boolean>();
371
372        // get the relations
373        List<CmsRelation> incomingRelationsOnline = new ArrayList<CmsRelation>();
374        List<CmsRelation> outgoingRelationsOffline = new ArrayList<CmsRelation>();
375        try {
376            if (!resource.getState().isDeleted()) {
377                // search the target of links in the current (offline) project
378                outgoingRelationsOffline = m_driverManager.getRelationsForResource(
379                    dbc,
380                    resource,
381                    CmsRelationFilter.TARGETS);
382            } else {
383                // search the source of links in the online project
384                CmsProject currentProject = dbc.currentProject();
385                dbc.getRequestContext().setCurrentProject(project);
386                try {
387                    incomingRelationsOnline = m_driverManager.getRelationsForResource(
388                        dbc,
389                        resource,
390                        CmsRelationFilter.SOURCES);
391                } finally {
392                    dbc.getRequestContext().setCurrentProject(currentProject);
393                }
394            }
395        } catch (CmsException e) {
396            LOG.error(Messages.get().getBundle().key(Messages.LOG_LINK_SEARCH_1, resource), e);
397            if (report != null) {
398                report.println(
399                    Messages.get().container(Messages.LOG_LINK_SEARCH_1, dbc.removeSiteRoot(resource.getRootPath())),
400                    I_CmsReport.FORMAT_ERROR);
401            }
402            return brokenRelations;
403        }
404
405        List<CmsRelation> relations = new ArrayList<CmsRelation>();
406        relations.addAll(incomingRelationsOnline);
407        relations.addAll(outgoingRelationsOffline);
408        // check the relations
409        boolean first = true;
410        Iterator<CmsRelation> itRelations = relations.iterator();
411        while (itRelations.hasNext()) {
412            CmsRelation relation = itRelations.next();
413            String link;
414            if (!resource.getState().isDeleted()) {
415                link = relation.getTargetPath();
416            } else {
417                link = relation.getSourcePath();
418            }
419            if (CmsStringUtil.isEmptyOrWhitespaceOnly(link)) {
420                // skip empty links
421                continue;
422            }
423            if (validatedLinks.keySet().contains(link)) {
424                // skip already validated links
425                if (validatedLinks.get(link).booleanValue()) {
426                    // add broken relation of different type
427                    brokenRelations.add(relation);
428                }
429                continue;
430            }
431            boolean result;
432            if (resource.getState().isDeleted()) {
433                result = checkLinkForDeletedLinkTarget(relation, link, fileLookup);
434            } else {
435                result = checkLinkForNewOrChangedLinkSource(dbc, resource, relation, link, project, fileLookup);
436
437            }
438            boolean isValidLink = result;
439            if (!isValidLink) {
440                if (first) {
441                    if (report != null) {
442                        report.println(
443                            Messages.get().container(Messages.RPT_HTMLLINK_FOUND_BROKEN_LINKS_0),
444                            I_CmsReport.FORMAT_WARNING);
445                    }
446                    first = false;
447                }
448                brokenRelations.add(relation);
449                if (report != null) {
450                    if (!resource.getState().isDeleted()) {
451                        report.println(
452                            Messages.get().container(
453                                Messages.RPT_HTMLLINK_BROKEN_TARGET_2,
454                                relation.getSourcePath(),
455                                dbc.removeSiteRoot(link)),
456                            I_CmsReport.FORMAT_WARNING);
457                    } else {
458                        report.println(
459                            Messages.get().container(
460                                Messages.RPT_HTMLLINK_BROKEN_SOURCE_2,
461                                dbc.removeSiteRoot(link),
462                                relation.getTargetPath()),
463                            I_CmsReport.FORMAT_WARNING);
464                    }
465                }
466            }
467            validatedLinks.put(link, Boolean.valueOf(!isValidLink));
468        }
469        return brokenRelations;
470    }
471
472}