Mercurial > hg > AnnotationManagerN4J
annotate src/main/java/de/mpiwg/itgroup/annotations/restlet/AnnotatorResourceImpl.java @ 14:629e15b345aa
permissions mostly work. need more server-side checking.
author | casties |
---|---|
date | Fri, 13 Jul 2012 20:41:02 +0200 |
parents | 90911b2da322 |
children | 58357a4b86de |
rev | line source |
---|---|
3 | 1 /** |
2 * Base class for Annotator resource classes. | |
3 */ | |
4 package de.mpiwg.itgroup.annotations.restlet; | |
5 | |
6 import java.io.UnsupportedEncodingException; | |
7 import java.security.InvalidKeyException; | |
8 import java.security.SignatureException; | |
5 | 9 import java.text.SimpleDateFormat; |
3 | 10 import java.util.ArrayList; |
5 | 11 import java.util.Calendar; |
3 | 12 import java.util.List; |
13 import java.util.regex.Matcher; | |
14 import java.util.regex.Pattern; | |
15 | |
4 | 16 import javax.servlet.ServletContext; |
17 | |
3 | 18 import net.oauth.jsontoken.Checker; |
19 import net.oauth.jsontoken.JsonToken; | |
20 import net.oauth.jsontoken.JsonTokenParser; | |
21 import net.oauth.jsontoken.SystemClock; | |
22 import net.oauth.jsontoken.crypto.HmacSHA256Verifier; | |
23 import net.oauth.jsontoken.crypto.Verifier; | |
24 | |
25 import org.apache.commons.codec.binary.Base64; | |
26 import org.apache.log4j.Logger; | |
27 import org.json.JSONArray; | |
28 import org.json.JSONException; | |
29 import org.json.JSONObject; | |
30 import org.restlet.data.Form; | |
31 import org.restlet.data.Status; | |
32 import org.restlet.representation.Representation; | |
33 import org.restlet.resource.Options; | |
34 import org.restlet.resource.ServerResource; | |
35 | |
9 | 36 import de.mpiwg.itgroup.annotations.Actor; |
4 | 37 import de.mpiwg.itgroup.annotations.Annotation; |
38 import de.mpiwg.itgroup.annotations.Annotation.FragmentTypes; | |
10 | 39 import de.mpiwg.itgroup.annotations.Group; |
40 import de.mpiwg.itgroup.annotations.Person; | |
4 | 41 import de.mpiwg.itgroup.annotations.neo4j.AnnotationStore; |
42 import de.mpiwg.itgroup.annotations.old.NS; | |
3 | 43 |
44 /** | |
45 * Base class for Annotator resource classes. | |
46 * | |
47 * @author dwinter, casties | |
48 * | |
49 */ | |
50 public abstract class AnnotatorResourceImpl extends ServerResource { | |
51 | |
4 | 52 protected static Logger logger = Logger.getLogger(AnnotatorResourceImpl.class); |
53 | |
54 private AnnotationStore store; | |
3 | 55 |
56 protected String getAllowedMethodsForHeader() { | |
57 return "OPTIONS,GET,POST"; | |
58 } | |
59 | |
4 | 60 protected AnnotationStore getAnnotationStore() { |
61 if (store == null) { | |
62 ServletContext sc = (ServletContext) getContext().getServerDispatcher().getContext().getAttributes() | |
63 .get("org.restlet.ext.servlet.ServletContext"); | |
64 logger.debug("Getting AnnotationStore from Context"); | |
65 store = (AnnotationStore) sc.getAttribute(RestServer.ANNSTORE_KEY); | |
66 } | |
67 return store; | |
68 } | |
69 | |
3 | 70 public String encodeJsonId(String id) { |
71 try { | |
72 return Base64.encodeBase64URLSafeString(id.getBytes("UTF-8")); | |
73 } catch (UnsupportedEncodingException e) { | |
74 return null; | |
75 } | |
76 } | |
77 | |
78 public String decodeJsonId(String id) { | |
79 try { | |
80 return new String(Base64.decodeBase64(id), "UTF-8"); | |
81 } catch (UnsupportedEncodingException e) { | |
82 return null; | |
83 } | |
84 } | |
85 | |
86 /** | |
87 * Handle options request to allow CORS for AJAX. | |
88 * | |
89 * @param entity | |
90 */ | |
91 @Options | |
92 public void doOptions(Representation entity) { | |
93 logger.debug("AnnotatorResourceImpl doOptions!"); | |
94 setCorsHeaders(); | |
95 } | |
96 | |
97 /** | |
98 * set headers to allow CORS for AJAX. | |
99 */ | |
100 protected void setCorsHeaders() { | |
101 Form responseHeaders = (Form) getResponse().getAttributes().get("org.restlet.http.headers"); | |
102 if (responseHeaders == null) { | |
103 responseHeaders = new Form(); | |
104 getResponse().getAttributes().put("org.restlet.http.headers", responseHeaders); | |
105 } | |
106 responseHeaders.add("Access-Control-Allow-Methods", getAllowedMethodsForHeader()); | |
107 // echo back Origin and Request-Headers | |
108 Form requestHeaders = (Form) getRequest().getAttributes().get("org.restlet.http.headers"); | |
109 String origin = requestHeaders.getFirstValue("Origin", true); | |
110 if (origin == null) { | |
111 responseHeaders.add("Access-Control-Allow-Origin", "*"); | |
112 } else { | |
113 responseHeaders.add("Access-Control-Allow-Origin", origin); | |
114 } | |
115 String allowHeaders = requestHeaders.getFirstValue("Access-Control-Request-Headers", true); | |
116 if (allowHeaders != null) { | |
117 responseHeaders.add("Access-Control-Allow-Headers", allowHeaders); | |
118 } | |
119 responseHeaders.add("Access-Control-Allow-Credentials", "true"); | |
120 responseHeaders.add("Access-Control-Max-Age", "60"); | |
121 } | |
122 | |
123 /** | |
124 * returns if authentication information from headers is valid. | |
125 * | |
126 * @param entity | |
127 * @return | |
128 */ | |
129 public boolean isAuthenticated(Representation entity) { | |
130 return (checkAuthToken(entity) != null); | |
131 } | |
132 | |
133 /** | |
4 | 134 * checks Annotator Auth plugin authentication information from headers. |
135 * returns userId if successful. | |
3 | 136 * |
137 * @param entity | |
138 * @return | |
139 */ | |
140 public String checkAuthToken(Representation entity) { | |
141 Form requestHeaders = (Form) getRequest().getAttributes().get("org.restlet.http.headers"); | |
142 String authToken = requestHeaders.getFirstValue("x-annotator-auth-token", true); | |
14
629e15b345aa
permissions mostly work. need more server-side checking.
casties
parents:
10
diff
changeset
|
143 if (authToken == null) return null; |
3 | 144 // decode token first to get consumer key |
145 JsonToken token = new JsonTokenParser(null, null).deserialize(authToken); | |
146 String userId = token.getParamAsPrimitive("userId").getAsString(); | |
147 String consumerKey = token.getParamAsPrimitive("consumerKey").getAsString(); | |
148 // get stored consumer secret for key | |
149 RestServer restServer = (RestServer) getApplication(); | |
150 String consumerSecret = restServer.getConsumerSecret(consumerKey); | |
151 logger.debug("requested consumer key=" + consumerKey + " secret=" + consumerSecret); | |
152 if (consumerSecret == null) { | |
153 return null; | |
154 } | |
155 // logger.debug(String.format("token=%s tokenString=%s signatureAlgorithm=%s",token,token.getTokenString(),token.getSignatureAlgorithm())); | |
156 try { | |
157 List<Verifier> verifiers = new ArrayList<Verifier>(); | |
158 // we only do HS256 yet | |
159 verifiers.add(new HmacSHA256Verifier(consumerSecret.getBytes("UTF-8"))); | |
160 // verify token signature(should really be static...) | |
161 new JsonTokenParser(new SystemClock(), null, (Checker[]) null).verify(token, verifiers); | |
162 } catch (SignatureException e) { | |
163 // TODO Auto-generated catch block | |
164 e.printStackTrace(); | |
165 } catch (InvalidKeyException e) { | |
166 // TODO Auto-generated catch block | |
167 e.printStackTrace(); | |
168 } catch (UnsupportedEncodingException e) { | |
169 // TODO Auto-generated catch block | |
170 e.printStackTrace(); | |
171 } | |
172 // must be ok then | |
173 logger.debug("auth OK! user=" + userId); | |
174 return userId; | |
175 } | |
176 | |
177 /** | |
178 * creates Annotator-JSON from an Annotation object. | |
179 * | |
180 * @param annot | |
14
629e15b345aa
permissions mostly work. need more server-side checking.
casties
parents:
10
diff
changeset
|
181 * @param forAnonymous TODO |
3 | 182 * @return |
183 */ | |
14
629e15b345aa
permissions mostly work. need more server-side checking.
casties
parents:
10
diff
changeset
|
184 public JSONObject createAnnotatorJson(Annotation annot, boolean forAnonymous) { |
5 | 185 // return user as a JSON object (otherwise just as string) |
3 | 186 boolean makeUserObject = true; |
187 JSONObject jo = new JSONObject(); | |
188 try { | |
4 | 189 jo.put("text", annot.getBodyText()); |
190 jo.put("uri", annot.getTargetBaseUri()); | |
3 | 191 |
192 if (makeUserObject) { | |
193 // create user object | |
194 JSONObject userObject = new JSONObject(); | |
10 | 195 Actor creator = annot.getCreator(); |
3 | 196 // save creator as uri |
10 | 197 userObject.put("uri", creator.getUri()); |
3 | 198 // make short user id |
10 | 199 String userId = creator.getIdString(); |
9 | 200 // set as id |
4 | 201 userObject.put("id", userId); |
3 | 202 // get full name |
10 | 203 String userName = creator.getName(); |
5 | 204 if (userName == null) { |
205 RestServer restServer = (RestServer) getApplication(); | |
206 userName = restServer.getFullNameFromLdap(userId); | |
207 } | |
3 | 208 userObject.put("name", userName); |
209 // save user object | |
210 jo.put("user", userObject); | |
211 } else { | |
212 // save user as string | |
4 | 213 jo.put("user", annot.getCreatorUri()); |
3 | 214 } |
215 | |
4 | 216 if (annot.getTargetFragment() != null) { |
3 | 217 // we only look at the first xpointer |
4 | 218 List<String> fragments = new ArrayList<String>(); |
219 fragments.add(annot.getTargetFragment()); | |
220 FragmentTypes xt = annot.getFragmentType(); | |
221 if (xt == FragmentTypes.XPOINTER) { | |
222 jo.put("ranges", transformToRanges(fragments)); | |
223 } else if (xt == FragmentTypes.AREA) { | |
224 jo.put("areas", transformToAreas(fragments)); | |
3 | 225 } |
226 } | |
10 | 227 |
228 // permissions | |
229 JSONObject perms = new JSONObject(); | |
230 jo.put("permissions", perms); | |
231 // admin | |
232 JSONArray adminPerms = new JSONArray(); | |
233 perms.put("admin", adminPerms); | |
234 Actor adminPerm = annot.getAdminPermission(); | |
235 if (adminPerm != null) { | |
236 adminPerms.put(adminPerm.getIdString()); | |
14
629e15b345aa
permissions mostly work. need more server-side checking.
casties
parents:
10
diff
changeset
|
237 } else if (forAnonymous) { |
629e15b345aa
permissions mostly work. need more server-side checking.
casties
parents:
10
diff
changeset
|
238 // set something because its not allowed for anonymous |
629e15b345aa
permissions mostly work. need more server-side checking.
casties
parents:
10
diff
changeset
|
239 adminPerms.put("not-you"); |
10 | 240 } |
241 // delete | |
242 JSONArray deletePerms = new JSONArray(); | |
243 perms.put("delete", deletePerms); | |
244 Actor deletePerm = annot.getDeletePermission(); | |
245 if (deletePerm != null) { | |
246 deletePerms.put(deletePerm.getIdString()); | |
14
629e15b345aa
permissions mostly work. need more server-side checking.
casties
parents:
10
diff
changeset
|
247 } else if (forAnonymous) { |
629e15b345aa
permissions mostly work. need more server-side checking.
casties
parents:
10
diff
changeset
|
248 // set something because its not allowed for anonymous |
629e15b345aa
permissions mostly work. need more server-side checking.
casties
parents:
10
diff
changeset
|
249 deletePerms.put("not-you"); |
10 | 250 } |
251 // update | |
252 JSONArray updatePerms = new JSONArray(); | |
253 perms.put("update", updatePerms); | |
254 Actor updatePerm = annot.getUpdatePermission(); | |
255 if (updatePerm != null) { | |
256 updatePerms.put(updatePerm.getIdString()); | |
14
629e15b345aa
permissions mostly work. need more server-side checking.
casties
parents:
10
diff
changeset
|
257 } else if (forAnonymous) { |
629e15b345aa
permissions mostly work. need more server-side checking.
casties
parents:
10
diff
changeset
|
258 // set something because its not allowed for anonymous |
629e15b345aa
permissions mostly work. need more server-side checking.
casties
parents:
10
diff
changeset
|
259 updatePerms.put("not-you"); |
10 | 260 } |
261 // read | |
262 JSONArray readPerms = new JSONArray(); | |
263 perms.put("read", readPerms); | |
264 Actor readPerm = annot.getReadPermission(); | |
265 if (readPerm != null) { | |
266 readPerms.put(readPerm.getIdString()); | |
267 } | |
268 | |
3 | 269 // encode Annotation URL (=id) in base64 |
4 | 270 String annotUrl = annot.getUri(); |
3 | 271 String annotId = encodeJsonId(annotUrl); |
272 jo.put("id", annotId); | |
273 return jo; | |
274 } catch (JSONException e) { | |
275 // TODO Auto-generated catch block | |
276 e.printStackTrace(); | |
277 } | |
278 return null; | |
279 } | |
280 | |
281 private JSONArray transformToRanges(List<String> xpointers) { | |
282 | |
283 JSONArray ja = new JSONArray(); | |
284 | |
285 Pattern rg = Pattern | |
4 | 286 .compile("xpointer\\(start-point\\(string-range\\(\"([^\"]*)\",([^,]*),1\\)\\)/range-to\\(end-point\\(string-range\\(\"([^\"]*)\",([^,]*),1\\)\\)\\)\\)"); |
287 Pattern rg1 = Pattern.compile("xpointer\\(start-point\\(string-range\\(\"([^\"]*)\",([^,]*),1\\)\\)\\)"); | |
3 | 288 |
289 try { | |
290 for (String xpointer : xpointers) { | |
10 | 291 // String decoded = URLDecoder.decode(xpointer, "utf-8"); |
5 | 292 String decoded = xpointer; |
3 | 293 Matcher m = rg.matcher(decoded); |
294 | |
295 if (m.find()) { | |
296 { | |
297 JSONObject jo = new JSONObject(); | |
298 jo.put("start", m.group(1)); | |
299 jo.put("startOffset", m.group(2)); | |
300 jo.put("end", m.group(3)); | |
301 jo.put("endOffset", m.group(4)); | |
302 ja.put(jo); | |
303 } | |
304 } | |
305 m = rg1.matcher(xpointer); | |
306 if (m.find()) { | |
307 JSONObject jo = new JSONObject(); | |
308 jo.put("start", m.group(1)); | |
309 jo.put("startOffset", m.group(2)); | |
310 | |
311 ja.put(jo); | |
312 } | |
313 } | |
314 } catch (JSONException e) { | |
315 // TODO Auto-generated catch block | |
316 e.printStackTrace(); | |
317 } | |
318 return ja; | |
319 } | |
320 | |
321 private JSONArray transformToAreas(List<String> xpointers) { | |
322 | |
323 JSONArray ja = new JSONArray(); | |
324 | |
4 | 325 Pattern rg = Pattern.compile("xywh=(\\w*:)([\\d\\.]+),([\\d\\.]+),([\\d\\.]+),([\\d\\.]+)"); |
3 | 326 |
327 try { | |
328 for (String xpointer : xpointers) { | |
10 | 329 // String decoded = URLDecoder.decode(xpointer, "utf-8"); |
5 | 330 String decoded = xpointer; |
3 | 331 Matcher m = rg.matcher(decoded); |
332 | |
333 if (m.find()) { | |
334 { | |
335 JSONObject jo = new JSONObject(); | |
10 | 336 @SuppressWarnings("unused") |
3 | 337 String unit = m.group(1); |
338 jo.put("x", m.group(2)); | |
339 jo.put("y", m.group(3)); | |
340 jo.put("width", m.group(4)); | |
341 jo.put("height", m.group(5)); | |
342 ja.put(jo); | |
343 } | |
344 } | |
345 } | |
346 } catch (JSONException e) { | |
347 // TODO Auto-generated catch block | |
348 e.printStackTrace(); | |
349 } | |
350 return ja; | |
351 } | |
352 | |
5 | 353 protected String parseArea(JSONObject area) throws JSONException { |
4 | 354 String x = area.getString("x"); |
355 String y = area.getString("y"); | |
356 String width = "0"; | |
357 String height = "0"; | |
358 if (area.has("width")) { | |
359 width = area.getString("width"); | |
360 height = area.getString("height"); | |
361 } | |
5 | 362 String fragment = String.format("xywh=fraction:%s,%s,%s,%s", x, y, width, height); |
4 | 363 return fragment; |
364 } | |
365 | |
5 | 366 protected String parseRange(JSONObject range) throws JSONException { |
4 | 367 String start = range.getString("start"); |
368 String end = range.getString("end"); | |
369 String startOffset = range.getString("startOffset"); | |
370 String endOffset = range.getString("endOffset"); | |
371 | |
5 | 372 String fragment = String.format( |
4 | 373 "xpointer(start-point(string-range(\"%s\",%s,1))/range-to(end-point(string-range(\"%s\",%s,1))))", start, |
5 | 374 startOffset, end, endOffset); |
4 | 375 return fragment; |
376 } | |
377 | |
3 | 378 /** |
5 | 379 * Creates an Annotation object with data from JSON. |
3 | 380 * |
4 | 381 * uses the specification from the annotator project: {@link https |
382 * ://github.com/okfn/annotator/wiki/Annotation-format} | |
3 | 383 * |
4 | 384 * The username will be transformed to an URI if not given already as URI, |
385 * if not it will set to the MPIWG namespace defined in | |
3 | 386 * de.mpiwg.itgroup.annotationManager.Constants.NS |
387 * | |
388 * @param jo | |
389 * @return | |
390 * @throws JSONException | |
4 | 391 * @throws UnsupportedEncodingException |
3 | 392 */ |
4 | 393 public Annotation createAnnotation(JSONObject jo, Representation entity) throws JSONException, UnsupportedEncodingException { |
3 | 394 return updateAnnotation(new Annotation(), jo, entity); |
395 } | |
396 | |
5 | 397 /** |
398 * Updates an Annotation object with data from JSON. | |
399 * | |
400 * uses the specification from the annotator project: {@link https | |
401 * ://github.com/okfn/annotator/wiki/Annotation-format} | |
402 * | |
403 * The username will be transformed to an URI if not given already as URI, | |
404 * if not it will set to the MPIWG namespace defined in | |
405 * de.mpiwg.itgroup.annotationManager.Constants.NS | |
406 * | |
407 * @param annot | |
408 * @param jo | |
409 * @return | |
410 * @throws JSONException | |
411 * @throws UnsupportedEncodingException | |
412 */ | |
4 | 413 public Annotation updateAnnotation(Annotation annot, JSONObject jo, Representation entity) throws JSONException, |
414 UnsupportedEncodingException { | |
5 | 415 // target uri |
3 | 416 if (jo.has("uri")) { |
4 | 417 annot.setTargetBaseUri(jo.getString("uri")); |
3 | 418 } |
419 // annotation text | |
420 if (jo.has("text")) { | |
4 | 421 annot.setBodyText(jo.getString("text")); |
3 | 422 } |
423 // check authentication | |
424 String authUser = checkAuthToken(entity); | |
425 if (authUser == null) { | |
4 | 426 /* |
427 * // try http auth User httpUser = getHttpAuthUser(entity); if | |
428 * (httpUser == null) { | |
429 */ | |
430 setStatus(Status.CLIENT_ERROR_FORBIDDEN); | |
431 return null; | |
432 /* | |
433 * } authUser = httpUser.getIdentifier(); | |
434 */ | |
3 | 435 } |
9 | 436 // get or create creator object |
437 Actor creator = annot.getCreator(); | |
438 if (creator == null) { | |
10 | 439 creator = new Person(); |
9 | 440 annot.setCreator(creator); |
441 } | |
3 | 442 // username not required, if no username given authuser will be used |
443 String username = null; | |
10 | 444 String userUri = creator.getUri(); |
3 | 445 if (jo.has("user")) { |
446 if (jo.get("user") instanceof String) { | |
447 // user is just a String | |
448 username = jo.getString("user"); | |
10 | 449 creator.setId(username); |
3 | 450 // TODO: what if username and authUser are different? |
451 } else { | |
452 // user is an object | |
453 JSONObject user = jo.getJSONObject("user"); | |
454 if (user.has("id")) { | |
10 | 455 String id = user.getString("id"); |
456 creator.setId(id); | |
457 username = id; | |
3 | 458 } |
459 if (user.has("uri")) { | |
460 userUri = user.getString("uri"); | |
461 } | |
462 } | |
463 } | |
464 if (username == null) { | |
465 username = authUser; | |
466 } | |
5 | 467 // try to get full name |
9 | 468 if (creator.getName() == null && username != null) { |
5 | 469 RestServer restServer = (RestServer) getApplication(); |
470 String fullName = restServer.getFullNameFromLdap(username); | |
9 | 471 creator.setName(fullName); |
5 | 472 } |
473 // userUri should be a URI, if not it will set to the MPIWG namespace | |
3 | 474 if (userUri == null) { |
475 if (username.startsWith("http")) { | |
476 userUri = username; | |
477 } else { | |
478 userUri = NS.MPIWG_PERSONS_URL + username; | |
479 } | |
480 } | |
481 // TODO: should we overwrite the creator? | |
9 | 482 if (creator.getUri() == null) { |
483 creator.setUri(userUri); | |
5 | 484 } |
10 | 485 |
5 | 486 if (annot.getCreated() == null) { |
487 // set creation date | |
488 SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); | |
489 String ct = format.format(Calendar.getInstance().getTime()); | |
490 annot.setCreated(ct); | |
491 } | |
3 | 492 |
493 // create xpointer from the first range/area | |
494 if (jo.has("ranges")) { | |
495 JSONObject ranges = jo.getJSONArray("ranges").getJSONObject(0); | |
4 | 496 annot.setFragmentType(FragmentTypes.XPOINTER); |
497 String fragment = parseRange(ranges); | |
498 annot.setTargetFragment(fragment); | |
3 | 499 } |
500 if (jo.has("areas")) { | |
501 JSONObject area = jo.getJSONArray("areas").getJSONObject(0); | |
4 | 502 annot.setFragmentType(FragmentTypes.AREA); |
503 String fragment = parseArea(area); | |
504 annot.setTargetFragment(fragment); | |
3 | 505 } |
10 | 506 |
507 // permissions | |
508 if (jo.has("permissions")) { | |
509 JSONObject permissions = jo.getJSONObject("permissions"); | |
510 if (permissions.has("admin")) { | |
511 JSONArray perms = permissions.getJSONArray("admin"); | |
512 Actor actor = getActorFromPermissions(perms); | |
513 annot.setAdminPermission(actor); | |
514 } | |
515 if (permissions.has("delete")) { | |
516 JSONArray perms = permissions.getJSONArray("delete"); | |
517 Actor actor = getActorFromPermissions(perms); | |
518 annot.setDeletePermission(actor); | |
519 } | |
520 if (permissions.has("update")) { | |
521 JSONArray perms = permissions.getJSONArray("update"); | |
522 Actor actor = getActorFromPermissions(perms); | |
523 annot.setUpdatePermission(actor); | |
524 } | |
525 if (permissions.has("read")) { | |
526 JSONArray perms = permissions.getJSONArray("read"); | |
527 Actor actor = getActorFromPermissions(perms); | |
528 annot.setReadPermission(actor); | |
529 } | |
530 } | |
531 | |
4 | 532 return annot; |
3 | 533 } |
534 | |
10 | 535 @SuppressWarnings("unused") |
536 protected Actor getActorFromPermissions(JSONArray perms) throws JSONException { | |
537 Actor actor = null; | |
538 for (int i = 0; i < perms.length(); ++i) { | |
539 String perm = perms.getString(i); | |
540 if (perm.toLowerCase().startsWith("group:")) { | |
541 String groupId = perm.substring(6); | |
542 actor = new Group(groupId); | |
543 } else { | |
544 actor = new Person(perm); | |
545 } | |
546 // we just take the first one | |
547 break; | |
548 } | |
549 return actor; | |
550 } | |
551 | |
3 | 552 } |