001/*
002 * (C) Copyright 2006-2015 Nuxeo SA (http://nuxeo.com/) and others.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *     Thierry Delprat
018 */
019package org.nuxeo.apidoc.browse;
020
021import static org.nuxeo.apidoc.snapshot.DistributionSnapshot.PROP_RELEASED;
022
023import java.io.File;
024import java.io.FileOutputStream;
025import java.io.IOException;
026import java.io.OutputStream;
027import java.io.Serializable;
028import java.sql.Date;
029import java.text.ParseException;
030import java.time.Instant;
031import java.time.LocalDate;
032import java.time.ZoneId;
033import java.util.HashMap;
034import java.util.List;
035import java.util.Map;
036import java.util.Optional;
037import java.util.regex.Matcher;
038import java.util.regex.Pattern;
039import java.util.stream.Collectors;
040
041import javax.naming.NamingException;
042import javax.transaction.HeuristicMixedException;
043import javax.transaction.HeuristicRollbackException;
044import javax.transaction.NotSupportedException;
045import javax.transaction.RollbackException;
046import javax.transaction.SystemException;
047import javax.transaction.UserTransaction;
048import javax.ws.rs.GET;
049import javax.ws.rs.POST;
050import javax.ws.rs.Path;
051import javax.ws.rs.PathParam;
052import javax.ws.rs.Produces;
053import javax.ws.rs.core.Response;
054
055import org.apache.commons.lang3.StringUtils;
056import org.apache.commons.logging.Log;
057import org.apache.commons.logging.LogFactory;
058import org.nuxeo.apidoc.documentation.DocumentationService;
059import org.nuxeo.apidoc.export.ArchiveFile;
060import org.nuxeo.apidoc.listener.AttributesExtractorStater;
061import org.nuxeo.apidoc.snapshot.DistributionSnapshot;
062import org.nuxeo.apidoc.snapshot.DistributionSnapshotDesc;
063import org.nuxeo.apidoc.snapshot.SnapshotFilter;
064import org.nuxeo.apidoc.snapshot.SnapshotManager;
065import org.nuxeo.apidoc.snapshot.SnapshotManagerComponent;
066import org.nuxeo.apidoc.snapshot.SnapshotResolverHelper;
067import org.nuxeo.apidoc.worker.ExtractXmlAttributesWorker;
068import org.nuxeo.common.Environment;
069import org.nuxeo.ecm.core.api.Blob;
070import org.nuxeo.ecm.core.api.CoreSession;
071import org.nuxeo.ecm.core.api.DocumentModel;
072import org.nuxeo.ecm.core.api.IterableQueryResult;
073import org.nuxeo.ecm.core.api.NuxeoException;
074import org.nuxeo.ecm.core.api.NuxeoPrincipal;
075import org.nuxeo.ecm.core.query.QueryFilter;
076import org.nuxeo.ecm.core.query.sql.NXQL;
077import org.nuxeo.ecm.core.work.api.Work;
078import org.nuxeo.ecm.core.work.api.WorkManager;
079import org.nuxeo.ecm.webengine.forms.FormData;
080import org.nuxeo.ecm.webengine.model.Resource;
081import org.nuxeo.ecm.webengine.model.WebObject;
082import org.nuxeo.ecm.webengine.model.exceptions.WebResourceNotFoundException;
083import org.nuxeo.ecm.webengine.model.impl.ModuleRoot;
084import org.nuxeo.runtime.api.Framework;
085import org.nuxeo.runtime.transaction.TransactionHelper;
086
087@Path("/distribution")
088// needed for 5.4.1
089@WebObject(type = "distribution")
090public class Distribution extends ModuleRoot {
091
092    public static final String DIST_ID = "distId";
093
094    protected static final Log log = LogFactory.getLog(Distribution.class);
095
096    protected static final Pattern VERSION_REGEX = Pattern.compile("^(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?(?:-.*)?$",
097            Pattern.CASE_INSENSITIVE);
098
099    // handle errors
100    @Override
101    public Object handleError(Throwable t) {
102        if (t instanceof WebResourceNotFoundException) {
103            return Response.status(404).entity(getTemplate("error/error_404.ftl")).type("text/html").build();
104        } else {
105            return super.handleError(t);
106        }
107    }
108
109    protected SnapshotManager getSnapshotManager() {
110        return Framework.getService(SnapshotManager.class);
111    }
112
113    public String getNavigationPoint() {
114        String currentUrl = getContext().getURL();
115        String navPoint = "somewhere";
116
117        if (currentUrl.contains("/listBundles")) {
118            navPoint = "listBundles";
119        } else if (currentUrl.contains("/listSeamComponents")) {
120            navPoint = "listSeamComponents";
121        } else if (currentUrl.contains("/viewSeamComponent")) {
122            navPoint = "viewSeamComponent";
123        } else if (currentUrl.contains("/listComponents")) {
124            navPoint = "listComponents";
125        } else if (currentUrl.contains("/listServices")) {
126            navPoint = "listServices";
127        } else if (currentUrl.contains("/listExtensionPoints")) {
128            navPoint = "listExtensionPoints";
129        } else if (currentUrl.contains("/listContributions")) {
130            navPoint = "listContributions";
131        } else if (currentUrl.contains("/listBundleGroups")) {
132            navPoint = "listBundleGroups";
133        } else if (currentUrl.contains("/viewBundleGroup")) {
134            navPoint = "viewBundleGroup";
135        } else if (currentUrl.contains("/viewComponent")) {
136            navPoint = "viewComponent";
137        } else if (currentUrl.contains("/viewService")) {
138            navPoint = "viewService";
139        } else if (currentUrl.contains("/viewExtensionPoint")) {
140            navPoint = "viewExtensionPoint";
141        } else if (currentUrl.contains("/viewContribution")) {
142            navPoint = "viewContribution";
143        } else if (currentUrl.contains("/viewBundle")) {
144            navPoint = "viewBundle";
145        } else if (currentUrl.contains("/listOperations")) {
146            navPoint = "listOperations";
147        } else if (currentUrl.contains("/viewOperation")) {
148            navPoint = "viewOperation";
149        } else if (currentUrl.contains("/doc")) {
150            navPoint = "documentation";
151        }
152        return navPoint;
153    }
154
155    @GET
156    @Produces("text/html")
157    public Object doGet() {
158        return getView("index").arg("hideNav", Boolean.TRUE);
159    }
160
161    @Path("latest")
162    public Resource getLatest() {
163        List<DistributionSnapshot> snaps = listPersistedDistributions();
164        Optional<DistributionSnapshot> distribution = snaps.stream()
165                                                           .filter(snap -> snap.getName()
166                                                                               .toLowerCase()
167                                                                               .startsWith("nuxeo platform"))
168                                                           .findFirst();
169
170        String latest = "current";
171        if (distribution.isPresent()) {
172            latest = distribution.get().getKey();
173        }
174        return ctx.newObject("redirectWO", "latest", latest);
175    }
176
177    @Path("{distributionId}")
178    public Resource viewDistribution(@PathParam("distributionId") String distributionId) {
179        if (distributionId == null || "".equals(distributionId)) {
180            return this;
181        }
182
183        List<DistributionSnapshot> snaps = getSnapshotManager().listPersistentSnapshots((ctx.getCoreSession()));
184        if (distributionId.matches(VERSION_REGEX.toString())) {
185            String finalDistributionId = distributionId;
186            String distribution = snaps.stream()
187                                       .filter(s -> s.getVersion().equals(finalDistributionId))
188                                       .findFirst()
189                                       .map(DistributionSnapshot::getKey)
190                                       .orElse("current");
191
192            return ctx.newObject("redirectWO", finalDistributionId, distribution);
193        }
194
195        String orgDistributionId = distributionId;
196        Boolean embeddedMode = Boolean.FALSE;
197        if ("adm".equals(distributionId)) {
198            embeddedMode = Boolean.TRUE;
199        } else {
200
201            snaps.add(getSnapshotManager().getRuntimeSnapshot());
202            distributionId = SnapshotResolverHelper.findBestMatch(snaps, distributionId);
203        }
204        if (distributionId == null || "".equals(distributionId)) {
205            distributionId = "current";
206        }
207
208        if (!orgDistributionId.equals(distributionId)) {
209            return ctx.newObject("redirectWO", orgDistributionId, distributionId);
210        }
211
212        ctx.setProperty("embeddedMode", embeddedMode);
213        ctx.setProperty("distribution", getSnapshotManager().getSnapshot(distributionId, ctx.getCoreSession()));
214        ctx.setProperty(DIST_ID, distributionId);
215        return ctx.newObject("apibrowser", distributionId, embeddedMode);
216    }
217
218    public List<DistributionSnapshotDesc> getAvailableDistributions() {
219        return getSnapshotManager().getAvailableDistributions(ctx.getCoreSession());
220    }
221
222    public String getRuntimeDistributionName() {
223        return SnapshotManagerComponent.RUNTIME;
224    }
225
226    public DistributionSnapshot getRuntimeDistribution() {
227        return getSnapshotManager().getRuntimeSnapshot();
228    }
229
230    public List<DistributionSnapshot> listPersistedDistributions() {
231        SnapshotManager sm = getSnapshotManager();
232        return sm.listPersistentSnapshots(ctx.getCoreSession())
233                 .stream()
234                 .sorted((o1, o2) -> {
235                     Matcher m1 = VERSION_REGEX.matcher(o1.getVersion());
236                     Matcher m2 = VERSION_REGEX.matcher(o2.getVersion());
237
238                     if (m1.matches() && m2.matches()) {
239                         for (int i = 0; i < 3; i++) {
240                             String s1 = m1.group(i + 1);
241                             int c1 = s1 != null ? Integer.parseInt(s1) : 0;
242                             String s2 = m2.group(i + 1);
243                             int c2 = s2 != null ? Integer.parseInt(s2) : 0;
244
245                             if (c1 != c2 || i == 2) {
246                                 return Integer.compare(c2, c1);
247                             }
248                         }
249                     }
250                     log.info(String.format("Comparing version using String between %s - %s", o1.getVersion(),
251                             o2.getVersion()));
252                     return o2.getVersion().compareTo(o1.getVersion());
253                 })
254                 .filter(s -> !s.isHidden())
255                 .collect(Collectors.toList());
256    }
257
258    public Map<String, DistributionSnapshot> getPersistedDistributions() {
259        return getSnapshotManager().getPersistentSnapshots(ctx.getCoreSession());
260    }
261
262    public DistributionSnapshot getCurrentDistribution() {
263        String distId = (String) ctx.getProperty(DIST_ID);
264        DistributionSnapshot currentDistribution = (DistributionSnapshot) ctx.getProperty("currentDistribution");
265        if (currentDistribution == null || !currentDistribution.getKey().equals(distId)) {
266            currentDistribution = getSnapshotManager().getSnapshot(distId, ctx.getCoreSession());
267            ctx.setProperty("currentDistribution", currentDistribution);
268        }
269        return currentDistribution;
270    }
271
272    @POST
273    @Path("save")
274    @Produces("text/html")
275    public Object doSave() throws NamingException, NotSupportedException, SystemException, RollbackException,
276            HeuristicMixedException, HeuristicRollbackException, ParseException {
277        if (!canAddDocumentation()) {
278            return null;
279        }
280        FormData formData = getContext().getForm();
281        String distribLabel = formData.getString("name");
282
283        log.info("Start Snapshot...");
284        boolean startedTx = false;
285        UserTransaction tx = TransactionHelper.lookupUserTransaction();
286        if (tx != null && !TransactionHelper.isTransactionActiveOrMarkedRollback()) {
287            tx.begin();
288            startedTx = true;
289        }
290
291        Map<String, Serializable> otherProperties = readFormData(formData);
292        try {
293            getSnapshotManager().persistRuntimeSnapshot(getContext().getCoreSession(), distribLabel, otherProperties);
294
295        } catch (NuxeoException e) {
296            log.error("Error during storage", e);
297            if (tx != null) {
298                tx.rollback();
299            }
300            return getView("savedKO").arg("message", e.getMessage());
301        }
302        log.info("Snapshot saved.");
303        if (tx != null && startedTx) {
304            tx.commit();
305        }
306
307        String redirectUrl = getContext().getBaseURL() + getPath();
308        log.debug("Path => " + redirectUrl);
309        return getView("saved");
310    }
311
312    protected Map<String, Serializable> readFormData(FormData formData) {
313        Map<String, Serializable> properties = new HashMap<>();
314
315        // Release date
316        String released = formData.getString("released");
317        if (StringUtils.isNotBlank(released)) {
318            LocalDate date = LocalDate.parse(released);
319            Instant instant = date.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant();
320            properties.put(PROP_RELEASED, Date.from(instant));
321        }
322
323        return properties;
324    }
325
326    @POST
327    @Path("saveExtended")
328    @Produces("text/html")
329    public Object doSaveExtended() throws NamingException, NotSupportedException, SystemException, SecurityException,
330            RollbackException, HeuristicMixedException, HeuristicRollbackException {
331        if (!canAddDocumentation()) {
332            return null;
333        }
334
335        FormData formData = getContext().getForm();
336
337        String distribLabel = formData.getString("name");
338        String bundleList = formData.getString("bundles");
339        String pkgList = formData.getString("packages");
340        SnapshotFilter filter = new SnapshotFilter(distribLabel);
341
342        if (bundleList != null) {
343            String[] bundles = bundleList.split("\n");
344            for (String bundleId : bundles) {
345                filter.addBundlePrefix(bundleId);
346            }
347        }
348
349        if (pkgList != null) {
350            String[] packages = pkgList.split("\\r?\\n");
351            for (String pkg : packages) {
352                filter.addPackagesPrefix(pkg);
353            }
354        }
355
356        Map<String, Serializable> otherProperties = readFormData(formData);
357
358        log.info("Start Snapshot...");
359        boolean startedTx = false;
360        UserTransaction tx = TransactionHelper.lookupUserTransaction();
361        if (tx != null && !TransactionHelper.isTransactionActiveOrMarkedRollback()) {
362            tx.begin();
363            startedTx = true;
364        }
365        try {
366            getSnapshotManager().persistRuntimeSnapshot(getContext().getCoreSession(), distribLabel, otherProperties,
367                    filter);
368        } catch (NuxeoException e) {
369            log.error("Error during storage", e);
370            if (tx != null) {
371                tx.rollback();
372            }
373            return getView("savedKO").arg("message", e.getMessage());
374        }
375        log.info("Snapshot saved.");
376        if (tx != null && startedTx) {
377            tx.commit();
378        }
379        return getView("saved");
380    }
381
382    public String getDocumentationInfo() {
383        DocumentationService ds = Framework.getService(DocumentationService.class);
384        return ds.getDocumentationStats(getContext().getCoreSession());
385    }
386
387    protected File getExportTmpFile() {
388        File tmpFile = new File(Environment.getDefault().getTemp(), "export.zip");
389        if (tmpFile.exists()) {
390            tmpFile.delete();
391        }
392        tmpFile.deleteOnExit();
393        return tmpFile;
394    }
395
396    @GET
397    @Path("downloadDoc")
398    public Response downloadDoc() throws IOException {
399        DocumentationService ds = Framework.getService(DocumentationService.class);
400        File tmp = getExportTmpFile();
401        tmp.createNewFile();
402        OutputStream out = new FileOutputStream(tmp);
403        ds.exportDocumentation(getContext().getCoreSession(), out);
404        out.flush();
405        out.close();
406        ArchiveFile aFile = new ArchiveFile(tmp.getAbsolutePath());
407        return Response.ok(aFile)
408                       .header("Content-Disposition", "attachment;filename=" + "nuxeo-documentation.zip")
409                       .type("application/zip")
410                       .build();
411    }
412
413    @GET
414    @Path("download/{distributionId}")
415    public Response downloadDistrib(@PathParam("distributionId") String distribId) throws IOException {
416        File tmp = getExportTmpFile();
417        tmp.createNewFile();
418        OutputStream out = new FileOutputStream(tmp);
419        getSnapshotManager().exportSnapshot(getContext().getCoreSession(), distribId, out);
420        out.close();
421        String fName = "nuxeo-distribution-" + distribId + ".zip";
422        fName = fName.replace(" ", "_");
423        ArchiveFile aFile = new ArchiveFile(tmp.getAbsolutePath());
424        return Response.ok(aFile)
425                       .header("Content-Disposition", "attachment;filename=" + fName)
426                       .type("application/zip")
427                       .build();
428    }
429
430    /**
431     * Use to allow authorized users to upload distribution even in site mode
432     *
433     * @since 8.3
434     */
435    @GET
436    @Path("_admin")
437    public Object getForms() {
438        NuxeoPrincipal principal = (NuxeoPrincipal) getContext().getPrincipal();
439        if (SecurityHelper.canEditDocumentation(principal)) {
440            return getView("forms").arg("hideNav", Boolean.TRUE);
441        } else {
442            return Response.status(401).build();
443        }
444    }
445
446    @POST
447    @Path("uploadDistrib")
448    @Produces("text/html")
449    public Object uploadDistrib() throws IOException {
450        if (!canAddDocumentation()) {
451            return null;
452        }
453        Blob blob = getContext().getForm().getFirstBlob();
454
455        getSnapshotManager().importSnapshot(getContext().getCoreSession(), blob.getStream());
456        getSnapshotManager().readPersistentSnapshots(getContext().getCoreSession());
457
458        return getView("index");
459    }
460
461    @POST
462    @Path("uploadDistribTmp")
463    @Produces("text/html")
464    public Object uploadDistribTmp() throws IOException {
465        if (!canAddDocumentation()) {
466            return null;
467        }
468        Blob blob = getContext().getForm().getFirstBlob();
469        if (blob == null || blob.getLength() == 0) {
470            return null;
471        }
472        DocumentModel snap = getSnapshotManager().importTmpSnapshot(getContext().getCoreSession(), blob.getStream());
473        if (snap == null) {
474            log.error("Unable to import archive");
475            return null;
476        }
477        DistributionSnapshot snapObject = snap.getAdapter(DistributionSnapshot.class);
478        return getView("uploadEdit").arg("tmpSnap", snap).arg("snapObject", snapObject);
479    }
480
481    @POST
482    @Path("uploadDistribTmpValid")
483    @Produces("text/html")
484    public Object uploadDistribTmpValid() {
485        if (!canAddDocumentation()) {
486            return null;
487        }
488
489        FormData formData = getContext().getForm();
490        String name = formData.getString("name");
491        String version = formData.getString("version");
492        String pathSegment = formData.getString("pathSegment");
493        String title = formData.getString("title");
494
495        getSnapshotManager().validateImportedSnapshot(getContext().getCoreSession(), name, version, pathSegment, title);
496        getSnapshotManager().readPersistentSnapshots(getContext().getCoreSession());
497        return getView("importDone");
498    }
499
500    @POST
501    @Path("uploadDoc")
502    @Produces("text/html")
503    public Object uploadDoc() throws IOException {
504        if (!canAddDocumentation()) {
505            return null;
506        }
507
508        Blob blob = getContext().getForm().getFirstBlob();
509        if (blob == null || blob.getLength() == 0) {
510            return null;
511        }
512
513        DocumentationService ds = Framework.getService(DocumentationService.class);
514        ds.importDocumentation(getContext().getCoreSession(), blob.getStream());
515
516        log.info("Documents imported.");
517
518        return getView("docImportDone");
519    }
520
521    @GET
522    @Path("_reindex")
523    @Produces("text/plain")
524    public Object reindex() {
525        NuxeoPrincipal nxPrincipal = (NuxeoPrincipal) getContext().getPrincipal();
526        if (!nxPrincipal.isAdministrator()) {
527            return Response.status(404).build();
528        }
529
530        CoreSession coreSession = getContext().getCoreSession();
531        String query = String.format(
532                "SELECT ecm:uuid FROM Document WHERE ecm:primaryType in ('%s') AND ecm:isProxy = 0 AND ecm:currentLifeCycleState <> 'deleted'",
533                StringUtils.join(AttributesExtractorStater.DOC_TYPES, "','"));
534
535        try (IterableQueryResult it = coreSession.queryAndFetch(query, NXQL.NXQL, QueryFilter.EMPTY);) {
536            for (Map<String, Serializable> map : it) {
537                String id = (String) map.get(NXQL.ECM_UUID);
538                Work work = new ExtractXmlAttributesWorker(coreSession.getRepositoryName(), nxPrincipal.getName(), id);
539                Framework.getService(WorkManager.class).schedule(work);
540            }
541        }
542
543        return Response.ok().build();
544    }
545
546    public boolean isEmbeddedMode() {
547        Boolean embed = (Boolean) getContext().getProperty("embeddedMode", Boolean.FALSE);
548        return embed != null && embed;
549    }
550
551    public boolean isEditor() {
552        if (isEmbeddedMode() || isSiteMode()) {
553            return false;
554        }
555        NuxeoPrincipal principal = (NuxeoPrincipal) getContext().getPrincipal();
556        return SecurityHelper.canEditDocumentation(principal);
557    }
558
559    public boolean canAddDocumentation() {
560        NuxeoPrincipal principal = (NuxeoPrincipal) getContext().getPrincipal();
561        return !isEmbeddedMode() && SecurityHelper.canEditDocumentation(principal);
562    }
563
564    public static boolean showCurrentDistribution() {
565        return !(Framework.isBooleanPropertyTrue("org.nuxeo.apidoc.hide.current.distribution") || isSiteMode());
566    }
567
568    public static boolean showSeamComponent() {
569        return !(Framework.isBooleanPropertyTrue("org.nuxeo.apidoc.hide.seam.components") || isSiteMode());
570    }
571
572    public static boolean isSiteMode() {
573        return Framework.isBooleanPropertyTrue("org.nuxeo.apidoc.site.mode");
574    }
575}