diff --git a/devops/staging/app-server.tf b/devops/staging/app-server.tf index bae5ad6..48f1413 100644 --- a/devops/staging/app-server.tf +++ b/devops/staging/app-server.tf @@ -128,6 +128,45 @@ resource "cloudflare_record" "resend_domain_key_txt" { type = "TXT" } +// S3 to store audiofiles and perform transcriptions +resource "aws_s3_bucket" "voxtir_audiofiles" { + bucket = "voxtir-audiofiles-${var.environment}" +} + +data "aws_iam_policy_document" "audio_bucket_sqs" { + statement { + effect = "Allow" + + principals { + type = "*" + identifiers = ["*"] + } + + actions = ["sqs:SendMessage"] + resources = ["arn:aws:sqs:*:*:s3-event-notification-queue"] + + condition { + test = "ArnEquals" + variable = "aws:SourceArn" + values = [aws_s3_bucket.voxtir_audiofiles.arn] + } + } +} + +resource "aws_sqs_queue" "audio_bucket_queue" { + name = "s3-event-notification-queue" + policy = data.aws_iam_policy_document.audio_bucket_sqs.json +} + +resource "aws_s3_bucket_notification" "audio_bucket_notification" { + bucket = aws_s3_bucket.voxtir_audiofiles.id + + queue { + queue_arn = aws_sqs_queue.audio_bucket_queue.arn + events = ["s3:ObjectCreated:*"] + } +} + #TODO: Add a load balancer to the app server #TODO: Add VPC and subnets to the app server #TODO: Create a security group for the app server diff --git a/server/.env-example b/server/.env-example index c1443b1..f0c89c5 100644 --- a/server/.env-example +++ b/server/.env-example @@ -8,4 +8,10 @@ PROJECT_SHARING_EXPIRATION_TIME=604800 JWT_SECRET="YOURSECRET" RESEND_API_KEY="" FRONTEND_BASE_URL="YOUR_FRONTEND_DOMAIN" -RESEND_DOMAIN="YOUR_RESEND_DOMAIN" \ No newline at end of file +RESEND_DOMAIN="YOUR_RESEND_DOMAIN" +AWS_REGION="eu-north-1" +AWS_AUDIO_BUCKET_NAME="voxtir-audio-staging" +AUTH0_CLIENT_ID="YOUR CLIENT ID" +AUTH0_CLIENT_SECRET="YOUR SECRET" +# Overwrite the auth0 login stuff and assume user identity. No user token will be created +DEVELOPMENT_USER="" \ No newline at end of file diff --git a/server/package-lock.json b/server/package-lock.json index 35b6063..67e4c3d 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -15,6 +15,7 @@ "@hocuspocus/server": "^2.0.6", "@prisma/client": "^5.0.0", "access-token-jwt": "^0.1.2", + "aws-sdk": "^2.1428.0", "axios": "^1.4.0", "body-parser": "^1.20.2", "chalk": "^4.1.2", @@ -4822,6 +4823,68 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/available-typed-arrays": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", + "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/aws-sdk": { + "version": "2.1428.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1428.0.tgz", + "integrity": "sha512-z8RfFfbPCbtEPeu2W7UJTRcwXu5WNj2ehoDAhmIkvwKMGb5Y1/TbGmny51bucSWt8QKqZlmLxWs9zBGgy7slXw==", + "dependencies": { + "buffer": "4.9.2", + "events": "1.1.1", + "ieee754": "1.1.13", + "jmespath": "0.16.0", + "querystring": "0.2.0", + "sax": "1.2.1", + "url": "0.10.3", + "util": "^0.12.4", + "uuid": "8.0.0", + "xml2js": "0.5.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/aws-sdk/node_modules/buffer": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", + "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, + "node_modules/aws-sdk/node_modules/events": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", + "integrity": "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==", + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/aws-sdk/node_modules/ieee754": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", + "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" + }, + "node_modules/aws-sdk/node_modules/uuid": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz", + "integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/axios": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", @@ -6701,6 +6764,14 @@ } } }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dependencies": { + "is-callable": "^1.1.3" + } + }, "node_modules/form-data": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", @@ -6846,6 +6917,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -7002,6 +7084,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/header-case": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.4.tgz", @@ -7389,6 +7485,21 @@ "node": ">=0.10.0" } }, + "node_modules/is-arguments": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", + "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -7412,6 +7523,17 @@ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extendable": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", @@ -7438,6 +7560,20 @@ "node": ">=8" } }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -7498,6 +7634,20 @@ "node": ">=0.10.0" } }, + "node_modules/is-typed-array": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", + "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "dependencies": { + "which-typed-array": "^1.1.11" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-unc-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", @@ -7548,6 +7698,11 @@ "node": ">=0.10.0" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -7581,6 +7736,14 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jmespath": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz", + "integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/jose": { "version": "4.14.4", "resolved": "https://registry.npmjs.org/jose/-/jose-4.14.4.tgz", @@ -9067,6 +9230,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==", + "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -9394,6 +9566,11 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/sax": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", + "integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==" + }, "node_modules/scheduler": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", @@ -10111,12 +10288,38 @@ "node": ">=6" } }, + "node_modules/url": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", + "integrity": "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==", + "dependencies": { + "punycode": "1.3.2", + "querystring": "0.2.0" + } + }, + "node_modules/url/node_modules/punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==" + }, "node_modules/urlpattern-polyfill": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-8.0.2.tgz", "integrity": "sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==", "dev": true }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -10235,6 +10438,24 @@ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", "dev": true }, + "node_modules/which-typed-array": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.11.tgz", + "integrity": "sha512-qe9UWWpkeG5yzZ0tNYxDmd7vo58HDBc39mZ0xWWpolAGADdFOzkfamWLDxkOWcvHQKVmdTyQdLD4NOfjLWTKew==", + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -10277,6 +10498,26 @@ } } }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "engines": { + "node": ">=4.0" + } + }, "node_modules/y-protocols": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.5.tgz", @@ -13948,6 +14189,55 @@ "integrity": "sha512-Hdw8qdNiqdJ8LqT0iK0sVzkFbzg6fhnQqqfWhBDxcHZvU75+B+ayzTy8x+k5Ix0Y92XOhOUlx74ps+bA6BeYMQ==", "dev": true }, + "available-typed-arrays": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", + "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==" + }, + "aws-sdk": { + "version": "2.1428.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1428.0.tgz", + "integrity": "sha512-z8RfFfbPCbtEPeu2W7UJTRcwXu5WNj2ehoDAhmIkvwKMGb5Y1/TbGmny51bucSWt8QKqZlmLxWs9zBGgy7slXw==", + "requires": { + "buffer": "4.9.2", + "events": "1.1.1", + "ieee754": "1.1.13", + "jmespath": "0.16.0", + "querystring": "0.2.0", + "sax": "1.2.1", + "url": "0.10.3", + "util": "^0.12.4", + "uuid": "8.0.0", + "xml2js": "0.5.0" + }, + "dependencies": { + "buffer": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", + "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", + "requires": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, + "events": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", + "integrity": "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==" + }, + "ieee754": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", + "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" + }, + "uuid": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz", + "integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==" + } + } + }, "axios": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", @@ -15354,6 +15644,14 @@ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==" }, + "for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "requires": { + "is-callable": "^1.1.3" + } + }, "form-data": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", @@ -15456,6 +15754,14 @@ "slash": "^3.0.0" } }, + "gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "requires": { + "get-intrinsic": "^1.1.3" + } + }, "graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -15558,6 +15864,14 @@ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" }, + "has-tostringtag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "requires": { + "has-symbols": "^1.0.2" + } + }, "header-case": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.4.tgz", @@ -15853,6 +16167,15 @@ "is-windows": "^1.0.1" } }, + "is-arguments": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", + "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "requires": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + } + }, "is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -15873,6 +16196,11 @@ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" }, + "is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==" + }, "is-extendable": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", @@ -15890,6 +16218,14 @@ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true }, + "is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "requires": { + "has-tostringtag": "^1.0.0" + } + }, "is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -15935,6 +16271,14 @@ "is-unc-path": "^1.0.0" } }, + "is-typed-array": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", + "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "requires": { + "which-typed-array": "^1.1.11" + } + }, "is-unc-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", @@ -15970,6 +16314,11 @@ "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", "dev": true }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -15994,6 +16343,11 @@ "integrity": "sha512-oVhqoRDaBXf7sjkll95LHVS6Myyyb1zaunVwk4Z0+WPSW4gjS0pl01zYKHScTuyEhQsFxV5L4DR5r+YqSyqyyg==", "dev": true }, + "jmespath": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz", + "integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==" + }, "jose": { "version": "4.14.4", "resolved": "https://registry.npmjs.org/jose/-/jose-4.14.4.tgz", @@ -17120,6 +17474,11 @@ "side-channel": "^1.0.4" } }, + "querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==" + }, "queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -17346,6 +17705,11 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "sax": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", + "integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==" + }, "scheduler": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", @@ -17891,12 +18255,40 @@ } } }, + "url": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", + "integrity": "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==", + "requires": { + "punycode": "1.3.2", + "querystring": "0.2.0" + }, + "dependencies": { + "punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==" + } + } + }, "urlpattern-polyfill": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-8.0.2.tgz", "integrity": "sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==", "dev": true }, + "util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "requires": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -17991,6 +18383,18 @@ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", "dev": true }, + "which-typed-array": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.11.tgz", + "integrity": "sha512-qe9UWWpkeG5yzZ0tNYxDmd7vo58HDBc39mZ0xWWpolAGADdFOzkfamWLDxkOWcvHQKVmdTyQdLD4NOfjLWTKew==", + "requires": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0" + } + }, "wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -18013,6 +18417,20 @@ "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", "requires": {} }, + "xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "requires": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + } + }, + "xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==" + }, "y-protocols": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.5.tgz", diff --git a/server/package.json b/server/package.json index 02691a6..9a522bf 100644 --- a/server/package.json +++ b/server/package.json @@ -24,6 +24,7 @@ "@hocuspocus/server": "^2.0.6", "@prisma/client": "^5.0.0", "access-token-jwt": "^0.1.2", + "aws-sdk": "^2.1428.0", "axios": "^1.4.0", "body-parser": "^1.20.2", "chalk": "^4.1.2", diff --git a/server/src/index.ts b/server/src/index.ts index 3a1268e..f21d54b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -58,7 +58,7 @@ async function main(): Promise { app.use( '/graphql', cors(), - graphqlUploadExpress({ maxFileSize: 10000000, maxFiles: 10 }), + graphqlUploadExpress({ maxFileSize: 10000000, maxFiles: 1 }), expressMiddleware(gqlServer, { context: async ({ req }) => ({ prisma: prisma, @@ -79,6 +79,7 @@ async function main(): Promise { `http://localhost:${APP_PORT}` )} (ctrl+click) `); + console.timeEnd('startup'); }); } diff --git a/server/src/middleware.ts b/server/src/middleware.ts index dde2b69..312a95f 100644 --- a/server/src/middleware.ts +++ b/server/src/middleware.ts @@ -1,30 +1,34 @@ import { auth } from 'express-oauth2-jwt-bearer'; -const { AUTH0_DOMAIN } = process.env; import { v4 as uuidv4 } from 'uuid'; import { Request, Response, NextFunction, Handler } from 'express'; import prisma from './prisma/index.js'; import { Auth0Client } from './services/auth0.js'; import { Prisma } from '@prisma/client'; +import { logger } from './services/logger.js'; const VOXTIR_SEEN_USER_COOKIE = 'voxtir_seen_user'; const NODE_ENV = process.env.NODE_ENV || 'development'; +const DEVELOPMENT_USER = process.env.DEVELOPMENT_USER || ''; +const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN; -const auth0Client = new Auth0Client(); -/* -Standard auth0 logic except for in development where the user can be defined as a header on the request -HEADER: x_voxtir_user = [AUTH0 user_id] -*/ +/** + * Standard auth0 logic except for in development where the user can be defined as an environment variable + * @param req + * @param res + * @param next + * @returns + */ export const accessControl: Handler = ( req: Request, res: Response, next: NextFunction ) => { - if (NODE_ENV === 'development' && req.headers?.x_voxtir_user) { + if (NODE_ENV === 'development' && DEVELOPMENT_USER) { // In development we allow for setting a user header to bypass auth0 req.auth = { payload: { iss: `https://${AUTH0_DOMAIN}/`, - sub: req.headers?.x_voxtir_user[0], + sub: DEVELOPMENT_USER, aud: [ `https://${AUTH0_DOMAIN}/api/v2/`, `https://${AUTH0_DOMAIN}/userinfo`, @@ -35,8 +39,9 @@ export const accessControl: Handler = ( scope: 'openid profile email read:current_user', }, header: { alg: 'RS256', typ: 'JWT', kid: 'DEVELOPMENT' }, - token: 'DEVELOPMENT', + token: `DEVELOPMENT`, }; + logger.info(`development user set to: ${DEVELOPMENT_USER}`); return next(); } auth({ @@ -49,12 +54,16 @@ export const accessControl: Handler = ( })(req, res, next); }; -/* -This is a middleware that will check if the user has an active session. +/** + * This is a middleware that will check if the user has an active session. The purpose is to determine if we should fetch user data from auth0 and update it in the database or not. If the user has a session we will not update the user data in the database. If the user does not have we will. Additionally it will serve as a way of determining if the user has been seen before. We don't get webhooks etc. on signup -*/ + * @param req + * @param res + * @param next + * @returns + */ export const userInfoSync = async ( req: Request, res: Response, @@ -62,35 +71,47 @@ export const userInfoSync = async ( ) => { if (!(req.cookies[VOXTIR_SEEN_USER_COOKIE] === 'seen')) { if (!req.auth?.payload?.sub) { - throw new Error('Sub found on request'); + logger.error('Sub not found on request that passed auth0 middleware'); + return res.status(500).send('Internal server error'); } + logger.info( + `User not seen before or cookie expired, setting cookie and updating user data` + ); res.cookie(VOXTIR_SEEN_USER_COOKIE, 'seen', { expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), secure: true, sameSite: 'none', }); - let auth0UserData = await auth0Client.getUserById( - req.auth.payload.sub, - req.auth.token - ); - await prisma.user.upsert({ - create: { - id: req.auth.payload.sub, - auth0ManagementApiUserDetails: - auth0UserData as unknown as Prisma.JsonObject, - }, - update: { - auth0ManagementApiUserDetails: - auth0UserData as unknown as Prisma.JsonObject, - }, - where: { - id: req.auth.payload.sub, - }, - }); + try { + let auth0UserData = await Auth0Client.getUserById(req.auth.payload.sub); + await prisma.user.upsert({ + create: { + id: req.auth.payload.sub, + auth0ManagementApiUserDetails: + auth0UserData as unknown as Prisma.JsonObject, + }, + update: { + auth0ManagementApiUserDetails: + auth0UserData as unknown as Prisma.JsonObject, + }, + where: { + id: req.auth.payload.sub, + }, + }); + } catch (err) { + return res.status(401).send('Unauthorized'); + } } next(); }; +/** + * A simple middleware that will add a unique id to the request object + * @param req + * @param res + * @param next + * @returns + */ export const requestId = (req: Request, res: Response, next: NextFunction) => { req.requestId = uuidv4(); return next(); diff --git a/server/src/prisma/schema.prisma b/server/src/prisma/schema.prisma index a2d8f07..c8a2fc4 100644 --- a/server/src/prisma/schema.prisma +++ b/server/src/prisma/schema.prisma @@ -13,24 +13,25 @@ enum TranscriptionType { } model Document { - id String @id @default(uuid()) - title String - data Bytes - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - audioFileUrl String - project Project @relation(fields: [projectId], references: [id]) - projectId String // relation scalar field (used in the `@relation` attribute above) + id String @id @default(uuid()) + title String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + project Project @relation(fields: [projectId], references: [id]) + projectId String // relation scalar field (used in the `@relation` attribute above) + // TipTap document data + data Bytes @default("") + // Audio file + uploadedAudioFileURL String? + processedAudioFileURL String? // Transcription data - language String? - speakerCount Int? - dialect String? - transcriptionType TranscriptionType - + language String? + speakerCount Int? + dialect String? + transcriptionType TranscriptionType // Automatic transcription data transcriptionStatus TranscriptionProcessStatus? doSpeakerDiarization Boolean? - rawAudioFileURL String? transcriptionAudioFileURL String? speakerDiarizationFileURL String? whisperTranscriptionFileURL String? diff --git a/server/src/prisma/seed.ts b/server/src/prisma/seed.ts index b6b6087..cf21a44 100644 --- a/server/src/prisma/seed.ts +++ b/server/src/prisma/seed.ts @@ -1,9 +1,4 @@ -import { - PrismaClient, - Document, - TranscriptionProcessStatus, - ProjectRole, -} from '@prisma/client'; +import { PrismaClient, ProjectRole } from '@prisma/client'; import { v4 as uuidv4 } from 'uuid'; const prisma = new PrismaClient(); @@ -57,22 +52,20 @@ async function main() { }); // Create documents - const document1: Document = await prisma.document.create({ + await prisma.document.create({ data: { data: Buffer.from('Sample data for document 1'), id: uuidv4(), - audioFileUrl: 'https://example.com/audio1.mp3', projectId: project1.id, transcriptionType: 'AUTOMATIC', title: 'Document 2', }, }); - const document2: Document = await prisma.document.create({ + await prisma.document.create({ data: { data: Buffer.from('Sample data for document 2'), id: uuidv4(), - audioFileUrl: 'https://example.com/audio2.mp3', projectId: project2.id, transcriptionType: 'AUTOMATIC', title: 'Document 2', @@ -80,11 +73,7 @@ async function main() { }); } -main() - .catch((e) => { - console.error(e); - process.exit(1); - }) - .finally(async () => { - await prisma.$disconnect(); - }); +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/server/src/routes/graphql/resolvers/index.ts b/server/src/routes/graphql/resolvers/index.ts index 4b9acd4..bb2916e 100644 --- a/server/src/routes/graphql/resolvers/index.ts +++ b/server/src/routes/graphql/resolvers/index.ts @@ -1,5 +1,4 @@ import { GraphQLUpload } from 'graphql-upload-minimal'; -import type { Readable } from 'stream'; import prisma from '../../../prisma/index.js'; import { Resolvers, Project } from '../generated/graphql'; @@ -13,7 +12,11 @@ import { } from '../../../helpers/jwt.js'; import { sendProjectShareEmail } from '../../../services/resend.js'; import { logger } from '../../../services/logger.js'; - +import { + uploadAudioFile, + getPresignedUrlForDocumentAudioFile, +} from '../../../transcription/index.js'; +import { FileAlreadyExistsError } from '../../../types/customErrors.js'; export const resolvers: Resolvers = { Upload: GraphQLUpload, @@ -128,21 +131,39 @@ export const resolvers: Resolvers = { }, Mutation: { - uploadDocuments: async (_, args) => { - const { docs } = args; - for (const doc of docs) { - const { createReadStream, filename } = await doc.file; - logger.info('uploading documents', doc.docType, filename); - const stream: Readable = createReadStream(); - stream.on('error', function (err) { - logger.error('Failed uploading documents', err); - stream.destroy(); - return { success: false, message: err.message }; - }); + createDocument: async (_, args, context) => { + const { + projectId, + title, + language, + dialect, + speakerCount, + transcriptionType, + } = args; + let userRights = await prisma.userOnProject.findFirst({ + where: { + userId: context.userId, + projectId: projectId, + }, + }); + + if (!userRights) { + return { + success: false, + message: 'Projectid not found or related to user', + }; } - return { success: true }; - }, - createDocument: async () => { + await prisma.document.create({ + data: { + title: title, + projectId: projectId, + language: language, + dialect: dialect, + speakerCount: speakerCount, + transcriptionType: transcriptionType, + }, + }); + return { success: true }; }, trashDocument: async (_, args, context) => { @@ -150,6 +171,7 @@ export const resolvers: Resolvers = { let userRights = await prisma.userOnProject.findFirst({ where: { userId: context.userId, + projectId: projectId, }, }); if (!userRights) { @@ -278,7 +300,7 @@ export const resolvers: Resolvers = { const { token } = args; const userId = context.userId; - var tokenVerificationRes: projectSharingJWTRes; + let tokenVerificationRes: projectSharingJWTRes; try { tokenVerificationRes = verifyProjectSharingToken(token); } catch (error) { @@ -354,17 +376,90 @@ export const resolvers: Resolvers = { }); return { success: true }; }, - uploadAudioFile: async (parent, args) => { - const { doc, documentId } = args; + uploadAudioFile: async (_, args, context) => { + logger.info('Uploading file'); + const { doc, documentId, projectId } = args; const { createReadStream, filename } = await doc.file; - logger.info('Uploading file', doc.docType, filename, documentId); - const stream: Readable = createReadStream(); - stream.on('error', function (err) { - logger.error(err); - stream.destroy(); - return { success: false, message: err.message }; + // assert user has permission + let userRelation = await prisma.userOnProject.findFirst({ + where: { + projectId: projectId, + userId: context.userId, + }, + }); + if (!userRelation) { + return { + success: false, + message: 'Projectid not found or related to user', + }; + } + // Document is on project + let docRelation = await prisma.document.findFirst({ + where: { + projectId: projectId, + id: documentId, + }, }); + + if (!docRelation) { + return { + success: false, + message: 'Document project combination not found', + }; + } + + logger.info('Uploading file', doc.docType, filename, documentId); + logger.info(doc); + const stream: Buffer = createReadStream(); + try { + await uploadAudioFile(documentId, stream, filename, doc.docType); + } catch (error) { + if (error instanceof FileAlreadyExistsError) { + return { + success: false, + message: 'File already exists', + }; + } + logger.error('error in raw fileupload to S3', error); + return { + success: false, + message: 'Error uploading file', + }; + } return { success: true }; }, + getPresignedUrlForAudioFile: async (_, args, context) => { + const { documentId, projectId } = args; + // assert user has permission + let userRelation = await prisma.userOnProject.findFirst({ + where: { + projectId: projectId, + userId: context.userId, + }, + }); + if (!userRelation) { + return { + success: false, + message: 'Projectid not found or related to user', + }; + } + // Document is on project + let docRelation = await prisma.document.findFirst({ + where: { + projectId: projectId, + id: documentId, + }, + }); + if (!docRelation) { + return { + success: false, + message: 'Document project combination not found', + }; + } + let signedUrlResponse = await getPresignedUrlForDocumentAudioFile( + `${documentId}` + ); + return signedUrlResponse; + }, }, }; diff --git a/server/src/routes/graphql/typedefs/Common.ts b/server/src/routes/graphql/typedefs/Common.ts index 04e50d4..820360c 100644 --- a/server/src/routes/graphql/typedefs/Common.ts +++ b/server/src/routes/graphql/typedefs/Common.ts @@ -17,6 +17,13 @@ export const typeDefs = gql` message: String } + type PresignedUrlResponse { + url: String! + expiresAt: Int! + } + + union AudioUploadResponse = PresignedUrlResponse | ActionResult + interface IUser { id: ID! name: String! diff --git a/server/src/routes/graphql/typedefs/Mutation.ts b/server/src/routes/graphql/typedefs/Mutation.ts index aa8f7f9..f377002 100644 --- a/server/src/routes/graphql/typedefs/Mutation.ts +++ b/server/src/routes/graphql/typedefs/Mutation.ts @@ -2,8 +2,15 @@ import gql from 'graphql-tag'; export const typeDefs = gql` type Mutation { - uploadAudioFile(doc: DocumentUploadInput!, documentId: ID!): ActionResult - uploadDocuments(docs: [DocumentUploadInput!]!): ActionResult + uploadAudioFile( + doc: DocumentUploadInput! + documentId: ID! + projectId: ID! + ): ActionResult + getPresignedUrlForAudioFile( + documentId: ID! + projectId: ID! + ): AudioUploadResponse """ Projects """ @@ -18,11 +25,12 @@ export const typeDefs = gql` Documents """ createDocument( + projectId: ID! title: String! language: String dialect: String speakerCount: Int - transcriptionType: TranscriptionType + transcriptionType: TranscriptionType! ): ActionResult! trashDocument(documentId: ID!, projectId: ID!): ActionResult! } diff --git a/server/src/services/auth0.ts b/server/src/services/auth0.ts index 0e11f35..f6c9014 100644 --- a/server/src/services/auth0.ts +++ b/server/src/services/auth0.ts @@ -1,34 +1,92 @@ -import axios, { AxiosInstance, AxiosResponse } from 'axios'; +import axios, { AxiosInstance } from 'axios'; import { Auth0ManagementApiUser } from '../types/auth0'; +import { logger } from '../services/logger.js'; +import jwt from 'jsonwebtoken'; // get environment variables const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN; +const AUT0_CLIENT_ID = process.env.AUTH0_CLIENT_ID; +const AUT0_CLIENT_SECRET = process.env.AUTH0_CLIENT_SECRET; +const baseUrl = `https://${AUTH0_DOMAIN}/`; + +interface auth0TokenResponse { + access_token: string; + token_type: string; +} + +/** + * Axios instance for making requests to Auth0 and managing the system token + * + * @static + * @type {AxiosInstance} + * @memberof Auth0Client + */ export class Auth0Client { - auth0: AxiosInstance; - constructor() { - this.auth0 = axios.create({ - baseURL: `https://${AUTH0_DOMAIN}/`, - headers: { - 'Content-Type': 'application/json', - }, - }); + static auth0: AxiosInstance = axios.create({ + baseURL: `${baseUrl}`, + headers: { 'content-type': 'application/json' }, + }); + static systemToken: string = ''; + static systemTokenExpiration: number = 0; + + static isSystemTokenExpired(): boolean { + return Auth0Client.systemTokenExpiration < Date.now(); + } + + static systemTokenExpiresAt(): string { + return new Date(Auth0Client.systemTokenExpiration).toISOString(); + } + + static async getSystemToken(): Promise { + logger.info('Getting system token from Auth0'); + if (Auth0Client.isSystemTokenExpired()) { + logger.info('System token expired, getting new one'); + try { + let tokenResponse = await Auth0Client.auth0.post('oauth/token', { + client_id: AUT0_CLIENT_ID, + client_secret: AUT0_CLIENT_SECRET, + audience: `https://${AUTH0_DOMAIN}/api/v2/`, + grant_type: 'client_credentials', + }); + + let tokenData = tokenResponse.data as auth0TokenResponse; + Auth0Client.systemToken = tokenData.access_token; + + let decodedToken = jwt.decode(tokenData.access_token, { json: true }); + if (!decodedToken?.exp) { + logger.error('Error decoding auth0 system token'); + throw new Error('Error decoding system token'); + } + + Auth0Client.systemTokenExpiration = decodedToken.exp * 1000; + } catch (err: any) { + console.log(err); + logger.error( + `Error getting system token from Auth0: ${err.response.status} ${err.response.statusText}` + ); + throw err; + } + } + return Promise.resolve(Auth0Client.systemToken); } /** * @throws {Error} */ - async getUserById( - userId: string, - userToken: String - ): Promise { - const response = await this.auth0.get(`/api/v2/users/${userId}`, { - headers: { Authorization: `Bearer ${userToken}` }, - }); - if (response.status !== 200) { - throw new Error('Error getting user'); - } else { + static async getUserById(userId: string): Promise { + logger.info(`Getting user ${userId} from Auth0`); + try { + let systemToken = await Auth0Client.getSystemToken(); + const response = await Auth0Client.auth0.get(`/api/v2/users/${userId}`, { + headers: { Authorization: `Bearer ${systemToken}` }, + }); return response.data as Auth0ManagementApiUser; + } catch (err: any) { + logger.warn( + `Error getting user ${userId} from Auth0: ${err.response.data} ${err.response.status} ${err.response.statusText}` + ); + throw new Error('Error getting user from Auth0'); } } } @@ -36,10 +94,8 @@ export class Auth0Client { let isRunningDirectly = false; if (isRunningDirectly) { // When running the file standalone, you can create an instance of Auth0Client and call its methods here. - const auth0Client = new Auth0Client(); - let fse = await auth0Client.getUserById( - 'auth0|64c035be0b7eb7c5797264ca', - 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImNLRHdhcjhQa0NQTUJ6bGV2cFB0diJ9.eyJpc3MiOiJodHRwczovL2Rldi1jbXEwNXlvYnR1dmhia3JpLmV1LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHw2NGMwMzViZTBiN2ViN2M1Nzk3MjY0Y2EiLCJhdWQiOlsiaHR0cHM6Ly9kZXYtY21xMDV5b2J0dXZoYmtyaS5ldS5hdXRoMC5jb20vYXBpL3YyLyIsImh0dHBzOi8vZGV2LWNtcTA1eW9idHV2aGJrcmkuZXUuYXV0aDAuY29tL3VzZXJpbmZvIl0sImlhdCI6MTY5MDUyNDA1OCwiZXhwIjoxNjkwNjEwNDU4LCJhenAiOiJrTlpvWkNCWlI2MWpialJpTHZCaHZuM3VZRVJTT053RyIsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwgcmVhZDpjdXJyZW50X3VzZXIifQ.ghZ_99FO2VM-muQukWIvO1XUGJtH4DLCKHSvcZMyHFBCnMwf1Hdrcq9dgwOhpUbLhrnsUlJ3mqANm7Dm_-WbuPMvFyERYpikRSWeYb_584l4K2Y_EXAyuMD2aL6vauL2gYP6QKdKooT0P3Xvhx5OKxW_5Qe2NA-GpflrUhi6K1bcjy27ovyEVOn6Sja4crgAUThuD1KkA_1U2jCwu2WtXVvs7qrC8yZCKUfC9qZyS2aDj52Cm9e-9UsvQ-uaTa2E4s50W9Eigll1sjHdB8lHDAZqJ1rSAyYBvFmRzDwgSFP_SIuCBzjdh8kSq2ES8CcZBAKytbmxMZfTLihIgY4B7A' - ); - console.log(fse); + logger.info(Auth0Client.systemTokenExpiresAt()); + await Auth0Client.getUserById('auth0|64c035be0b7eb7c5797264ca'); + logger.info(Auth0Client.systemToken); + logger.info(Auth0Client.systemTokenExpiresAt()); } diff --git a/server/src/services/aws.ts b/server/src/services/aws.ts new file mode 100644 index 0000000..13a55ab --- /dev/null +++ b/server/src/services/aws.ts @@ -0,0 +1,57 @@ +import aws from 'aws-sdk'; +import { logger } from './logger.js'; +import { FileAlreadyExistsError } from '../types/customErrors.js'; +// ENV +const AWS_REGION = process.env.AWS_REGION; + +aws.config.update({ + region: AWS_REGION, +}); + +const s3 = new aws.S3({ apiVersion: '2006-03-01' }); + +export const uploadObject = async ( + bucket: string, + key: string, + body: Buffer, + contentType: string, + overwrite: boolean = false +): Promise => { + const uploadParams = { + Bucket: bucket, + Key: key, + Body: body, + ContentType: contentType, + }; + if (!overwrite) { + try { + await s3.headObject({ Bucket: bucket, Key: key }).promise(); + throw new FileAlreadyExistsError(`File ${key} already exists`); + } catch (err: any) { + if (err.statusCode === 404 || err.code === 'NotFound') { + logger.info(`File ${key} not found, uploading`); + } else if (err.status === 409) { + logger.info(`file ${key} already exists, skipping upload`); + throw new FileAlreadyExistsError(`File ${key} already exists`); + } else { + logger.error(`Unexpected error in S3 checking if file exists`); + throw err; + } + } + } + return s3.upload(uploadParams).promise(); +}; + +// Generate a pre-signed URL for a file +export const generatePresignedUrlForObject = async ( + bucket: string, + key: string, + expiration: number +): Promise => { + const params = { + Bucket: bucket, + Key: key, + Expires: expiration, + }; + return s3.getSignedUrlPromise('getObject', params); +}; diff --git a/server/src/transcription/index.ts b/server/src/transcription/index.ts new file mode 100644 index 0000000..0557abd --- /dev/null +++ b/server/src/transcription/index.ts @@ -0,0 +1,83 @@ +import aws from 'aws-sdk'; +import { + uploadObject, + generatePresignedUrlForObject, +} from '../services/aws.js'; +import { logger } from '../services/logger.js'; + +// CONSTANTS +const AWS_AUDIO_BUCKET_PRESIGNED_URL_EXPIRATION = 60 * 60 * 2; // 2 Hours in milliseconds +const AWS_REGION = process.env.AWS_REGION; +const AWS_AUDIO_BUCKET_NAME = process.env.AWS_AUDIO_BUCKET_NAME; + +// TRANSCRIPTION BUCKET SETUP +export const uploadedAudioFilePrefix = 'raw-audio'; +export const processedAudioFilePrefix = 'processed-audio'; +export const transcriptionFilePrefix = 'transcription'; +export const speakerDiarizationFilePrefix = 'speaker-diarization'; +export const mergedTranscriptionFilePrefix = 'merged-transcription'; + +export const processedFileFormat = 'wav'; + +if (!AWS_REGION || !AWS_AUDIO_BUCKET_NAME) { + throw new Error('Missing env - not defined'); +} + +/** + * Basic function for uploading an audio file to S3 from user. Intended to be used for raw audio files + * than then will be processed by the transcription service (lambda). This function will not overwrite + * existing files. And should only really be used for the initial user upload. + * @param documentId + * @param body + * @param fileEnding + * @param contentType + * @returns + */ +export const uploadAudioFile = async ( + documentId: string, + body: Buffer, + fileEnding: string = '', + contentType: string = 'audio/wav' +): Promise => { + const key = `${uploadedAudioFilePrefix}/${documentId}.${fileEnding}`; + logger.info(`Uploading audio file to ${key}`); + return uploadObject(AWS_AUDIO_BUCKET_NAME, key, body, contentType, false); +}; + +/** + * This function will generate a pre-signed URL for a processed audio file. This is intended to be used + * for the client to download the processed audio file. The URL will expire after 2 hours. At assumes that + * the file is in the processed-audio folder in S3. Thus the caller is responsible for ensuring that the file + * is in the correct folder / has been processed. + * @param documentId + * @returns + */ +export const getPresignedUrlForDocumentAudioFile = async ( + documentId: string +): Promise<{ url: string; expiresAt: number }> => { + const key = `${processedAudioFilePrefix}/${documentId}.${processedFileFormat}`; + let url = await generatePresignedUrlForObject( + AWS_AUDIO_BUCKET_NAME, + key, + AWS_AUDIO_BUCKET_PRESIGNED_URL_EXPIRATION + ); + // calculate expiration for client + const expiration = new Date(); + + expiration.setTime( + expiration.getTime() + AWS_AUDIO_BUCKET_PRESIGNED_URL_EXPIRATION * 1000 + ); + return { url, expiresAt: expiration.getTime() }; +}; + +let isRunningDirectly = false; +if (isRunningDirectly) { + let documentId = 'tawefwaefcsdfsffsefssdvfsesefsst'; + let body = Buffer.from('girglpershjg'); + let fileEnding = 'txt'; + let t2 = await uploadAudioFile(documentId, body, fileEnding, 'text/plain'); + console.log(t2); + console.log('Uploaded audio file'); + let t3 = await getPresignedUrlForDocumentAudioFile(documentId); + console.log(t3); +} diff --git a/server/src/types/customErrors.ts b/server/src/types/customErrors.ts new file mode 100644 index 0000000..d145e89 --- /dev/null +++ b/server/src/types/customErrors.ts @@ -0,0 +1,15 @@ +export class FileAlreadyExistsError extends Error { + status: number; // Add the 'status' property here + + constructor(message: string) { + super(message); + Error.captureStackTrace(this, this.constructor); + + this.name = this.constructor.name; + this.status = 409; // 409 Conflict HTTP status code (you can change it to another suitable code) + } + + statusCode() { + return this.status; + } +}