pinstripe 0.30.0 → 0.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/development.db ADDED
File without changes
@@ -8,7 +8,7 @@ export default {
8
8
  console.log('The following apps are available:');
9
9
  console.log('');
10
10
  App.names.forEach(appName => {
11
- console.log(` * ${chalk.green(appName)} (composed of ${JSON.stringify(App.create(appName, this.context).compose())} views)`);
11
+ console.log(` * ${chalk.green(appName)}`);
12
12
  });
13
13
  console.log('');
14
14
  }
@@ -6,31 +6,13 @@ export default {
6
6
  run(){
7
7
  const { extractOptions } = this.cliUtils;
8
8
 
9
- const { app } = extractOptions();
9
+ const { app = 'main' } = extractOptions();
10
10
 
11
- if(app){
12
- this.listComposedViews(typeof app == 'string' ? app : 'main');
13
- } else {
14
- this.listAllViews();
15
- }
16
- },
17
-
18
- listComposedViews(appName){
19
- const { viewNames, resolveView } = View.mapperFor(App.create(appName, this.context).compose());
20
- console.log('');
21
- console.log(`The following views have been composed for app "${appName}":`);
22
- console.log('');
23
- viewNames.forEach(viewName => {
24
- console.log(` * ${chalk.green(viewName)} -> ${chalk.green(resolveView(viewName))}`);
25
- });
26
- console.log('');
27
- },
28
-
29
- listAllViews(){
11
+ const { viewNames } = View.mapperFor(App.create(app, this.context).compose());
30
12
  console.log('');
31
13
  console.log(`The following views are available:`);
32
14
  console.log('');
33
- View.names.forEach(viewName => {
15
+ viewNames.forEach(viewName => {
34
16
  console.log(` * ${chalk.green(viewName)}`);
35
17
  });
36
18
  console.log('');
@@ -0,0 +1,14 @@
1
+
2
+ export default {
3
+ meta(){
4
+ this.schedule('*/5 * * * *');
5
+ },
6
+
7
+ minutesUntilExpiry: 30,
8
+
9
+ async run(){
10
+ await this.database.withoutTenantScope.usedHashes.where({
11
+ expiresAtLt: new Date()
12
+ }).delete();
13
+ }
14
+ };
package/lib/component.js CHANGED
@@ -2,12 +2,13 @@
2
2
  import { fileURLToPath } from 'url'; // pinstripe-if-client: const fileURLToPath = undefined;
3
3
 
4
4
  import { Class } from './class.js';
5
- import { TEXT_ONLY_TAGS, IS_SERVER } from './constants.js';
5
+ import { TEXT_ONLY_TAGS } from './constants.js';
6
6
  import { Inflector } from './inflector.js';
7
7
  import { VirtualNode } from './virtual_node.js';
8
8
  import { Registry } from './registry.js';
9
9
  import { ComponentEvent } from './component_event.js';
10
10
  import { Client } from './client.js'; // pinstripe-if-client: const Client = undefined;
11
+ import { generateProofOfWork } from './proof_of_work.js';
11
12
 
12
13
  export const Component = Class.extend().include({
13
14
  meta(){
@@ -462,7 +463,10 @@ export const Component = Class.extend().include({
462
463
  if(!(otherOptions.body instanceof FormData)) throw new Error(`Proof of work requires form data to be present`);
463
464
  const values = {};
464
465
  otherOptions.body.forEach((value, key) => values[key] = value);
465
- otherOptions.body.append('_proofOfWork', await generateProofOfWork(values, abortController.signal))
466
+ otherOptions.body.append('_proofOfWork', await generateProofOfWork(values, {
467
+ abortSignal: abortController.signal,
468
+ onProgress: progress => this.trigger('proofOfWorkProgress', { data: progress, bubbles: false })
469
+ }))
466
470
  }
467
471
  const promises = [
468
472
  fetch(normalizedUrl, { signal: abortController.signal, ...otherOptions }),
@@ -712,38 +716,4 @@ function normalizeVirtualNode(){
712
716
  }
713
717
  }
714
718
 
715
- async function generateProofOfWork(values, abortSignal){
716
- const stringifiedValues = JSON.stringify(values);
717
- const timestamp = getUTCTimestamp();
718
- const random = btoa(`${Math.random()}`);
719
- let counter = 0;
720
- while(!abortSignal.aborted){
721
- const candidateSolution = `1:20:${timestamp}:?::${random}:${btoa(`${counter}`)}`;
722
- const hash = await createSha1Hash(candidateSolution.replace(/\?/, stringifiedValues));
723
- if(hash.match(/^00000/)) return candidateSolution;
724
- counter++;
725
- }
726
- return '';
727
- }
728
-
729
- function getUTCTimestamp() {
730
- const now = new Date();
731
-
732
- const year = now.getUTCFullYear() % 100;
733
- const month = String(now.getUTCMonth() + 1).padStart(2, '0');
734
- const day = String(now.getUTCDate()).padStart(2, '0');
735
- const hours = String(now.getUTCHours()).padStart(2, '0');
736
- const minutes = String(now.getUTCMinutes()).padStart(2, '0');
737
- const seconds = String(now.getUTCSeconds()).padStart(2, '0');
738
-
739
- return `${year}${month}${day}${hours}${minutes}${seconds}`;
740
- }
741
-
742
-
743
- async function createSha1Hash(input) {
744
- const buffer = new TextEncoder().encode(input);
745
- const hashArray = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-1', buffer)));
746
- return hashArray.map(byte => byte.toString(16).padStart(2, '0')).join('');
747
- }
748
-
749
719
  ComponentEvent.Component = Component;
@@ -0,0 +1,9 @@
1
+
2
+ export default {
3
+ async migrate(){
4
+ await this.database.table('usedHashes', async usedHashes => {
5
+ await usedHashes.addColumn('value', 'string', { index: true });
6
+ await usedHashes.addColumn('expiresAt', 'datetime', { index: true });
7
+ });
8
+ }
9
+ };
@@ -0,0 +1,2 @@
1
+
2
+ export { Migration as default } from 'pinstripe/database';
@@ -0,0 +1,2 @@
1
+
2
+ export { Row as default } from 'pinstripe/database';
@@ -0,0 +1,7 @@
1
+
2
+ export default {
3
+ meta(){
4
+ this.mustNotBeBlank('value');
5
+ this.mustNotBeBlank('expiresAt');
6
+ }
7
+ };
@@ -0,0 +1,84 @@
1
+
2
+ import crypto from 'crypto' // pinstripe-if-client: const crypto = window.crypto;
3
+
4
+ import { Class } from './class.js';
5
+ import { Singleton } from './singleton.js';
6
+
7
+ const HASH_CASH_TARGET = Math.pow(2, 32 - 20);
8
+ const DEFAULT_DIFFICULTY = 1 / 20;
9
+ const DEFAULT_EXPIRY_IN_SECONDS = 10 * 60;
10
+
11
+ export const ProofOfWork = Class.extend().include({
12
+ meta(){
13
+ this.include(Singleton)
14
+ },
15
+
16
+ async generateProofOfWork(input, options = {}){
17
+ const { difficulty = DEFAULT_DIFFICULTY, steps = 1000, onProgress = () => {}, abortSignal = { aborted: false } } = options;
18
+ const inputHash = await this.createSha1Hash(JSON.stringify(input));
19
+ const salt = await this.createSha1Hash(Math.random());
20
+ const timestamp = Math.floor(Date.now() / 1000);
21
+ const target = this.calculateTarget(difficulty, steps);
22
+ const solution = [];
23
+
24
+ let counter = -1;
25
+ for(let i = 1; i <= steps && !abortSignal.aborted; i++){
26
+ while(!abortSignal.aborted){
27
+ counter++;
28
+ const hash = await this.createSha1Hash(`${difficulty}:${steps}:${inputHash}:${salt}:${timestamp}:${counter}`);
29
+ const integers = this.hexToIntegers(hash);
30
+ if(integers[0] <= target) break;
31
+ }
32
+ solution.push(counter);
33
+ const progressPercentage = Math.floor(i * (100 / steps) * 100) / 100;
34
+ onProgress(progressPercentage);
35
+ }
36
+
37
+ if(abortSignal.aborted) return;
38
+
39
+ return JSON.stringify({
40
+ salt,
41
+ timestamp,
42
+ solution,
43
+ });
44
+ },
45
+
46
+ async verifyProofOfWork(input, proofOfWork, options = {}){
47
+ const { difficulty = DEFAULT_DIFFICULTY, expiryInSeconds = DEFAULT_EXPIRY_IN_SECONDS } = options;
48
+ const { salt, timestamp, solution } = JSON.parse(proofOfWork);
49
+ if(Math.floor(Date.now() / 1000) - timestamp > expiryInSeconds) return false;
50
+ const steps = solution.length;
51
+ if(typeof steps !== 'number' || steps <= 0) return false;
52
+ const inputHash = await this.createSha1Hash(JSON.stringify(input));
53
+ const target = this.calculateTarget(difficulty, steps);
54
+ for(let i = 0; i < steps; i++){
55
+ const counter = solution[i];
56
+ const hash = await this.createSha1Hash(`${difficulty}:${steps}:${inputHash}:${salt}:${timestamp}:${counter}`.toString(), true);
57
+ const integers = this.hexToIntegers(hash);
58
+ if(integers[0] > target) return false;
59
+ }
60
+ return true;
61
+ },
62
+
63
+ async createSha1Hash(input) {
64
+ const buffer = new TextEncoder().encode(input);
65
+ const hashArray = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-1', buffer)));
66
+ return hashArray.map(byte => byte.toString(16).padStart(2, '0')).join('');
67
+ },
68
+
69
+ hexToIntegers(hex){
70
+ const out = [];
71
+ for(let i = 0; i < hex.length; i += 8){
72
+ out.push(parseInt(hex.slice(i, i + 8), 16));
73
+ }
74
+ return out;
75
+ },
76
+
77
+ calculateTarget(difficulty, steps){
78
+ return (HASH_CASH_TARGET / difficulty) * steps;
79
+ }
80
+ });
81
+
82
+ export const generateProofOfWork = (...args) => ProofOfWork.instance.generateProofOfWork(...args);
83
+
84
+ export const verifyProofOfWork = (...args) => ProofOfWork.instance.verifyProofOfWork(...args);
@@ -15,29 +15,18 @@ export default {
15
15
  const normalizedParams = this.normalizeParams(params);
16
16
  this.context.params = normalizedParams;
17
17
 
18
- const viewName = normalizedParams._url.pathname.replace(/^\/|\/$/g, '');
18
+ const viewName = normalizedParams._url.pathname.replace(/^\/|\/$/g, '') || 'index';
19
19
 
20
20
  let out = await this.renderGuardViews(viewName, normalizedParams);
21
- if(out){
22
- return out;
23
- }
21
+ if(out) return out;
24
22
 
25
23
  if(!viewName.match(/(^|\/)_[^\/]+(|\/index)$/)){
26
- out = this.normalizeResponse(await this.app.renderView(viewName != '' ? `${viewName}/index`: 'index', normalizedParams));
27
- if(out){
28
- return out;
29
- }
30
-
31
24
  out = this.normalizeResponse(await this.app.renderView(viewName, normalizedParams));
32
- if(out){
33
- return out;
34
- }
25
+ if(out) return out;
35
26
  }
36
27
 
37
28
  out = await this.renderDefaultViews(viewName, normalizedParams);
38
- if(out){
39
- return out;
40
- }
29
+ if(out) return out;
41
30
 
42
31
  return [404, {'content-type': 'text/plain'}, ['Not found']];
43
32
  },
@@ -45,22 +34,12 @@ export default {
45
34
  async renderGuardViews(viewName, params){
46
35
  const viewNameSegments = viewName != '' ? viewName.split(/\//) : [];
47
36
 
48
- const candidateIndexView = [...viewNameSegments, 'index'].join('/');
49
- const candidateDefaultView = [...viewNameSegments, 'default'].join('/');
50
-
51
- if(!this.app.isView(candidateIndexView) && !this.app.isView(candidateDefaultView)){
52
- viewNameSegments.pop();
53
- }
54
-
55
37
  const prefixSegments = [];
56
38
  while(true){
57
- const out = this.normalizeResponse(await this.app.renderView(prefixSegments.length ? [...prefixSegments, 'guard'].join('/') : 'guard', params));
58
- if(out){
59
- return out;
60
- }
61
- if(viewNameSegments.length == 0){
62
- break;
63
- }
39
+ const candidateGuardViewName = prefixSegments.length ? [...prefixSegments, 'guard'].join('/') : 'guard';
40
+ const out = this.normalizeResponse(await this.app.renderView(candidateGuardViewName, params));
41
+ if(out) return out;
42
+ if(viewNameSegments.length == 0) break;
64
43
  prefixSegments.push(viewNameSegments.shift());
65
44
  }
66
45
  },
@@ -68,28 +47,19 @@ export default {
68
47
  async renderDefaultViews(viewName, params){
69
48
  const prefixSegments = viewName != '' ? viewName.split(/\//) : [];
70
49
  while(true){
71
- const out = this.normalizeResponse(await this.app.renderView(prefixSegments.length ? [...prefixSegments, 'default'].join('/') : 'default', params));
72
- if(out){
73
- return out;
74
- }
75
- if(prefixSegments.length == 0){
76
- break;
77
- }
50
+ const candidateDefaultViewName = prefixSegments.length ? [...prefixSegments, 'default'].join('/') : 'default';
51
+ const out = this.normalizeResponse(await this.app.renderView(candidateDefaultViewName, params));
52
+ if(out) return out;
53
+ if(prefixSegments.length == 0) break;
78
54
  prefixSegments.pop();
79
55
  }
80
56
  },
81
57
 
82
58
  normalizeParams(params){
83
59
  const out = { ...params }
84
- if(!out._method){
85
- out._method = 'get';
86
- }
87
- if(!params._url){
88
- out._url = new URL('http://localhost')
89
- }
90
- if(!params._headers){
91
- out._headers = {};
92
- }
60
+ if(!out._method) out._method = 'get';
61
+ if(!params._url) out._url = new URL('http://localhost');
62
+ if(!params._headers) out._headers = {};
93
63
  return out;
94
64
  },
95
65
 
@@ -109,7 +79,7 @@ export default {
109
79
  const normalizedHeaders = {};
110
80
  Object.keys(headers).forEach(name => {
111
81
  normalizedHeaders[name.toLowerCase()] = headers[name];
112
- })
82
+ });
113
83
 
114
84
  return [ status, normalizedHeaders, body ];
115
85
  }
@@ -0,0 +1,24 @@
1
+
2
+ import crypto from 'crypto';
3
+
4
+ export default {
5
+ create(){
6
+ return this;
7
+ },
8
+
9
+ async hasBeenUsed(key){
10
+ return await this.database.withoutTenantScope.usedHashes.where({value: this.createHash(key)}).count() > 0;
11
+ },
12
+
13
+ async markAsUsed(key, options = {}){
14
+ const { expiresAt = new Date(Date.now() + (1000 * 60 * 60 * 24)) } = options;
15
+ await this.database.usedHashes.insert({
16
+ value: this.createHash(key),
17
+ expiresAt
18
+ });
19
+ },
20
+
21
+ createHash(key){
22
+ return crypto.createHash('sha1').update(JSON.stringify(key)).digest('base64');
23
+ }
24
+ };
@@ -1,7 +1,7 @@
1
- import * as crypto from 'crypto';
2
1
 
3
2
  import { ValidationError } from '../validation_error.js';
4
3
  import { Inflector } from '../inflector.js';
4
+ import { verifyProofOfWork } from '../proof_of_work.js';
5
5
 
6
6
  export default {
7
7
  create(){
@@ -24,13 +24,16 @@ export default {
24
24
  try {
25
25
  if(requiresProofOfWork){
26
26
  if(!this.params._proofOfWork) throw new ValidationError({ _proofOfWork: 'Must not be blank' });
27
- if(!this.verifyProofOfWork(this.params._proofOfWork, values)) throw new ValidationError({ _proofOfWork: 'Must be a valid stamp' });
27
+ if(!await verifyProofOfWork(values, this.params._proofOfWork)) throw new ValidationError({ _proofOfWork: 'Must be a valid' });
28
+ if(await this.onlyOnce.hasBeenUsed({ proofOfWork: this.params._proofOfWork })) throw new ValidationError({ _proofOfWork: 'Must be unused' });
28
29
  }
29
- return await formAdapter.submit(values, success) || this.renderHtml`
30
+ const out = await formAdapter.submit(values, success) || this.renderHtml`
30
31
  <span data-component="pinstripe-anchor" data-target="_parent">
31
32
  <script type="pinstripe">this.parent.trigger('click');</script>
32
33
  </span>
33
34
  `;
35
+ if(requiresProofOfWork) await this.onlyOnce.markAsUsed({ proofOfWork: this.params._proofOfWork });
36
+ return out;
34
37
  } catch(e){
35
38
  if(!(e instanceof ValidationError)){
36
39
  throw e;
@@ -74,12 +77,6 @@ export default {
74
77
  submitTitle,
75
78
  cancelTitle
76
79
  });
77
- },
78
-
79
- verifyProofOfWork(proofOfWork, values){
80
- const hash = crypto.createHash('sha1').update(proofOfWork.replace(/\?/, JSON.stringify(values)), 'binary').digest('hex');
81
- if(hash.match(/^00000/)) return true;
82
- return false;
83
80
  }
84
81
  }
85
82
 
@@ -1,12 +1,6 @@
1
1
 
2
2
  export default {
3
3
  create(){
4
- return async (name, params = {}) => {
5
- const out = await this.app.renderView(name != '' ? `${name}/index`: 'index', params);
6
- if(out){
7
- return out;
8
- }
9
- return this.app.renderView(name, params);
10
- }
4
+ return (name, params = {}) => this.app.renderView(name, params);
11
5
  }
12
6
  };
package/lib/view.js CHANGED
@@ -35,6 +35,7 @@ export const View = Class.extend().include({
35
35
  if(!this.cache.mappers) this.cache.mappers = {};
36
36
  if(!this.cache.mappers[cacheKey]){
37
37
  const map = {};
38
+
38
39
  namespaces.forEach(namespace => {
39
40
  this.names.forEach(name => {
40
41
  const pattern = new RegExp(`^${namespace}/(.*)$`);
@@ -43,6 +44,11 @@ export const View = Class.extend().include({
43
44
  map[matches[1]] = name;
44
45
  })
45
46
  });
47
+
48
+ Object.keys(map).forEach(name => {
49
+ const matches = name.match(/^(.*)\/index$/);
50
+ if(matches && !map[matches[1]]) map[matches[1]] = map[name];
51
+ });
46
52
 
47
53
  this.cache.mappers[cacheKey] = Class.extend().include({
48
54
  isView(name){
@@ -91,35 +91,25 @@ export const styles = `
91
91
  font-size: 12px;
92
92
  }
93
93
 
94
- @keyframes form-spinner {
95
- to {transform: rotate(360deg);}
96
- }
97
-
98
- .spinner {
99
- position: relative;
94
+ .proof-of-work-progress {
100
95
  display: inline-block;
101
- width: 1em;
102
- height: 1em;
103
- margin-right: 1em;
104
- }
105
-
106
- .spinner:before {
107
- content: '';
108
- box-sizing: border-box;
109
- position: absolute;
110
- top: 50%;
111
- left: 50%;
112
- width: 1em;
113
- height: 1em;
114
- margin-top: -0.5em;
115
- margin-left: -0.5em;
116
- border-radius: 50%;
117
- border: 2px solid #ccc;
118
- border-top-color: #000;
119
- animation: form-spinner .6s linear infinite;
96
+ margin-left: 0.5em;
120
97
  }
121
98
  `;
122
99
 
100
+ export const decorators = {
101
+ proofOfWorkProgress(){
102
+ this.frame.on('proofOfWorkProgress', e => {
103
+ const progress = e.detail;
104
+ this.patch({
105
+ ...this.attributes,
106
+ value: progress
107
+ });
108
+ this.patch(`${progress} %`);
109
+ });
110
+ }
111
+ };
112
+
123
113
  export default {
124
114
  render(){
125
115
  const {
@@ -224,8 +214,7 @@ export default {
224
214
  ${() => {
225
215
  if(isPlaceholder && requiresProofOfWork) return this.renderHtml`
226
216
  <span class="${this.cssClasses.loadingIndicator}">
227
- <span class="${this.cssClasses.spinner}"></span>
228
- Generating anti-spam code - please be patient...
217
+ Generating anti-spam code <progress class="${this.cssClasses.proofOfWorkProgress}" max="100">0.0 %</progress>
229
218
  </span>
230
219
  `;
231
220
  }}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "type": "module",
3
3
  "name": "pinstripe",
4
4
  "description": "A slick web framework for Node.js.",
5
- "version": "0.30.0",
5
+ "version": "0.31.1",
6
6
  "author": "Jody Salt",
7
7
  "license": "MIT",
8
8
  "exports": {
@@ -53,5 +53,5 @@
53
53
  "url": "git://github.com/blognami/pinstripe.git",
54
54
  "directory": "packages/pinstripe"
55
55
  },
56
- "gitHead": "07d0e77a033fd16381b2641ad22158dc1857899e"
56
+ "gitHead": "73c659004059facb56e4f96e32d2d443e989d348"
57
57
  }