diff --git a/cdk/bin/cdk.ts b/cdk/bin/cdk.ts index c7c5f0f..299afc3 100644 --- a/cdk/bin/cdk.ts +++ b/cdk/bin/cdk.ts @@ -3,28 +3,38 @@ import 'source-map-support/register'; import * as cdk from 'aws-cdk-lib'; import { LambdaStack } from '../lib/lambda-stack'; import * as dotenv from 'dotenv' -import { AwsSolutionsChecks } from 'cdk-nag' -import { Aspects } from 'aws-cdk-lib'; declare var process : { env: { + VPC_ID: string HOSTED_ZONE_ID: string HOSTED_ZONE_NAME: string DOMAIN_NAME: string ENV_NAME: string CERTIFICATE_ARN: string + CDK_ACCOUNT: string + CDK_REGION: string + REST_API_URL: string } } dotenv.config({path: '../.env'}) const app = new cdk.App(); -const stack = new LambdaStack(app, 'ExplorerLambdaStack', { +const stack = new LambdaStack(app, `ExplorerLambdaStack-${process.env.ENV_NAME}`, { + vpcId: process.env.VPC_ID, hostedZoneId: process.env.HOSTED_ZONE_ID, hostedZoneName: process.env.HOSTED_ZONE_NAME, domainName: process.env.DOMAIN_NAME, envName: process.env.ENV_NAME, - certificateArn: process.env.CERTIFICATE_ARN + certificateArn: process.env.CERTIFICATE_ARN, + lambdaEnv: { + REST_API_URL: process.env.REST_API_URL + }, + env: { + account: process.env.CDK_ACCOUNT, + region: process.env.CDK_REGION + } }); cdk.Tags.of(stack).add('project', 'openaq'); diff --git a/cdk/cdk.context.json b/cdk/cdk.context.json new file mode 100644 index 0000000..9bc6505 --- /dev/null +++ b/cdk/cdk.context.json @@ -0,0 +1,58 @@ +{ + "vpc-provider:account=470049585876:filter.vpc-id=vpc-01de015177eedd05e:region=us-east-1:returnAsymmetricSubnets=true": { + "vpcId": "vpc-01de015177eedd05e", + "vpcCidrBlock": "10.0.0.0/16", + "ownerAccountId": "470049585876", + "availabilityZones": [], + "subnetGroups": [ + { + "name": "Public", + "type": "Public", + "subnets": [ + { + "subnetId": "subnet-0baeac8d7cea3fece", + "cidr": "10.0.0.0/19", + "availabilityZone": "us-east-1a", + "routeTableId": "rtb-000e19ce83d0905d6" + }, + { + "subnetId": "subnet-07a17a8257f4250c5", + "cidr": "10.0.32.0/19", + "availabilityZone": "us-east-1b", + "routeTableId": "rtb-088a4b51a5453d51c" + }, + { + "subnetId": "subnet-0524632c1b5d3e5ea", + "cidr": "10.0.64.0/19", + "availabilityZone": "us-east-1c", + "routeTableId": "rtb-097d6b80c46bd7fd8" + } + ] + }, + { + "name": "Private", + "type": "Private", + "subnets": [ + { + "subnetId": "subnet-09f93828e47072297", + "cidr": "10.0.96.0/19", + "availabilityZone": "us-east-1a", + "routeTableId": "rtb-03e599fde8ca3336a" + }, + { + "subnetId": "subnet-01f1f2600e62bd260", + "cidr": "10.0.128.0/19", + "availabilityZone": "us-east-1b", + "routeTableId": "rtb-01c38feaf28ba3134" + }, + { + "subnetId": "subnet-08c5b31b1d655912b", + "cidr": "10.0.160.0/19", + "availabilityZone": "us-east-1c", + "routeTableId": "rtb-0cf296aaf6574cb3f" + } + ] + } + ] + } +} diff --git a/cdk/lib/lambda-stack.ts b/cdk/lib/lambda-stack.ts index de8e459..7b1b99d 100644 --- a/cdk/lib/lambda-stack.ts +++ b/cdk/lib/lambda-stack.ts @@ -7,6 +7,7 @@ import { aws_lambda as lambda, aws_s3 as s3, aws_s3_deployment, + aws_ec2 as ec2, } from 'aws-cdk-lib'; import { RemovalPolicy } from 'aws-cdk-lib'; import { @@ -20,15 +21,23 @@ import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations import { OriginProtocolPolicy } from 'aws-cdk-lib/aws-cloudfront'; import { Construct } from 'constructs'; +interface LambdaEnv { + [key: string]: string; +} + interface StackProps extends cdk.StackProps { hostedZoneId: string; hostedZoneName: string; domainName: string; envName: string; certificateArn: string; + vpcId: string; + lambdaEnv: LambdaEnv; } + + export class LambdaStack extends cdk.Stack { constructor( scope: Construct @@ -39,11 +48,19 @@ export class LambdaStack extends cdk.Stack { domainName, envName, certificateArn, + vpcId, + lambdaEnv, ...props }: StackProps ) { super(scope, id, props); + + const vpc = ec2.Vpc.fromLookup(this, `${id}-explorer-vpc`, { + vpcId: vpcId + }); + + const lambdaFunction = new lambda.Function( this, `${id}-explorer-lambda`, @@ -53,7 +70,11 @@ export class LambdaStack extends cdk.Stack { handler: 'server/index.handler', memorySize: 512, runtime: lambda.Runtime.NODEJS_20_X, + vpc: vpc, + allowPublicSubnet: true, + vpcSubnets: {subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS}, timeout: cdk.Duration.seconds(10), + environment: lambdaEnv } ); diff --git a/package-lock.json b/package-lock.json index 55febb1..7709004 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "minisearch": "^6.3.0", "nanoid": "^5.0.4", "openaq-design-system": "github:openaq/openaq-design-system#v-4.0.0", + "pg": "^8.12.0", "postgres": "^3.4.3", "solid-js": "^1.8.17", "solid-map-gl": "1.10.0", @@ -45,6 +46,7 @@ "@types/d3": "^7.4.3", "@types/events": "^3.0.3", "@types/node": "^20.11.19", + "@types/pg": "^8.11.6", "@types/testing-library__jest-dom": "^5.14.9", "autoprefixer": "^10.4.19", "jsdom": "^23.2.0", @@ -5007,6 +5009,74 @@ "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==" }, + "node_modules/@types/pg": { + "version": "8.11.6", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.6.tgz", + "integrity": "sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==", + "dev": true, + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^4.0.1" + } + }, + "node_modules/@types/pg/node_modules/pg-types": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", + "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", + "dev": true, + "dependencies": { + "pg-int8": "1.0.1", + "pg-numeric": "1.0.2", + "postgres-array": "~3.0.1", + "postgres-bytea": "~3.0.0", + "postgres-date": "~2.1.0", + "postgres-interval": "^3.0.0", + "postgres-range": "^1.1.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/pg/node_modules/postgres-array": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.2.tgz", + "integrity": "sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@types/pg/node_modules/postgres-bytea": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", + "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", + "dev": true, + "dependencies": { + "obuf": "~1.1.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@types/pg/node_modules/postgres-date": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", + "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@types/pg/node_modules/postgres-interval": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", + "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -12524,6 +12594,12 @@ "node": ">=0.10.0" } }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dev": true + }, "node_modules/ofetch": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.3.4.tgz", @@ -12843,6 +12919,96 @@ "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==" }, + "node_modules/pg": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.12.0.tgz", + "integrity": "sha512-A+LHUSnwnxrnL/tZ+OLfqR1SxLN3c/pgDztZ47Rpbsd4jUytsTtwQo/TLPRzPJMp/1pbhYVhH9cuSZLAajNfjQ==", + "dependencies": { + "pg-connection-string": "^2.6.4", + "pg-pool": "^3.6.2", + "pg-protocol": "^1.6.1", + "pg-types": "^2.1.0", + "pgpass": "1.x" + }, + "engines": { + "node": ">= 8.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.1.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", + "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", + "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-numeric": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz", + "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-pool": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz", + "integrity": "sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz", + "integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", @@ -12950,6 +13116,47 @@ "url": "https://github.com/sponsors/porsager" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-range": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz", + "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==", + "dev": true + }, "node_modules/potpack": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.0.0.tgz", @@ -13942,6 +14149,14 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", diff --git a/package.json b/package.json index 9243b58..ed63c6f 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@types/d3": "^7.4.3", "@types/events": "^3.0.3", "@types/node": "^20.11.19", + "@types/pg": "^8.11.6", "@types/testing-library__jest-dom": "^5.14.9", "autoprefixer": "^10.4.19", "jsdom": "^23.2.0", @@ -51,6 +52,7 @@ "minisearch": "^6.3.0", "nanoid": "^5.0.4", "openaq-design-system": "github:openaq/openaq-design-system#v-4.0.0", + "pg": "^8.12.0", "postgres": "^3.4.3", "solid-js": "^1.8.17", "solid-map-gl": "1.10.0", diff --git a/src/client/backend.tsx b/src/client/backend.tsx new file mode 100644 index 0000000..7eb7495 --- /dev/null +++ b/src/client/backend.tsx @@ -0,0 +1,202 @@ +const baseUrl = process.env.REST_API_URL || 'http://localhost:8080'; + +interface CreateUserDefinition { + fullName: string; + emailAddress: string; + passwordHash: string; + ipAddress: string; +} + +interface UserPasswordDefinition { + usersId: number; + passwordHash: string; +} + +interface CreateListDefinition { + usersId: number; + label: string; + description: string; +} + +interface UpdateListDefinition { + listsId: number; + label: string; + description: string; +}; + +interface ListLocationDefinition { + locationsId: number +} + + +export const db = { + createUser: async ( + user: CreateUserDefinition + ): Promise => { + const res = await fetch(`${baseUrl}/users`, { + method: 'POST', + body: JSON.stringify(user), + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + }); + return res; + }, + + getUserById: async (usersId: number): Promise => { + const res = await fetch(`${baseUrl}/users/${usersId}`, { + method: 'GET', + }); + return res; + }, + + getUserByEmailAddress: async ( + emailAddress: string + ): Promise => { + const res = await fetch( + `${baseUrl}/users/email/${emailAddress}`, + { + method: 'GET', + } + ); + return res; + }, + + getUserByVerificationCode: async ( + verificationCode: string + ): Promise => { + const res = await fetch( + `${baseUrl}/users/verification-code/${verificationCode}`, + { + method: 'GET', + } + ); + return res; + }, + + verifyUser: async ( + usersId: number + ): Promise => { + const res = await fetch( + `${baseUrl}/users/${usersId}/verification`, + { + method: 'PATCH', + } + ); + return res; + }, + + createNewUserToken: async(usersId: number): Promise => { + const res = await fetch( + `${baseUrl}/users/${usersId}/token`, + { + method: 'POST', + } + ); + return res; + }, + + updateUserPassword: async ( + user: UserPasswordDefinition + ): Promise => { + const res = await fetch(`${baseUrl}/users/password`, { + method: 'PUT', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(user), + }); + return res; + }, + + createList: async (list: CreateListDefinition): Promise => { + const res = await fetch(`${baseUrl}/lists`, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(list) + }); + return res; + }, + + getList: async (listsId: number): Promise => { + const res = await fetch(`${baseUrl}/lists/${listsId}`, { + method: 'GET', + }); + return res; + }, + + updateList: async (list: UpdateListDefinition): Promise => { + const res = await fetch(`${baseUrl}/lists`, { + method: 'PUT', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(list) + }); + return res; + }, + + deleteList: async (listsId: number): Promise => { + const res = await fetch(`${baseUrl}/lists/${listsId}`, { + method: 'DELETE', + }); + return res; + }, + + deleteListLocation: async ( + listsId: number, + locationsId: number + ): Promise => { + const res = await fetch( + `${baseUrl}/lists/${listsId}/locations/${locationsId}`, + { + method: 'DELETE', + } + ); + return res; + }, + + getUserLists: async (usersId: number): Promise => { + const res = await fetch(`${baseUrl}/users/${usersId}/lists`, { + method: 'GET', + }); + return res; + }, + + getLocationLists: async ( + usersId: number, + locationsId: number + ): Promise => { + const res = await fetch( + `${baseUrl}/users/${usersId}/locations/${locationsId}/lists`, + { + method: 'GET', + } + ); + return res; + }, + getListLocations: async (listsId: number): Promise => { + const res = await fetch(`${baseUrl}/lists/${listsId}/locations`, { + method: 'GET', + }); + return res; + }, + + createListLocation: async (listsId: number, listLocation: ListLocationDefinition): Promise => { + const res = await fetch(`${baseUrl}/lists/${listsId}/locations`, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(listLocation) + }); + return res; + }, +}; diff --git a/src/client/index.tsx b/src/client/index.tsx index ab51f52..14aa669 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -20,7 +20,7 @@ export async function fetchLocation(locationsId: number) { return await res.json(); } -async function fetchSensorMeasurements( +async function fetchSensorMeasurementsDownload( sensorsId: number, datetimeFrom: string, datetimeTo: string, @@ -29,16 +29,49 @@ async function fetchSensorMeasurements( 'use server'; const url = new URL(import.meta.env.VITE_API_BASE_URL); url.pathname = `/v3/sensors/${sensorsId}/measurements`; - url.search = `?date_from=${datetimeFrom.replace( + url.search = `?datetime_from=${datetimeFrom.replace( + ' ', + '%2b' + )}&datetime_to=${datetimeTo.replace(' ', '%2b')}&limit=${limit}`; + console.info(`fetching ${url.href}`) + const res = await fetch(url.href, { + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': `${import.meta.env.VITE_EXPLORER_API_KEY}`, + }, + }); + if (res.status !== 200) { + console.error(`${url.href} failed with HTTP ${res.status}`); + throw new Error('failed to fetch') + } + const data = await res.json(); + return data.results; +} + +async function fetchSensorMeasurements( + sensorsId: number, + datetimeFrom: string, + datetimeTo: string, + limit: number +) { + 'use server'; + const url = new URL(import.meta.env.VITE_API_BASE_URL); + url.pathname = `/v3/sensors/${sensorsId}/hours`; + url.search = `?datetime_from=${datetimeFrom.replace( ' ', '%2b' - )}&date_to=${datetimeTo.replace(' ', '%2b')}&limit=${limit}`; + )}&datetime_to=${datetimeTo.replace(' ', '%2b')}&limit=${limit}`; + console.info(`fetching ${url.href}`) const res = await fetch(url.href, { headers: { 'Content-Type': 'application/json', 'X-API-Key': `${import.meta.env.VITE_EXPLORER_API_KEY}`, }, }); + if (res.status !== 200) { + console.error(`${url.href} failed with HTTP ${res.status}`); + throw new Error('failed to fetch') + } const data = await res.json(); return data.results; } @@ -51,12 +84,13 @@ async function fetchSensorTrends( ) { 'use server'; const url = new URL(import.meta.env.VITE_API_BASE_URL); - url.pathname = `/v3/sensors/${sensorsId}/measurements`; - url.search = `?period_name=${periodName}&date_from=${datetimeFrom.replace( + url.pathname = `/v3/sensors/${sensorsId}/hours/${periodName}`; + url.search = `?datetime_from=${datetimeFrom.replace( ' ', - '%2b')}&date_to=${datetimeTo.replace( + '%2b')}&datetime_to=${datetimeTo.replace( ' ', '%2b')}`; + console.info(`fetching ${url.href}`) const res = await fetch(url.href, { headers: { 'Content-Type': 'application/json', @@ -99,6 +133,24 @@ export const getSensorMeasurements = GET( } ); +export const getSensorMeasurementsDownload = GET( + async ( + sensorsId: number, + datetimeFrom: string, + datetimeTo: string, + limit: number = 1000 + ) => { + 'use server'; + const data = await fetchSensorMeasurementsDownload( + sensorsId, + datetimeFrom, + datetimeTo, + limit + ); + return json(data, { headers: { 'cache-control': 'max-age=60' } }); + } +); + interface ParameterDefinition { id: number; name: string; diff --git a/src/components/Cards/LocationDetailCard.tsx b/src/components/Cards/LocationDetailCard.tsx index ddb3df0..3614328 100644 --- a/src/components/Cards/LocationDetailCard.tsx +++ b/src/components/Cards/LocationDetailCard.tsx @@ -76,7 +76,7 @@ export function LocationDetailCard() {

