]> err.no Git - scalable-opengroupware.org/blob - SoObjects/SOGo/SOGoObject.m
57b13cd78a0858d6b4a53609943ed59400b84fe9
[scalable-opengroupware.org] / SoObjects / SOGo / SOGoObject.m
1 /*
2   Copyright (C) 2004-2005 SKYRIX Software AG
3
4   This file is part of OpenGroupware.org.
5
6   OGo is free software; you can redistribute it and/or modify it under
7   the terms of the GNU Lesser General Public License as published by the
8   Free Software Foundation; either version 2, or (at your option) any
9   later version.
10
11   OGo is distributed in the hope that it will be useful, but WITHOUT ANY
12   WARRANTY; without even the implied warranty of MERCHANTABILITY or
13   FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public
14   License for more details.
15
16   You should have received a copy of the GNU Lesser General Public
17   License along with OGo; see the file COPYING.  If not, write to the
18   Free Software Foundation, 59 Temple Place - Suite 330, Boston, MA
19   02111-1307, USA.
20 */
21
22 #if LIB_FOUNDATION_LIBRARY
23 #error SOGo will not work properly with libFoundation. \
24        Please use gnustep-base instead.
25 #endif
26
27 #import <NGObjWeb/WEClientCapabilities.h>
28 #import <NGObjWeb/SoObject+SoDAV.h>
29 #import <NGObjWeb/WOContext.h>
30 #import <NGObjWeb/WOResponse.h>
31 #import <NGObjWeb/WORequest.h>
32 #import <NGObjWeb/WOApplication.h>
33 #import <NGCards/NSDictionary+NGCards.h>
34 #import <GDLContentStore/GCSFolder.h>
35
36 #import "common.h"
37
38 #import "NSArray+Utilities.h"
39 #import "NSString+Utilities.h"
40
41 #import "SOGoPermissions.h"
42 #import "SOGoUser.h"
43 #import "SOGoAuthenticator.h"
44 #import "SOGoUserFolder.h"
45
46 #import "SOGoDAVRendererTypes.h"
47
48 #import "SOGoObject.h"
49
50 NSString *SOGoDefaultUserID = @"<default>";
51
52 @interface SOGoObject(Content)
53 - (NSString *)contentAsString;
54 @end
55
56 @interface SoClassSecurityInfo (SOGoAcls)
57
58 + (id) defaultWebDAVPermissionsMap;
59
60 - (NSArray *) allPermissions;
61 - (NSArray *) allDAVPermissions;
62 - (NSArray *) DAVPermissionsForRole: (NSString *) role;
63 - (NSArray *) DAVPermissionsForRoles: (NSArray *) roles;
64
65 @end
66
67 @implementation SoClassSecurityInfo (SOGoAcls)
68
69 + (id) defaultWebDAVPermissionsMap
70 {
71   return [NSDictionary dictionaryWithObjectsAndKeys:
72                          @"read", SoPerm_AccessContentsInformation,
73                        @"read", SoPerm_View, 
74                        @"bind", SoPerm_AddDocumentsImagesAndFiles,
75                        @"unbind", SoPerm_DeleteObjects,
76                        @"write-acl", SoPerm_ChangePermissions,
77                        @"write-content", SoPerm_ChangeImagesAndFiles,
78                        @"read-free-busy", SOGoPerm_FreeBusyLookup,
79                        NULL];
80 }
81
82 - (NSArray *) allPermissions
83 {
84   return [defRoles allKeys];
85 }
86
87 - (NSArray *) allDAVPermissions
88 {
89   NSEnumerator *allPermissions;
90   NSMutableArray *davPermissions;
91   NSDictionary *davPermissionsMap;
92   NSString *sopePermission, *davPermission;
93
94   davPermissions = [NSMutableArray array];
95
96   davPermissionsMap = [[self class] defaultWebDAVPermissionsMap];
97   allPermissions = [[self allPermissions] objectEnumerator];
98   sopePermission = [allPermissions nextObject];
99   while (sopePermission)
100     {
101       davPermission = [davPermissionsMap objectForCaseInsensitiveKey: sopePermission];
102       if (davPermission && ![davPermissions containsObject: davPermission])
103         [davPermissions addObject: davPermission];
104       sopePermission = [allPermissions nextObject];
105     }
106
107   return davPermissions;
108 }
109
110 - (NSArray *) DAVPermissionsForRole: (NSString *) role
111 {
112   return [self DAVPermissionsForRoles: [NSArray arrayWithObject: role]];
113 }
114
115 - (NSArray *) DAVPermissionsForRoles: (NSArray *) roles
116 {
117   NSEnumerator *allPermissions;
118   NSMutableArray *davPermissions;
119   NSDictionary *davPermissionsMap;
120   NSString *sopePermission, *davPermission;
121
122   davPermissions = [NSMutableArray array];
123
124   davPermissionsMap = [[self class] defaultWebDAVPermissionsMap];
125   allPermissions = [[self allPermissions] objectEnumerator];
126   sopePermission = [allPermissions nextObject];
127   while (sopePermission)
128     {
129       if ([[defRoles objectForCaseInsensitiveKey: sopePermission]
130             firstObjectCommonWithArray: roles])
131         {
132           davPermission
133             = [davPermissionsMap objectForCaseInsensitiveKey: sopePermission];
134           if (davPermission
135               && ![davPermissions containsObject: davPermission])
136             [davPermissions addObject: davPermission];
137         }
138       sopePermission = [allPermissions nextObject];
139     }
140
141   return davPermissions;
142 }
143
144 @end
145
146 @implementation SOGoObject
147
148 static BOOL kontactGroupDAV = YES;
149
150 + (int)version {
151   return 0;
152 }
153
154 + (void) initialize
155 {
156   NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
157
158   kontactGroupDAV = 
159     [ud boolForKey:@"SOGoDisableKontact34GroupDAVHack"] ? NO : YES;
160
161 //   SoClass security declarations
162   
163 //   require View permission to access the root (bound to authenticated ...)
164   [[self soClassSecurityInfo] declareObjectProtected: SoPerm_View];
165
166 //   to allow public access to all contained objects (subkeys)
167   [[self soClassSecurityInfo] setDefaultAccess: @"allow"];
168   
169 //   /* require Authenticated role for View and WebDAV */
170 //   [[self soClassSecurityInfo] declareRole: SoRole_Owner
171 //                               asDefaultForPermission: SoPerm_View];
172 //   [[self soClassSecurityInfo] declareRole: SoRole_Owner
173 //                               asDefaultForPermission: SoPerm_WebDAVAccess];
174 }
175
176 + (void) _fillDictionary: (NSMutableDictionary *) dictionary
177           withDAVMethods: (NSString *) firstMethod, ...
178 {
179   va_list ap;
180   NSString *aclMethodName;
181   NSString *methodName;
182   SEL methodSel;
183
184   va_start (ap, firstMethod);
185   aclMethodName = firstMethod;
186   while (aclMethodName)
187     {
188       methodName = [aclMethodName davMethodToObjC];
189       methodSel = NSSelectorFromString (methodName);
190       if (methodSel && [self instancesRespondToSelector: methodSel])
191         [dictionary setObject: methodName
192                     forKey: [NSString stringWithFormat: @"{DAV:}%@",
193                                       aclMethodName]];
194       else
195         NSLog(@"************ method '%@' is still unimplemented!",
196               methodName);
197       aclMethodName = va_arg (ap, NSString *);
198     }
199
200   va_end (ap);
201 }
202
203 + (NSDictionary *) defaultWebDAVAttributeMap
204 {
205   static NSMutableDictionary *map = nil;
206
207   if (!map)
208     {
209       map = [NSMutableDictionary
210               dictionaryWithDictionary: [super defaultWebDAVAttributeMap]];
211       [map retain];
212       [self _fillDictionary: map
213             withDAVMethods: @"owner", @"group", @"supported-privilege-set",
214             @"current-user-privilege-set", @"acl", @"acl-restrictions",
215             @"inherited-acl-set", @"principal-collection-set", nil];
216     }
217
218   return map;
219 }
220
221 /* containment */
222
223 + (id) objectWithName: (NSString *)_name inContainer:(id)_container
224 {
225   SOGoObject *object;
226
227   object = [[self alloc] initWithName: _name inContainer: _container];
228   [object autorelease];
229
230   return object;
231 }
232
233 /* DAV ACL properties */
234 - (NSString *) davOwner
235 {
236   return [NSString stringWithFormat: @"%@users/%@",
237                    [self rootURLInContext: context],
238                    [self ownerInContext: nil]];
239 }
240
241 - (NSString *) davAclRestrictions
242 {
243   NSMutableString *restrictions;
244
245   restrictions = [NSMutableString string];
246   [restrictions appendString: @"<D:grant-only/>"];
247   [restrictions appendString: @"<D:no-invert/>"];
248
249   return restrictions;
250 }
251
252 - (SOGoDAVSet *) davPrincipalCollectionSet
253 {
254   NSString *usersUrl;
255
256   usersUrl = [NSString stringWithFormat: @"%@users",
257                        [self rootURLInContext: context]];
258
259   return [SOGoDAVSet davSetWithArray: [NSArray arrayWithObject: usersUrl]
260                      ofValuesTaggedAs: @"D:href"];
261 }
262
263 - (SOGoDAVSet *) davCurrentUserPrivilegeSet
264 {
265   SOGoAuthenticator *sAuth;
266   SoUser *user;
267   NSArray *roles;
268   SoClassSecurityInfo *sInfo;
269   NSArray *davPermissions;
270
271   sAuth = [SOGoAuthenticator sharedSOGoAuthenticator];
272   user = [sAuth userInContext: context];
273   roles = [user rolesForObject: self inContext: context];
274   sInfo = [[self class] soClassSecurityInfo];
275
276   davPermissions
277     = [[sInfo DAVPermissionsForRoles: roles] stringsWithFormat: @"<D:%@/>"];
278
279   return [SOGoDAVSet davSetWithArray: davPermissions
280                      ofValuesTaggedAs: @"D:privilege"];
281 }
282
283 - (SOGoDAVSet *) davSupportedPrivilegeSet
284 {
285   SoClassSecurityInfo *sInfo;
286   NSArray *allPermissions;
287
288   sInfo = [[self class] soClassSecurityInfo];
289
290   allPermissions = [[sInfo allDAVPermissions] stringsWithFormat: @"<D:%@/>"];
291
292   return [SOGoDAVSet davSetWithArray: allPermissions
293                      ofValuesTaggedAs: @"D:privilege"];
294 }
295
296 - (NSArray *) _davAcesFromAclsDictionary: (NSDictionary *) aclsDictionary
297 {
298   NSEnumerator *keys;
299   NSArray *privileges;
300   NSMutableString *currentAce;
301   NSMutableArray *davAces;
302   NSString *currentKey, *principal;
303   SOGoDAVSet *privilegesDS;
304
305   davAces = [NSMutableArray array];
306   keys = [[aclsDictionary allKeys] objectEnumerator];
307   currentKey = [keys nextObject];
308   while (currentKey)
309     {
310       currentAce = [NSMutableString string];
311       if ([currentKey hasPrefix: @":"])
312         [currentAce
313           appendFormat: @"<D:principal><D:property><D:%@/></D:property></D:principal>",
314           [currentKey substringFromIndex: 1]];
315       else
316         {
317           principal = [NSString stringWithFormat: @"%@users/%@",
318                                 [self rootURLInContext: context],
319                                 currentKey];
320           [currentAce
321             appendFormat: @"<D:principal><D:href>%@</D:href></D:principal>",
322             principal];
323         }
324
325       privileges = [[aclsDictionary objectForKey: currentKey]
326                      stringsWithFormat: @"<D:%@/>"];
327       privilegesDS = [SOGoDAVSet davSetWithArray: privileges
328                                  ofValuesTaggedAs: @"privilege"];
329       [currentAce appendString: [privilegesDS stringForTag: @"{DAV:}grant"
330                                               rawName: @"grant"
331                                               inContext: nil prefixes: nil]];
332       [davAces addObject: currentAce];
333       currentKey = [keys nextObject];
334     }
335
336   return davAces;
337 }
338
339 - (void) _appendRolesForPseudoPrincipals: (NSMutableDictionary *) aclsDictionary
340                    withClassSecurityInfo: (SoClassSecurityInfo *) sInfo
341 {
342   NSArray *perms;
343
344   perms = [sInfo DAVPermissionsForRole: SoRole_Owner];
345   if ([perms count])
346     [aclsDictionary setObject: perms forKey: @":owner"];
347   perms = [sInfo DAVPermissionsForRole: SoRole_Authenticated];
348   if ([perms count])
349     [aclsDictionary setObject: perms forKey: @":authenticated"];
350   perms = [sInfo DAVPermissionsForRole: SoRole_Anonymous];
351   if ([perms count])
352     [aclsDictionary setObject: perms forKey: @":unauthenticated"];
353 }
354
355 - (SOGoDAVSet *) davAcl
356 {
357   NSArray *role;
358   NSEnumerator *acls;
359   NSMutableDictionary *aclsDictionary;
360   NSDictionary *currentAcl;
361   SoClassSecurityInfo *sInfo;
362
363   acls = [[self acls] objectEnumerator];
364   aclsDictionary = [NSMutableDictionary dictionary];
365   sInfo = [[self class] soClassSecurityInfo];
366
367   currentAcl = [acls nextObject];
368   while (currentAcl)
369     {
370       role = [NSArray arrayWithObject: [currentAcl objectForKey: @"role"]];
371       [aclsDictionary setObject: [sInfo DAVPermissionsForRoles: role]
372                       forKey: [currentAcl objectForKey: @"uid"]];
373       currentAcl = [acls nextObject];
374     }
375   [self _appendRolesForPseudoPrincipals: aclsDictionary
376         withClassSecurityInfo: sInfo];
377
378   return [SOGoDAVSet davSetWithArray:
379                        [self _davAcesFromAclsDictionary: aclsDictionary]
380                      ofValuesTaggedAs: @"D:ace"];
381 }
382
383 /* end of properties */
384
385 - (BOOL) doesRetainContainer
386 {
387   return YES;
388 }
389
390 - (id)initWithName:(NSString *)_name inContainer:(id)_container {
391   if ((self = [self init]))
392     {
393       context = [[WOApplication application] context];
394       [context retain];
395       nameInContainer = [_name copy];
396       container = 
397         [self doesRetainContainer] ? [_container retain] : _container;
398       customOwner = nil;
399     }
400
401   return self;
402 }
403
404 - (id) init
405 {
406   if ((self = [super init]))
407     {
408       nameInContainer = nil;
409       container = nil;
410     }
411
412   return self;
413 }
414
415 - (void)dealloc {
416   [context release];
417   [customOwner release];
418   if ([self doesRetainContainer])
419     [container release];
420   [nameInContainer release];
421   [super dealloc];
422 }
423
424 /* accessors */
425
426 - (NSString *)nameInContainer {
427   return nameInContainer;
428 }
429 - (id)container {
430   return container;
431 }
432
433 /* ownership */
434
435 - (void) setOwner: (NSString *) newOwner
436 {
437   ASSIGN (customOwner, newOwner);
438 }
439
440 - (NSString *)ownerInContext:(id)_ctx {
441   return ((customOwner)
442           ? customOwner
443           : [[self container] ownerInContext:_ctx]);
444 }
445
446 /* hierarchy */
447
448 - (NSArray *)fetchSubfolders {
449   NSMutableArray *ma;
450   NSArray  *names;
451   unsigned i, count;
452   
453   if ((names = [self toManyRelationshipKeys]) == nil)
454     return nil;
455   
456   count = [names count];
457   ma    = [NSMutableArray arrayWithCapacity:count + 1];
458   for (i = 0; i < count; i++) {
459     id folder;
460
461     folder = [self lookupName: [names objectAtIndex:i]
462                    inContext: nil 
463                    acquire: NO];
464     if (folder == nil)
465       continue;
466     if ([folder isKindOfClass:[NSException class]])
467       continue;
468     
469     [ma addObject:folder];
470   }
471   return ma;
472 }
473
474 /* looking up shared objects */
475
476 - (SOGoUserFolder *)lookupUserFolder {
477   if (![container respondsToSelector:_cmd])
478     return nil;
479   
480   return [container lookupUserFolder];
481 }
482 - (SOGoGroupsFolder *)lookupGroupsFolder {
483   return [[self lookupUserFolder] lookupGroupsFolder];
484 }
485
486 - (void)sleep {
487   if ([self doesRetainContainer])
488     [container release];
489   container = nil;
490 }
491
492 /* operations */
493
494 - (NSException *)delete {
495   return [NSException exceptionWithHTTPStatus:501 /* not implemented */
496                       reason:@"delete not yet implemented, sorry ..."];
497 }
498
499 /* KVC hacks */
500
501 - (id)valueForUndefinedKey:(NSString *)_key {
502   return nil;
503 }
504
505 /* WebDAV */
506
507 - (NSString *)davDisplayName {
508   return [self nameInContainer];
509 }
510
511 /* actions */
512
513 - (id)DELETEAction:(id)_ctx {
514   NSException *error;
515
516   if ((error = [self delete]) != nil)
517     return error;
518   
519   /* Note: returning 'nil' breaks in SoObjectRequestHandler */
520   return [NSNumber numberWithBool:YES]; /* delete worked out ... */
521 }
522
523 - (id)GETAction:(id)_ctx {
524   // TODO: I guess this should really be done by SOPE (redirect to
525   //       default method)
526   WORequest  *rq;
527   WOResponse *r;
528   NSString *uri;
529   
530   r  = [(WOContext *)_ctx response];
531   rq = [(WOContext *)_ctx request];
532   
533   if ([rq isSoWebDAVRequest]) {
534     if ([self respondsToSelector:@selector(contentAsString)]) {
535       NSException *error;
536       id etag;
537       
538       if ((error = [self matchesRequestConditionInContext:_ctx]) != nil)
539         return error;
540       
541       [r appendContentString:[self contentAsString]];
542       
543       if ((etag = [self davEntityTag]) != nil)
544         [r setHeader:etag forKey:@"etag"];
545
546       return r;
547     }
548     
549     return [NSException exceptionWithHTTPStatus:501 /* not implemented */
550                         reason:@"no WebDAV GET support?!"];
551   }
552   
553   uri = [rq uri];
554   [r setStatus:302 /* moved */];
555   [r setHeader: [uri composeURLWithAction: @"view"
556                      parameters: [rq formValues]
557                      andHash: NO]
558      forKey:@"location"];
559
560   return r;
561 }
562
563 /* etag support */
564
565 - (NSArray *)parseETagList:(NSString *)_c {
566   NSMutableArray *ma;
567   NSArray  *etags;
568   unsigned i, count;
569   
570   if ([_c length] == 0)
571     return nil;
572   if ([_c isEqualToString:@"*"])
573     return nil;
574   
575   etags = [_c componentsSeparatedByString:@","];
576   count = [etags count];
577   ma    = [NSMutableArray arrayWithCapacity:count];
578   for (i = 0; i < count; i++) {
579     NSString *etag;
580     
581     etag = [[etags objectAtIndex:i] stringByTrimmingSpaces];
582 #if 0 /* this is non-sense, right? */
583     if ([etag hasPrefix:@"\""] && [etag hasSuffix:@"\""])
584       etag = [etag substringWithRange:NSMakeRange(1, [etag length] - 2)];
585 #endif
586     
587     if (etag != nil) [ma addObject:etag];
588   }
589   return ma;
590 }
591
592 - (NSException *)checkIfMatchCondition:(NSString *)_c inContext:(id)_ctx {
593   /* 
594      Only run the request if one of the etags matches the resource etag,
595      usually used to ensure consistent PUTs.
596   */
597   NSArray  *etags;
598   NSString *etag;
599   
600   if ([_c isEqualToString:@"*"])
601     /* to ensure that the resource exists! */
602     return nil;
603   
604   if ((etags = [self parseETagList:_c]) == nil)
605     return nil;
606   if ([etags count] == 0) /* no etags to check for? */
607     return nil;
608   
609   etag = [self davEntityTag];
610   if ([etag length] == 0) /* has no etag, ignore */
611     return nil;
612   
613   if ([etags containsObject:etag]) {
614     [self debugWithFormat:@"etag '%@' matches: %@", etag, 
615           [etags componentsJoinedByString:@","]];
616     return nil; /* one etag matches, so continue with request */
617   }
618
619   /* hack for Kontact 3.4 */
620   
621   if (kontactGroupDAV) {
622     WEClientCapabilities *cc;
623     
624     cc = [[(WOContext *)_ctx request] clientCapabilities];
625     if ([[cc userAgentType] isEqualToString:@"Konqueror"]) {
626       if ([cc majorVersion] == 3 && [cc minorVersion] == 4) {
627         [self logWithFormat:
628                 @"WARNING: applying Kontact 3.4 GroupDAV hack"
629                 @" - etag check is disabled!"
630                 @" (can be enabled using 'ZSDisableKontact34GroupDAVHack')"];
631         return nil;
632       }
633     }
634   }
635   
636   // TODO: we might want to return the davEntityTag in the response
637   [self debugWithFormat:@"etag '%@' does not match: %@", etag, 
638         [etags componentsJoinedByString:@","]];
639   return [NSException exceptionWithHTTPStatus:412 /* Precondition Failed */
640                       reason:@"Precondition Failed"];
641 }
642
643 - (NSException *)checkIfNoneMatchCondition:(NSString *)_c inContext:(id)_ctx {
644   /*
645     If one of the etags is still the same, we can ignore the request.
646     
647     Can be used for PUT to ensure that the object does not exist in the store
648     and for GET to retrieve the content only if if the etag changed.
649   */
650   
651   if (![_c isEqualToString:@"*"] && 
652       [[[_ctx request] method] isEqualToString:@"GET"]) {
653     NSString *etag;
654     NSArray  *etags;
655     
656     if ((etags = [self parseETagList:_c]) == nil)
657       return nil;
658     if ([etags count] == 0) /* no etags to check for? */
659       return nil;
660     
661     etag = [self davEntityTag];
662     if ([etag length] == 0) /* has no etag, ignore */
663       return nil;
664     
665     if ([etags containsObject:etag]) {
666       [self debugWithFormat:@"etag '%@' matches: %@", etag, 
667               [etags componentsJoinedByString:@","]];
668       /* one etag matches, so stop the request */
669       return [NSException exceptionWithHTTPStatus:304 /* Not Modified */
670                           reason:@"object was not modified"];
671     }
672     
673     return nil;
674   }
675   
676 #if 0
677   if ([_c isEqualToString:@"*"])
678     return nil;
679   
680   if ((a = [self parseETagList:_c]) == nil)
681     return nil;
682 #else
683   [self logWithFormat:@"TODO: implement if-none-match for etag: '%@'", _c];
684 #endif
685   return nil;
686 }
687
688 - (NSException *)matchesRequestConditionInContext:(id)_ctx {
689   NSException *error;
690   WORequest *rq;
691   NSString  *c;
692   
693   if ((rq = [(WOContext *)_ctx request]) == nil)
694     return nil; /* be tolerant - no request, no condition */
695   
696   if ((c = [rq headerForKey:@"if-match"]) != nil) {
697     if ((error = [self checkIfMatchCondition:c inContext:_ctx]) != nil)
698       return error;
699   }
700   if ((c = [rq headerForKey:@"if-none-match"]) != nil) {
701     if ((error = [self checkIfNoneMatchCondition:c inContext:_ctx]) != nil)
702       return error;
703   }
704   
705   return nil;
706 }
707
708 /* acls */
709
710 - (NSArray *) acls
711 {
712   [self subclassResponsibility: _cmd];
713
714   return nil;
715 }
716
717 - (NSArray *) aclsForUser: (NSString *) uid
718 {
719   [self subclassResponsibility: _cmd];
720
721   return nil;
722 }
723
724 - (NSArray *) defaultAclRoles
725 {
726   [self subclassResponsibility: _cmd];
727
728   return nil;
729 }
730
731 - (void) setRoles: (NSArray *) roles
732           forUser: (NSString *) uid
733 {
734   [self subclassResponsibility: _cmd];
735 }
736
737 - (void) removeAclsForUsers: (NSArray *) users
738 {
739   [self subclassResponsibility: _cmd];
740 }
741
742 /* description */
743
744 - (void)appendAttributesToDescription:(NSMutableString *)_ms {
745   if (nameInContainer) 
746     [_ms appendFormat:@" name=%@", nameInContainer];
747   if (container)
748     [_ms appendFormat:@" container=0x%08X/%@", 
749          container, [container valueForKey:@"nameInContainer"]];
750 }
751
752 - (NSString *)description {
753   NSMutableString *ms;
754
755   ms = [NSMutableString stringWithCapacity:64];
756   [ms appendFormat:@"<0x%08X[%@]:", self, NSStringFromClass([self class])];
757   [self appendAttributesToDescription:ms];
758   [ms appendString:@">"];
759
760   return ms;
761 }
762
763 @end /* SOGoObject */