001package org.nuxeo.ecm.platform.indexing.gateway.ws;
002
003import java.io.Serializable;
004import java.util.ArrayList;
005import java.util.List;
006import java.util.Map;
007import java.util.concurrent.ConcurrentHashMap;
008import java.util.concurrent.TimeUnit;
009import java.util.concurrent.locks.ReentrantLock;
010
011import javax.jws.WebMethod;
012import javax.jws.WebParam;
013import javax.jws.WebService;
014import javax.jws.soap.SOAPBinding;
015import javax.jws.soap.SOAPBinding.Style;
016
017import org.apache.commons.logging.Log;
018import org.apache.commons.logging.LogFactory;
019import org.nuxeo.common.utils.ExceptionUtils;
020import org.nuxeo.ecm.core.api.CoreSession;
021import org.nuxeo.ecm.core.api.DocumentModel;
022import org.nuxeo.ecm.core.api.IdRef;
023import org.nuxeo.ecm.core.api.IterableQueryResult;
024import org.nuxeo.ecm.core.api.PathRef;
025import org.nuxeo.ecm.core.api.security.ACL;
026import org.nuxeo.ecm.core.api.security.ACP;
027import org.nuxeo.ecm.core.query.sql.NXQL;
028import org.nuxeo.ecm.core.schema.DocumentType;
029import org.nuxeo.ecm.core.schema.SchemaManager;
030import org.nuxeo.ecm.platform.api.ws.DocumentBlob;
031import org.nuxeo.ecm.platform.api.ws.DocumentDescriptor;
032import org.nuxeo.ecm.platform.api.ws.DocumentProperty;
033import org.nuxeo.ecm.platform.api.ws.DocumentSnapshot;
034import org.nuxeo.ecm.platform.api.ws.NuxeoRemoting;
035import org.nuxeo.ecm.platform.api.ws.WsACE;
036import org.nuxeo.ecm.platform.api.ws.session.WSRemotingSession;
037import org.nuxeo.ecm.platform.audit.ws.EventDescriptorPage;
038import org.nuxeo.ecm.platform.audit.ws.ModifiedDocumentDescriptor;
039import org.nuxeo.ecm.platform.audit.ws.ModifiedDocumentDescriptorPage;
040import org.nuxeo.ecm.platform.audit.ws.WSAuditBean;
041import org.nuxeo.ecm.platform.audit.ws.api.WSAudit;
042import org.nuxeo.ecm.platform.indexing.gateway.adapter.IndexingAdapter;
043import org.nuxeo.ecm.platform.indexing.gateway.ws.api.WSIndexingGateway;
044import org.nuxeo.ecm.platform.ws.AbstractNuxeoWebService;
045import org.nuxeo.ecm.platform.ws.NuxeoRemotingBean;
046import org.nuxeo.runtime.api.Framework;
047
048/**
049 * Base class for WS beans used for external indexers. Implements most of NuxeoRemotingBean trying as hard as possible
050 * no to throw when a requested document is missing but returning empty descriptions instead so as to
051 * make external indexers not view recently deleted documents as applicative errors.
052 *
053 * @author tiry
054 */
055@WebService(name = "WSIndexingGatewayInterface", serviceName = "WSIndexingGatewayService")
056@SOAPBinding(style = Style.DOCUMENT)
057public class WSIndexingGatewayBean extends AbstractNuxeoWebService implements WSIndexingGateway {
058
059    protected static final String ENFORCE_SYNC_PROP_NAME = "nuxeo.indexing.gateway.forceSync";
060
061    protected static Log log = LogFactory.getLog(WSIndexingGatewayBean.class);
062
063    private static final long serialVersionUID = 4696352633818100451L;
064
065    protected transient WSAudit auditBean;
066
067    protected transient NuxeoRemoting platformRemoting;
068
069    protected IndexingAdapter adapter;
070
071    protected ConcurrentHashMap<String, ReentrantLock> sessionIdLocks = new ConcurrentHashMap<String, ReentrantLock>();
072
073    protected Boolean enforceSync = null;
074
075    protected boolean forceSync() {
076        if (enforceSync == null) {
077            String value = Framework.getProperty(ENFORCE_SYNC_PROP_NAME, null);
078            enforceSync = false;
079            if (value != null) {
080                enforceSync = Boolean.parseBoolean(value);
081            } else {
082                enforceSync = false;
083            }
084        }
085        return enforceSync;
086    }
087
088    protected void lockSession(String sid) {
089        if (forceSync()) {
090            ReentrantLock lock = sessionIdLocks.putIfAbsent(sid, new ReentrantLock());
091            boolean aquired = false;
092            if (lock == null) {
093                lock = sessionIdLocks.get(sid);
094            }
095            try {
096                aquired = lock.tryLock(10, TimeUnit.SECONDS);
097            } catch (InterruptedException e) {
098                ExceptionUtils.checkInterrupt(e);
099            }
100            if (!aquired) {
101                log.error("Failed to acquire lock (timeout) for sid " + sid);
102            }
103        }
104    }
105
106    protected void releaseSession(String sid) {
107        if (forceSync()) {
108            ReentrantLock lock = sessionIdLocks.get(sid);
109            if (lock != null) {
110                lock.unlock();
111            }
112        }
113    }
114
115    protected WSAudit getWSAudit() {
116        if (auditBean == null) {
117            auditBean = new WSAuditBean();
118        }
119        return auditBean;
120    }
121
122    protected NuxeoRemoting getWSNuxeoRemoting() {
123        if (platformRemoting == null) {
124            platformRemoting = new NuxeoRemotingBean();
125        }
126        return platformRemoting;
127    }
128
129    protected IndexingAdapter getAdapter() {
130        if (adapter == null) {
131            adapter = Framework.getLocalService(IndexingAdapter.class);
132        }
133        return adapter;
134    }
135
136    @WebMethod
137    public DocumentDescriptor[] getChildren(@WebParam(name = "sessionId") String sessionId,
138            @WebParam(name = "uuid") String uuid) {
139        try {
140            lockSession(sessionId);
141            CoreSession session = initSession(sessionId).getDocumentManager();
142            if (session.exists(new IdRef(uuid))) {
143                return getWSNuxeoRemoting().getChildren(sessionId, uuid);
144            } else {
145                return new DocumentDescriptor[0];
146            }
147        } finally {
148            releaseSession(sessionId);
149        }
150    }
151
152    @WebMethod
153    public DocumentDescriptor getCurrentVersion(@WebParam(name = "sessionId") String sid,
154            @WebParam(name = "uuid") String uid) {
155        try {
156            lockSession(sid);
157            CoreSession session = initSession(sid).getDocumentManager();
158            if (session.exists(new IdRef(uid))) {
159                return getWSNuxeoRemoting().getCurrentVersion(sid, uid);
160            } else {
161                return missingDocumentDescriptor(uid);
162            }
163        } finally {
164            releaseSession(sid);
165        }
166    }
167
168    @WebMethod
169    public DocumentDescriptor getDocument(@WebParam(name = "sessionId") String sessionId,
170            @WebParam(name = "uuid") String uuid) {
171        try {
172            lockSession(sessionId);
173            CoreSession session = initSession(sessionId).getDocumentManager();
174            DocumentDescriptor dd;
175            if (session.exists(new IdRef(uuid))) {
176                dd = getWSNuxeoRemoting().getDocument(sessionId, uuid);
177            } else {
178                dd = missingDocumentDescriptor(uuid);
179            }
180            return getAdapter().adaptDocumentDescriptor(session, uuid, dd);
181        } finally {
182            releaseSession(sessionId);
183        }
184    }
185
186    @WebMethod
187    public WsACE[] getDocumentACL(@WebParam(name = "sessionId") String sid, @WebParam(name = "uuid") String uuid)
188            {
189        try {
190            lockSession(sid);
191            CoreSession session = initSession(sid).getDocumentManager();
192            WsACE[] aces;
193            if (session.exists(new IdRef(uuid))) {
194                aces = getWSNuxeoRemoting().getDocumentACL(sid, uuid);
195            } else {
196                aces = new WsACE[0];
197            }
198            return getAdapter().adaptDocumentACL(session, uuid, aces);
199        } finally {
200            releaseSession(sid);
201        }
202    }
203
204    @WebMethod
205    public WsACE[] getDocumentLocalACL(@WebParam(name = "sessionId") String sid, @WebParam(name = "uuid") String uuid)
206            {
207        try {
208            lockSession(sid);
209            CoreSession session = initSession(sid).getDocumentManager();
210            WsACE[] aces;
211            if (session.exists(new IdRef(uuid))) {
212                aces = getWSNuxeoRemoting().getDocumentLocalACL(sid, uuid);
213            } else {
214                aces = new WsACE[0];
215            }
216            return getAdapter().adaptDocumentLocalACL(session, uuid, aces);
217        } finally {
218            releaseSession(sid);
219        }
220    }
221
222    public DocumentBlob[] getDocumentBlobsExt(@WebParam(name = "sessionId") String sid,
223            @WebParam(name = "uuid") String uuid, @WebParam(name = "useDownloadUrl") boolean useDownloadUrl)
224            {
225        try {
226            lockSession(sid);
227            CoreSession session = initSession(sid).getDocumentManager();
228            DocumentBlob[] blobs;
229            if (session.exists(new IdRef(uuid))) {
230                blobs = getWSNuxeoRemoting().getDocumentBlobsExt(sid, uuid, useDownloadUrl);
231            } else {
232                blobs = new DocumentBlob[0];
233            }
234            return getAdapter().adaptDocumentBlobs(session, uuid, blobs);
235
236        } finally {
237            releaseSession(sid);
238        }
239    }
240
241    @WebMethod
242    public DocumentBlob[] getDocumentBlobs(@WebParam(name = "sessionId") String sid,
243            @WebParam(name = "uuid") String uuid) {
244        return getDocumentBlobsExt(sid, uuid, getAdapter().useDownloadUrlForBlob());
245    }
246
247    @WebMethod
248    public DocumentProperty[] getDocumentNoBlobProperties(@WebParam(name = "sessionId") String sid,
249            @WebParam(name = "uuid") String uuid) {
250
251        try {
252            lockSession(sid);
253            CoreSession session = initSession(sid).getDocumentManager();
254            DocumentProperty[] properties;
255            if (session.exists(new IdRef(uuid))) {
256                properties = getWSNuxeoRemoting().getDocumentNoBlobProperties(sid, uuid);
257            } else {
258                properties = new DocumentProperty[0];
259            }
260            return getAdapter().adaptDocumentNoBlobProperties(session, uuid, properties);
261        } finally {
262            releaseSession(sid);
263        }
264
265    }
266
267    @WebMethod
268    public DocumentProperty[] getDocumentProperties(@WebParam(name = "sessionId") String sid,
269            @WebParam(name = "uuid") String uuid) {
270        try {
271            lockSession(sid);
272            CoreSession session = initSession(sid).getDocumentManager();
273            DocumentProperty[] properties;
274            if (session.exists(new IdRef(uuid))) {
275                properties = getWSNuxeoRemoting().getDocumentProperties(sid, uuid);
276            } else {
277                properties = new DocumentProperty[0];
278            }
279            return getAdapter().adaptDocumentProperties(session, uuid, properties);
280        } finally {
281            releaseSession(sid);
282        }
283    }
284
285    @WebMethod
286    public String[] getGroups(@WebParam(name = "sessionId") String sid,
287            @WebParam(name = "parentGroup") String parentGroup) {
288        return getWSNuxeoRemoting().getGroups(sid, parentGroup);
289    }
290
291    @WebMethod
292    public String getRepositoryName(@WebParam(name = "sessionId") String sid) {
293        return getWSNuxeoRemoting().getRepositoryName(sid);
294    }
295
296    @WebMethod
297    public DocumentDescriptor getRootDocument(@WebParam(name = "sessionId") String sessionId) {
298        try {
299            lockSession(sessionId);
300            return getWSNuxeoRemoting().getRootDocument(sessionId);
301        } finally {
302            releaseSession(sessionId);
303        }
304    }
305
306    @WebMethod
307    public String resolvePathToUUID(@WebParam(name = "sessionId") String sessionId, @WebParam(name = "path") String path)
308            {
309        try {
310            lockSession(sessionId);
311            CoreSession session = initSession(sessionId).getDocumentManager();
312            if (session != null) {
313                PathRef pathRef = new PathRef(path);
314                if (session.exists(pathRef)) {
315                    return session.getDocument(pathRef).getId();
316                }
317            }
318            return null;
319        } finally {
320            releaseSession(sessionId);
321        }
322    }
323
324    @WebMethod
325    public UUIDPage getRecursiveChildrenUUIDsByPage(@WebParam(name = "sessionId") String sid,
326            @WebParam(name = "uuid") String uuid, @WebParam(name = "page") int page,
327            @WebParam(name = "pageSize") int pageSize) {
328
329        try {
330            lockSession(sid);
331            CoreSession session = initSession(sid).getDocumentManager();
332
333            List<String> uuids = new ArrayList<String>();
334            IdRef parentRef = new IdRef(uuid);
335            DocumentModel parent = session.getDocument(parentRef);
336            String path = parent.getPathAsString();
337
338            String query = "select ecm:uuid from Document where ecm:path startswith '" + path + "' order by ecm:uuid";
339
340            IterableQueryResult result = session.queryAndFetch(query, "NXQL");
341            boolean hasMore = false;
342            try {
343                if (page > 1) {
344                    int skip = (page - 1) * pageSize;
345                    result.skipTo(skip);
346                }
347
348                for (Map<String, Serializable> record : result) {
349                    uuids.add((String) record.get(NXQL.ECM_UUID));
350                    if (uuids.size() == pageSize) {
351                        hasMore = true;
352                        break;
353                    }
354                }
355            } finally {
356                result.close();
357            }
358            return new UUIDPage(uuids.toArray(new String[uuids.size()]), page, hasMore);
359        } finally {
360            releaseSession(sid);
361        }
362
363    }
364
365    @WebMethod
366    public String[] getRecursiveChildrenUUIDs(@WebParam(name = "sessionId") String sid,
367            @WebParam(name = "uuid") String uuid) {
368
369        try {
370            lockSession(sid);
371            CoreSession session = initSession(sid).getDocumentManager();
372
373            List<String> uuids = new ArrayList<String>();
374            IdRef parentRef = new IdRef(uuid);
375            DocumentModel parent = session.getDocument(parentRef);
376            String path = parent.getPathAsString();
377
378            String query = "select ecm:uuid from Document where ecm:path startswith '" + path + "' order by ecm:uuid";
379
380            IterableQueryResult result = session.queryAndFetch(query, "NXQL");
381
382            try {
383                for (Map<String, Serializable> record : result) {
384                    uuids.add((String) record.get(NXQL.ECM_UUID));
385                }
386            } finally {
387                result.close();
388            }
389
390            return uuids.toArray(new String[uuids.size()]);
391        } finally {
392            releaseSession(sid);
393        }
394
395    }
396
397    @WebMethod
398    public DocumentTypeDescriptor[] getTypeDefinitions() {
399
400        List<DocumentTypeDescriptor> result = new ArrayList<DocumentTypeDescriptor>();
401        SchemaManager sm = Framework.getService(SchemaManager.class);
402
403        for (DocumentType dt : sm.getDocumentTypes()) {
404            result.add(new DocumentTypeDescriptor(dt));
405        }
406
407        return result.toArray(new DocumentTypeDescriptor[result.size()]);
408    }
409
410    @WebMethod
411    public DocumentDescriptor getDocumentFromPath(@WebParam(name = "sessionId") String sessionId,
412            @WebParam(name = "path") String path) {
413        try {
414            lockSession(sessionId);
415            String uuid = resolvePathToUUID(sessionId, path);
416            if (uuid != null) {
417                return getWSNuxeoRemoting().getDocument(sessionId, uuid);
418            } else {
419                // should we return a missing document with an null uuid
420                // instead?
421                return null;
422            }
423        } finally {
424            releaseSession(sessionId);
425        }
426    }
427
428    @WebMethod
429    public DocumentDescriptor getSourceDocument(@WebParam(name = "sessionId") String sid,
430            @WebParam(name = "uuid") String uid) {
431        try {
432            lockSession(sid);
433            CoreSession session = initSession(sid).getDocumentManager();
434            if (session.exists(new IdRef(uid))) {
435                return getWSNuxeoRemoting().getSourceDocument(sid, uid);
436            } else {
437                return missingDocumentDescriptor(uid);
438            }
439        } finally {
440            releaseSession(sid);
441        }
442    }
443
444    @WebMethod
445    public String[] getUsers(@WebParam(name = "sessionId") String sid,
446            @WebParam(name = "parentGroup") String parentGroup) {
447        return getWSNuxeoRemoting().getUsers(sid, parentGroup);
448    }
449
450    @WebMethod
451    public DocumentDescriptor[] getVersions(@WebParam(name = "sessionId") String sid,
452            @WebParam(name = "uuid") String uid) {
453        try {
454            lockSession(sid);
455            CoreSession session = initSession(sid).getDocumentManager();
456            if (session.exists(new IdRef(uid))) {
457                return getWSNuxeoRemoting().getVersions(sid, uid);
458            } else {
459                return new DocumentDescriptor[0];
460            }
461        } finally {
462            releaseSession(sid);
463        }
464    }
465
466    @WebMethod
467    public String[] listGroups(@WebParam(name = "sessionId") String sid, @WebParam(name = "from") int from,
468            @WebParam(name = "to") int to) {
469        return getWSNuxeoRemoting().listGroups(sid, from, to);
470    }
471
472    @WebMethod
473    public String[] listUsers(@WebParam(name = "sessionId") String sid, @WebParam(name = "from") int from,
474            @WebParam(name = "to") int to) {
475        return getWSNuxeoRemoting().listUsers(sid, from, to);
476    }
477
478    @WebMethod
479    public ModifiedDocumentDescriptor[] listModifiedDocuments(@WebParam(name = "sessionId") String sessionId,
480            @WebParam(name = "dateRangeQuery") String dateRangeQuery) {
481        return getWSAudit().listModifiedDocuments(sessionId, dateRangeQuery);
482    }
483
484    @WebMethod
485    public ModifiedDocumentDescriptorPage listModifiedDocumentsByPage(@WebParam(name = "sessionId") String sessionId,
486            @WebParam(name = "dateRangeQuery") String dateRangeQuery, @WebParam(name = "path") String path,
487            @WebParam(name = "page") int page, @WebParam(name = "pageSize") int pageSize) {
488        return getWSAudit().listModifiedDocumentsByPage(sessionId, dateRangeQuery, path, page, pageSize);
489    }
490
491    @WebMethod
492    public EventDescriptorPage listEventsByPage(@WebParam(name = "sessionId") String sessionId,
493            @WebParam(name = "dateRangeQuery") String dateRangeQuery, @WebParam(name = "page") int page,
494            @WebParam(name = "pageSize") int pageSize) {
495        return getWSAudit().listEventsByPage(sessionId, dateRangeQuery, page, pageSize);
496    }
497
498    @WebMethod
499    public EventDescriptorPage listDocumentEventsByPage(@WebParam(name = "sessionId") String sessionId,
500            @WebParam(name = "dateRangeQuery") String dateRangeQuery, @WebParam(name = "startDate") String startDate,
501            @WebParam(name = "path") String path, @WebParam(name = "page") int page,
502            @WebParam(name = "pageSize") int pageSize) {
503        return getWSAudit().listDocumentEventsByPage(sessionId, dateRangeQuery, startDate, path, page, pageSize);
504    }
505
506    @WebMethod
507    public String getRelativePathAsString(@WebParam(name = "sessionId") String sessionId,
508            @WebParam(name = "uuid") String uuid) {
509        try {
510            lockSession(sessionId);
511            CoreSession session = initSession(sessionId).getDocumentManager();
512            if (session.exists(new IdRef(uuid))) {
513                return getWSNuxeoRemoting().getRelativePathAsString(sessionId, uuid);
514            } else {
515                return null;
516            }
517        } finally {
518            releaseSession(sessionId);
519        }
520    }
521
522    @WebMethod
523    public boolean hasPermission(@WebParam(name = "sessionId") String sid, @WebParam(name = "uuid") String uuid,
524            @WebParam(name = "permission") String permission) {
525        try {
526            lockSession(sid);
527            CoreSession session = initSession(sid).getDocumentManager();
528            if (session.exists(new IdRef(uuid))) {
529                return getWSNuxeoRemoting().hasPermission(sid, uuid, permission);
530            } else {
531                return false;
532            }
533        } finally {
534            releaseSession(sid);
535        }
536    }
537
538    @WebMethod
539    public String uploadDocument(@WebParam(name = "sessionId") String sid, String path, String type, String[] properties)
540            {
541        try {
542            lockSession(sid);
543            return getWSNuxeoRemoting().uploadDocument(sid, path, type, properties);
544        } finally {
545            releaseSession(sid);
546        }
547    }
548
549    @WebMethod
550    public String connect(@WebParam(name = "userName") String username, @WebParam(name = "password") String password)
551            {
552        return getWSNuxeoRemoting().connect(username, password);
553    }
554
555    @WebMethod
556    public void disconnect(@WebParam(name = "sessionId") String sid) {
557        getWSNuxeoRemoting().disconnect(sid);
558        if (forceSync()) {
559            ReentrantLock lock = sessionIdLocks.get(sid);
560            if (lock != null) {
561                if (lock.isLocked()) {
562                    lock.unlock();
563                }
564                sessionIdLocks.remove(sid);
565            }
566        }
567    }
568
569    @WebMethod
570    public EventDescriptorPage queryEventsByPage(@WebParam(name = "sessionId") String sessionId,
571            @WebParam(name = "whereClause") String whereClause, @WebParam(name = "pageIndex") int page,
572            @WebParam(name = "pageSize") int pageSize) {
573        return getWSAudit().queryEventsByPage(sessionId, whereClause, page, pageSize);
574    }
575
576    @WebMethod
577    public boolean validateUserPassword(@WebParam(name = "sessionId") String sessionId,
578            @WebParam(name = "username") String username, @WebParam(name = "password") String password)
579            {
580        WSRemotingSession rs = initSession(sessionId);
581        return rs.getUserManager().checkUsernamePassword(username, password);
582    }
583
584    @WebMethod
585    public String[] getUserGroups(@WebParam(name = "sessionId") String sessionId,
586            @WebParam(name = "username") String username) {
587        WSRemotingSession rs = initSession(sessionId);
588        List<String> groups = rs.getUserManager().getPrincipal(username).getAllGroups();
589        String[] groupArray = new String[groups.size()];
590        groups.toArray(groupArray);
591        return groupArray;
592    }
593
594    public DocumentSnapshot getDocumentSnapshotExt(@WebParam(name = "sessionId") String sessionId,
595            @WebParam(name = "uuid") String uuid, @WebParam(name = "useDownloadUrl") boolean useDownloadUrl)
596            {
597
598        try {
599            lockSession(sessionId);
600            WSRemotingSession rs = initSession(sessionId);
601            DocumentModel doc = rs.getDocumentManager().getDocument(new IdRef(uuid));
602
603            DocumentProperty[] props = getDocumentNoBlobProperties(sessionId, uuid);
604            DocumentBlob[] blobs = getDocumentBlobs(sessionId, uuid);
605
606            WsACE[] resACP = null;
607
608            ACP acp = doc.getACP();
609            if (acp != null && acp.getACLs().length > 0) {
610                ACL acl = acp.getMergedACLs("MergedACL");
611                resACP = WsACE.wrap(acl.getACEs());
612            }
613            DocumentSnapshot ds = new DocumentSnapshot(props, blobs, doc.getPathAsString(), resACP);
614            return ds;
615        } finally {
616            releaseSession(sessionId);
617        }
618    }
619
620    @WebMethod
621    public DocumentSnapshot getDocumentSnapshot(@WebParam(name = "sessionId") String sessionId,
622            @WebParam(name = "uuid") String uuid) {
623        return getDocumentSnapshotExt(sessionId, uuid, getAdapter().useDownloadUrlForBlob());
624    }
625
626    public ModifiedDocumentDescriptorPage listDeletedDocumentsByPage(@WebParam(name = "sessionId") String sessionId,
627            @WebParam(name = "dataRangeQuery") String dateRangeQuery, @WebParam(name = "docPath") String path,
628            @WebParam(name = "pageIndex") int page, @WebParam(name = "pageSize") int pageSize) {
629
630        return getWSAudit().listDeletedDocumentsByPage(sessionId, dateRangeQuery, path, page, pageSize);
631    }
632
633    /**
634     * Utility method to build descriptor for a document that is non longer to be found in the repository.
635     *
636     * @param uuid
637     * @return
638     */
639    protected DocumentDescriptor missingDocumentDescriptor(String uuid) {
640        // TODO: if we have to make the API / WSDL evolve it would be nice to
641        // include an explicit attribute in DocumentDescriptor to mark missing
642        // documents
643        DocumentDescriptor dd = new DocumentDescriptor();
644        dd.setUUID(uuid);
645        return dd;
646    }
647
648}