- {location()?.results?.[0].name.slice(0,20)} + {location()?.results?.[0].name?.slice(0,20) || 'No label'}

diff --git a/src/components/Charts/LineChart.jsx b/src/components/Charts/LineChart.jsx index dee037b..3cf0491 100644 --- a/src/components/Charts/LineChart.jsx +++ b/src/components/Charts/LineChart.jsx @@ -63,6 +63,63 @@ export function multiFormat(date, timezone) { ); } +function fillHourlyGaps(data) { + if (data.length === 0) return []; + + const oneHour = 60 * 60 * 1000; // One hour in milliseconds + const filledData = []; + + data.forEach((currentItem, index) => { + // Push the current item to the filledData array + filledData.push(currentItem); + + // Check if this is the last item, skip adding gaps if so + if (index === data.length - 1) return; + + const currentEndTime = new Date( + currentItem.period.datetimeTo.utc + ).getTime(); + const nextStartTime = new Date( + data[index + 1].period.datetimeFrom.utc + ).getTime(); + + // Calculate the number of missing hours between the current item and the next one + const missingHours = (nextStartTime - currentEndTime) / oneHour; + + // If there's a gap, create new objects with value null for each missing hour + Array.from({ length: missingHours - 1 }).forEach( + (_, gapIndex) => { + const newTimeFrom = new Date( + currentEndTime + oneHour * (gapIndex + 1) + ).toISOString(); + const newTimeTo = new Date( + currentEndTime + oneHour * (gapIndex + 2) + ).toISOString(); + + const gapItem = { + ...currentItem, + value: null, + period: { + ...currentItem.period, + datetimeFrom: { + utc: newTimeFrom, + local: newTimeFrom, // Adjust the local time as needed + }, + datetimeTo: { + utc: newTimeTo, + local: newTimeTo, // Adjust the local time as needed + }, + }, + }; + + // Push the gap item into the filledData array + filledData.push(gapItem); + } + ); + }); + + return filledData; +} // splits single measurements series into multiple subseries // if dates are not continuous export function splitMeasurements(measurements, timezone) { @@ -71,24 +128,26 @@ export function splitMeasurements(measurements, timezone) { if (!measurements || measurements.length === 0) { return [[]]; } - measurements.reduce((acc, curr, idx, arr) => { - const date = dayjs(curr.period.datetimeTo.local, timezone); - if ( - !( - lastDate === undefined || - (date - lastDate) / (60 * 60 * 1000) === 1 - ) - ) { - result.push(acc); - acc = []; - } - acc.push(curr); - if (idx === arr.length - 1 && acc.length > 0) { - result.push(acc); - } - lastDate = date; - return acc; - }, []); + measurements + .filter((o) => o.value !== null) + .reduce((acc, curr, idx, arr) => { + const date = dayjs(curr.period.datetimeTo.local, timezone); + if ( + !( + lastDate === undefined || + (date - lastDate) / (60 * 60 * 1000) === 1 + ) + ) { + result.push(acc); + acc = []; + } + acc.push(curr); + if (idx === arr.length - 1 && acc.length > 0) { + result.push(acc); + } + lastDate = date; + return acc; + }, []); return result; } @@ -254,7 +313,7 @@ export default function LineChart(props) { props.margin / 2 })`} > - + {(lineData) => ( o.value !== null) ?? [], xScale(props.width, props.dateFrom, props.dateTo), yScale(props.scale, props.height, props.data) )} diff --git a/src/components/DetailCharts/index.tsx b/src/components/DetailCharts/index.tsx index 56bd6ae..f3f29de 100644 --- a/src/components/DetailCharts/index.tsx +++ b/src/components/DetailCharts/index.tsx @@ -98,10 +98,10 @@ export function DetailCharts(props: DetailChartsDefinition) { Date.now() - calculateTimeDiff(defaultTimePeriod), props.timezone ) - .format() + .toISOString() ); const [dateTo] = createSignal( - dayjs.tz(Date.now(), props.timezone).format() + dayjs.tz(Date.now(), props.timezone).toISOString() ); // static for now const [measurements, setMeasurements] = createSignal([]); @@ -111,12 +111,12 @@ export function DetailCharts(props: DetailChartsDefinition) { function calculateDatetimeFrom() { const year = patternPeriod() - return dayjs(`${year}-01-01T00:00:00`).tz(props.timezone).format() + return dayjs(new Date(`${year}-01-01`)).tz(props.timezone).toISOString() } function calculateDatetimeTo() { const year = patternPeriod() - return dayjs(`${Number(year) + 1}-01-01T00:00:00`).tz(props.timezone).format() + return dayjs(new Date(`${Number(year) + 1}-01-01`)).tz(props.timezone).toISOString() } @@ -130,7 +130,7 @@ export function DetailCharts(props: DetailChartsDefinition) { ) ); setLoading(false); - setPatterns(await getSensorTrends(patternsSensorsId(), 'hod', calculateDatetimeFrom(),calculateDatetimeTo() )); + setPatterns(await getSensorTrends(patternsSensorsId(), 'hourofday', calculateDatetimeFrom(), calculateDatetimeTo())); setPatternsLoading(false); }); @@ -142,7 +142,7 @@ export function DetailCharts(props: DetailChartsDefinition) { }); createEffect(async () => { - setPatterns(await getSensorTrends(patternsSensorsId(), 'hod', calculateDatetimeFrom(),calculateDatetimeTo())); + setPatterns(await getSensorTrends(patternsSensorsId(), 'hourofday', calculateDatetimeFrom(),calculateDatetimeTo())); setPatternsLoading(false); }); @@ -152,9 +152,9 @@ export function DetailCharts(props: DetailChartsDefinition) { setSensorsId(selectedSensor()); setScaleType(selectedScale()); setDateFrom( - new Date( + dayjs( Date.now() - calculateTimeDiff(selectedTimePeriod()) - ).toISOString() + ).tz(props.timezone).toISOString() ); }; diff --git a/src/components/DetailOverview/index.tsx b/src/components/DetailOverview/index.tsx index 99328a7..bd2f9b3 100644 --- a/src/components/DetailOverview/index.tsx +++ b/src/components/DetailOverview/index.tsx @@ -80,7 +80,7 @@ export function DetailOverview(props: DetailOverviewDefinition) { {props.country?.name} -

{props.name}

+

{props.name || 'No label'}

diff --git a/src/components/TabView/index.tsx b/src/components/TabView/index.tsx index c7a2c1e..57e3610 100644 --- a/src/components/TabView/index.tsx +++ b/src/components/TabView/index.tsx @@ -94,7 +94,7 @@ export function TabView(props: TabViewDefintion) { {(parameter, i) => ( )} diff --git a/src/db/db.ts b/src/db/db.ts deleted file mode 100644 index 4cb1615..0000000 --- a/src/db/db.ts +++ /dev/null @@ -1,345 +0,0 @@ -import postgres from 'postgres'; -import { encode } from '~/lib/auth'; - -const sql = postgres({ - host: import.meta.env.VITE_DB_HOST, - port: import.meta.env.VITE_DB_PORT, - database: import.meta.env.VITE_DB_DATABASE, - username: import.meta.env.VITE_DB_USER, - password: import.meta.env.VITE_DB_PASSWORD, -}); - -interface ListDefinition { - listsId: number; - ownersId: number; - usersId: number; - role: string; - label: string; - description: string; - visibility: boolean; - userCount: number; - locationsCount: number; - sensorNodesIds: number[]; - bbox: number[][]; -} - -export interface SensorDefinition { - id: number; - name: string; - parameter: ParameterDefinition; -} - -export interface ParameterDefinition { - id: number; - name: string; - units: string; - value_last: number; - display_name: string; - datetime_last: string; -} - -interface LocationListItemDefinition { - id: number; - name: string; - country: string; - timezone: string; - ismonitor: boolean; - provider: string; - sensors: SensorDefinition[]; - parameterIds: number[]; -} - -interface CreateListDefinition { - create_list: number -} - -interface ModifySensorNodesListDefinition { - sensor_nodes_list_id: number -} - - -interface GetUserDefinition { - usersId: number; - passwordHash: string; - active: boolean; -} - -interface UserByVerificationCodeDefinition { - usersId : number; - active : boolean; - expiresOn: string; - emailAddress: string; -} - -interface CreateUserDefinition { - token : string; -} - -interface UserByIdDefinition { - usersId : number; - active : boolean; - emailAddress: string; - fullName: string; - passwordHash: string; - token :string; -} - -interface UpdateTokenDefintion { - newToken: string; -} - -export const db = { - user: { - async getUserById(usersId: number) { - const user = await sql` - SELECT - users_id AS "usersId" - , CASE - WHEN verified_on IS NULL THEN false - ELSE true - END as active - , email_address AS "emailAddress" - , e.full_name AS "fullName" - , u.password_hash AS "passwordHash" - , uk.token - FROM - users u - JOIN - users_entities ue USING (users_id) - JOIN - entities e USING (entities_id) - JOIN - user_keys uk USING (users_id) - WHERE users_id = ${usersId}`; - return user; - }, - async getUser(email: string) { - const user = await sql` - SELECT - users_id AS "usersId" - , password_hash AS "passwordHash" - , CASE - WHEN verified_on IS NULL THEN false - ELSE true - END as active - FROM - users - WHERE - email_address = ${email} - `; - return user; - }, - async create( - fullName: string, - emailAddress: string, - passwordHash: string, - ipAddress: string | undefined - ) { - if (!ipAddress) { - ipAddress = '0.0.0.0'; - } - try { - const user = await sql` - SELECT * FROM create_user( - ${fullName} - , ${emailAddress} - , ${passwordHash} - , ${ipAddress} - , 'Person' - )`; - return user; - } catch (err) { - return err as Error; - } - }, - async changePassword(usersId: number, passwordHash: string) { - await sql` - UPDATE - users - SET - password_hash = ${passwordHash} - WHERE - users_id = ${usersId}`; - }, - async getUserByVerificationCode(verificationCode: string) { - const user = await sql` - SELECT - users.users_id AS "usersId" - , CASE - WHEN verified_on IS NULL THEN false - ELSE true - END as active - , users.expires_on AS "expiresOn" - , users.email_address AS "emailAddress" - FROM - users - WHERE - verification_code = ${verificationCode} - `; - return user - }, - async verifyUserEmail(usersId: number) { - await sql` - SELECT * FROM get_user_token(${usersId}) - `; - }, - async regenerateKey(usersId: number) { - try { - const token = await sql` - UPDATE - user_keys - SET - token = generate_token() - WHERE - users_id = ${usersId} - RETURNING token AS "newToken"; - `; - return token; - } catch (err) { - return err as Error - } - - }, - }, - - lists: { - async createList(usersId: number, label: string, description: string) { - const listsId = await sql` - SELECT create_list(${usersId}, ${label}, ${description}) - ` - return listsId - }, - async updateList(listsId: number, label: string, description: string) { - await sql` - UPDATE - lists - SET - label = ${label}, description = ${description} - WHERE - lists_id = ${listsId};` - }, - async deleteList(listsId: number) { - await sql` - SELECT delete_list(${listsId}) - ` - }, - async deleteListLocation(listsId: number, locationsId: number) { - await sql` - DELETE FROM - sensor_nodes_list - WHERE - lists_id = ${listsId} AND sensor_nodes_id = ${locationsId}; - ` - }, - async getListsByUserId(usersId: number) { - const lists = await sql` - SELECT - lists_id AS "listsId" - , users_id AS "ownersId" - , users_id AS "usersId" - , role - , label - , description - , visibility - , user_count AS "userCount" - , locations_count AS "locationsCount" - , sensor_nodes_ids AS "sensorNodesIds" - , bbox - FROM - user_lists_view - WHERE - users_id = ${usersId}`; - return lists; - }, - async getListById(usersId: number, listsId: number) { - const list = await sql` - SELECT - lists_id AS "listsId" - , users_id AS "ownersId" - , users_id AS "usersId" - , role - , label - , description - , visibility - , user_count AS "userCount" - , locations_count AS "locationsCount" - , sensor_nodes_ids AS "sensorNodesIds" - , bbox - FROM - user_lists_view - WHERE - lists_id = ${listsId} - AND - users_id = ${usersId}`; - return list[0]; - }, - async getListsBySensorNodesId(usersId: number, sensorNodesId: number) { - const lists = await sql` - SELECT - lists_id AS "listsId" - , users_id AS "ownersId" - , users_id AS "usersId" - , role - , label - , description - , visibility - , user_count AS "userCount" - , locations_count AS "locationsCount" - , sensor_nodes_ids AS "sensorNodesIds" - , bbox - FROM - user_lists_view - WHERE - ${sensorNodesId} = ANY (sensor_nodes_ids) - AND - users_id = ${usersId}`; - return lists; - }, - async getLocationsByListId(usersId: number,listsId: number) { - const lists = await sql` - SELECT - lvc.id - , lvc.name - , lvc.country->>'name' as country - , lvc.ismonitor - , lvc.timezone - , lvc.provider->>'name' as provider - , lvc.sensors - , lvc.parameter_ids - , ul.users_id - FROM - locations_view_cached lvc - JOIN - sensor_nodes_list snl ON snl.sensor_nodes_id = lvc.id - JOIN - user_lists_view ul USING (lists_id) - WHERE - snl.lists_id = ${listsId} - AND - users_id = ${usersId}`; - return lists; - }, - async addSensorNodeToList(listsId: number, sensorNodesId: number) { - const sensorNodesListId = await sql` - INSERT INTO - sensor_nodes_list (sensor_nodes_id, lists_id) - VALUES - (${sensorNodesId}, ${listsId}) - RETURNING - sensor_nodes_lists_id; - `; - return sensorNodesListId; - }, - async removeSensorNodeToList(listsId: number, sensorNodesId: number) { - const sensorNodesListId= await sql` - DELETE FROM sensor_nodes_list - WHERE - sensor_nodes_id = ${sensorNodesId} - AND - lists_id = ${listsId} - RETURNING sensor_nodes_lists_id; - `; - return sensorNodesListId - }, - }, -}; diff --git a/src/db/index.ts b/src/db/index.ts index 19bc35f..75b05ad 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -6,7 +6,6 @@ import { logout, login, register, - nameChange, changePassword, userLists, regenerateKey, @@ -31,7 +30,6 @@ export const getUser = cache(gU, 'user'); export const loginAction = action(login, 'login'); export const logoutAction = action(logout, 'logout'); export const registerAction = action(register, 'register'); -export const nameChangeAction = action(nameChange, 'name-change'); export const passwordChangeAction = action( changePassword, 'password-change' diff --git a/src/db/server.ts b/src/db/server.ts index 5924809..da0a995 100644 --- a/src/db/server.ts +++ b/src/db/server.ts @@ -1,25 +1,26 @@ 'use server'; + import { redirect } from '@solidjs/router'; import { useSession } from 'vinxi/http'; -import { getRequestEvent } from 'solid-js/web'; -import { db } from './db'; import crypto from 'crypto'; import { promisify } from 'util'; import { Buffer } from 'buffer'; import { encode, passlibify, verifyPassword } from '~/lib/auth'; import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; +import {getRequestEvent} from "solid-js/web"; import { validatePassword } from '~/lib/password'; import { disposableDomains } from '~/data/auth'; +import { ListDefinition, ListItemDefinition, UserByIdDefinition } from './types'; +import { db } from '~/client/backend'; const pbkdf2Async = promisify(crypto.pbkdf2); dayjs.extend(utc); -async function checkPassword(password: string, hash: string) { - 'use server'; +async function checkPassword(password: string, hash: string) { const parts = hash.split('$'); let hashedPassword = await pbkdf2Async( password, @@ -40,8 +41,6 @@ const SESSION_SECRET = import.meta.env.VITE_SESSION_SECRET; const USER_SESSION_MAX_AGE = 60 * 60 * 24; async function getSession() { - 'use server'; - return useSession({ name: 'oaq_explorer_session', password: SESSION_SECRET, @@ -51,68 +50,37 @@ async function getSession() { export async function getUsersId(): Promise { 'use server'; - const session = await getSession(); const usersId = session.data.usersId; if (!usersId || usersId === 'undefined') return; return Number(usersId); } -export async function getUser() { +export async function getUser(): Promise { 'use server'; try { const usersId = await getUsersId(); if (usersId === undefined) { throw new Error('User not found'); } - const user = await db.user.getUserById(usersId); - if (!user) { + const res = await db.getUserById(usersId); + if (res.status == 404) { throw new Error('User not found'); } - return user[0]; - } catch { + let user; + const rows = await res.json(); + if (rows.length === 0) { + throw new Error('User not found'); + } + user = rows[0] as UserByIdDefinition + return user; + } catch (err) { + console.info(err); throw redirect('/login'); } } -interface ListDefinition { - listsId: number; - ownersId: number; - usersId: number; - role: string; - label: string; - description: string; - visibility: boolean; - userCount: number; - locationsCount: number; - sensorNodesIds: number[]; - bbox: number[][]; -} -export interface SensorDefinition { - id: number; - name: string; - parameter: ParameterDefinition; -} - -export interface ParameterDefinition { - id: number; - name: string; - units: string; - value_last: number; - display_name: string; - datetime_last: string; -} - -interface ListItemDefinition { - id: number; - name: string; - country: string; - ismonitor: boolean; - provider: string; - sensors: SensorDefinition[]; - parameterIds: number[]; -} export async function userLists(): Promise { 'use server'; @@ -121,15 +89,21 @@ export async function userLists(): Promise { if (typeof usersId !== 'number') { throw new Error('User not found'); } - const lists = await db.lists.getListsByUserId(usersId); - return lists; - } catch { + const res = await db.getUserLists(usersId); + if (res.status !== 200) { + throw new Error(); + } + const lists = await res.json(); + return lists as ListDefinition[]; + } catch(err) { + console.error(err) return []; } } export async function list(listsId: number): Promise { 'use server'; + try { const usersId = await getUsersId(); if (usersId === undefined) { @@ -138,7 +112,15 @@ export async function list(listsId: number): Promise { if (typeof usersId !== 'number') { throw new Error('User not found'); } - const list = db.lists.getListById(usersId, listsId); + const res = await db.getList(listsId); + const rows = await res.json(); + if (rows.length === 0) { + throw new Error('List not found'); + } + const list = rows[0] as ListDefinition; + if (list.ownersId !== usersId) { + throw redirect('/lists'); + } return list; } catch { throw redirect('/login'); @@ -149,6 +131,7 @@ export async function listLocations( listsId: number ): Promise { 'use server'; + try { const usersId = await getUsersId(); if (usersId === undefined) { @@ -157,7 +140,8 @@ export async function listLocations( if (typeof usersId !== 'number') { throw new Error('User not found'); } - const lists = db.lists.getLocationsByListId(usersId, listsId); + const res = await db.getListLocations(listsId); + const lists = await res.json() as ListItemDefinition[]; return lists; } catch { throw redirect('/login'); @@ -176,10 +160,8 @@ export async function sensorNodesLists( if (typeof usersId !== 'number') { throw new Error('User not found'); } - const lists = db.lists.getListsBySensorNodesId( - Number(usersId), - Number(sensorNodesId) - ); + const res = await db.getLocationLists(usersId,sensorNodesId); + const lists = await res.json(); return lists; } catch { return []; @@ -196,7 +178,11 @@ export function isValidEmailDomain(email: string): boolean { export async function register(formData: FormData) { 'use server'; - const clientAddress = `0.0.0.0`; + + const event = getRequestEvent() + const xForwardedFor = event?.request.headers.get("x-forwarded-for") || ''; + const ips = xForwardedFor?.split(', '); + const ipAddress = ips[0] || '0.0.0.0'; const fullName = String(formData.get('fullname')); const emailAddress = String(formData.get('email-address')); const password = String(formData.get('password')); @@ -215,6 +201,7 @@ export async function register(formData: FormData) { throw new Error('Valid email address required'); } if (!isValidEmailDomain(emailAddress)) { + console.info(`invalid email domain attempt: ${emailAddress}`); throw new Error('Valid email address required - disposable email domains not allowed.'); } if (fullName === '') { @@ -228,23 +215,34 @@ export async function register(formData: FormData) { } const passwordHash = await encode(password); try { - let user = await db.user.getUser(emailAddress); - if (user[0]) { - if (user[0].active) { + let res = await db.getUserByEmailAddress(emailAddress); + if (res.status == 200) { + const user = await res.json(); + if (user.active) { throw redirect('/login'); } else { throw redirect(`/verify-email?email=${emailAddress}`); } } - const token = await db.user.create( + const createUserRes = await db.createUser({ fullName, emailAddress, passwordHash, - clientAddress - ); - const record = await db.user.getUser(emailAddress); - await sendVerificationEmail(record[0].usersId); + ipAddress + }) + const newUser = await createUserRes.json(); + console.info(newUser) + res = await db.getUserByEmailAddress(emailAddress); + + if (res.status === 200) { + const user = await res.json() + await sendVerificationEmail(user[0].usersId); + } + if (res.status === 404) { + throw new Error("failed to create new user") + } } catch (err) { + console.error(err); return err as Error; } throw redirect(`/verify-email?email=${emailAddress}`); @@ -252,22 +250,25 @@ export async function register(formData: FormData) { export async function login(formData: FormData) { 'use server'; - const email = String(formData.get('email-address')); const password = String(formData.get('password')); const rememberMe = String(formData.get('remember-me')); const redirectTo = String(formData.get('redirect')); try { - const user = await db.user.getUser(email); - if (!user[0]) { + const res = await db.getUserByEmailAddress(email); + if (res.status !== 200) { + } + const rows = await res.json() + if (rows.length == 0) { throw new Error('Invalid credentials'); } - if (!user[0].active) { + const user = rows[0] + if (!user.isActive) { throw redirect('/verify-email'); } const isCorrectPassword = await verifyPassword( password, - user[0].passwordHash + user.passwordHash ); if (!isCorrectPassword) { throw new Error('Invalid credentials'); @@ -275,7 +276,7 @@ export async function login(formData: FormData) { const remember = rememberMe == 'on' ? true : false; const maxAge = remember ? 60 * 60 * 24 * 30 : 60 * 60 * 24; const session = await getSession(); - await session.update((d) => (d.usersId = user[0].usersId)); + await session.update((d) => (d.usersId = user.usersId)); await session.update((d) => (d.maxAge = maxAge)); } catch (err) { return err as Error; @@ -299,11 +300,12 @@ export async function changePassword(formData: FormData) { return new Error('New password fields must match'); } try { - const user = await db.user.getUserById(usersId); - if (!user[0]) { + const res = await db.getUserById(usersId); + if (res.status === 404) { throw redirect('/'); } - if (!user[0].active) { + const user = await res.json(); + if (!user.active) { throw redirect('/verify-email'); } const newPasswordHash = await encode(newPassword); @@ -314,7 +316,10 @@ export async function changePassword(formData: FormData) { if (!isCorrectPassword) { return new Error('Invalid credentials'); } - await db.user.changePassword(user[0].usersId, newPasswordHash); + await db.updateUserPassword({ + usersId: user.usersId, + passwordHash: newPasswordHash + }); } catch (err) { return err as Error; } @@ -325,8 +330,8 @@ export async function forgotPasswordLink(formData: FormData) { 'use server'; const emailAddress = String(formData.get('email-address')); - const user = await db.user.getUser(emailAddress); - if (!user[0]) { + const res = await db.getUserByEmailAddress(emailAddress); + if (res.status === 404) { throw redirect('/check-email'); } try { @@ -349,16 +354,17 @@ export async function forgotPasswordLink(formData: FormData) { export async function forgotPassword(formData: FormData) { 'use server'; - const verificationCode = String(formData.get('verification-code')); const newPassword = String(formData.get('new-password')); const newPasswordConfirm = String( formData.get('confirm-new-password') ); - const user = await db.user.getUserByVerificationCode( - verificationCode - ); - if (new Date(user[0].expiresOn) < new Date()) { + const res = await db.getUserByVerificationCode(verificationCode); + if (res.status !== 200) { + throw redirect('/login'); + } + const user = await res.json(); + if (new Date(user.expiresOn) < new Date()) { return new Error( 'Verification code expired, request a new password change email.' ); @@ -366,7 +372,10 @@ export async function forgotPassword(formData: FormData) { try { validatePassword(newPassword, newPasswordConfirm); const newPasswordHash = await encode(newPassword); - await db.user.changePassword(user[0].usersId, newPasswordHash); + const res = await db.updateUserPassword({ + usersId: user.usersId, + passwordHash: newPasswordHash + }) } catch (err) { return err as Error; } @@ -389,20 +398,14 @@ export async function forgotPassword(formData: FormData) { } export async function logout(formData: FormData) { + 'use server'; const redirectTo = String(formData.get('redirect')); - const session = await getSession(); await session.update((d) => (d.usersId = undefined)); throw redirect(redirectTo); } -export async function nameChange(formData: FormData) { - const email = String(formData.get('email-address')); - const password = String(formData.get('fullname')); - const rememberMe = String(formData.get('new-fullname')); -} - -export async function regenerateKey(formData: FormData) { +export async function regenerateKey() { 'use server'; try { const usersId = await getUsersId(); @@ -413,11 +416,18 @@ export async function regenerateKey(formData: FormData) { if (typeof usersId !== 'number') { throw new Error('User not found'); } - - const user = await db.user.getUserById(usersId); + const userRes = await db.getUserById(usersId); + if (userRes.status === 404) { + throw new Error('User not found'); + } + const rows = await userRes.json() + if (rows.length === 0) { + throw redirect('/login'); + } + const user = rows[0] const url = new URL(import.meta.env.VITE_API_BASE_URL); url.pathname = `/auth/regenerate-token`; - const data = { usersId: usersId, token: user[0].token }; + const data = { usersId: usersId, token: user.token }; const res = await fetch(url.href, { method: 'POST', headers: { @@ -433,15 +443,15 @@ export async function regenerateKey(formData: FormData) { throw redirect('/account'); } -export async function resendVerificationEmail(formData) { +export async function resendVerificationEmail(formData: FormData) { + 'use server'; const verificationCode = String(formData.get('verification-code')); - const user = await db.user.getUserByVerificationCode( - verificationCode - ); - if (!user[0]) { - return new Error('Not a valid code'); + const res = await db.getUserByVerificationCode(verificationCode); + if (res.status === 404) { + throw redirect(`/login`); } - if (user[0].active) { + const user = await res.json(); + if (user.active) { throw redirect(`/login`); } try { @@ -466,30 +476,39 @@ export async function resendVerificationEmail(formData) { } export async function newList(formData: FormData) { - const usersId = Number(formData.get('users-id')); + 'use server'; + const usersId = await getUsersId(); + if (!usersId) { + throw redirect(`/login`); + } const label = String(formData.get('list-name')); const description = String(formData.get('list-description')); if (!label || label == '') { throw Error('Name required'); } try { - const listsId = await db.lists.createList( + const res = await db.createList({ usersId, label, description - ); - throw redirect(`/lists/${listsId[0].create_list}`); + }); + const newList = await res.json(); + throw redirect(`/lists/${newList.create_list}`); + } catch (err) { return err as Error; } } export async function updateList(formData: FormData) { + 'use server'; const listsId = Number(formData.get('lists-id')); const label = String(formData.get('list-name')); const description = String(formData.get('list-description')); try { - await db.lists.updateList(listsId, label, description); + const res = await db.updateList({ + listsId, label, description + }); throw redirect(`/lists/${listsId}`); } catch (err) { return err as Error; @@ -497,6 +516,7 @@ export async function updateList(formData: FormData) { } export async function deleteList(formData: FormData) { + 'use server'; const listsId = Number(formData.get('lists-id')); try { @@ -512,7 +532,7 @@ export async function deleteList(formData: FormData) { } try { - await db.lists.deleteList(listsId); + await db.deleteList(listsId); throw redirect(`/lists`); } catch (err) { return err as Error; @@ -520,6 +540,7 @@ export async function deleteList(formData: FormData) { } export async function getLocationById(locationsId: number) { + 'use server'; const url = new URL(import.meta.env.VITE_API_BASE_URL); url.pathname = `/v3/locations/${locationsId}`; @@ -537,15 +558,18 @@ export async function removeSensorNodesList( listsId: number, sensorNodesId: number ) { + 'use server'; const usersId = await getUsersId(); if (!usersId) { throw redirect(`/lists/${listsId}`); } - await db.lists.removeSensorNodeToList(listsId, sensorNodesId); + await db.deleteListLocation(listsId, sensorNodesId); throw redirect(`/lists/${listsId}`); } export async function addRemoveSensorNodesList(formData: FormData) { + 'use server'; + const usersId = await getUsersId(); if (!usersId) { throw redirect('/'); @@ -556,17 +580,17 @@ export async function addRemoveSensorNodesList(formData: FormData) { if (k.includes('list-')) { const listsId = Number(k.split('-')[1]); const isOn = Number(v) == 1; - const locations = await db.lists.getLocationsByListId( - usersId, + const res = await db.getListLocations( listsId ); + const locations = await res.json(); const locationIds = locations.map((o) => o.id); if (locationIds.indexOf(sensorNodesId) === -1 && isOn) { - await db.lists.addSensorNodeToList(listsId, sensorNodesId); + await db.createListLocation(listsId, {locationsId: sensorNodesId}); throw redirect(`/lists/${listsId}`); } if (locationIds.indexOf(sensorNodesId) != -1 && !isOn) { - await db.lists.removeSensorNodeToList(listsId, sensorNodesId); + await db.deleteListLocation(listsId, sensorNodesId); throw redirect(redirectTo); } } @@ -580,7 +604,8 @@ export async function sendVerificationEmail(usersId: number) { const data = { usersId: usersId }; const res = await fetch(url.href, { method: 'POST', - headers: { + headers: { + 'Accept': 'application/json', 'Content-Type': 'application/json', 'X-API-Key': `${import.meta.env.VITE_EXPLORER_API_KEY}`, }, @@ -589,6 +614,7 @@ export async function sendVerificationEmail(usersId: number) { } async function registerToken(usersId: any) { + 'use server'; const url = new URL(import.meta.env.VITE_API_BASE_URL); url.pathname = `/auth/register-token`; const data = { @@ -606,23 +632,23 @@ async function registerToken(usersId: any) { export async function verifyEmail(verificationCode: string) { 'use server'; - const user = await db.user.getUserByVerificationCode( + const res = await db.getUserByVerificationCode( verificationCode ); - if (!user[0]) { - //not a valid code + if (res.status == 404) { throw redirect('/'); } + const user = await res.json() if (user[0].active) { // already verified throw redirect('/login'); } - if (dayjs(user[0].expiresOn) < dayjs(new Date())) { + if (dayjs(user.expiresOn) < dayjs(new Date())) { // expired throw redirect(`/expired?code=${verificationCode}`); } - await db.user.verifyUserEmail(user[0].usersId); + await db.verifyUser(user[0].usersId) try { await registerToken(user[0].usersId); } catch (err) { @@ -632,9 +658,9 @@ export async function verifyEmail(verificationCode: string) { } export async function deleteListLocation(formData: FormData) { + 'use server'; const listsId = Number(formData.get('lists-id')); const locationsId = Number(formData.get('locations-id')); - try { const usersId = await getUsersId(); if (usersId === undefined) { @@ -648,7 +674,7 @@ export async function deleteListLocation(formData: FormData) { } try { - await db.lists.deleteListLocation(listsId, locationsId); + await db.deleteListLocation(listsId, locationsId); throw redirect(`/lists/${listsId}`); } catch (err) { return err as Error; @@ -657,6 +683,7 @@ export async function deleteListLocation(formData: FormData) { export async function redirectIfLoggedIn() { + 'use server'; try { const usersId = await getUsersId(); if (usersId !== undefined) { diff --git a/src/db/types.ts b/src/db/types.ts new file mode 100644 index 0000000..3a27d0b --- /dev/null +++ b/src/db/types.ts @@ -0,0 +1,102 @@ +export interface ListDefinition { + listsId: number; + ownersId: number; + usersId: number; + role: string; + label: string; + description: string; + visibility: boolean; + userCount: number; + locationsCount: number; + sensorNodesIds: number[]; + bbox: number[][]; +} + +export interface SensorDefinition { + id: number; + name: string; + parameter: ParameterDefinition; +} + +export interface ParameterDefinition { + id: number; + name: string; + units: string; + value_last: number; + display_name: string; + datetime_last: string; +} + +export interface LocationListItemDefinition { + id: number; + name: string; + country: string; + timezone: string; + ismonitor: boolean; + provider: string; + sensors: SensorDefinition[]; + parameterIds: number[]; +} + +export interface CreateListDefinition { + create_list: number; +} + +export interface ModifySensorNodesListDefinition { + sensor_nodes_list_id: number; +} + +export interface GetUserDefinition { + usersId: number; + passwordHash: string; + active: boolean; +} + +export interface UserByVerificationCodeDefinition { + usersId: number; + active: boolean; + expiresOn: string; + emailAddress: string; +} + +export interface CreateUserDefinition { + token: string; +} + +export interface UserByIdDefinition { + usersId: number; + active: boolean; + emailAddress: string; + fullName: string; + passwordHash: string; + token: string; +} + +export interface UpdateTokenDefintion { + newToken: string; +} + +export interface SensorDefinition { + id: number; + name: string; + parameter: ParameterDefinition; +} + +export interface ParameterDefinition { + id: number; + name: string; + units: string; + value_last: number; + display_name: string; + datetime_last: string; +} + +export interface ListItemDefinition { + id: number; + name: string; + country: string; + ismonitor: boolean; + provider: string; + sensors: SensorDefinition[]; + parameterIds: number[]; +} diff --git a/src/entry-server.tsx b/src/entry-server.tsx index df64dad..1f15c52 100644 --- a/src/entry-server.tsx +++ b/src/entry-server.tsx @@ -1,32 +1,37 @@ import { createHandler, StartServer } from '@solidjs/start/server'; -export default createHandler(() => ( - ( - - - - - - {import.meta.env.VITE_ENV == 'prod' ? ( - - ) : ( - '' - )} - {assets} - - -
{children}
- {scripts} - - - )} - /> -)); +export default createHandler( + () => ( + ( + + + + + + {import.meta.env.VITE_ENV == 'prod' ? ( + + ) : ( + '' + )} + {assets} + + +
{children}
+ {scripts} + + + )} + /> + ), + { + mode: 'async', + } +); diff --git a/src/routes/account.tsx b/src/routes/account.tsx index ed52710..23235d0 100644 --- a/src/routes/account.tsx +++ b/src/routes/account.tsx @@ -10,7 +10,9 @@ import '~/assets/scss/routes/account.scss'; import { Suspense } from 'solid-js'; export const route = { - load: () => getUser(), + load: () => { + getUser(); + }, }; function copyApiKey(token: string) { @@ -38,7 +40,7 @@ export default function Acount() { name="name" id="name" class="text-input" - value={user()?.fullName} + value={user()?.fullname} /> diff --git a/src/routes/login.tsx b/src/routes/login.tsx index 6b38459..5c73449 100644 --- a/src/routes/login.tsx +++ b/src/routes/login.tsx @@ -1,13 +1,12 @@ import { Show } from 'solid-js'; import { A, useSubmission, useSearchParams, createAsync } from '@solidjs/router'; -import { getUserId, loginAction, redirectIfLoggedIn } from '~/db'; +import { loginAction, redirectIfLoggedIn } from '~/db'; import '~/assets/scss/routes/login.scss'; import { Header } from '~/components/Header'; export const route = { load() { void redirectIfLoggedIn(); - void getUserId(); }, }; @@ -18,6 +17,14 @@ export default function Login() { const [searchParams] = useSearchParams(); + let redirect; + if (['/login', '/verify-email', '/register'].includes(searchParams.redirect ?? '/')) { + redirect = '/' + } else { + redirect = searchParams.redirect; + } + + const loggingIn = useSubmission(loginAction); return ( @@ -34,7 +41,7 @@ export default function Login() {