diff --git a/.gitignore b/.gitignore index 0798930ccb4dd74f0d3a55ee2452bd7322ef27f1..efdfc92830476b1c1e661937b031245a179b0e87 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ node_modules .env *.iml **/dist/ -public/js/ +public/scripts/ stats.json client-legacy/ .dck/ diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 0000000000000000000000000000000000000000..fadc4b566dbcea76fca72380553aa01c1674154f --- /dev/null +++ b/.woodpecker.yml @@ -0,0 +1,43 @@ +pipeline: + build: + image: node:16-alpine + pull: true + commands: + - npm ci + + tests: + image: node:16-alpine + pull: true + commands: + - sleep 5 + - npm test + environment: + - APP_KEY=12345678901234567890123456789012 + - APP_NAME=Jetsam + - USE_EPHEMERAL_KEYS=false + - DATABASE_HOST=postgres + - DATABASE_NAME=jetsam + - DATABASE_USER=jetsam + - DATABASE_PASS=jetsam + - DATABASE_PORT=5432 + - DATABASE_SSL_REQUIRE=false + - MAIL_DRIVER=log + - FS_DRIVER=local + - CACHE_DRIVER=memory + - QUEUE_DRIVER=async + - JWK_KEY_ID=test_system + - SENTRY_ENABLED=false + secrets: + - rsa_public_key_b64 + - rsa_private_key_b64 + - rsa_private_passphrase + +services: + postgres: + image: timescale/timescaledb-postgis:latest-pg13 + environment: + POSTGRES_USER: jetsam + POSTGRES_PASSWORD: jetsam + POSTGRES_DB: jetsam + ports: + - "5432:5432" \ No newline at end of file diff --git a/database/migrations/20211208000001-create-classification-roots-table.js b/database/migrations/20211208000001-create-classification-roots-table.js new file mode 100644 index 0000000000000000000000000000000000000000..f408a876cc72df7f25dddd3528bfc7ff2c486084 --- /dev/null +++ b/database/migrations/20211208000001-create-classification-roots-table.js @@ -0,0 +1,57 @@ +module.exports = { + up: (migration, Types) => { + return migration.createTable('classification_roots', { + metric_id: { + type: Types.UUID, + allowNull: false, + unique: true, + primaryKey: true, + }, + upload_id: { + type: Types.UUID, + allowNull: false, + references: { + model: 'uploads', + key: 'id', + }, + }, + image_id: { + type: Types.UUID, + allowNull: false, + }, + url: { + type: Types.TEXT, + allowNull: false, + }, + status: { + type: Types.TEXT, + allowNull: false, + defaultValue: 'pending', + }, + meta: { + type: Types.JSONB, + defaultValue: {}, + allowNull: false, + }, + created_at: { + type: Types.DATE, + defaultValue: Types.fn('now'), + allowNull: false, + }, + updated_at: { + type: Types.DATE, + defaultValue: Types.fn('now'), + allowNull: false, + }, + deleted_at: { + type: Types.DATE, + defaultValue: null, + allowNull: true, + }, + }) + }, + + down: (migration, Types) => { + return migration.dropTable('') + }, +} \ No newline at end of file diff --git a/database/migrations/20211210000001-create-session-boot-time-view.js b/database/migrations/20211210000001-create-session-boot-time-view.js new file mode 100644 index 0000000000000000000000000000000000000000..33cb4528fe7fd0885eb08309c57e7b97c1b70810 --- /dev/null +++ b/database/migrations/20211210000001-create-session-boot-time-view.js @@ -0,0 +1,23 @@ +module.exports = { + up: (migration, Types) => { + return migration.sequelize.query(` +CREATE OR REPLACE VIEW session_boot_time as SELECT session_id, + max(start_time) - min(start_time) as boot_time, + device->>'id' as device_id, + concat_ws(' ', device -> 'info' ->> 'brand', device -> 'info' ->> 'system', + device -> 'info' ->> 'system_version') as device_info +FROM analytics +where event = 'app_open' + or event = 'navigation_ready' +group by session_id, + device->>'id', + concat_ws(' ', device -> 'info' ->> 'brand', device -> 'info' ->> 'system', + device -> 'info' ->> 'system_version') +having max(start_time) - min(start_time) > interval '0 seconds' +;`) + }, + + down: (migration, Types) => { + return migration.sequelize.query('DROP VIEW IF EXISTS session_boot_time') + }, +} \ No newline at end of file diff --git a/database/migrations/20220329000001-create-server-tokens-table.js b/database/migrations/20220329000001-create-server-tokens-table.js new file mode 100644 index 0000000000000000000000000000000000000000..43f85d2a3ab4e70715629b911ae40e2eb569daf1 --- /dev/null +++ b/database/migrations/20220329000001-create-server-tokens-table.js @@ -0,0 +1,56 @@ +module.exports = { + up: (migration, Types) => { + return migration.createTable('server_tokens', { + id: { + type: Types.UUID, + primaryKey: true, + defaultValue: Types.UUIDV4, + allowNull: false, + }, + value: { + type: Types.TEXT, + allowNull: false, + }, + name: { + type: Types.TEXT, + allowNull: false, + }, + description: { + type: Types.TEXT, + allowNull: true, + }, + owner_id: { + type: Types.UUID, + allowNull: false, + references: { + model: 'users', + key: 'id', + }, + }, + meta: { + type: Types.JSONB, + defaultValue: {}, + allowNull: false, + }, + created_at: { + type: Types.DATE, + defaultValue: Types.fn('now'), + allowNull: false, + }, + updated_at: { + type: Types.DATE, + defaultValue: Types.fn('now'), + allowNull: false, + }, + deleted_at: { + type: Types.DATE, + defaultValue: null, + allowNull: true, + }, + }) + }, + + down: (migration, Types) => { + return migration.dropTable('server_tokens') + }, +} diff --git a/docker-compose.yml b/docker-compose.yml index 2ae61a811f85d119bc24d4b2a30768d11a7782ce..c32a3b016120cb37f7fda9f1f911009c73a7e9f1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,15 +33,15 @@ services: - "5432:5432" labels: tech.jetsam.environment: 'staging' - graphql-engine: - image: hasura/graphql-engine:v2.0.10 - restart: on-failure - ports: - - "15432:8080" - depends_on: - - "postgres" - env_file: - - hasura/.env +# graphql-engine: +# image: hasura/graphql-engine:v2.4.0-beta.1 +# restart: on-failure +# ports: +# - "15432:8080" +# depends_on: +# - "postgres" +# env_file: +# - hasura/.env # hsaura: # image: hasura/graphql-engine:v1.3.3 # ports: diff --git a/hasura/jwk_props_dev.json b/hasura/jwk_props_dev.json index 84d71062d53fbef34d6cea5478255c221f954f67..7d46d84c53cffe88d0a39cb59dd33c10fc44b7e3 100644 --- a/hasura/jwk_props_dev.json +++ b/hasura/jwk_props_dev.json @@ -3,11 +3,11 @@ "header": { "type": "Authorization" }, - "issuer": "urn:hackerfest:systems:auth", - "claims_namespace": "urn:hackerfest:resources:claims", + "issuer": "urn:jetsam:systems:auth", + "claims_namespace": "urn:jetsam:resources:claims", "claims_map": { - "x-hasura-user-id": { "path": "$$['urn:hackerfest:resources:claims']['user-id']" }, - "x-hasura-default-role": { "path": "$$['urn:hackerfest:resources:claims']['default-role']" }, - "x-hasura-allowed-roles": { "path": "$$['urn:hackerfest:resources:claims']['allowed-roles']" } + "x-hasura-user-id": { "path": "$$['urn:jetsam:resources:claims']['user-id']" }, + "x-hasura-default-role": { "path": "$$['urn:jetsam:resources:claims']['default-role']" }, + "x-hasura-allowed-roles": { "path": "$$['urn:jetsam:resources:claims']['allowed-roles']" } } } \ No newline at end of file diff --git a/hasura/metadata/databases/default/tables/public_oauth_clients.yaml b/hasura/metadata/databases/default/tables/public_oauth_clients.yaml index 749d7f3013648dd033e6f4fd50731aa2422ec78a..44a7b977d69e8e7a4184591d1a2bb245cd628788 100644 --- a/hasura/metadata/databases/default/tables/public_oauth_clients.yaml +++ b/hasura/metadata/databases/default/tables/public_oauth_clients.yaml @@ -45,3 +45,21 @@ select_permissions: - description filter: {} role: overseer +- permission: + columns: + - description + - grant_types + - id + - internal + - meta + - name + - owner_id + - redirect_uris + - secret + filter: + _and: + - owner_id: + _eq: X-Hasura-User-Id + - deleted_at: + _is_null: false + role: user diff --git a/helm/api/Chart.yaml b/helm/api/Chart.yaml index 39e74c3368970b707425f78e7b685a5039adf073..cdf0ae7fe01fbcc05dffdb5a5bba6a9eb61bbcbd 100644 --- a/helm/api/Chart.yaml +++ b/helm/api/Chart.yaml @@ -15,10 +15,10 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.3.1 +version: 0.3.3 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "2.2.4" +appVersion: "2.4.0" diff --git a/package-lock.json b/package-lock.json index 15800f41045b724d1448575117ed9364f3c2ab16..df92de0248ab19072cb5998f27bea3c99e0dae5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jetsam-api", - "version": "2.2.2", + "version": "2.5.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "jetsam-api", - "version": "2.2.2", + "version": "2.5.0", "license": "GPL-3.0+", "dependencies": { "@commander-lol/vault-client": "^0.1.1", @@ -28,7 +28,7 @@ "handlebars": "^4.7.6", "ioredis": "^4.17.3", "joi": "^17.3.0", - "jose": "^3.6.1", + "jose": "^4.6.1", "koa": "^2.13.0", "koa-bodyparser": "^4.3.0", "koa-compose": "^4.1.0", @@ -47,7 +47,7 @@ "node-fetch": "^2.6.1", "nodemailer": "^6.4.17", "oauth2-server": "^3.1.1", - "oidc-provider": "^7.10.1", + "oidc-provider": "git+ssh://git@github.com:Commander-lol/node-oidc-provider.git#de6429dd4aaab02c73a6d22faaee33efd78d4584", "pg": "^8.3.0", "pg-hstore": "^2.3.3", "pluralize": "^8.0.0", @@ -61,11 +61,12 @@ "yargs": "^13.3.2" }, "devDependencies": { - "hasura-cli": "^2.0.9", + "hasura-cli": "^2.2.0", "jest": "^26.6.3", "nodemon": "^2.0.4", "prettier": "^2.2.1", - "supertest": "^6.1.3" + "supertest": "^6.1.3", + "umzug": "^3.1.1" } }, "node_modules/@babel/code-frame": { @@ -1356,6 +1357,18 @@ "resolved": "https://npm.lcr.gr/@root%2frequest/-/request-1.7.0.tgz", "integrity": "sha512-lre7XVeEwszgyrayWWb/kRn5fuJfa+n0Nh+rflM9E+EpC28yIYA+FPm/OL1uhzp3TxhQM0HFN4FE2RDIPGlnmg==" }, + "node_modules/@rushstack/ts-command-line": { + "version": "4.10.9", + "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.10.9.tgz", + "integrity": "sha512-TE3eZgHNVHOY3p8lp38FoNEJUr0+swPb24sCcYuwlC+MHgMGXyJNM+p7l3TKSBRiY01XShoL2k601oGwL00KlA==", + "dev": true, + "dependencies": { + "@types/argparse": "1.0.38", + "argparse": "~1.0.9", + "colors": "~1.2.1", + "string-argv": "~0.3.1" + } + }, "node_modules/@sendgrid/client": { "version": "7.4.2", "resolved": "https://npm.lcr.gr/@sendgrid%2fclient/-/client-7.4.2.tgz", @@ -1551,6 +1564,12 @@ "node": ">= 6" } }, + "node_modules/@types/argparse": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz", + "integrity": "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==", + "dev": true + }, "node_modules/@types/babel__core": { "version": "7.1.12", "resolved": "https://npm.lcr.gr/@types%2fbabel__core/-/babel__core-7.1.12.tgz", @@ -2986,6 +3005,15 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" }, + "node_modules/colors": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.2.5.tgz", + "integrity": "sha512-erNRLao/Y3Fv54qUa0LBB+//Uf3YwMUmdJinN20yMXm9zdKKqH9wt7R9IIVZ+K7ShzfpLV/Zg8+VyrBJYB4lpg==", + "dev": true, + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -4847,9 +4875,9 @@ "integrity": "sha512-Gjzu0Xn7IagXVkSu9cSFuK1fqzwtLwFhNhVL8IFJijRNMgUttFbBSIAzKuSIrsFMO1+g1RlsoN49zPIbwPDMGQ==" }, "node_modules/hasura-cli": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/hasura-cli/-/hasura-cli-2.0.9.tgz", - "integrity": "sha512-95xAxNFfF1nntncULGKGQ9UEbhEWsgcMHdqOLsreq9E1emh2CVu1xuY/WezGMaCe1D4ZII7HxSQZBIhdnF9vKg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hasura-cli/-/hasura-cli-2.2.0.tgz", + "integrity": "sha512-lBpEt94CkaLbn0iqWx9TNnnuET9prz067gKUuTzm3y8PtRw1T1dZV8t/tfAZRRG/WNGNFp1d0UWs/fpwO3aJdA==", "dev": true, "hasInstallScript": true, "dependencies": { @@ -7686,9 +7714,9 @@ } }, "node_modules/jose": { - "version": "3.6.1", - "resolved": "https://npm.lcr.gr/jose/-/jose-3.6.1.tgz", - "integrity": "sha512-AZ+dcXaYbX79uvqedXl7QMDkhpQBVXFezLRP734phyVw8EEcnwRIsOMLw4JAMJ+7Iyhv5Eb7isQUEZvqCCk6vA==", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.6.1.tgz", + "integrity": "sha512-EFnufEivlIB6j7+JwaenYQzdUDs/McajDr9WnhT6EI0WxbexnfuZimpWX1GnobF6OnQsUFmWFXUXdWyZHWdQow==", "funding": { "url": "https://github.com/sponsors/panva" } @@ -8974,9 +9002,10 @@ "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==" }, "node_modules/oidc-provider": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-7.10.1.tgz", - "integrity": "sha512-SRFVePq/ki76SbKB/vx7ach6mgc6iof2dtYOKMA88f3i1gZ6XKbqqxqyEVAj4n9L13nssUWJE9O4AyN8M0bNKA==", + "version": "7.10.6", + "resolved": "git+ssh://git@github.com/Commander-lol/node-oidc-provider.git#de6429dd4aaab02c73a6d22faaee33efd78d4584", + "integrity": "sha512-qXHx7oEjm0UgTHC+KLLCRzCyup5J/kNKGUxQOxmIG0xeA2IRSkYkBHZgElTulqrJNZfGiJYxMTO/Wk3KdgEUsA==", + "license": "MIT", "dependencies": { "@koa/cors": "^3.1.0", "cacheable-lookup": "^6.0.1", @@ -8990,7 +9019,7 @@ "nanoid": "^3.1.28", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.1", - "paseto2": "npm:paseto@^2.1.3", + "paseto": "^2.1.3", "quick-lru": "^5.1.1", "raw-body": "^2.4.1" }, @@ -9001,7 +9030,7 @@ "url": "https://github.com/sponsors/panva" }, "optionalDependencies": { - "paseto3": "npm:paseto@^3.0.0" + "paseto3": "npm:paseto@^3.1.0" } }, "node_modules/oidc-provider/node_modules/@sindresorhus/is": { @@ -9111,14 +9140,6 @@ "node": ">=10.6.0" } }, - "node_modules/oidc-provider/node_modules/jose": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.3.6.tgz", - "integrity": "sha512-A/JgZGUerqG2IMuxkUDBtZ4aTxg/l1Y+pt/QAAYiRAR3EFlxIE0Su0xdpB8tQcPZK5eudB7g1PHCZ5uHatbY+g==", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/oidc-provider/node_modules/jsesc": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", @@ -9436,8 +9457,7 @@ "node": ">=0.10.0" } }, - "node_modules/paseto2": { - "name": "paseto", + "node_modules/paseto": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/paseto/-/paseto-2.1.3.tgz", "integrity": "sha512-BNkbvr0ZFDbh3oV13QzT5jXIu8xpFc9r0o5mvWBhDU1GBkVt1IzHK1N6dcYmN7XImrUmPQ0HCUXmoe2WPo8xsg==", @@ -9704,6 +9724,15 @@ "node": ">=4" } }, + "node_modules/pony-cause": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-1.1.1.tgz", + "integrity": "sha512-PxkIc/2ZpLiEzQXu5YRDOUgBlfGYBY8156HY5ZcRAwwonMk5W/MrJP2LLkG/hF7GEQzaHo2aS7ho6ZLCOvf+6g==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/posix-character-classes": { "version": "0.1.1", "resolved": "https://npm.lcr.gr/posix-character-classes/-/posix-character-classes-0.1.1.tgz", @@ -10871,6 +10900,17 @@ "node": ">=10.0.0" } }, + "node_modules/sequelize-cli/node_modules/umzug": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/umzug/-/umzug-2.3.0.tgz", + "integrity": "sha512-Z274K+e8goZK8QJxmbRPhl89HPO1K+ORFtm6rySPhFKfKc5GHhqdzD0SGhSWHkzoXasqJuItdhorSvY7/Cgflw==", + "dependencies": { + "bluebird": "^3.7.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/sequelize-pool": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-6.1.0.tgz", @@ -11423,6 +11463,15 @@ } ] }, + "node_modules/string-argv": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz", + "integrity": "sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==", + "dev": true, + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-length": { "version": "4.0.1", "resolved": "https://npm.lcr.gr/string-length/-/string-length-4.0.1.tgz", @@ -12023,14 +12072,54 @@ } }, "node_modules/umzug": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/umzug/-/umzug-2.3.0.tgz", - "integrity": "sha512-Z274K+e8goZK8QJxmbRPhl89HPO1K+ORFtm6rySPhFKfKc5GHhqdzD0SGhSWHkzoXasqJuItdhorSvY7/Cgflw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/umzug/-/umzug-3.1.1.tgz", + "integrity": "sha512-sgMDzUK6ZKS3pjzRJpAHqSkvAQ+64Dourq6JfQv11i0nMu0/QqE3V3AUpj2pWYxFBaSvnUxKrzZQmPr6NZhvdQ==", + "dev": true, "dependencies": { - "bluebird": "^3.7.2" + "@rushstack/ts-command-line": "^4.7.7", + "emittery": "^0.10.2", + "fs-jetpack": "^4.1.0", + "glob": "^7.1.6", + "pony-cause": "^1.1.1", + "type-fest": "^2.0.0" }, "engines": { - "node": ">=6.0.0" + "node": ">=12" + } + }, + "node_modules/umzug/node_modules/emittery": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.10.2.tgz", + "integrity": "sha512-aITqOwnLanpHLNXZJENbOgjUBeHocD+xsSJmNrjovKBW5HbSpW3d1pEls7GFQPUWXiwG9+0P4GtHfEqC/4M0Iw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/umzug/node_modules/fs-jetpack": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/fs-jetpack/-/fs-jetpack-4.3.1.tgz", + "integrity": "sha512-dbeOK84F6BiQzk2yqqCVwCPWTxAvVGJ3fMQc6E2wuEohS28mR6yHngbrKuVCK1KHRx/ccByDylqu4H5PCP2urQ==", + "dev": true, + "dependencies": { + "minimatch": "^3.0.2", + "rimraf": "^2.6.3" + } + }, + "node_modules/umzug/node_modules/type-fest": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.12.2.tgz", + "integrity": "sha512-qt6ylCGpLjZ7AaODxbpyBZSs9fCI9SkL3Z9q2oxMBQhs/uyY+VD8jHA8ULCGmWQJlBgqvO3EJeAngOHD8zQCrQ==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/undefsafe": { @@ -13723,6 +13812,18 @@ "resolved": "https://npm.lcr.gr/@root%2frequest/-/request-1.7.0.tgz", "integrity": "sha512-lre7XVeEwszgyrayWWb/kRn5fuJfa+n0Nh+rflM9E+EpC28yIYA+FPm/OL1uhzp3TxhQM0HFN4FE2RDIPGlnmg==" }, + "@rushstack/ts-command-line": { + "version": "4.10.9", + "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.10.9.tgz", + "integrity": "sha512-TE3eZgHNVHOY3p8lp38FoNEJUr0+swPb24sCcYuwlC+MHgMGXyJNM+p7l3TKSBRiY01XShoL2k601oGwL00KlA==", + "dev": true, + "requires": { + "@types/argparse": "1.0.38", + "argparse": "~1.0.9", + "colors": "~1.2.1", + "string-argv": "~0.3.1" + } + }, "@sendgrid/client": { "version": "7.4.2", "resolved": "https://npm.lcr.gr/@sendgrid%2fclient/-/client-7.4.2.tgz", @@ -13879,6 +13980,12 @@ "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==" }, + "@types/argparse": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz", + "integrity": "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==", + "dev": true + }, "@types/babel__core": { "version": "7.1.12", "resolved": "https://npm.lcr.gr/@types%2fbabel__core/-/babel__core-7.1.12.tgz", @@ -15071,6 +15178,12 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" }, + "colors": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.2.5.tgz", + "integrity": "sha512-erNRLao/Y3Fv54qUa0LBB+//Uf3YwMUmdJinN20yMXm9zdKKqH9wt7R9IIVZ+K7ShzfpLV/Zg8+VyrBJYB4lpg==", + "dev": true + }, "combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -16564,9 +16677,9 @@ "integrity": "sha512-Gjzu0Xn7IagXVkSu9cSFuK1fqzwtLwFhNhVL8IFJijRNMgUttFbBSIAzKuSIrsFMO1+g1RlsoN49zPIbwPDMGQ==" }, "hasura-cli": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/hasura-cli/-/hasura-cli-2.0.9.tgz", - "integrity": "sha512-95xAxNFfF1nntncULGKGQ9UEbhEWsgcMHdqOLsreq9E1emh2CVu1xuY/WezGMaCe1D4ZII7HxSQZBIhdnF9vKg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hasura-cli/-/hasura-cli-2.2.0.tgz", + "integrity": "sha512-lBpEt94CkaLbn0iqWx9TNnnuET9prz067gKUuTzm3y8PtRw1T1dZV8t/tfAZRRG/WNGNFp1d0UWs/fpwO3aJdA==", "dev": true, "requires": { "axios": "^0.21.1", @@ -18734,9 +18847,9 @@ } }, "jose": { - "version": "3.6.1", - "resolved": "https://npm.lcr.gr/jose/-/jose-3.6.1.tgz", - "integrity": "sha512-AZ+dcXaYbX79uvqedXl7QMDkhpQBVXFezLRP734phyVw8EEcnwRIsOMLw4JAMJ+7Iyhv5Eb7isQUEZvqCCk6vA==" + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.6.1.tgz", + "integrity": "sha512-EFnufEivlIB6j7+JwaenYQzdUDs/McajDr9WnhT6EI0WxbexnfuZimpWX1GnobF6OnQsUFmWFXUXdWyZHWdQow==" }, "js-beautify": { "version": "1.11.0", @@ -19771,9 +19884,9 @@ "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==" }, "oidc-provider": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-7.10.1.tgz", - "integrity": "sha512-SRFVePq/ki76SbKB/vx7ach6mgc6iof2dtYOKMA88f3i1gZ6XKbqqxqyEVAj4n9L13nssUWJE9O4AyN8M0bNKA==", + "version": "git+ssh://git@github.com/Commander-lol/node-oidc-provider.git#de6429dd4aaab02c73a6d22faaee33efd78d4584", + "integrity": "sha512-qXHx7oEjm0UgTHC+KLLCRzCyup5J/kNKGUxQOxmIG0xeA2IRSkYkBHZgElTulqrJNZfGiJYxMTO/Wk3KdgEUsA==", + "from": "oidc-provider@git+ssh://git@github.com:Commander-lol/node-oidc-provider.git#de6429dd4aaab02c73a6d22faaee33efd78d4584", "requires": { "@koa/cors": "^3.1.0", "cacheable-lookup": "^6.0.1", @@ -19787,8 +19900,8 @@ "nanoid": "^3.1.28", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.1", - "paseto2": "npm:paseto@^2.1.3", - "paseto3": "npm:paseto@^3.0.0", + "paseto": "^2.1.3", + "paseto3": "npm:paseto@^3.1.0", "quick-lru": "^5.1.1", "raw-body": "^2.4.1" }, @@ -19866,11 +19979,6 @@ } } }, - "jose": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.3.6.tgz", - "integrity": "sha512-A/JgZGUerqG2IMuxkUDBtZ4aTxg/l1Y+pt/QAAYiRAR3EFlxIE0Su0xdpB8tQcPZK5eudB7g1PHCZ5uHatbY+g==" - }, "jsesc": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", @@ -20102,8 +20210,8 @@ "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=", "dev": true }, - "paseto2": { - "version": "npm:paseto@2.1.3", + "paseto": { + "version": "2.1.3", "resolved": "https://registry.npmjs.org/paseto/-/paseto-2.1.3.tgz", "integrity": "sha512-BNkbvr0ZFDbh3oV13QzT5jXIu8xpFc9r0o5mvWBhDU1GBkVt1IzHK1N6dcYmN7XImrUmPQ0HCUXmoe2WPo8xsg==" }, @@ -20305,6 +20413,12 @@ "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==" }, + "pony-cause": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-1.1.1.tgz", + "integrity": "sha512-PxkIc/2ZpLiEzQXu5YRDOUgBlfGYBY8156HY5ZcRAwwonMk5W/MrJP2LLkG/hF7GEQzaHo2aS7ho6ZLCOvf+6g==", + "dev": true + }, "posix-character-classes": { "version": "0.1.1", "resolved": "https://npm.lcr.gr/posix-character-classes/-/posix-character-classes-0.1.1.tgz", @@ -21224,6 +21338,16 @@ "resolve": "^1.5.0", "umzug": "^2.3.0", "yargs": "^13.1.0" + }, + "dependencies": { + "umzug": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/umzug/-/umzug-2.3.0.tgz", + "integrity": "sha512-Z274K+e8goZK8QJxmbRPhl89HPO1K+ORFtm6rySPhFKfKc5GHhqdzD0SGhSWHkzoXasqJuItdhorSvY7/Cgflw==", + "requires": { + "bluebird": "^3.7.2" + } + } } }, "sequelize-pool": { @@ -21675,6 +21799,12 @@ } } }, + "string-argv": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz", + "integrity": "sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==", + "dev": true + }, "string-length": { "version": "4.0.1", "resolved": "https://npm.lcr.gr/string-length/-/string-length-4.0.1.tgz", @@ -22137,11 +22267,41 @@ } }, "umzug": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/umzug/-/umzug-2.3.0.tgz", - "integrity": "sha512-Z274K+e8goZK8QJxmbRPhl89HPO1K+ORFtm6rySPhFKfKc5GHhqdzD0SGhSWHkzoXasqJuItdhorSvY7/Cgflw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/umzug/-/umzug-3.1.1.tgz", + "integrity": "sha512-sgMDzUK6ZKS3pjzRJpAHqSkvAQ+64Dourq6JfQv11i0nMu0/QqE3V3AUpj2pWYxFBaSvnUxKrzZQmPr6NZhvdQ==", + "dev": true, "requires": { - "bluebird": "^3.7.2" + "@rushstack/ts-command-line": "^4.7.7", + "emittery": "^0.10.2", + "fs-jetpack": "^4.1.0", + "glob": "^7.1.6", + "pony-cause": "^1.1.1", + "type-fest": "^2.0.0" + }, + "dependencies": { + "emittery": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.10.2.tgz", + "integrity": "sha512-aITqOwnLanpHLNXZJENbOgjUBeHocD+xsSJmNrjovKBW5HbSpW3d1pEls7GFQPUWXiwG9+0P4GtHfEqC/4M0Iw==", + "dev": true + }, + "fs-jetpack": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/fs-jetpack/-/fs-jetpack-4.3.1.tgz", + "integrity": "sha512-dbeOK84F6BiQzk2yqqCVwCPWTxAvVGJ3fMQc6E2wuEohS28mR6yHngbrKuVCK1KHRx/ccByDylqu4H5PCP2urQ==", + "dev": true, + "requires": { + "minimatch": "^3.0.2", + "rimraf": "^2.6.3" + } + }, + "type-fest": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.12.2.tgz", + "integrity": "sha512-qt6ylCGpLjZ7AaODxbpyBZSs9fCI9SkL3Z9q2oxMBQhs/uyY+VD8jHA8ULCGmWQJlBgqvO3EJeAngOHD8zQCrQ==", + "dev": true + } } }, "undefsafe": { diff --git a/package.json b/package.json index d71f47cf4f24e3ca9171028d71d0f5f28f8adc80..ea10a5565ae2e730231a242aba6c0a9869429d8e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jetsam-api", - "version": "2.2.3", + "version": "2.5.0", "description": "The Jetsam App API Server", "main": "server.js", "scripts": { @@ -9,6 +9,7 @@ "exec:env": "docker-compose -p jetenv up", "exec:ngrok": "ngrok http 7124 --hostname trash.4l2.uk", "exec:check_img": "NODE_PATH=src node scripts/exec-boot 'node scripts/check_img.js'", + "exec:populate_roots": "NODE_PATH=src node scripts/exec-boot 'node scripts/populate_roots.js'", "test": "NODE_ENV=testing NODE_PATH=src node scripts/jest.js", "start": "NODE_PATH=src node server", "cmd": "NODE_PATH=src node run", @@ -38,7 +39,7 @@ "handlebars": "^4.7.6", "ioredis": "^4.17.3", "joi": "^17.3.0", - "jose": "^3.6.1", + "jose": "^4.6.1", "koa": "^2.13.0", "koa-bodyparser": "^4.3.0", "koa-compose": "^4.1.0", @@ -57,7 +58,7 @@ "node-fetch": "^2.6.1", "nodemailer": "^6.4.17", "oauth2-server": "^3.1.1", - "oidc-provider": "^7.10.1", + "oidc-provider": "git+ssh://git@github.com:Commander-lol/node-oidc-provider.git#de6429dd4aaab02c73a6d22faaee33efd78d4584", "pg": "^8.3.0", "pg-hstore": "^2.3.3", "pluralize": "^8.0.0", @@ -71,10 +72,11 @@ "yargs": "^13.3.2" }, "devDependencies": { - "hasura-cli": "^2.0.9", + "hasura-cli": "^2.2.0", "jest": "^26.6.3", "nodemon": "^2.0.4", "prettier": "^2.2.1", - "supertest": "^6.1.3" + "supertest": "^6.1.3", + "umzug": "^3.1.1" } } diff --git a/public/icons/plus.svg b/public/icons/plus.svg new file mode 100644 index 0000000000000000000000000000000000000000..a8b122b94f2a8994ed06c607071f2599b1529f2d --- /dev/null +++ b/public/icons/plus.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="white" stroke="white"> + <path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" /> +</svg> \ No newline at end of file diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..b2b998ab8912ef5f33da64817116681e186ee9fd --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,4 @@ +<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M0 24C0 10.7452 10.7452 0 24 0H276C289.255 0 300 10.7452 300 24V276C300 289.255 289.255 300 276 300H24C10.7452 300 0 289.255 0 276V24Z" fill="#FF5E82"/> +<path d="M159.986 49.6563L201.071 44.9422M112.032 61.4361L128.357 50.9985L130.245 40.8337L140.983 39.4861L144.791 31.3401L157.367 32.5404L173.267 20.6897L179.672 33.7953L201.349 44.6312L204.437 74.9782L171.924 138.564L174.09 179.747L187.818 212.986V233.217L175.536 260.672L150.967 277.292H133.622L105.441 268.278L101.829 243.333L122.781 227.439L133.617 233.943L132.847 246.617L132.176 239.001L122.781 235.389L114.831 243.338L119.169 259.957L133.622 263.569L149.516 254.174L158.186 228.885L150.962 205.762L117.723 168.191V129.894L133.617 103.159L166.13 74.9782L146.619 72.0865L114.106 73.5323L101.824 78.5902L95.3202 72.0865L98.9322 62.691L112.032 61.4361ZM133.72 102.859L172.11 138.553L133.72 102.859ZM117.554 168.185L172.104 138.553L117.554 168.185ZM173.452 170.204L145.167 153.366L173.452 170.204ZM139.106 192.432L180.861 196.475L139.106 192.432ZM145.167 153.372L159.986 194.457L145.167 153.372ZM166.043 75.2456L203.084 61.1033L166.043 75.2456ZM157.291 32.8187L159.986 49.6563L157.291 32.8187ZM141.124 39.808L159.981 49.6563L141.124 39.808ZM149.21 254.392L175.476 260.454L149.21 254.392ZM157.962 228.798L175.471 260.449L157.962 228.798ZM151.229 205.904L187.6 213.313L151.229 205.904ZM171.433 211.96L166.719 244.959L171.433 211.96ZM179.514 123.063L155.267 84.0027L179.514 123.063ZM118.902 259.777L104.083 244.959L118.902 259.777ZM133.72 263.82V279.31V263.82ZM159.986 49.6563L166.048 75.251L159.986 49.6563ZM128.33 50.9985L166.043 75.2456L128.33 50.9985ZM114.188 73.2268L145.839 62.451L114.188 73.2268Z" stroke="#FAF9F9" stroke-width="4" stroke-miterlimit="10"/> +</svg> diff --git a/scripts/check_img.js b/scripts/check_img.js new file mode 100644 index 0000000000000000000000000000000000000000..8225d866ad5d1f559e51ca6319b0e0f63d500b76 --- /dev/null +++ b/scripts/check_img.js @@ -0,0 +1,45 @@ +async function main() { + const { URL } = require('url') + const { Upload } = require('database/models') + const { fs } = require('services') + let uploads = [] + const total = await Upload.count({ where: { status: 'pending' } }) + let count = 0 + + uploads = await Upload.findAll({ limit: 25, order: ['created_at'], where: { status: 'pending' } }) + while (uploads.length > 0) { + console.log('Processing entries %d to %d, of total %d', count, count + uploads.length, total) + count += uploads.length + + for (const upload of uploads) { + await new Promise(r => setTimeout(r, 50)) + // const path = `${ upload.user_id }/${ upload.id }.jpg` + const url = new URL(upload.upload_url) + url.search = '' + + const [_, bucket, ...pathParts] = url.pathname.split('/') + const path = pathParts.join('/') + + try { + await fs.makePublic(path) + } catch(e) { + console.error(e) + console.error('Couldnt make ', path, ' public') + upload.status = 'failed' + upload.status_reason = 'bogus upload url' + await upload.save() + continue + } + + console.log(url.toString()) + upload.status = 'success' + await upload.save() + } + await new Promise(r => setTimeout(r, 500)) + uploads = await Upload.findAll({ limit: 25, order: ['created_at'], where: { status: 'pending' } }) + } + + process.exit(0) +} + +main() \ No newline at end of file diff --git a/scripts/jest.js b/scripts/jest.js index 36eb8faa049f070db0d53a7bb7c29338a76aafde..5bd90421f82df55f8268f4dc2f16973920fb07cc 100644 --- a/scripts/jest.js +++ b/scripts/jest.js @@ -2,6 +2,8 @@ const { execSync } = require('child_process') const bootstrap = require('../src/bootstrap') const { v4: uuid } = require('uuid') const { exec } = require('core/utils/process') +const Sequelize = require("sequelize"); +const {config} = require("bootstrap"); async function run() { process.env.NODE_ENV = 'testing' @@ -10,25 +12,77 @@ async function run() { const id = uuid().replace(/-/g, '') const dbname = `${ bootstrap.config('database.database') }_${ id }` - await bootstrap.invoke('db:fresh', [id, '--and-migrate'], false) + const pg = require('pg') + const conf = bootstrap.config('database') + const dbconf = { + user: conf.username, + password: conf.password, + host: conf.host, + database: conf.database, + port: conf.port, + application_name: `${ bootstrap.config('app.name') } Test Harness` + } + + console.log("[BOOT] Setting up database ", dbname) + let client = new pg.Client(dbconf) + await client.connect() + await client.query(`CREATE DATABASE ${ dbname }`) + + const { Sequelize } = require('sequelize') + const sqlconf = bootstrap.config('sequelize')['testing'] + const sql = new Sequelize( + dbname, + conf.username, + conf.password, + sqlconf, + ) + + const { Umzug, SequelizeStorage } = require('umzug') + const umzug = new Umzug({ + storage: new SequelizeStorage({ sequelize: sql }), + context: [sql.getQueryInterface(), Sequelize], + migrations: { + glob: 'database/migrations/*.js', + resolve: ({ name, path, context }) => { + // console.log(path, context, name) + return { + name, + up: () => require(path).up(...context), + down: () => require(path).down(...context), + } + } + } + }) + + console.log("[BOOT] Migrating database ", dbname) + await umzug.up() + await sql.close() try { - const runner = await exec('npx jest --forceExit --runInBand', { + console.log("[BOOT] Running tests ", dbname) + const runner = await exec('npx jest --env=node --forceExit --runInBand', { env: { ...process.env, DATABASE_NAME: dbname, + QUEUE_DRIVER: 'async', + CACHE_DRIVER: 'memory', SENTRY_ENABLED: false, }, stdio: 'inherit', }, true) + if (runner > 0) { + throw new Error(`Tests failed with code ${ runner }`) + } } finally { - await bootstrap.invoke('db:prune') - process.exit() + console.log("[BOOT] Dropping database ", dbname) + await client.query(`DROP DATABASE ${ dbname }`) + await client.end() } } run() + .then(() => process.exit()) .catch(e => { console.error(e) process.exit(1) diff --git a/scripts/populate_roots.js b/scripts/populate_roots.js new file mode 100644 index 0000000000000000000000000000000000000000..58babb8fbb8f1190926183516ad1df80799e4e42 --- /dev/null +++ b/scripts/populate_roots.js @@ -0,0 +1,43 @@ +const SelectQuery = `select + metrics.id as metric_id, + uploads.id as upload_id, + (metrics.meta->>'image_id')::uuid as image_id, + (regexp_split_to_array(uploads.upload_url, '\\?'::text))[1] as url +from metrics + join uploads on uploads.upload_url like concat('%', metrics.meta ->> 'image_id', '%') +where + metrics.meta -> 'image_id' IS NOT NULL + and uploads.status = 'success' + and not exists( + select classification_roots.metric_id + from classification_roots + where classification_roots.metric_id = metrics.id + ) +limit 100` + +const InsertQuery = `insert into + classification_roots(metric_id, upload_id, image_id, url) + ${ SelectQuery } + ON CONFLICT DO NOTHING +;` + +async function main() { + const { sequelize } = require('database/models') + + let totalCount = 0 + let lastCount = 0 + + do { + const [_, inserted] = await sequelize.query(InsertQuery, { raw: true }) + lastCount = inserted + totalCount += inserted + console.log("Inserted %d entries", lastCount) + await new Promise(r => setTimeout(r, 250)) + } while (lastCount > 0) + + console.log("Created %d New Roots", totalCount) + + process.exit(0) +} + +main() \ No newline at end of file diff --git a/src/app.js b/src/app.js index 98f4a76cd8219f886b8b0c8d86ba8b7525f52a1b..1fcec2c77849ef80ccb4f3bde943d69a023dd576 100644 --- a/src/app.js +++ b/src/app.js @@ -4,7 +4,7 @@ const logger = require('koa-logger') const bodyparser = require('koa-bodyparser') const etag = require('koa-etag') const session = require('koa-session') -const static = require('koa-static') +const files = require('koa-static') const mount = require('koa-mount') const createOIDCServer = require('domain/auth/oidc/OIDCServer') @@ -19,9 +19,15 @@ const debug = require('debug')('server:boot') const requestLog = require('debug')('server:request') const serviceProvider = require('core/injection/ServiceProvider') +const notFound = require("http/middleware/NotFoundHandler"); module.exports = async function createApp(app = new Koa()) { - const { fs } = require('bootstrap') + const { fs, boot } = require('bootstrap') + + if (process.env.NODE_ENV === 'testing') { + await boot() + } + const routers = require('http/routes') app.keys = [config('app.key')] @@ -34,7 +40,19 @@ module.exports = async function createApp(app = new Koa()) { app.use(etag({ weak: true })) app.use(bodyparser()) app.use(logger(s => requestLog(s))) - app.use(static(pathutil.resolve(__dirname + '/../public'))) + app.use(files(pathutil.resolve(__dirname + '/../public'))) + app.use(notFound) + + app.use(async (ctx, next) => { + if (ctx.method === 'OPTIONS') { + ctx.response.set('Access-Control-Allow-Origin', ctx.request.origin) + ctx.response.set('Access-Control-Allow-Method', ctx.request.get('Access-Control-Request-Method')) + ctx.response.set('Access-Control-Allow-Headers', ctx.request.get('Access-Control-Request-Headers')) + ctx.response.status = 201 + } else { + return await next() + } + }) app.use( session( diff --git a/src/console/GenerateRSAPair.js b/src/console/GenerateRSAPair.js new file mode 100644 index 0000000000000000000000000000000000000000..75b4aa50ffacf343df4397b5351a7b89fc9c027a --- /dev/null +++ b/src/console/GenerateRSAPair.js @@ -0,0 +1,19 @@ +module.exports = { + command: 'crypto:rsa:generate', + description: 'Generate an RSA key pair, encoded as Base64', + async handler(args) { + const { generateRsaKeys } = require('core/utils/jwt') + const { secureHexString, toBase64 } = require('core/utils/crypto') + + const key = await secureHexString(16) + const { pub, priv } = await generateRsaKeys(key) + + console.log({ + passphrase: key, + public_key: toBase64(pub), + private_key: toBase64(priv), + }) + + process.exit(0) + }, +} diff --git a/src/core/errors/InputValidationError.js b/src/core/errors/InputValidationError.js index 1a97aef27efdefed093e24b1bb7b66e11450238b..115cb4ded35d53d773cee4a574467e3621967155 100644 --- a/src/core/errors/InputValidationError.js +++ b/src/core/errors/InputValidationError.js @@ -4,4 +4,22 @@ module.exports = class InputValidationError extends HttpError { constructor(fields) { super(422, 'The supplied input was not valid', { fields }) } + + toJSON() { + return { + status: this._status, + message: this._message, + ...this._payload, + } + } + + respondTo(ctx) { + ctx.status = this._status + ctx.body = { + errors: { + general: [this._message], + ...this._payload, + } + } + } } diff --git a/src/core/services/serialise.js b/src/core/services/serialise.js index 3e80e4d68ecf00ab3741db874cb99ba6fbeafb60..172ecbe08349790c4a3400e7d47d548c9d24b3bf 100644 --- a/src/core/services/serialise.js +++ b/src/core/services/serialise.js @@ -6,7 +6,6 @@ exports.serialise = async function serialise(pattern, data) { case 'string': case 'number': case 'symbol': - console.log(data[mapper]) output[key] = data[mapper] break case 'function': diff --git a/src/core/utils/jwt.js b/src/core/utils/jwt.js index 53bc5074a203c5a86c0633a687ec0935a7f695d4..6114e4d5c6501b43b785c7aacd339e64a7a6ed1e 100644 --- a/src/core/utils/jwt.js +++ b/src/core/utils/jwt.js @@ -1,5 +1,6 @@ const { generateKeyPair, createPublicKey, createPrivateKey } = require('crypto') -async function generateRsaKeys() { + +exports.generateRsaKeys = async (pass = null) => { const { config } = require('bootstrap') return new Promise((resolve, reject) => { generateKeyPair( @@ -14,7 +15,7 @@ async function generateRsaKeys() { type: 'pkcs8', format: 'pem', cipher: 'aes-256-cbc', - passphrase: config('app.key'), + passphrase: pass ?? config('app.key'), }, }, (err, pub, priv) => { @@ -27,6 +28,7 @@ async function generateRsaKeys() { ) }) } +const generateRsaKeys = exports.generateRsaKeys exports.getKeys = () => { const { config } = require('bootstrap') @@ -39,10 +41,10 @@ exports.getKeys = () => { exports.getJWKS = async (type = 'pub') => { const { config } = require('bootstrap') const keys = exports.getKeys() - const { default: fromKeyLike } = require('jose/jwk/from_key_like') + const jose = require('jose') const key = keys[type] - const jwk = await fromKeyLike(key) + const jwk = await jose.exportJWK(key) const kid = config('app.security.key_id') return { @@ -97,11 +99,11 @@ exports.loadKeys = async () => { exports.sign = async payload => { const threadContext = require('core/injection/ThreadContext') const { config } = require('bootstrap') - const { default: SignJWT } = require('jose/jwt/sign') + const jose = require('jose') const { priv } = exports.getKeys() return await threadContext.profile('jwt.sign', JSON.stringify(payload), () => - new SignJWT(payload) + new jose.SignJWT(payload) .setIssuer(exports.jwtOptions.issuer) .setIssuedAt() .setProtectedHeader({ alg: 'RS256', kid: exports.jwtOptions.keyid_prefix + config('app.security.key_id') }) @@ -111,12 +113,12 @@ exports.sign = async payload => { exports.verify = async token => { const threadContext = require('core/injection/ThreadContext') - const { default: jwtVerify } = require('jose/jwt/verify') + const jose = require('jose') const { getKeys, jwtOptions } = exports const { pub } = getKeys() return await threadContext.profile('jwt.verify', undefined, async () => { - const { payload } = await jwtVerify(token, pub, jwtOptions) + const { payload } = await jose.jwtVerify(token, pub, jwtOptions) return payload }) } diff --git a/src/core/utils/queue.js b/src/core/utils/queue.js index c96201aaf208f97175decc160526c05021db73e5..26154610ccf1a52c071d8c83f06a90cccb59c9b8 100644 --- a/src/core/utils/queue.js +++ b/src/core/utils/queue.js @@ -3,6 +3,10 @@ const HANDLERS = [ 'send-user-password-reset', require('domain/auth/handlers/SendUserPasswordReset'), ], + [ + 'upload-successful', + require('domain/uploads/handlers/MarkUploadSuccessful'), + ], ] module.exports = function bindJobHandlers() { diff --git a/src/core/utils/urls.js b/src/core/utils/urls.js index 2f04912890771a49b43ce85b2bf5d97f0eb04604..70f6934277f45decd75620a8bc1505bdca077466 100644 --- a/src/core/utils/urls.js +++ b/src/core/utils/urls.js @@ -30,7 +30,7 @@ const urlToName = redirectPairs.reduce((cur, [k, v]) => ({ ...cur, [v]: k }), {} exports.createRedirectState = async ctx => { const path = ctx.path - const redirect = urlToName[path] ?? '/' + const redirect = urlToName[path] ?? `$$${ctx.path}` const query = ctx.request.query ?? {} return crypto.encrypt(JSON.stringify({ redirect, query })) } @@ -50,7 +50,9 @@ exports.parseRedirectState = async ctx => { const value = JSON.parse(raw) return { ...value, - path: nameToUrl[value.redirect] ?? '/', + path: value.redirect.startsWith('$$') ? + value.redirect.substr(2) : + nameToUrl[value.redirect] ?? '/', } } catch(e) { console.error(e) @@ -68,8 +70,6 @@ exports.createRedirectedUrl = async (ctx) => { const values = await exports.parseRedirectState(ctx) const params = new URLSearchParams() - console.log(values, params) - Object.entries(values.query).forEach(([key, value]) => { params.set(key, value) }) diff --git a/src/database/models/ClassificationRoot.js b/src/database/models/ClassificationRoot.js new file mode 100644 index 0000000000000000000000000000000000000000..fe18ca26a420073d2381f9e8a980562d64f5a224 --- /dev/null +++ b/src/database/models/ClassificationRoot.js @@ -0,0 +1,64 @@ +const BaseModel = require('./BaseModel') +const timestamps = require('./properties/timestamps') + +class ClassificationRoot extends BaseModel { + static associate(models) { + this.belongsTo(models.Upload, { foreignKey: 'upload_id' }) + } + + toJSON() { + return { + metric_id: this.metric_id, + upload_id: this.upload_id, + image_id: this.image_id, + url: this.url, + status: this.status, + } + } + +} + +module.exports = (sequelize, DataTypes) => { + ClassificationRoot.init(Object.assign( + { + metric_id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + validate: { + isUUID: 4, + }, + }, + upload_id: { + type: DataTypes.UUID, + validate: { + isUUID: 4, + }, + }, + image_id: { + type: DataTypes.UUID, + validate: { + isUUID: 4, + }, + }, + url: { + type: DataTypes.TEXT, + }, + status: { + type: DataTypes.TEXT, + }, + meta: { + type: DataTypes.JSONB, + }, + }, + timestamps(DataTypes), + ), { + sequelize, + paranoid: true, + tableName: 'classification_roots', + }) + + return ClassificationRoot +} + +module.exports.Model = ClassificationRoot diff --git a/src/database/models/Metric.js b/src/database/models/Metric.js index a47f28aa6601a9306d12911bff2c0b131d5a1754..d19aadd9ecc25a6a70bca01dab8322e98a02117d 100644 --- a/src/database/models/Metric.js +++ b/src/database/models/Metric.js @@ -4,6 +4,18 @@ const BaseModel = require('./BaseModel') const metricConversionMap = { trash: Number, numeric: Number, + 'pm2.5': Number, + co2_ppm: Number, + wind_mph: Number, + wind_kph: Number, + temp_c: Number, + temp_f: Number, + temp_k: Number, + pressure_pa: Number, + pressure_mbar: Number, + rain_mm: Number, + custom_string: String, + custom_number: Number, } class Metric extends BaseModel { diff --git a/src/database/models/OAuthClient.js b/src/database/models/OAuthClient.js index 5eaa4d320f7645e1ea3bad60fdb51bf134613ce0..9032d6234814814d1e7298f7af8400f54ac0c1e8 100644 --- a/src/database/models/OAuthClient.js +++ b/src/database/models/OAuthClient.js @@ -21,6 +21,7 @@ class OAuthClient extends BaseModel { owner_id: userId, name: params.name ?? '', description: params.description ?? '', + redirect_uris: params.redirect_uris ?? [], secret, grant_types: ['authorization_code', 'refresh_token'], internal, diff --git a/src/database/models/ServerToken.js b/src/database/models/ServerToken.js new file mode 100644 index 0000000000000000000000000000000000000000..40986a0a48b7127ea07392252f7ab46446fd4a03 --- /dev/null +++ b/src/database/models/ServerToken.js @@ -0,0 +1,60 @@ +const timestamps = require('./properties/timestamps') +const BaseModel = require('./BaseModel') + +class ServerToken extends BaseModel { + static associate(models) { + this.belongsTo(models.User, { foreignKey: 'owner_id' }) + } + + toJSON() { + const user = this.user ? { user: this.user } : {} + return { + id: this.id, + value: this.value, + name: this.name, + description: this.description, + expires_at: this.expires_at, + ...user, + meta: this.meta, + created_at: this.created_at, + updated_at: this.updated_at, + } + } +} + +module.exports = (sequelize, DataTypes) => { + ServerToken.init( + Object.assign( + { + id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + validate: { + isUUID: 4, + }, + }, + value: { + type: DataTypes.TEXT, + }, + name: { + type: DataTypes.TEXT, + }, + description: { + type: DataTypes.TEXT, + }, + meta: { + type: DataTypes.JSONB, + }, + }, + timestamps(DataTypes), + ), + { + sequelize, + paranoid: true, + tableName: 'server_tokens', + }, + ) + + return ServerToken +} diff --git a/src/database/models/User.js b/src/database/models/User.js index 7aa94e4010e14a00f44c48462010043a869e9d9d..ac7d3ec51cc4439a59b738c7676c69e70378d0d9 100644 --- a/src/database/models/User.js +++ b/src/database/models/User.js @@ -7,6 +7,7 @@ class User extends BaseModel { this.hasMany(models.AuthorizationCode, { foreignKey: 'user_id' }) this.hasMany(models.OAuthClient, { foreignKey: 'owner_id' }) this.hasMany(models.AccessToken, { foreignKey: 'user_id' }) + this.hasMany(models.ServerToken, { foreignKey: 'owner_id' }) this.hasMany(models.RefreshToken, { foreignKey: 'user_id' }) this.hasMany(models.File, { foreignKey: 'user_id' }) this.hasMany(models.Upload, { foreignKey: 'user_id' }) diff --git a/src/domain/auth/AuthServer.js b/src/domain/auth/AuthServer.js index fed5552c8c8dc0c999c15dc921c5e43b3807fbd5..728af4e63689c42870a656fdb0d1cd76ea8670c0 100644 --- a/src/domain/auth/AuthServer.js +++ b/src/domain/auth/AuthServer.js @@ -223,9 +223,6 @@ class KoaOAuthServer { this.token = async ctx => { const { req, res } = this.transformContext(ctx) - console.log(ctx.request.query) - console.log(ctx.request.body) - await authServer.token(req, res, { allowExtendedTokenAttributes: true, accessTokenLifetime: 3600 * 24 * 7, diff --git a/src/domain/auth/AuthenticationService.js b/src/domain/auth/AuthenticationService.js index 78a5b5c579134e293f06b349c5715958fcf1d48f..57d17f8766a6131365d697c52f5d6fc2f7e739aa 100644 --- a/src/domain/auth/AuthenticationService.js +++ b/src/domain/auth/AuthenticationService.js @@ -1,6 +1,6 @@ const ContextualModule = require('core/injection/ContextualModule') const crypto = require('core/utils/crypto') -const { User, AccessToken } = require('database/models') +const { User, AccessToken, ServerToken } = require('database/models') const HttpError = require('core/errors/HttpError') const { reportContextError } = require('../../vendor/sentry') @@ -17,6 +17,8 @@ module.exports = class AuthenticationService extends ContextualModule { init() { this._user = null + this._method = null + this._model = null } async attemptLogin(email, password) { @@ -33,7 +35,7 @@ module.exports = class AuthenticationService extends ContextualModule { const user = await this.ctx.services['core.users'].findByEmail(email) if (user) { if (await user.checkPassword(password)) { - this.authenticateAs(user) + this.authenticateAs(user, 'email') return user } else { throw new HttpError(403, 'Invalid username or password') @@ -52,19 +54,42 @@ module.exports = class AuthenticationService extends ContextualModule { const user = await User.findByPk(value.id) if (user) { - this.authenticateAs(user) + this.authenticateAs(user, 'cookie') return this._user } } else if (this.ctx.get('Authorization')) { const token = this.ctx.get('Authorization').substr(HEADER_PREFIX.length) - const accessToken = await AccessToken.findOne({ - where: { token }, + let accessToken = await ServerToken.findOne({ + where: { value: token }, include: [{ model: User }], }) - if (accessToken.User) { - this.authenticateAs(accessToken.User) + if (accessToken == null) { + accessToken = await AccessToken.findOne({ + where: { token }, + include: [{ model: User }] + }) + } else { + if (accessToken?.User) { + this.authenticateAs(accessToken.User, 'server_token', accessToken) + } + } + + if (accessToken == null) { + accessToken = await this.ctx.services['auth.oidc'].withProvider(provider => provider.AccessToken.find(token)) + if (accessToken) { + const user = await User.findByPk(accessToken.accountId) + accessToken.User = user + } + } else { + if (accessToken?.User) { + this.authenticateAs(accessToken.User, 'oauth_token', accessToken) + } + } + + if (accessToken?.User) { + this.authenticateAs(accessToken.User, 'oidc_token', accessToken) return this._user } } else if (this.ctx.get('x-api-token')) { @@ -72,7 +97,7 @@ module.exports = class AuthenticationService extends ContextualModule { try { const user = await User.fromToken(token, this.ctx.get('x-token-type')) if (user) { - this.authenticateAs(user) + this.authenticateAs(user, `xat_${ this.ctx.get('x-token-type') }`) return this._user } } catch (e) { @@ -84,8 +109,18 @@ module.exports = class AuthenticationService extends ContextualModule { return null } - authenticateAs(user) { + async getMethod() { + await this.getUser() + return { + model: this._model, + method: this._method, + } + } + + authenticateAs(user, method = null, model = null) { this._user = user + this._method = method + this._model = model } async saveToSession(logoutIfEmpty = true) { diff --git a/src/domain/auth/OAuthFlow.js b/src/domain/auth/OAuthFlow.js index ce25c979f4e629ce78b374d5feb6cacd34ac6016..4029033198f417aa2f5b67085bfe6e4558919f4d 100644 --- a/src/domain/auth/OAuthFlow.js +++ b/src/domain/auth/OAuthFlow.js @@ -189,6 +189,11 @@ const scopeDescriptionMap = exports.validScopes = { description: 'The ability to see information about your account stats, including your points and citizen scientist level', }, + 'developer:admin': { + name: 'Developer Settings Admin Access', + description: 'Read / Write access to your server tokens and OAuth applications. Granting this permission to an' + + ' application could leak confidential information' + } } /** diff --git a/src/domain/auth/oidc/DBAdapter.js b/src/domain/auth/oidc/DBAdapter.js index baf2d7088c2491e84eb8cfc36377fad031da8930..797fa27e51fd989da4a31a8b2b8439c5f4504b3e 100644 --- a/src/domain/auth/oidc/DBAdapter.js +++ b/src/domain/auth/oidc/DBAdapter.js @@ -2,7 +2,9 @@ const moment = require('moment') const { OIDCEntity, User, OAuthClient, AccessToken, RefreshToken, sequelize, Sequelize } = require('database/models') const debug = require('debug')('server:auth:oidc:adapter') -const key = (id, name) => `urn:jetsam:resources:oidc:${name}/${id}` +const key = (id, name) => { + return `urn:jetsam:resources:oidc:${name}/${id}`; +} class DBAdapter { constructor(name, { model = OIDCEntity } = {}) { @@ -16,6 +18,7 @@ class DBAdapter { const oidc = await this.model.upsert({ id: key(id, this.name), grant_id: data.grantId ?? null, + related_user_id: data.accountId ?? null, user_code: data.user_code ?? null, uid: data.uid ?? null, data, diff --git a/src/domain/auth/oidc/OIDCServer.js b/src/domain/auth/oidc/OIDCServer.js index 02084f998cc69f503e19f5c8ff29737963224a82..1d5fe26a08a5e0eed0c027f18fcd1fe40b858ffb 100644 --- a/src/domain/auth/oidc/OIDCServer.js +++ b/src/domain/auth/oidc/OIDCServer.js @@ -8,7 +8,7 @@ module.exports = async function createOIDCServer() { const {validScopes} = require("../OAuthFlow"); debug(`Creating OIDC Provider with base ${config('app.host.web')}`) - const provider = new Provider( /*config('app.host.web') + 'oidc/' */ 'http://trash.4l2.uk/oidc/', { + const provider = new Provider(config('app.host.web') + 'oidc/', { clients: [ { client_id: 'kbyuFDidLLm280LIwVFiazOqjO3ty8KH', @@ -63,6 +63,10 @@ module.exports = async function createOIDCServer() { pkce: { required: () => false, }, + ttl: { + AccessToken: 3600 * 24, + IdToken: 3600 * 24, + }, routes: { authorization: '/oidc/auth', backchannel_authentication: '/oidc/backchannel', diff --git a/src/domain/data/MetricsService.js b/src/domain/data/MetricsService.js index 6775c843d5c443488867d21435e9ff1476f1ec32..0cee2c510742c9ccb91b1d5670245c5793d376cd 100644 --- a/src/domain/data/MetricsService.js +++ b/src/domain/data/MetricsService.js @@ -4,9 +4,18 @@ const { User, AccessToken, Metric, Sequelize } = require('database/models') const { unset } = require('bootstrap') const HttpError = require('core/errors/HttpError') const moment = require("moment"); +const InputValidationError = require("../../core/errors/InputValidationError"); const { Op } = Sequelize +const aggregateTypeMap = { + count: 'COUNT', + min: 'MIN', + max: 'MAX', + sum: 'SUM', + average: 'AVG', +} + module.exports = class MetricsService extends ContextualModule { static getServiceName() { return 'data.metrics' @@ -54,8 +63,13 @@ module.exports = class MetricsService extends ContextualModule { return await Metric.create(payload, { transaction }) } - async queryAggregate(pointBuffer, types, from, to) { + async queryAggregate(pointBuffer, types, from, to, method = 'count') { const snapClause = `ST_SNAPTOGRID("Metric"."location"::geometry, 0.001)` + const aggregator = aggregateTypeMap[method] + + if (aggregator == null) { + throw new InputValidationError(['aggregate']) + } return await Metric.findAll({ where: { @@ -73,7 +87,7 @@ module.exports = class MetricsService extends ContextualModule { }, attributes: [ 'type', - [Sequelize.fn('COUNT', Sequelize.col('value')), 'value'], + [Sequelize.fn(aggregator, Sequelize.literal('"value"::numeric')), 'value'], [Sequelize.literal(snapClause), 'location'], ], group: [Sequelize.literal(snapClause), 'type'], @@ -95,6 +109,13 @@ module.exports = class MetricsService extends ContextualModule { [Op.in]: types, }, }, + attributes: [ + 'id', + 'value', + 'type', + 'location', + 'recorded_at' + ] }) } } diff --git a/src/domain/uploads/handlers/MarkUploadSuccessful.js b/src/domain/uploads/handlers/MarkUploadSuccessful.js new file mode 100644 index 0000000000000000000000000000000000000000..f10a57e33bf831c945ded56c50cf5ef2e9bd2ca3 --- /dev/null +++ b/src/domain/uploads/handlers/MarkUploadSuccessful.js @@ -0,0 +1,27 @@ + +module.exports = async (body, ctx) => { + const {bucket, key} = body + const stub = `${bucket}/${key}` + + const {Upload, Sequelize} = require('database/models') + const upload = await Upload.findOne({ + where: { + upload_url: { + [Sequelize.Op.like]: `%${stub}%`, + } + } + }) + + if (upload) { + upload.status = 'success' + + try { + const { fs } = require('services') + await fs.makePublic(key) + } catch (e) { + upload.status_reason = 'Failed to make public' + } + + await upload.save() + } +} \ No newline at end of file diff --git a/src/http/controllers/api/content.js b/src/http/controllers/api/content.js index 0bf47a95f53516a8ea61e1641e83ed85de54619b..1f26cd80b83f04198f21f6740dea8459335a99fa 100644 --- a/src/http/controllers/api/content.js +++ b/src/http/controllers/api/content.js @@ -2,6 +2,7 @@ const HttpError = require('core/errors/HttpError') const { Sequelize, sequelize, Metric } = require('database/models') const { Op } = Sequelize const moment = require('moment') +const InputValidationError = require("../../../core/errors/InputValidationError"); exports.postMetric = async ctx => { const allowedTypes = new Set(Metric.getSupportedMetricTypes()) @@ -20,19 +21,16 @@ exports.postMetric = async ctx => { transaction: t, ctx, }) - metrics.push(result) } }) } catch (e) { console.log(e) - - throw new HttpError({ - status: 400, - code: 'MTR-001', - title: 'Failed to save metric', - description: 'Something went wrong trying to save the metric', - }) + if (e instanceof HttpError) { + throw e + } else { + throw new HttpError(500, 'Failed to save metric') + } } ctx.body = { @@ -56,12 +54,7 @@ const save = async ( location.longitude == null || location.latitude == null ) { - throw new HttpError({ - status: 400, - code: 'MTR-001', - title: 'Invalid Metric', - description: 'A location must be provided for metrics', - }) + throw new HttpError(422, 'A location must be provided for metrics') } if (allowedTypes.has(type)) { @@ -73,12 +66,7 @@ const save = async ( transaction, ) } else { - throw new HttpError({ - status: 400, - code: 'MTR-002', - title: 'Invalid Metric', - description: `${type} is not a supported type`, - }) + throw new HttpError(422, `${type} is not a supported type`) } } @@ -132,9 +120,16 @@ exports.getWithin = async ctx => { date_to = moment.utc(), types = '', format = 'full', + aggregate = 'count', } = ctx.request.query - const pointBufferData = within ? bufferFromPolygon(within) : bufferFromCorners(point_from, point_to) + let pointBufferData = null + + try { + pointBufferData = within ? bufferFromPolygon(within) : bufferFromCorners(point_from, point_to) + } catch (e) { + throw new InputValidationError(['within', 'point_from', 'point_to']) + } const pointBuffer = pointBufferData.map(pb => pb.map(Number).join(' ')).join(',') const fromDate = moment.utc(date_from) @@ -142,7 +137,6 @@ exports.getWithin = async ctx => { const metricTypes = splitString(types) - const query = format === 'marker' ? ctx.services['data.metrics'].queryAggregate @@ -154,6 +148,7 @@ exports.getWithin = async ctx => { fromDate.toISOString(), // moment().subtract(12, 'months').toISOString(), toDate.toISOString(), + aggregate, ) ctx.body = { metrics } diff --git a/src/http/controllers/api/user.js b/src/http/controllers/api/user.js index 7c100458b8f026af5fddecdeb2a4409b351fbf6f..7d080180739a26afdd808128c7487c1b082e5d79 100644 --- a/src/http/controllers/api/user.js +++ b/src/http/controllers/api/user.js @@ -7,11 +7,7 @@ exports.self = async ctx => { user: await user.serialise(), } } else { - throw new HttpError({ - status: 404, - title: 'No such user', - description: 'No user is currently logged in', - }) + throw new HttpError(404, 'No user is currently logged in') } } diff --git a/src/http/controllers/api/v2/classifications.js b/src/http/controllers/api/v2/classifications.js new file mode 100644 index 0000000000000000000000000000000000000000..163ecec20142109b1881bc382bba926e49ea4a8b --- /dev/null +++ b/src/http/controllers/api/v2/classifications.js @@ -0,0 +1,71 @@ +const publicStatus = new Set(['accepted']) +const privateStatus = new Set(['pending', 'rejected']) + +const { Sequelize, ClassificationRoot } = require('database/models') +const NotFoundError = require("core/errors/NotFoundError"); +const UnauthorizedError = require("core/errors/UnauthorizedError"); +const InputValidationError = require("core/errors/InputValidationError"); + +exports.listRoots = async ctx => { + const { status = '', limit = 25, after = null } = ctx.query + + const requestedStatus = status.split(',') + .map(s => s.trim()) + .filter(s => publicStatus.has(s) || privateStatus.has(s)) + + const query = { + order: ['metric_id', ['created_at', 'DESC']], + where: { + status: { + [Sequelize.Op.in]: requestedStatus, + } + }, + limit, + } + + const count = await ClassificationRoot.count(query) + + if (after) { + query.where.metric_id = { + [Sequelize.Op.gt]: after, + } + } + + const roots = await ClassificationRoot.findAll(query) + + ctx.body = { + roots, + meta: { + total: count, + } + } +} + +exports.putRootStatus = async ctx => { + const user = await ctx.services['core.auth'].getUser() + + if (!user) { + throw new UnauthorizedError() + } + + if (ctx.models.classification == null) { + throw new NotFoundError('Classification Root') + } + + const { status } = ctx.request.body + const { classification } = ctx.models + + if (!publicStatus.has(status) && !privateStatus.has(status)) { + throw new InputValidationError(['status']) + } + + classification.status = status + classification.meta = { + approved_by: user.id, + } + await classification.save() + + ctx.body = { + root: classification, + } +} \ No newline at end of file diff --git a/src/http/controllers/api/v2/uploads.js b/src/http/controllers/api/v2/uploads.js index 62f03586532c700bf6cd8bb2ebcc8689f5f44a75..af118767eb3dc45f71a831e1126c78c1d123c85e 100644 --- a/src/http/controllers/api/v2/uploads.js +++ b/src/http/controllers/api/v2/uploads.js @@ -1,3 +1,4 @@ +const {queue} = require("services"); exports.createUpload = async ctx => { const { fs } = require('services') const { v4: uuid } = require('uuid') @@ -28,3 +29,43 @@ exports.createUpload = async ctx => { ctx.body = { upload: payload } } + +/** + * { + * message: { + * attributes: { + * bucketId: 'jetsam-uploads-staging', + * eventTime: '2022-02-23T18:10:22.885464Z', + * eventType: 'OBJECT_FINALIZE', + * notificationConfig: 'projects/_/buckets/jetsam-uploads-staging/notificationConfigs/3', + * objectGeneration: '1645639822790386', + * objectId: 'funi.png', + * overwroteGeneration: '1645638845013021', + * payloadFormat: 'JSON_API_V1' + * }, + * data: 'ewogICJraW5kIjogInN0b3JhZ2Ujb2JqZWN0IiwKICAiaWQiOiAiamV0c2FtLXVwbG9hZHMtc3RhZ2luZy9mdW5pLnBuZy8xNjQ1NjM5ODIyNzkwMzg2IiwKICAic2VsZkxpbmsiOiAiaHR0cHM6Ly93d3cuZ29vZ2xlYXBpcy5jb20vc3RvcmFnZS92MS9iL2pldHNhbS11cGxvYWRzLXN0YWdpbmcvby9mdW5pLnBuZyIsCiAgIm5hbWUiOiAiZnVuaS5wbmciLAogICJidWNrZXQiOiAiamV0c2FtLXVwbG9hZHMtc3RhZ2luZyIsCiAgImdlbmVyYXRpb24iOiAiMTY0NTYzOTgyMjc5MDM4NiIsCiAgIm1ldGFnZW5lcmF0aW9uIjogIjEiLAogICJjb250ZW50VHlwZSI6ICJpbWFnZS9wbmciLAogICJ0aW1lQ3JlYXRlZCI6ICIyMDIyLTAyLTIzVDE4OjEwOjIyLjg4NVoiLAogICJ1cGRhdGVkIjogIjIwMjItMDItMjNUMTg6MTA6MjIuODg1WiIsCiAgInN0b3JhZ2VDbGFzcyI6ICJTVEFOREFSRCIsCiAgInRpbWVTdG9yYWdlQ2xhc3NVcGRhdGVkIjogIjIwMjItMDItMjNUMTg6MTA6MjIuODg1WiIsCiAgInNpemUiOiAiMTI2MDkiLAogICJtZDVIYXNoIjogIlZhakdKT3Q0QlV4OWxubVF4YURQaUE9PSIsCiAgIm1lZGlhTGluayI6ICJodHRwczovL3d3dy5nb29nbGVhcGlzLmNvbS9kb3dubG9hZC9zdG9yYWdlL3YxL2IvamV0c2FtLXVwbG9hZHMtc3RhZ2luZy9vL2Z1bmkucG5nP2dlbmVyYXRpb249MTY0NTYzOTgyMjc5MDM4NiZhbHQ9bWVkaWEiLAogICJjcmMzMmMiOiAiZUZ1L0VBPT0iLAogICJldGFnIjogIkNQS2R3NGkybHZZQ0VBRT0iCn0K', + * messageId: '4057500370063368', + * message_id: '4057500370063368', + * publishTime: '2022-02-23T18:10:23.229Z', + * publish_time: '2022-02-23T18:10:23.229Z' + * }, + * subscription: 'projects/hot-trash-server/subscriptions/jetsam-uploads-staging-events-subscription' + * } + */ + +exports.storageEventsWebhook = async ctx => { + const { queue } = require('services') + + const { message } = ctx.request.body + const bucket = message?.attributes?.bucketId + const key = message?.attributes?.objectId + + if (message?.attributes?.eventType === 'OBJECT_FINALIZE') { + await queue.dispatch('upload-successful', { + bucket, + key, + }) + } + + ctx.status = 204 +} \ No newline at end of file diff --git a/src/http/controllers/auth.js b/src/http/controllers/auth.js index fe8927530bc5916e61aaf0f5baea715cc1c9afbe..3e911f1454cc61c35da6d436c0edb1d294375871 100644 --- a/src/http/controllers/auth.js +++ b/src/http/controllers/auth.js @@ -10,7 +10,7 @@ exports.login = async ctx => { } exports.logout = async ctx => { await ctx.services['core.auth'].clearSessionAuth() - return ctx.redirect('/') + return ctx.redirect('https://jetsam.tech') } exports.showLogin = async ctx => { @@ -31,25 +31,6 @@ exports.showLogin = async ctx => { exports.handleLoginRedirect = async ctx => { const redirectTo = await createRedirectedUrl(ctx) return ctx.redirect(redirectTo) - - // const query = ctx.request.query - // - // if (query.login_state || query.auth_state) { - // const { login_state, auth_state } = ctx.request.query - // const state = login_state ?? auth_state - // - // const values = JSON.parse(await crypto.decrypt(state)) - // - // if (values.redirect === 'authorize') { - // return ctx.redirect( - // `/auth/authorize?auth_state=${state}`, - // ) - // } else { - // return ctx.redirect('/') - // } - // } else { - // return ctx.redirect('/') - // } } const resetErrorMessages = { diff --git a/src/http/controllers/dev/clients.js b/src/http/controllers/dev/clients.js new file mode 100644 index 0000000000000000000000000000000000000000..9e59939329f291d281d20517413cdc85d3aea015 --- /dev/null +++ b/src/http/controllers/dev/clients.js @@ -0,0 +1,154 @@ +const HttpError = require("../../../core/errors/HttpError"); +const {validScopes} = require("../../../domain/auth/OAuthFlow"); +const {OAuthClient} = require("database/models"); + +exports.showClients = async ctx => { + const user = await ctx.services['core.auth'].getUser() + const serverTokens = await user.getServerTokens() + const clients = await user.getOAuthClients() + + return await ctx.render('developers/index', { + tokens: serverTokens.map(t => t.toJSON()), + clients: clients.map(c => c.toJSON()), + }) +} +exports.showCreateToken = async ctx => { + return await ctx.render('developers/token/new') +} +exports.showCreateApplication = async ctx => { + const scopes = Object.entries(validScopes) + .filter(([scope]) => scope !== '*' && !scope.includes('admin')) + .map(([scope, data]) => ({ + key: scope, + ...data, + })) + + return await ctx.render('developers/application/new', { scopes }) +} + +exports.tokenAction = async ctx => { + const method = ctx.request.body?._method + + switch (method) { + case 'delete': + await exports.deleteToken(ctx) + return + default: + return + } +} + +exports.deleteToken = async ctx => { + const user = await ctx.services['core.auth'].getUser() + const tokenId = ctx.params.id + + const token = await user.getServerTokens({ where: { id: tokenId } }) + + if (token?.length > 0) { + await token[0].destroy() + } + + ctx.redirect('/developers') +} + +exports.createToken = async ctx => { + const user = await ctx.services['core.auth'].getUser() + + const { name, description } = ctx.request.body + + const token = await user.asJWTToken() + const serverToken = await user.createServerToken({ + value: token, + name, + description, + }) + + if (serverToken) { + return ctx.redirect('/developers') + } + + throw new HttpError(500, 'Failed to create server token') +} + +exports.createApplication = async ctx => { + const user = await ctx.services['core.auth'].getUser() + + const { name, description, redirects } = ctx.request.body + const redirectList = redirects.split(',') + .map(r => r.trim()) + .filter(Boolean) + + const client = await OAuthClient.generateClient(user.id, { name, description, redirect_uris: redirectList }) + if (client) { + return ctx.redirect('/developers') + } + + throw new HttpError(500, 'Failed to create an OAuth Application') +} + +exports.applicationAction = async ctx => { + const method = ctx.request.body?._method + + switch (method) { + case 'delete': + await exports.deleteApplication(ctx) + return + case 'put': + await exports.updateApplication(ctx) + default: + return + } +} + +exports.deleteApplication = async ctx => { + const user = await ctx.services['core.auth'].getUser() + const appId = ctx.params.id + + const apps = await user.getOAuthClients({ where: { id: appId } }) + + if (apps?.length > 0) { + await apps[0].destroy() + } + + ctx.redirect('/developers') +} + +exports.updateApplication = async ctx => { + const user = await ctx.services['core.auth'].getUser() + const appId = ctx.params.id + + const apps = await user.getOAuthClients({ where: { id: appId } }) + + if (apps.length > 0) { + const application = apps[0] + + const { name, description, redirects } = ctx.request.body + const redirectList = redirects.split(',') + .map(r => r.trim()) + .filter(Boolean) + + application.name = name + application.description = description + application.redirect_uris = redirectList + + await application.save() + ctx.redirect('/developers') + } +} +exports.showEditApplication = async ctx => { + const user = await ctx.services['core.auth'].getUser() + const appId = ctx.params.id + + const apps = await user.getOAuthClients({ where: { id: appId } }) + if (apps.length > 0) { + const application = apps[0] + return await ctx.render('developers/application/edit', { + application: { + id: application.id, + name: application.name, + description: application.description ?? '', + redirects: application.redirect_uris.join(',') + } + }) + } +} \ No newline at end of file diff --git a/src/http/controllers/oidc.js b/src/http/controllers/oidc.js index d8d6a9794ab8c971b82d6e635a6002b6d18bd2bf..4e9acfb1c28255d7aa3385caeb7b304b1588bf89 100644 --- a/src/http/controllers/oidc.js +++ b/src/http/controllers/oidc.js @@ -20,12 +20,13 @@ exports.handleLogin = async ctx => { } const { email, password } = ctx.request.body - const user = await ctx.services['core.auth'].attemptLogin(email, password) - - const result = { - login: { - accountId: user.id, - }, + const result = {} + try { + const user = await ctx.services['core.auth'].attemptLogin(email, password) + result.login = { accountId: user.id } + } catch(e) { + result.error = 'invalid_credentials' + result.error_description = e.message } return await ctx.services['auth.oidc'].withProvider(p => p.interactionFinished(ctx.req, ctx.res, result, { diff --git a/src/http/middleware/DeviceProperties.js b/src/http/middleware/DeviceProperties.js index ae8f9578b6bf2aa6dd2c3c7d250323538daf2dc5..cbe1fee27a05b78bc92b8a70d6ee600e4af48d42 100644 --- a/src/http/middleware/DeviceProperties.js +++ b/src/http/middleware/DeviceProperties.js @@ -1,7 +1,7 @@ exports.extractDevice = async (ctx, next) => { - const deviceId = ctx.get('x-request-device') - const platform = ctx.get('x-request-platform') - const rawSlug = ctx.get('x-request-slug') + const deviceId = ctx.get('x-request-device') || ctx.get('Request-Device') + const platform = ctx.get('x-request-platform') || ctx.get('Request-Platform') + const rawSlug = ctx.get('x-request-slug') || ctx.get('Request-Info') const slug = rawSlug ? Buffer.from(rawSlug, 'base64').toString('utf-8') : null diff --git a/src/http/middleware/ErrorHandler.js b/src/http/middleware/ErrorHandler.js index 29985984bc80c85b4a333e9c38f1e3c69e8d3cea..5e6a0d55f8fb24429669fd51caced5b7cbf8ab29 100644 --- a/src/http/middleware/ErrorHandler.js +++ b/src/http/middleware/ErrorHandler.js @@ -8,9 +8,7 @@ module.exports = async (ctx, next) => { try { await next(ctx) } catch (e) { - if (e instanceof SafeModeError) { - console.error(e) - } else { + if (!(e instanceof SafeModeError)) { await SentryReporter.report(e, ctx) } hasHandledError = true diff --git a/src/http/middleware/NotFoundHandler.js b/src/http/middleware/NotFoundHandler.js new file mode 100644 index 0000000000000000000000000000000000000000..573e9ef2d80a6dfee154dc73bf10e5ff866d9fab --- /dev/null +++ b/src/http/middleware/NotFoundHandler.js @@ -0,0 +1,49 @@ +const SentryReporter = require('./SentryReporter') + +module.exports = async (ctx, next) => { + try { + await next() + await applyResponse(ctx) + } catch(e) { + console.error(e) + ctx.status = 500 + await applyResponse(ctx, e) + } +} + +async function applyResponse(ctx, e = null) { + if (e) { + await SentryReporter.report(e, ctx) + } + + if (ctx.status >= 400) { + const initialStatus = ctx.status + + if (ctx.accepts('html')) { + if (ctx.status === 404 && ctx.body == null) { + await ctx.render('404', { status: '404', title: 'Page not found', description: 'Sorry, we couldn\'t find the page you were looking for.' }) + } + + if (ctx.status === 500) { + await ctx.render('404', { status: '500', title: 'Something went wrong', description: 'There was a problem with the action that you just attempted. Sorry about that.' }) + } + } else if (ctx.accepts('json')) { + if (ctx.status === 404 && ctx.body == null) { + ctx.body = { + errors: { + general: ['The requested resource does not exist'], + }, + } + } + if (ctx.status === 500 && ctx.body == null) { + ctx.body = { + errors: { + general: ['There was a problem with the action that you just attempted'], + }, + } + } + } + + ctx.status = initialStatus + } +} diff --git a/src/http/params/classification.js b/src/http/params/classification.js new file mode 100644 index 0000000000000000000000000000000000000000..23362fe0f1b472e75b82432c30a43ca96fb322d0 --- /dev/null +++ b/src/http/params/classification.js @@ -0,0 +1,7 @@ +const { ClassificationRoot } = require('database/models') + +module.exports = async (id, ctx, next) => { + ctx.models = ctx.models ?? {} + ctx.models.classification = await ClassificationRoot.findByPk(id) + return await next() +} diff --git a/src/http/params/survey.js b/src/http/params/survey.js index 92ded5d8e02be21c1f5280ff39c08f028dc2ec2e..70af8a61436c8a348e71720018dc6f8b01314ce6 100644 --- a/src/http/params/survey.js +++ b/src/http/params/survey.js @@ -14,9 +14,6 @@ module.exports = async (id, ctx, next) => { } }] : [] - console.log("INCLUDES", includes) - - ctx.models.survey = await Survey.findOne({ where: { id, @@ -31,7 +28,5 @@ module.exports = async (id, ctx, next) => { include: includes }) - console.log(ctx.models.survey) - return await next() } diff --git a/src/http/routers/routes_v2.js b/src/http/routers/routes_v2.js index 45691f1663a664f4481155ab33d3614e2eec492e..5e1c91183b3fcf34d8450ad130459c13150daf15 100644 --- a/src/http/routers/routes_v2.js +++ b/src/http/routers/routes_v2.js @@ -22,6 +22,7 @@ router.get( ctx => (ctx.body = { name: 'Jetsam Data API', + version: require('../../../package.json').version, prefix: ctx.path, }), ) @@ -54,6 +55,7 @@ router.put('/uploads/:upload_id/:property', noop) router.param('survey', param('survey')) router.param('excerpt', param('survey_excerpt')) +router.param('classification', param('classification')) router.get('/surveys', controller('api/v2/surveys', 'list')) router.get('/excerpts', controller('api/v2/surveys', 'listExcerpts')) @@ -65,6 +67,11 @@ if (config('app.dev')) { router.post('/surveys/factory', safemode, controller('api/v2/factories', 'survey')) } +router.get('/classifications/roots', controller('api/v2/classifications', 'listRoots')) +router.put('/classifications/roots/:classification/status', controller('api/v2/classifications', 'putRootStatus')) + +router.post('/webhooks/storage/events', controller('api/v2/uploads', 'storageEventsWebhook')) + router.post('/an/ev', safemode, controller('api/analytics', 'track')) module.exports = router diff --git a/src/http/routes.js b/src/http/routes.js index c948e3ba9ec693d6a0d075dfffce30e7fecc657b..e71aecafe5bb33d5ba4a6b0c8be9fa094ae639d5 100644 --- a/src/http/routes.js +++ b/src/http/routes.js @@ -14,6 +14,7 @@ const errors = require('http/middleware/ErrorHandler') const includes = require('http/middleware/ParseIncludes') const profiling = require('http/middleware/Profiler') const loaders = require('http/middleware/MountLoaders') +const notFound = require('http/middleware/NotFoundHandler') const userGate = require('http/middleware/RequiresAuth') const authRedirect = require('http/middleware/RedirectToLogin') const device = require('http/middleware/DeviceProperties').extractDevice @@ -63,6 +64,17 @@ web.post('/reset-password', controller('auth', 'handleResetPassword')) web.get('/auth/authorize', authRedirect, AuthServer.authorize) web.post('/auth/authorize', AuthServer.authorize) web.post('/auth/token', AuthServer.token) + +web.get('/developers', authRedirect, controller('dev/clients', 'showClients')) +web.get('/developers/tokens/new', authRedirect, controller('dev/clients', 'showCreateToken')) +web.get('/developers/applications/new', authRedirect, controller('dev/clients', 'showCreateApplication')) +web.get('/developers/applications/:id', authRedirect, controller('dev/clients', 'showEditApplication')) + +web.post('/developers/tokens', authRedirect, controller('dev/clients', 'createToken')) +web.post('/developers/tokens/:id', authRedirect, controller('dev/clients', 'tokenAction')) +web.post('/developers/applications', authRedirect, controller('dev/clients', 'createApplication')) +web.post('/developers/applications/:id', authRedirect, controller('dev/clients', 'applicationAction')) + env('FS_DRIVER', 'local') === 'local' && (function () { const debug = require('debug')('server:routes') diff --git a/src/vendor/koa-handlebars.js b/src/vendor/koa-handlebars.js index 363b9293e19b13afa5c58e348759198cb7c971f1..28c97f11eeec5334a3ae56b29d8afe922a9820f0 100644 --- a/src/vendor/koa-handlebars.js +++ b/src/vendor/koa-handlebars.js @@ -109,7 +109,7 @@ module.exports = function createRenderMiddleware(root, opts = {}) { data = {}, opts = {}, ) { - const content = await instance.render(template, data, opts) + const content = await instance.render(template, { ...data, ctx }, opts) if (content == null) { this.status = 404 return diff --git a/tests/integration/authentication/Login.test.js b/tests/integration/authentication/Login.test.js index 16b61fcc556b323deb676243ff9b2844a6f4afcb..f989762bbe2655342fd40f68d28f798b4f903ecf 100644 --- a/tests/integration/authentication/Login.test.js +++ b/tests/integration/authentication/Login.test.js @@ -5,6 +5,7 @@ test('Logging in returns a token', async () => { const app = await createApp() const response = await request(app.callback()) .post('/api/register') + .set('Accept', 'application/json') .send({ email: 'login-returns-token@example.com', password: 'password', @@ -15,6 +16,7 @@ test('Logging in returns a token', async () => { const response2 = await request(app.callback()) .post('/api/login') + .set('Accept', 'application/json') .send({ email: 'login-returns-token@example.com', password: 'password', diff --git a/tests/integration/authentication/Registration.test.js b/tests/integration/authentication/Registration.test.js index 09bcf458fde13a95543b799b46567ee769bbeabc..9b4396194f05ebcb3f79e83f9ae8f8c5bc268749 100644 --- a/tests/integration/authentication/Registration.test.js +++ b/tests/integration/authentication/Registration.test.js @@ -13,6 +13,7 @@ test('Can register a new account', async () => { const app = await createApp() const response = await request(app.callback()) .post('/api/register') + .set('Accept', 'application/json') .send({ email: 'johnson@example.com', password: 'foo', @@ -30,6 +31,7 @@ test('Invalid registration request returns 400', async () => { const app = await createApp() const response = await request(app.callback()) .post('/api/register') + .set('Accept', 'application/json') .send({ incorrect: 'property' }) @@ -41,6 +43,7 @@ test('Two accounts cannot share an email address', async () => { const app = await createApp() const value1 = await request(app.callback()) .post('/api/register') + .set('Accept', 'application/json') .send({ email: 'example@example.com', password: 'foo', @@ -49,6 +52,7 @@ test('Two accounts cannot share an email address', async () => { const value2 = await request(app.callback()) .post('/api/register') + .set('Accept', 'application/json') .send({ email: 'example@example.com', password: 'foo', diff --git a/tests/integration/system/SafeMode.test.js b/tests/integration/system/SafeMode.test.js new file mode 100644 index 0000000000000000000000000000000000000000..9b5ddb210640159be5e1eb3f7cdef41f6b90b106 --- /dev/null +++ b/tests/integration/system/SafeMode.test.js @@ -0,0 +1,12 @@ +const request = require('supertest') +const createApp = require('app') + +test('Safe Mode Blocks Mutations', async () => { + process.env.DISABLE_MUTATION = 'true' + const app = await createApp() + const response = await request(app.callback()) + .post('/api/v2/metrics') + .set('Accept', 'application/json') + + expect(response.status).toBe(5400) +}) \ No newline at end of file diff --git a/views/404.hbs b/views/404.hbs new file mode 100644 index 0000000000000000000000000000000000000000..6d14251f6ed315b66c5f689a49462f9d70f800db --- /dev/null +++ b/views/404.hbs @@ -0,0 +1,67 @@ +<!DOCTYPE html> + +<html class="h-full bg-white" lang="en"> +<head> + <title>Not Found | Jetsam</title> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> + <meta name="description" content=""> + <meta name="author" content=""> + <!-- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css">--> + <script src="https://cdn.tailwindcss.com"></script> + <script> + tailwind.config = { + theme: { + extend: { + colors: { + jetsam: '#FF5E82', + 'jetsam-dark': '#FF1D3C', + 'jetsam-light': '#FF7BAC', + } + } + } + } + </script> + + <style> + body, body > div { + height: 100%; + } + </style> +</head> +<body class="h-full"> +<div class="min-h-full pt-16 pb-12 flex flex-col bg-white"> + <main class="flex-grow flex flex-col max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8"> + <div class="flex-shrink-0 flex justify-center mt-auto"> + <a href="/" class="inline-flex"> + <span class="sr-only">Workflow</span> + <img class="h-48 aspect-square" src="/logo.svg" alt="Jetsam"> + </a> + </div> + <div class="py-16 mb-auto"> + <div class="text-center"> + <p class="text-sm font-semibold text-jetsam uppercase tracking-wide">{{ status }} error</p> + <h1 class="mt-2 text-4xl font-extrabold text-gray-900 tracking-tight sm:text-5xl">{{ title }}</h1> + <p class="mt-2 text-base text-gray-500">{{ description }}</p> + <p class="text-base text-gray-500">Here are a few other things you can do:</p> + <div class="mt-6 space-y-3 flex flex-col"> + <a href="https://jetsam.tech" class="text-base font-medium text-jetsam hover:text-jetsam-light">Visit jetsam.tech<span aria-hidden="true"> →</span></a> + <a href="https://play.google.com/store/apps/details?id=tech.jetsam.catalog&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1" class="text-base font-medium text-jetsam hover:text-jetsam-light">Download Jetsam for Android<span aria-hidden="true"> →</span></a> + <a href="https://apps.apple.com/gb/app/jetsam/id1494342033?ls=1" class="text-base font-medium text-jetsam hover:text-jetsam-light">Download Jetsam for iOS<span aria-hidden="true"> →</span></a> + </div> + </div> + </div> + </main> + <footer class="flex-shrink-0 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 pb-8"> + <nav class="flex justify-center space-x-4"> + <a href="https://www.facebook.com/JetsamTech" class="text-sm font-medium text-gray-500 hover:text-gray-600">Facebook</a> + <span class="inline-block border-l border-gray-300" aria-hidden="true"></span> + <a href="https://instagram.com/jetsam.tech_app" class="text-sm font-medium text-gray-500 hover:text-gray-600">Instagram</a> + <span class="inline-block border-l border-gray-300" aria-hidden="true"></span> + <a href="https://twitter.com/jetsam_tech" class="text-sm font-medium text-gray-500 hover:text-gray-600">Twitter</a> + </nav> + </footer> +</div> + +</body> +</html> diff --git a/views/developers/application/edit.hbs b/views/developers/application/edit.hbs new file mode 100644 index 0000000000000000000000000000000000000000..136209e986de8ef2cdd63c4cb5905a054449583b --- /dev/null +++ b/views/developers/application/edit.hbs @@ -0,0 +1,153 @@ +<!DOCTYPE html> + +<html class="h-full bg-gray-100" lang="en"> +<head> + <title>Edit OAuth Application | Jetsam</title> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> + <meta name="description" content=""> + <meta name="author" content=""> + + <script src="/scripts/edit-application.js"></script> + <script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio"></script> + <script> + tailwind.config = { + theme: { + extend: { + colors: { + jetsam: '#FF5E82', + 'jetsam-dark': '#FF1D3C', + 'jetsam-light': '#FF7BAC', + } + } + } + } + </script> + + <style> + body, body > div { + height: 100%; + } + </style> +</head> +<body class="h-full"> + +<div class="flex flex-col md:flex-row"> + + {{> developer-sidebar }} + + <div class="flex flex-col flex-1"> + <main class="flex-1"> + <div class="py-6"> + <div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8 space-y-4"> + <!-- Content --> + <div class="bg-white shadow sm:rounded-lg"> + <div class="px-4 py-5 sm:p-6"> + + <h3 class="text-lg leading-6 font-medium text-gray-900">Create your OAuth Application</h3> + + <div class="mt-2 text-sm text-gray-500 bg-blue-200 p-4 border-radius-8"> + <p><span class="font-bold">Remember:</span> + When authenticating, users will be shown the application name, the application description, and a short piece of information about each scope that you request. + </p> + </div> + + <form id="new-app-form" class="mt-5 flex flex-col space-y-3" method="post" action="/developers/applications/{{application.id}}"> + <input type="hidden" name="_method" value="put" /> + + <div> + <label for="name" class="block text-sm font-medium text-gray-700">App Name</label> + <div class="mt-1"> + <input + type="text" + name="name" + id="name" + value="{{application.name}}" + minlength="1" + maxlength="50" + required + class="shadow-sm focus:ring-jetsam focus:border-jetsam block w-full sm:text-sm border-gray-300 rounded-md" + placeholder="Litterbug 9000" + aria-describedby="name-description"> + </div> + <p class="mt-2 text-sm text-gray-500" id="name-description">The name of your business or application</p> + </div> + + <div> + <label for="description" class="block text-sm font-medium text-gray-700">App Description</label> + <div class="mt-1"> + <textarea + type="text" + rows="4" + name="description" + id="description" + maxlength="500" + class="shadow-sm focus:ring-jetsam focus:border-jetsam block w-full sm:text-sm border-gray-300 rounded-md" + placeholder="This app saves the world" + aria-describedby="description-description">{{application.description}}</textarea> + </div> + <p class="mt-2 text-sm text-gray-500" id="description-description">Describe what your application does with a user's Jetsam account</p> + </div> + + <div id="redirects-container"> + <label for="redirects" class="block text-sm font-medium text-gray-700"> + <p>Redirect URIs</p> + <p id="no-js-description" class="text-sm italic text-gray-600">Provide a comma seperated list of redirect URIs</p> + </label> + <div class="mt-1 flex" id="redirects-wrapper"> + <input + type="text" + name="redirects" + id="redirects" + value="{{application.redirects}}" + maxlength="500" + class="flex-1 shadow-sm focus:ring-jetsam focus:border-jetsam block w-full sm:text-sm border-gray-300 rounded-md" + placeholder="https://example.com" + aria-describedby="redirects-description"> + </div> + <p class="mt-2 text-sm text-gray-500" id="redirects-description">The auth process will only successfully complete if the redirect URI provided during the OAuth exchange exactly matches the protocol, host and path of one of these URIs.</p> + <p id="redirects-error" class="hidden p-4 bg-red-200 text-red-800 rounded-md mt-2"></p> + <div id="redirects-list" class="hidden space-x-2 space-y-2 flex-wrap flex items-center border border-dashed border-gray-300 mt-4 p-4" style="min-height: 2rem;"></div> + </div> + + {{!-- + <fieldset class="space-y-5"> + <legend class="sr-only">Scopes</legend> + <h2> + App Permissions + </h2> + <p class="text-sm">Users will be asked to approve the selected permissions when authenticating, and will be shown the following descriptions of each:</p> + {{#each scopes}} + <div class="relative flex items-start"> + <div class="flex items-center h-5"> + <input id="scope-{{key}}" aria-describedby="scope-{{key}}-description" name="scope:{{key}}" type="checkbox" class="focus:ring-jetsam h-4 w-4 text-jetsam border-gray-300 rounded"> + </div> + <div class="ml-3 text-sm"> + <label for="scope-{{key}}" class="flex flex-col space-y-2 "> + <span class="inline-flex items-center space-x-2"> + <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"> {{key}} </span> + <span class="font-medium text-gray-700">{{name}}</span> + </span> + <span id="scope-{{key}}-description" class="text-gray-500">{{description}}</span> + </label> + </div> + </div> + {{/each}} + </fieldset> + --}} + + <button type="submit" + class="mt-3 w-full inline-flex items-center justify-center px-4 py-2 border border-transparent shadow-sm font-medium rounded-md text-white bg-emerald-600 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 sm:mt-0 sm:w-auto sm:text-sm"> + Save + </button> + </form> + </div> + </div> + </div> + </div> + </main> + </div> +</div> + +</body> +</html> diff --git a/views/developers/application/new.hbs b/views/developers/application/new.hbs new file mode 100644 index 0000000000000000000000000000000000000000..8f065e5db8bd1dd83ce8308421b9608edc26c68d --- /dev/null +++ b/views/developers/application/new.hbs @@ -0,0 +1,150 @@ +<!DOCTYPE html> + +<html class="h-full bg-gray-100" lang="en"> +<head> + <title>New OAuth Application | Jetsam</title> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> + <meta name="description" content=""> + <meta name="author" content=""> + + <script src="/scripts/create-application.js"></script> + <script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio"></script> + <script> + tailwind.config = { + theme: { + extend: { + colors: { + jetsam: '#FF5E82', + 'jetsam-dark': '#FF1D3C', + 'jetsam-light': '#FF7BAC', + } + } + } + } + </script> + + <style> + body, body > div { + height: 100%; + } + </style> +</head> +<body class="h-full"> + +<div class="flex flex-col md:flex-row"> + + {{> developer-sidebar }} + + <div class="flex flex-col flex-1"> + <main class="flex-1"> + <div class="py-6"> + <div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8 space-y-4"> + <!-- Content --> + <div class="bg-white shadow sm:rounded-lg"> + <div class="px-4 py-5 sm:p-6"> + + <h3 class="text-lg leading-6 font-medium text-gray-900">Create your OAuth Application</h3> + + <div class="mt-2 text-sm text-gray-500 bg-blue-200 p-4 border-radius-8"> + <p><span class="font-bold">Remember:</span> + When authenticating, users will be shown the application name, the application description, and a short piece of information about each scope that you request. + </p> + </div> + + <form id="new-app-form" class="mt-5 flex flex-col space-y-3" method="post" action="/developers/applications"> + + <div> + <label for="name" class="block text-sm font-medium text-gray-700">App Name</label> + <div class="mt-1"> + <input + type="text" + name="name" + id="name" + minlength="1" + maxlength="50" + required + class="shadow-sm focus:ring-jetsam focus:border-jetsam block w-full sm:text-sm border-gray-300 rounded-md" + placeholder="Litterbug 9000" + aria-describedby="name-description"> + </div> + <p class="mt-2 text-sm text-gray-500" id="name-description">The name of your business or application</p> + </div> + + <div> + <label for="description" class="block text-sm font-medium text-gray-700">App Description</label> + <div class="mt-1"> + <textarea + type="text" + rows="4" + name="description" + id="description" + maxlength="500" + class="shadow-sm focus:ring-jetsam focus:border-jetsam block w-full sm:text-sm border-gray-300 rounded-md" + placeholder="This app saves the world" + aria-describedby="description-description"></textarea> + </div> + <p class="mt-2 text-sm text-gray-500" id="description-description">Describe what your application does with a user's Jetsam account</p> + </div> + + <div id="redirects-container"> + <label for="redirects" class="block text-sm font-medium text-gray-700"> + <p>Redirect URIs</p> + <p id="no-js-description" class="text-sm italic text-gray-600">Provide a comma seperated list of redirect URIs</p> + </label> + <div class="mt-1 flex" id="redirects-wrapper"> + <input + type="text" + name="redirects" + id="redirects" + maxlength="500" + class="flex-1 shadow-sm focus:ring-jetsam focus:border-jetsam block w-full sm:text-sm border-gray-300 rounded-md" + placeholder="https://example.com" + aria-describedby="redirects-description"> + </div> + <p class="mt-2 text-sm text-gray-500" id="redirects-description">The auth process will only successfully complete if the redirect URI provided during the OAuth exchange exactly matches the protocol, host and path of one of these URIs.</p> + <p id="redirects-error" class="hidden p-4 bg-red-200 text-red-800 rounded-md mt-2"></p> + <div id="redirects-list" class="hidden space-x-2 space-y-2 flex-wrap flex items-center border border-dashed border-gray-300 mt-4 p-4" style="min-height: 2rem;"></div> + </div> + + {{!-- + <fieldset class="space-y-5"> + <legend class="sr-only">Scopes</legend> + <h2> + App Permissions + </h2> + <p class="text-sm">Users will be asked to approve the selected permissions when authenticating, and will be shown the following descriptions of each:</p> + {{#each scopes}} + <div class="relative flex items-start"> + <div class="flex items-center h-5"> + <input id="scope-{{key}}" aria-describedby="scope-{{key}}-description" name="scope:{{key}}" type="checkbox" class="focus:ring-jetsam h-4 w-4 text-jetsam border-gray-300 rounded"> + </div> + <div class="ml-3 text-sm"> + <label for="scope-{{key}}" class="flex flex-col space-y-2 "> + <span class="inline-flex items-center space-x-2"> + <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"> {{key}} </span> + <span class="font-medium text-gray-700">{{name}}</span> + </span> + <span id="scope-{{key}}-description" class="text-gray-500">{{description}}</span> + </label> + </div> + </div> + {{/each}} + </fieldset> + --}} + + <button type="submit" + class="mt-3 w-full inline-flex items-center justify-center px-4 py-2 border border-transparent shadow-sm font-medium rounded-md text-white bg-emerald-600 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 sm:mt-0 sm:w-auto sm:text-sm"> + Save + </button> + </form> + </div> + </div> + </div> + </div> + </main> + </div> +</div> + +</body> +</html> diff --git a/views/developers/index.hbs b/views/developers/index.hbs new file mode 100644 index 0000000000000000000000000000000000000000..acaabc11196785e583a2cae7f20acc0f37f2f415 --- /dev/null +++ b/views/developers/index.hbs @@ -0,0 +1,185 @@ +<!DOCTYPE html> + +<html class="h-full bg-gray-100" lang="en"> +<head> + <title>Developer Settings | Jetsam</title> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> + <meta name="description" content=""> + <meta name="author" content=""> + <!-- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css">--> + <script src="/scripts/developers.js"></script> + <script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio"></script> + <script> + tailwind.config = { + theme: { + extend: { + colors: { + jetsam: '#FF5E82', + 'jetsam-dark': '#FF1D3C', + 'jetsam-light': '#FF7BAC', + } + } + } + } + </script> + + <style> + body, body > div { + height: 100%; + } + </style> +</head> +<body class="h-full"> + +<div class="flex flex-col md:flex-row"> + + {{> developer-sidebar}} + + <div class="flex flex-col flex-1 "> + <main class="flex-1"> + <div class="py-6"> + <div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8 space-y-4"> + + <div class="bg-white overflow-hidden shadow rounded-lg divide-y divide-gray-200"> + <div class="px-4 py-5 sm:px-6"> + <p class="text-md font-semibold text-gray-700">Learn how to use your tokens and OAuth applications by visiting the <a class="text-jetsam hover:text-jetsam-dark underline" href="https://docs.jetsam.tech">developer documentation</a></p> + </div> + </div> + + <!-- Card --> + <div class="bg-white overflow-hidden shadow rounded-lg divide-y divide-gray-200"> + <!-- Header --> + <div class="px-4 py-5 sm:px-6"> + <h2 class="text-2xl font-semibold text-gray-900 text-right">Your Server Tokens</h2> + </div> + + <!-- Content --> + <div class="px-4 py-5 sm:p-6 "> + <!-- Token List --> + {{#if tokens}} + <ul role="list" class="divide-y divide-gray-200"> + {{#each tokens}} + <!-- Token Details --> + <li id="{{id}}" class="flex flex-col space-y-2"> + <div class="relative flex px-4 py-4"> + <div class="flex-1 flex flex-col space-y-2"> + <h3 class="text-sm font-medium text-jetsam truncate">{{ name }}</h3> + <p>{{ description }}</p> + </div> + + <div class="w-max flex items-end space-x-2"> + <button data-show-token="{{id}}" data-token-value="{{value}}" type="button" class="ml-auto inline-flex items-center px-3 py-2 border border-transparent shadow-sm text-sm leading-4 font-medium rounded-md text-white bg-sky-600 hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500"> + Show Token + </button> + <form action="/developers/tokens/{{id}}" method="post"> + <input type="hidden" value="delete" name="_method" /> + <button type="submit" class="ml-auto inline-flex items-center px-3 py-2 border border-transparent shadow-sm text-sm leading-4 font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"> + Delete Token + </button> + </form> + </div> + </div> + </li> + {{/each}} + </ul> + {{else}} + <div class="text-center"> + <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> + <path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" /> + </svg> + <h3 class="mt-2 text-sm font-medium text-gray-900">No Tokens</h3> + <p class="mt-1 text-sm text-gray-500">Get started by creating a new server token.</p> + </div> + {{/if}} + </div> + + <!-- Footer --> + <div class="px-4 py-4 sm:px-6 flex"> + <a href="/developers/tokens/new" class="ml-auto inline-flex items-center px-3 py-2 border border-transparent shadow-sm text-sm leading-4 font-medium rounded-md text-white bg-emerald-600 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500"> + Create A Token + <svg class="ml-3 -mr-1 h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> + <path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd" /> + </svg> + </a> + </div> + </div> + + <!-- Card --> + <div class="bg-white overflow-hidden shadow rounded-lg divide-y divide-gray-200"> + <!-- Header --> + <div class="px-4 py-5 sm:px-6"> + <h2 class="text-2xl font-semibold text-gray-900 text-right">Your OAuth Applications</h2> + </div> + + <!-- Content --> + <div class="px-4 py-5 sm:p-6"> + + <!-- Application List --> + {{#if clients}} + <ul role="list" class="divide-y divide-gray-200"> + {{#each clients}} + <li id="{{id}}" class="flex flex-col space-y-2"> + <div class="relative flex px-4 py-4"> + <div class="flex-1 flex flex-col space-y-2"> + <h3 class="text-sm font-medium text-jetsam truncate">{{ name }}</h3> + <p>{{ description }}</p> + </div> + + <div class="w-max gap-2 grid grid-cols-2"> + <div class="flex justify-center items-center"> + <button data-show-token="{{id}}" data-token-value="{{id}}" data-postfix="-app-id" type="button" class="inline-flex items-center px-3 py-2 border border-transparent shadow-sm text-sm leading-4 font-medium rounded-md text-white bg-sky-600 hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500"> + Show App ID + </button> + </div> + <div class="flex justify-center items-center"> + <button data-show-token="{{id}}" data-token-value="{{secret}}" data-postfix="-app-secret" type="button" class="inline-flex items-center px-3 py-2 border border-transparent shadow-sm text-sm leading-4 font-medium rounded-md text-white bg-sky-600 hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500"> + Show App Secret + </button> + </div> + <div class="flex justify-center items-center"> + <a href="/developers/applications/{{id}}" class="inline-flex items-center px-3 py-2 border border-transparent shadow-sm text-sm leading-4 font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-amber-500"> + Edit App + </a> + </div> + + <form action="/developers/applications/{{id}}" method="post" class="flex justify-center items-center"> + <input type="hidden" value="delete" name="_method" /> + <button type="submit" class="inline-flex items-center px-3 py-2 border border-transparent shadow-sm text-sm leading-4 font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"> + Delete App + </button> + </form> + </div> + </div> + </li> + {{/each}} + </ul> + {{else}} + <div class="text-center"> + <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /> + </svg> + <h3 class="mt-2 text-sm font-medium text-gray-900">No Applications</h3> + <p class="mt-1 text-sm text-gray-500">Get started by creating a new OAuth Application.</p> + </div> + {{/if}} + </div> + + <!-- Footer --> + <div class="px-4 py-4 sm:px-6 flex"> + <a href="/developers/applications/new" class="ml-auto inline-flex items-center px-3 py-2 border border-transparent shadow-sm text-sm leading-4 font-medium rounded-md text-white bg-emerald-600 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500"> + Create An Application + <svg class="ml-3 -mr-1 h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> + <path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd" /> + </svg> + </a> + </div> + </div> + </div> + </div> + </main> + </div> +</div> + +</body> +</html> diff --git a/views/developers/token/new.hbs b/views/developers/token/new.hbs new file mode 100644 index 0000000000000000000000000000000000000000..62a3bccf03f27207086b1fb0cc2dfaf88787d854 --- /dev/null +++ b/views/developers/token/new.hbs @@ -0,0 +1,101 @@ +<!DOCTYPE html> + +<html class="h-full bg-gray-100" lang="en"> +<head> + <title>New Server Token | Jetsam</title> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> + <meta name="description" content=""> + <meta name="author" content=""> + <!-- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css">--> + <script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio"></script> + <script> + tailwind.config = { + theme: { + extend: { + colors: { + jetsam: '#FF5E82', + 'jetsam-dark': '#FF1D3C', + 'jetsam-light': '#FF7BAC', + } + } + } + } + </script> + + <style> + body, body > div { + height: 100%; + } + </style> +</head> +<body class="h-full"> + +<div class="flex flex-col md:flex-row"> + + {{> developer-sidebar }} + + <div class="flex flex-col flex-1"> + <main class="flex-1"> + <div class="py-6"> + <div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8 space-y-4"> + <!-- Content --> + <div class="bg-white shadow sm:rounded-lg"> + <div class="px-4 py-5 sm:p-6"> + + <h3 class="text-lg leading-6 font-medium text-gray-900">Create your token</h3> + + <div class="mt-2 text-sm text-gray-500 bg-red-200 p-4 border-radius-8"> + <p><span class="font-bold">Remember:</span> A server token acts just like you've logged in to Jetsam as yourself, giving access to your account. + It should be treated with as much care as any other secret key or password.</p> + </div> + + <form class="mt-5 flex flex-col space-y-3" method="post" action="/developers/tokens"> + + <div> + <label for="name" class="block text-sm font-medium text-gray-700">Token Name</label> + <div class="mt-1"> + <input + type="text" + name="name" + id="name" + minlength="1" + maxlength="50" + required + class="shadow-sm focus:ring-jetsam focus:border-jetsam block w-full sm:text-sm border-gray-300 rounded-md" + placeholder="Litterbug 9000" + aria-describedby="name-description"> + </div> + <p class="mt-2 text-sm text-gray-500" id="name-description">A short name to identify the token at a glance</p> + </div> + + <div> + <label for="description" class="block text-sm font-medium text-gray-700">Token Description</label> + <div class="mt-1"> + <input + type="text" + name="description" + id="description" + maxlength="200" + class="shadow-sm focus:ring-jetsam focus:border-jetsam block w-full sm:text-sm border-gray-300 rounded-md" + placeholder="Tracking litter, left right up and down" + aria-describedby="description-description"> + </div> + <p class="mt-2 text-sm text-gray-500" id="description-description">A short description that can remind you about the use of this token</p> + </div> + + <button type="submit" + class="mt-3 w-full inline-flex items-center justify-center px-4 py-2 border border-transparent shadow-sm font-medium rounded-md text-white bg-emerald-600 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 sm:mt-0 sm:w-auto sm:text-sm"> + Save + </button> + </form> + </div> + </div> + </div> + </div> + </main> + </div> +</div> + +</body> +</html> diff --git a/views/helpers/bootstrap.js b/views/helpers/bootstrap.js index 4376c1d25b9dfd302300d6a6c9f21d183c93589c..1c1e3c219e662a3918b3538360be7d90f5f39d0a 100644 --- a/views/helpers/bootstrap.js +++ b/views/helpers/bootstrap.js @@ -40,6 +40,17 @@ exports.ifconf = function(name, fallback, options) { } } +exports.isroute = function(path, options) { + const actualPath = options.data.root.ctx.path + + if (path === actualPath) { + return options.fn(this) + } else { + return options.inverse(this) + } + +} + exports.current_year = function() { const date = new Date() return new Handlebars.SafeString(String(date.getFullYear())) diff --git a/views/partials/developer-sidebar.hbs b/views/partials/developer-sidebar.hbs new file mode 100644 index 0000000000000000000000000000000000000000..fc43e6c9c5bdacd2146390222b9ced1f059fdba0 --- /dev/null +++ b/views/partials/developer-sidebar.hbs @@ -0,0 +1,22 @@ +<div class="w-full h-max md:h-full flex-shrink-0 md:w-64 flex flex-col md:sticky md:top-0 bg-jetsam p-4 md:pt-5"> + + <div class="hidden md:flex items-center flex-shrink-0 px-4 mb-4"> + <img class="aspect-square w-full max-w-4 mx-auto" src="/logo.svg" alt="Jetsam"> + </div> + + <a href="/developers" class="{{#isroute "/developers"}}bg-jetsam-dark {{/isroute}}text-white hover:bg-jetsam-light hover:bg-opacity-75 group flex items-center px-2 py-2 text-sm font-medium rounded-md"> + <!-- Heroicon name: outline/users --> + <span class="mr-auto"> + Developer Settings + </span> + <svg class="mr-3 flex-shrink-0 h-6 w-6 text-emerald-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="white" aria-hidden="true"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /> + </svg> + </a> + + <a href="/logout" class="text-white hover:bg-jetsam-light hover:bg-opacity-75 group flex items-center px-2 py-2 md:px-5 md:py-5 text-sm font-medium rounded-md mt-auto md:border md:border-white"> + <span class="mx-auto"> + Log Out + </span> + </a> +</div> \ No newline at end of file