-rw-r--r-- | backend/node/src/app.js | 7 | ||||
-rw-r--r-- | backend/node/src/clipperz.js | 6 |
2 files changed, 10 insertions, 3 deletions
diff --git a/backend/node/src/app.js b/backend/node/src/app.js index bbc62f8..09dcfac 100644 --- a/backend/node/src/app.js +++ b/backend/node/src/app.js | |||
@@ -1,51 +1,52 @@ | |||
1 | var BUNYAN = require('bunyan'); | 1 | var BUNYAN = require('bunyan'); |
2 | var LOGGER = BUNYAN.createLogger({ | 2 | var LOGGER = BUNYAN.createLogger({ |
3 | name: 'clipperz', | 3 | name: 'clipperz', |
4 | streams: [ | 4 | streams: [ |
5 | { name: "console", stream:process.stderr,level:'trace'} | 5 | { name: "console", stream:process.stderr,level:'trace'} |
6 | ], | 6 | ], |
7 | serializers: { | 7 | serializers: { |
8 | req: BUNYAN.stdSerializers.req, | 8 | req: BUNYAN.stdSerializers.req, |
9 | res: BUNYAN.stdSerializers.res, | 9 | res: BUNYAN.stdSerializers.res, |
10 | err: BUNYAN.stdSerializers.err | 10 | err: BUNYAN.stdSerializers.err |
11 | }, | 11 | }, |
12 | src: true | 12 | src: true |
13 | }); | 13 | }); |
14 | 14 | ||
15 | 15 | ||
16 | var EXPRESS = require('express'); | 16 | var EXPRESS = require('express'); |
17 | var HTTP = require('http'); | 17 | var HTTP = require('http'); |
18 | var PATH = require('path'); | 18 | var PATH = require('path'); |
19 | 19 | ||
20 | 20 | ||
21 | var CLIPPERZ = require('./clipperz'); | 21 | var CLIPPERZ = require('./clipperz'); |
22 | var CONF = require('./conf'); | 22 | var CONF = require('./conf'); |
23 | var clipperz = CLIPPERZ({ | 23 | var clipperz = CLIPPERZ({ |
24 | psql: CONF.psql||'postgresql:///clipperz', | 24 | psql: CONF.psql||'postgresql:///clipperz', |
25 | logger: LOGGER, | 25 | logger: LOGGER, |
26 | dump_template: PATH.join(__dirname,'htdocs/beta/index.html') | 26 | dump_template: PATH.join(__dirname,'htdocs/beta/index.html') |
27 | }); | 27 | }); |
28 | 28 | ||
29 | 29 | ||
30 | var app = EXPRESS(); | 30 | var app = EXPRESS(); |
31 | 31 | ||
32 | app.set('port', process.env.PORT || 3000); | 32 | app.set('port', process.env.PORT || 3000); |
33 | app.use(require('morgan')('dev')); | 33 | app.use(require('morgan')('dev')); |
34 | app.use(require('body-parser').urlencoded({extended:true})); | 34 | app.use(require('body-parser').urlencoded({extended:true})); |
35 | app.use(require('cookie-parser')('your secret here')); | 35 | app.use(require('cookie-parser')('your secret here')); |
36 | app.use(require('express-session')({secret:'99 little bugs in the code', key:'sid', store: clipperz.session_store(), resave: false, saveUninitialized: false })); | 36 | app.use(require('express-session')({secret:'99 little bugs in the code', key:'sid', store: clipperz.session_store(), resave: false, saveUninitialized: false })); |
37 | 37 | ||
38 | app.post('/json',clipperz.json); | 38 | /* Like this: */ |
39 | app.get('/dump',clipperz.dump); | 39 | app.use(clipperz.router); |
40 | /* Or this: */ | ||
41 | app.use('/clz/',clipperz.router); | ||
40 | 42 | ||
41 | app.use(EXPRESS.static(PATH.join(__dirname, 'htdocs/'))); | ||
42 | if ('development' == app.get('env')) { | 43 | if ('development' == app.get('env')) { |
43 | app.use(require('express-error-with-sources')()); | 44 | app.use(require('express-error-with-sources')()); |
44 | } | 45 | } |
45 | 46 | ||
46 | 47 | ||
47 | 48 | ||
48 | 49 | ||
49 | HTTP.createServer(app).listen(app.get('port'), function(){ | 50 | HTTP.createServer(app).listen(app.get('port'), function(){ |
50 | LOGGER.info({port:app.get('port')},"Listener established"); | 51 | LOGGER.info({port:app.get('port')},"Listener established"); |
51 | }); | 52 | }); |
diff --git a/backend/node/src/clipperz.js b/backend/node/src/clipperz.js index 842de31..2a3df73 100644 --- a/backend/node/src/clipperz.js +++ b/backend/node/src/clipperz.js | |||
@@ -1,100 +1,102 @@ | |||
1 | var FS = require('fs'); | 1 | var FS = require('fs'); |
2 | var CRYPTO = require('crypto'); | 2 | var CRYPTO = require('crypto'); |
3 | var BIGNUM = require('bignum'); | 3 | var BIGNUM = require('bignum'); |
4 | var ASYNC = require('async'); | 4 | var ASYNC = require('async'); |
5 | var EXPRESS = require('express'); | ||
6 | var PATH = require('path'); | ||
5 | 7 | ||
6 | var express_store = require('express-session').Store; | 8 | var express_store = require('express-session').Store; |
7 | 9 | ||
8 | function clipperz_hash(v) { | 10 | function clipperz_hash(v) { |
9 | return CRYPTO.createHash('sha256').update( | 11 | return CRYPTO.createHash('sha256').update( |
10 | CRYPTO.createHash('sha256').update(v).digest('binary') | 12 | CRYPTO.createHash('sha256').update(v).digest('binary') |
11 | ).digest('hex'); | 13 | ).digest('hex'); |
12 | }; | 14 | }; |
13 | function clipperz_random() { | 15 | function clipperz_random() { |
14 | for(var r = '';r.length<64;r+=''+BIGNUM(Math.floor(Math.random()*1e18)).toString(16)); | 16 | for(var r = '';r.length<64;r+=''+BIGNUM(Math.floor(Math.random()*1e18)).toString(16)); |
15 | return r.substr(0,64); | 17 | return r.substr(0,64); |
16 | }; | 18 | }; |
17 | function clipperz_store(PG) { | 19 | function clipperz_store(PG) { |
18 | var rv = function(o) { express_store.call(this,o); } | 20 | var rv = function(o) { express_store.call(this,o); } |
19 | rv.prototype.get = function(sid,cb) { PG.Q( | 21 | rv.prototype.get = function(sid,cb) { PG.Q( |
20 | "SELECT s_data FROM clipperz.thesession WHERE s_id=$1",[sid], | 22 | "SELECT s_data FROM clipperz.thesession WHERE s_id=$1",[sid], |
21 | function(e,r) { cb(e,(e||!r.rowCount)?null:JSON.parse(r.rows[0].s_data)); } | 23 | function(e,r) { cb(e,(e||!r.rowCount)?null:JSON.parse(r.rows[0].s_data)); } |
22 | ) }; | 24 | ) }; |
23 | rv.prototype.set = function(sid,data,cb) { | 25 | rv.prototype.set = function(sid,data,cb) { |
24 | var d = JSON.stringify(data); | 26 | var d = JSON.stringify(data); |
25 | PG.Q( | 27 | PG.Q( |
26 | "UPDATE clipperz.thesession SET s_data=$1, s_mtime=current_timestamp" | 28 | "UPDATE clipperz.thesession SET s_data=$1, s_mtime=current_timestamp" |
27 | +" WHERE s_id=$2",[d,sid], function(e,r) { | 29 | +" WHERE s_id=$2",[d,sid], function(e,r) { |
28 | if(e) return cb(e); | 30 | if(e) return cb(e); |
29 | if(r.rowCount) return cb(); | 31 | if(r.rowCount) return cb(); |
30 | PG.Q("INSERT INTO clipperz.thesession (s_id,s_data) VALUES ($1,$2)",[sid,d],cb); | 32 | PG.Q("INSERT INTO clipperz.thesession (s_id,s_data) VALUES ($1,$2)",[sid,d],cb); |
31 | }); | 33 | }); |
32 | }; | 34 | }; |
33 | rv.prototype.destroy = function(sid,cb) { PG.Q( | 35 | rv.prototype.destroy = function(sid,cb) { PG.Q( |
34 | "DELETE FROM clipperz.thesession WHERE s_id=$1",[sid],cb | 36 | "DELETE FROM clipperz.thesession WHERE s_id=$1",[sid],cb |
35 | ) }; | 37 | ) }; |
36 | rv.prototype.length = function(cb) { PG.Q( | 38 | rv.prototype.length = function(cb) { PG.Q( |
37 | "SELECT count(*) AS c FROM clipperz.thesession", function(e,r) { | 39 | "SELECT count(*) AS c FROM clipperz.thesession", function(e,r) { |
38 | cb(e,e?null:r.rows[0].c); | 40 | cb(e,e?null:r.rows[0].c); |
39 | } | 41 | } |
40 | ) }; | 42 | ) }; |
41 | rv.prototype.clear = function(cb) { PQ.Q( | 43 | rv.prototype.clear = function(cb) { PQ.Q( |
42 | "TRUNCATE clipperz.thesession", cb | 44 | "TRUNCATE clipperz.thesession", cb |
43 | ) }; | 45 | ) }; |
44 | rv.prototype.__proto__ = express_store.prototype; | 46 | rv.prototype.__proto__ = express_store.prototype; |
45 | return rv; | 47 | return rv; |
46 | } | 48 | } |
47 | 49 | ||
48 | var srp_g = BIGNUM(2); | 50 | var srp_g = BIGNUM(2); |
49 | var srp_n = BIGNUM("115b8b692e0e045692cf280b436735c77a5a9e8a9e7ed56c965f87db5b2a2ece3",16); | 51 | var srp_n = BIGNUM("115b8b692e0e045692cf280b436735c77a5a9e8a9e7ed56c965f87db5b2a2ece3",16); |
50 | var srp_k = BIGNUM("64398bff522814e306a97cb9bfc4364b7eed16a8c17c5208a40a2bad2933c8e",16); | 52 | var srp_k = BIGNUM("64398bff522814e306a97cb9bfc4364b7eed16a8c17c5208a40a2bad2933c8e",16); |
51 | var srp_hn = "597626870978286801440197562148588907434001483655788865609375806439877501869636875571920406529"; | 53 | var srp_hn = "597626870978286801440197562148588907434001483655788865609375806439877501869636875571920406529"; |
52 | var n123 = '112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00'; | 54 | var n123 = '112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00'; |
53 | 55 | ||
54 | 56 | ||
55 | var CLIPPERZ = module.exports = function(CONFIG) { | 57 | var CLIPPERZ = module.exports = function(CONFIG) { |
56 | 58 | ||
57 | var LOGGER = CONFIG.logger||{trace:function(){}}; | 59 | var LOGGER = CONFIG.logger||{trace:function(){}}; |
58 | 60 | ||
59 | var PG = { | 61 | var PG = { |
60 | url: CONFIG.psql, | 62 | url: CONFIG.psql, |
61 | PG: require('pg').native, | 63 | PG: require('pg').native, |
62 | Q: function(q,a,cb) { | 64 | Q: function(q,a,cb) { |
63 | if('function'===typeof a) cb=a,a=[]; | 65 | if('function'===typeof a) cb=a,a=[]; |
64 | LOGGER.trace({query:q,args:a},'SQL: %s',q); | 66 | LOGGER.trace({query:q,args:a},'SQL: %s',q); |
65 | PG.PG.connect(PG.url,function(e,C,D) { | 67 | PG.PG.connect(PG.url,function(e,C,D) { |
66 | if(e) return cb(e); | 68 | if(e) return cb(e); |
67 | var t0=new Date(); | 69 | var t0=new Date(); |
68 | C.query(q,a,function(e,r) { | 70 | C.query(q,a,function(e,r) { |
69 | var t1=new Date(), dt=t1-t0; | 71 | var t1=new Date(), dt=t1-t0; |
70 | D(); | 72 | D(); |
71 | LOGGER.trace({query:q,args:a,ms:dt,rows:r&&r.rowCount,err:e},"SQL query '%s' took %dms",q,dt); | 73 | LOGGER.trace({query:q,args:a,ms:dt,rows:r&&r.rowCount,err:e},"SQL query '%s' took %dms",q,dt); |
72 | cb(e,r); | 74 | cb(e,r); |
73 | }); | 75 | }); |
74 | }); | 76 | }); |
75 | }, | 77 | }, |
76 | T: function(cb) { | 78 | T: function(cb) { |
77 | PG.PG.connect(PG.url,function(e,C,D) { | 79 | PG.PG.connect(PG.url,function(e,C,D) { |
78 | if(e) return cb(e); | 80 | if(e) return cb(e); |
79 | C.query('BEGIN',function(e){ | 81 | C.query('BEGIN',function(e){ |
80 | if(e) return D(),cb(e); | 82 | if(e) return D(),cb(e); |
81 | LOGGER.trace('SQL: transaction begun'); | 83 | LOGGER.trace('SQL: transaction begun'); |
82 | cb(null,{ | 84 | cb(null,{ |
83 | Q: function(q,a,cb) { | 85 | Q: function(q,a,cb) { |
84 | LOGGER.trace({query:q,args:a},'SQL: %s',q); | 86 | LOGGER.trace({query:q,args:a},'SQL: %s',q); |
85 | if(this.over) return cb(new Error('game over')); | 87 | if(this.over) return cb(new Error('game over')); |
86 | if('function'===typeof a) cb=a,a=[]; | 88 | if('function'===typeof a) cb=a,a=[]; |
87 | var t0=new Date(); | 89 | var t0=new Date(); |
88 | C.query(q,a,function(e,r) { | 90 | C.query(q,a,function(e,r) { |
89 | var t1=new Date(), dt=t1-t0; | 91 | var t1=new Date(), dt=t1-t0; |
90 | LOGGER.trace({query:q,args:a,ms:dt,rows:r&&r.rowCount,err:e},"SQL query '%s' took %dms",q,dt); | 92 | LOGGER.trace({query:q,args:a,ms:dt,rows:r&&r.rowCount,err:e},"SQL query '%s' took %dms",q,dt); |
91 | cb(e,r); | 93 | cb(e,r); |
92 | }); | 94 | }); |
93 | }, | 95 | }, |
94 | commit: function(cb) { | 96 | commit: function(cb) { |
95 | LOGGER.trace('SQL: commit'); | 97 | LOGGER.trace('SQL: commit'); |
96 | if(this.over) return cb(new Error('game over')); | 98 | if(this.over) return cb(new Error('game over')); |
97 | return (this.over=true),C.query('COMMIT',function(e){D();cb&&cb(e)}); | 99 | return (this.over=true),C.query('COMMIT',function(e){D();cb&&cb(e)}); |
98 | }, | 100 | }, |
99 | rollback: function(cb) { | 101 | rollback: function(cb) { |
100 | LOGGER.trace('SQL: rollback'); | 102 | LOGGER.trace('SQL: rollback'); |
@@ -486,99 +488,103 @@ var CLIPPERZ = module.exports = function(CONFIG) { | |||
486 | },function(e,r) { | 488 | },function(e,r) { |
487 | T.end(e, function(e) { | 489 | T.end(e, function(e) { |
488 | if(e) return cb(e); | 490 | if(e) return cb(e); |
489 | res.res({result:'done',lock:r.user.u_lock}); | 491 | res.res({result:'done',lock:r.user.u_lock}); |
490 | }); | 492 | }); |
491 | }); | 493 | }); |
492 | }); | 494 | }); |
493 | 495 | ||
494 | case 'deleteUser': return PG.Q( | 496 | case 'deleteUser': return PG.Q( |
495 | "DELETE FROM clipperz.theuser WHERE u_id=$1", | 497 | "DELETE FROM clipperz.theuser WHERE u_id=$1", |
496 | [req.session.u],function(e,r) { | 498 | [req.session.u],function(e,r) { |
497 | if(e) return cb(e); | 499 | if(e) return cb(e); |
498 | res.res({result:'ok'}); | 500 | res.res({result:'ok'}); |
499 | }); | 501 | }); |
500 | 502 | ||
501 | case 'echo': return res.res({result:ppp}); | 503 | case 'echo': return res.res({result:ppp}); |
502 | case 'getOneTimePasswordsDetails': return res.res({}); | 504 | case 'getOneTimePasswordsDetails': return res.res({}); |
503 | case 'getLoginHistory': return res.res({result:[]}); | 505 | case 'getLoginHistory': return res.res({result:[]}); |
504 | } | 506 | } |
505 | break; | 507 | break; |
506 | case 'logout': return req.session.destroy(function(e){res.res({})}); | 508 | case 'logout': return req.session.destroy(function(e){res.res({})}); |
507 | } | 509 | } |
508 | cb(); | 510 | cb(); |
509 | }, | 511 | }, |
510 | 512 | ||
511 | dump: function(req,res,cb) { | 513 | dump: function(req,res,cb) { |
512 | if(!req.session.u) return cb(new Error('logging in helps')); | 514 | if(!req.session.u) return cb(new Error('logging in helps')); |
513 | return ASYNC.parallel({ | 515 | return ASYNC.parallel({ |
514 | u: function(cb) { | 516 | u: function(cb) { |
515 | PG.Q( | 517 | PG.Q( |
516 | "SELECT" | 518 | "SELECT" |
517 | +" u_name, u_srp_s, u_srp_v, u_authversion, u_header, u_statistics, u_version" | 519 | +" u_name, u_srp_s, u_srp_v, u_authversion, u_header, u_statistics, u_version" |
518 | +" FROM clipperz.theuser WHERE u_id=$1",[req.session.u],function(e,r) { | 520 | +" FROM clipperz.theuser WHERE u_id=$1",[req.session.u],function(e,r) { |
519 | if(e) return cb(e); | 521 | if(e) return cb(e); |
520 | if(!r.rowCount) return cb(new Error("user's gone AWOL")); | 522 | if(!r.rowCount) return cb(new Error("user's gone AWOL")); |
521 | r = r.rows[0]; | 523 | r = r.rows[0]; |
522 | return cb(null,{u:r.u_name,d:{s:r.u_srp_s,v:r.u_srp_v, version:r.u_authversion, | 524 | return cb(null,{u:r.u_name,d:{s:r.u_srp_s,v:r.u_srp_v, version:r.u_authversion, |
523 | maxNumberOfRecords: '100', userDetails: r.u_header, | 525 | maxNumberOfRecords: '100', userDetails: r.u_header, |
524 | statistics: r.u_statistics, userDetailsVersion: r.u_version | 526 | statistics: r.u_statistics, userDetailsVersion: r.u_version |
525 | }}); | 527 | }}); |
526 | }); | 528 | }); |
527 | }, | 529 | }, |
528 | records: function(cb) { | 530 | records: function(cb) { |
529 | PG.Q( | 531 | PG.Q( |
530 | "SELECT" | 532 | "SELECT" |
531 | +" r.r_id, r.r_ref, r_data, r_version, r_ctime, r_mtime, r_atime," | 533 | +" r.r_id, r.r_ref, r_data, r_version, r_ctime, r_mtime, r_atime," |
532 | +" rv.rv_id, rv.rv_ref AS rv_ref, rv_header, rv_data, rv_version, rv_ctime, rv_mtime, rv_atime" | 534 | +" rv.rv_id, rv.rv_ref AS rv_ref, rv_header, rv_data, rv_version, rv_ctime, rv_mtime, rv_atime" |
533 | +" FROM" | 535 | +" FROM" |
534 | +" clipperz.therecord AS r" | 536 | +" clipperz.therecord AS r" |
535 | +" LEFT JOIN clipperz.therecordversion AS rv USING (r_id)" | 537 | +" LEFT JOIN clipperz.therecordversion AS rv USING (r_id)" |
536 | +" WHERE r.u_id=$1" | 538 | +" WHERE r.u_id=$1" |
537 | +" ORDER BY r.r_id ASC, rv.rv_id ASC", [req.session.u],function(e,r) { | 539 | +" ORDER BY r.r_id ASC, rv.rv_id ASC", [req.session.u],function(e,r) { |
538 | if(e) return cb(e); | 540 | if(e) return cb(e); |
539 | var rv = {}; | 541 | var rv = {}; |
540 | r.rows.forEach(function(r) { | 542 | r.rows.forEach(function(r) { |
541 | if(!rv[r.r_ref]) rv[r.r_ref] = { | 543 | if(!rv[r.r_ref]) rv[r.r_ref] = { |
542 | data: r.r_data, version: r.r_version, | 544 | data: r.r_data, version: r.r_version, |
543 | creationDate: r.r_ctime.toString(), | 545 | creationDate: r.r_ctime.toString(), |
544 | updateDate: r.r_mtime.toString(), | 546 | updateDate: r.r_mtime.toString(), |
545 | accessDate: r.r_atime.toString(), | 547 | accessDate: r.r_atime.toString(), |
546 | versions: {} | 548 | versions: {} |
547 | }; | 549 | }; |
548 | if(!r.rv_id) return; | 550 | if(!r.rv_id) return; |
549 | rv[r.r_ref].versions[rv[r.r_ref].currentVersion=r.rv_ref] = { | 551 | rv[r.r_ref].versions[rv[r.r_ref].currentVersion=r.rv_ref] = { |
550 | header: r.rv_header, data: r.rv_data, version: r.rv_version, | 552 | header: r.rv_header, data: r.rv_data, version: r.rv_version, |
551 | creationDate: r.rv_ctime.toString(), | 553 | creationDate: r.rv_ctime.toString(), |
552 | updateDate: r.rv_mtime.toString(), | 554 | updateDate: r.rv_mtime.toString(), |
553 | accessDate: r.rv_atime.toString() | 555 | accessDate: r.rv_atime.toString() |
554 | }; | 556 | }; |
555 | }); | 557 | }); |
556 | cb(null,rv); | 558 | cb(null,rv); |
557 | }); | 559 | }); |
558 | }, | 560 | }, |
559 | html: function(cb) { | 561 | html: function(cb) { |
560 | FS.readFile(CONFIG.dump_template,{encoding:'utf-8'},cb); | 562 | FS.readFile(CONFIG.dump_template,{encoding:'utf-8'},cb); |
561 | } | 563 | } |
562 | },function(e,r) { | 564 | },function(e,r) { |
563 | if(e) return cb(e); | 565 | if(e) return cb(e); |
564 | var d = new Date(); | 566 | var d = new Date(); |
565 | res.attachment('Clipperz_'+d.getFullYear()+'_'+(d.getMonth()+1)+'_'+d.getDate()+'.html'); | 567 | res.attachment('Clipperz_'+d.getFullYear()+'_'+(d.getMonth()+1)+'_'+d.getDate()+'.html'); |
566 | var ojs = { users: { | 568 | var ojs = { users: { |
567 | catchAllUser: { __masterkey_test_value__: 'masterkey', s: n123, v: n123 } | 569 | catchAllUser: { __masterkey_test_value__: 'masterkey', s: n123, v: n123 } |
568 | } }; | 570 | } }; |
569 | r.u.d.records = r.records; | 571 | r.u.d.records = r.records; |
570 | ojs.users[r.u.u] = r.u.d; | 572 | ojs.users[r.u.u] = r.u.d; |
571 | res.send(r.html.replace('/*offline_data_placeholder*/', | 573 | res.send(r.html.replace('/*offline_data_placeholder*/', |
572 | "_clipperz_dump_data_="+JSON.stringify(ojs) | 574 | "_clipperz_dump_data_="+JSON.stringify(ojs) |
573 | +";" | 575 | +";" |
574 | +"Clipperz.PM.Proxy.defaultProxy = new Clipperz.PM.Proxy.Offline();" | 576 | +"Clipperz.PM.Proxy.defaultProxy = new Clipperz.PM.Proxy.Offline();" |
575 | +"Clipperz.Crypto.PRNG.defaultRandomGenerator().fastEntropyAccumulationForTestingPurpose();")); | 577 | +"Clipperz.Crypto.PRNG.defaultRandomGenerator().fastEntropyAccumulationForTestingPurpose();")); |
576 | }); | 578 | }); |
577 | } | 579 | } |
578 | 580 | ||
579 | }; | 581 | }; |
580 | rv.__defineGetter__('session_store',function(){ return function(o) { return new (clipperz_store(PG))(o) } }); | 582 | rv.__defineGetter__('session_store',function(){ return function(o) { return new (clipperz_store(PG))(o) } }); |
581 | 583 | ||
584 | (rv.router = require('express').Router()) | ||
585 | .post('/json',rv.json).get('/dump',rv.dump) | ||
586 | .use(EXPRESS.static(PATH.join(__dirname,'htdocs/'))); | ||
587 | |||
582 | return rv; | 588 | return rv; |
583 | 589 | ||
584 | }; | 590 | }; |