From 09a6e6f9ad66ede09c6d4786b2829ae3b493f957 Mon Sep 17 00:00:00 2001 From: Will Freeman Date: Wed, 13 Nov 2024 23:17:18 -0700 Subject: [PATCH] works with all browser-native formats --- shotgun/build.sbt | 1 + .../me/deflock/shotgun/ShotgunServer.scala | 35 ++++ .../src/main/scala/services/AWSClient.scala | 99 +++++++++ terraform/variables.tf | 8 - terraform/webapp.tf | 167 --------------- webapp/package-lock.json | 70 +++++++ webapp/package.json | 2 + webapp/src/components/OSMTagSelector.vue | 13 -- webapp/src/components/ReviewSubmission.vue | 121 +++++++++++ webapp/src/main.ts | 11 + webapp/src/router/index.ts | 15 ++ webapp/src/services/apiService.ts | 15 ++ webapp/src/types.ts | 7 + webapp/src/views/Dashboard.vue | 82 ++++++++ webapp/src/views/ReportPhoto.vue | 190 ++++++++++++++++++ webapp/src/views/ReportView.vue | 10 - 16 files changed, 648 insertions(+), 198 deletions(-) create mode 100644 shotgun/src/main/scala/services/AWSClient.scala delete mode 100644 terraform/variables.tf delete mode 100644 terraform/webapp.tf create mode 100644 webapp/src/components/ReviewSubmission.vue create mode 100644 webapp/src/views/Dashboard.vue create mode 100644 webapp/src/views/ReportPhoto.vue diff --git a/shotgun/build.sbt b/shotgun/build.sbt index 4e249dc..dbe9dda 100644 --- a/shotgun/build.sbt +++ b/shotgun/build.sbt @@ -20,4 +20,5 @@ libraryDependencies ++= Seq( "org.apache.pekko" %% "pekko-http-spray-json" % PekkoHttpVersion, "org.apache.pekko" %% "pekko-http-cors" % PekkoHttpVersion, "org.apache.pekko" %% "pekko-slf4j" % PekkoVersion, + "software.amazon.awssdk" % "s3" % "2.25.27", ) diff --git a/shotgun/src/main/scala/me/deflock/shotgun/ShotgunServer.scala b/shotgun/src/main/scala/me/deflock/shotgun/ShotgunServer.scala index 4af1026..b07f67a 100644 --- a/shotgun/src/main/scala/me/deflock/shotgun/ShotgunServer.scala +++ b/shotgun/src/main/scala/me/deflock/shotgun/ShotgunServer.scala @@ -12,6 +12,10 @@ import pekko.http.scaladsl.model._ import pekko.http.scaladsl.server.Directives.{path, _} import org.apache.pekko.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._ import org.apache.pekko.http.scaladsl.server.RejectionHandler +import services.DeleteObjectRequest +import services.DeleteObjectRequestJsonProtocol._ +import spray.json.DefaultJsonProtocol._ +import services.ImageSubmissionJsonProtocol._ import java.net.URLEncoder import java.nio.charset.StandardCharsets @@ -28,6 +32,11 @@ object ShotgunServer { val client = new services.OverpassClient() val nominatim = new services.NominatimClient() + val awsClient = new services.AWSClient( + accessKeyId = sys.env("AWS_ACCESS_KEY_ID"), + secretAccessKey = sys.env("AWS_SECRET_ACCESS_KEY"), + region = "us-east-1" + ) // CORS val allowedOrigins = List( @@ -72,6 +81,32 @@ object ShotgunServer { } } }, + path("presigned-urls") { + get { + parameters("count".as[Int], "contentType".as[String], "author".as[String]) { (imageCount, contentType, author) => + if (imageCount > 5) + complete(StatusCodes.BadRequest, "Cannot request more than 5 presigned URLs at a time") + else { + val urls = awsClient.getMultiplePutPresignedUrls("deflock-photo-uploads", imageCount, contentType, author, 5) + complete(urls) + } + } + } + }, + path("user-submissions") { + get { + val submissions = awsClient.getAllObjects("deflock-photo-uploads") + complete(submissions) + } + }, + path("delete-object") { + post { + entity(as[DeleteObjectRequest]) { request => + val res = awsClient.deleteObject("deflock-photo-uploads", request.objectKey) + complete(res) + } + } + }, path("oauth2" / "callback") { get { parameters(Symbol("code").?) { (code) => diff --git a/shotgun/src/main/scala/services/AWSClient.scala b/shotgun/src/main/scala/services/AWSClient.scala new file mode 100644 index 0000000..b395edf --- /dev/null +++ b/shotgun/src/main/scala/services/AWSClient.scala @@ -0,0 +1,99 @@ +package services + +import software.amazon.awssdk.auth.credentials.{AwsBasicCredentials, StaticCredentialsProvider} +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.{GetObjectTaggingRequest, ListObjectsRequest, PutObjectRequest} +import software.amazon.awssdk.services.s3.presigner.S3Presigner +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest + +import spray.json.DefaultJsonProtocol._ +import spray.json.RootJsonFormat + +import scala.collection.JavaConverters._ +import java.net.URL +import java.time.Duration + +case class ImageSubmission(bucketName: String, objectKey: String, author: String, publicUrl: String) +object ImageSubmissionJsonProtocol { + implicit val imageSubmissionFormat: RootJsonFormat[ImageSubmission] = jsonFormat4(ImageSubmission) +} + +case class DeleteObjectRequest(objectKey: String) +object DeleteObjectRequestJsonProtocol { + implicit val deleteObjectRequestFormat: RootJsonFormat[DeleteObjectRequest] = jsonFormat1(DeleteObjectRequest) +} + +class AWSClient(accessKeyId: String, secretAccessKey: String, region: String) { + + private val credentials = AwsBasicCredentials.create(accessKeyId, secretAccessKey) + private val s3Client = S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .build() + + private val s3Presigner = S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .build() + + def putPresignedUrl(bucketName: String, objectKey: String, contentType: String, expirationMinutes: Long): URL = { + val putObjectRequest = PutObjectRequest.builder() + .bucket(bucketName) + .key(objectKey) + .contentType(contentType) + .build() + + val presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(expirationMinutes)) + .putObjectRequest(putObjectRequest) + .build() + + s3Presigner.presignPutObject(presignRequest).url() + } + + private val contentTypeToExtension: Map[String, String] = Map( + "image/jpeg" -> "jpg", + "image/png" -> "png", + "image/gif" -> "gif", + "image/svg+xml" -> "svg", + "image/webp" -> "webp", + "image/tiff" -> "tiff", + "image/bmp" -> "bmp", + "image/heic" -> "heic", + "image/heif" -> "heif" + ) + + def getMultiplePutPresignedUrls(bucketName: String, count: Int, contentType: String, author: String, expirationMinutes: Long): Seq[String] = { + if (!contentTypeToExtension.contains(contentType)) + throw new IllegalArgumentException(s"Unsupported content type: $contentType") + + val uuids = (1 to count).map(_ => author + "/" + java.util.UUID.randomUUID().toString + "." + contentTypeToExtension(contentType)) + uuids.take(5).map { objectKey => + putPresignedUrl(bucketName, objectKey, contentType, expirationMinutes).toString + } + } + + def getAllObjects(bucketName: String): Seq[ImageSubmission] = { + val listObjectsResponse = s3Client.listObjects( + ListObjectsRequest.builder().bucket(bucketName).build() + ).contents() + + listObjectsResponse.asScala.map { obj => + val parts = obj.key().split("/") + val author = parts.head + val objectKey = parts.tail.mkString("/") + ImageSubmission(bucketName, objectKey, author, s"https://$bucketName.s3.amazonaws.com/$author/$objectKey") + } + } + + def deleteObject(bucketName: String, objectKey: String): String = { + s3Client.deleteObject( + software.amazon.awssdk.services.s3.model.DeleteObjectRequest.builder() + .bucket(bucketName) + .key(objectKey) + .build() + ) + s"Deleted object $objectKey from bucket $bucketName" + } +} diff --git a/terraform/variables.tf b/terraform/variables.tf deleted file mode 100644 index 68d8c57..0000000 --- a/terraform/variables.tf +++ /dev/null @@ -1,8 +0,0 @@ -variable "domain_name" { - type = string - description = "Domain name" -} -variable "bucket_name" { - type = string - description = "S3 bucket name for the static site" -} diff --git a/terraform/webapp.tf b/terraform/webapp.tf deleted file mode 100644 index 55e768d..0000000 --- a/terraform/webapp.tf +++ /dev/null @@ -1,167 +0,0 @@ -# Provider Configuration -provider "aws" { - region = "us-east-1" # ACM certificates for CloudFront must be in us-east-1 -} - -# Route 53 Zone for Domain -resource "aws_route53_zone" "deflock_me" { - name = var.domain_name -} - -# S3 Bucket for Static Site Hosting -resource "aws_s3_bucket" "vue_app" { - bucket = var.bucket_name - - tags = { - Name = "Vue App Static Site Bucket" - } -} - -resource "aws_s3_bucket_acl" "vue_app_acl" { - bucket = aws_s3_bucket.vue_app.id - acl = "private" -} - -resource "aws_s3_bucket_website_configuration" "vue_app" { - bucket = aws_s3_bucket.vue_app.id - index_document { - suffix = "index.html" - } - error_document { - key = "index.html" - } -} - -resource "aws_s3_bucket_policy" "s3_access_policy" { - bucket = aws_s3_bucket.vue_app.id - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Sid = "AllowPublic" - Effect = "Allow" - Principal = "*" - Action = "s3:GetObject" - Resource = "${aws_s3_bucket.vue_app.arn}/*" - }, - ] - }) -} - -# ACM Certificate for HTTPS -resource "aws_acm_certificate" "deflock_me_cert" { - domain_name = var.domain_name - validation_method = "DNS" - subject_alternative_names = [ - "www.${var.domain_name}" - ] - - lifecycle { - create_before_destroy = true - } -} - -# DNS Validation Records for ACM Certificate -resource "aws_route53_record" "deflock_me_cert_validation" { - for_each = { - for dvo in aws_acm_certificate.deflock_me_cert.domain_validation_options : dvo.domain_name => { - name = dvo.resource_record_name - type = dvo.resource_record_type - value = dvo.resource_record_value - zone_id = aws_route53_zone.deflock_me.zone_id - } - } - - zone_id = each.value.zone_id - name = each.value.name - type = each.value.type - ttl = 60 - records = [each.value.value] -} - -# ACM Certificate Validation Completion -resource "aws_acm_certificate_validation" "deflock_me_cert_validation" { - certificate_arn = aws_acm_certificate.deflock_me_cert.arn - validation_record_fqdns = [for record in aws_route53_record.deflock_me_cert_validation : record.fqdn] -} - -# CloudFront Distribution for CDN and HTTPS -resource "aws_cloudfront_distribution" "vue_app_cdn" { - origin { - domain_name = aws_s3_bucket.vue_app.website_endpoint - origin_id = "S3-VueApp" - - custom_origin_config { - http_port = 80 - https_port = 443 - origin_protocol_policy = "http-only" - origin_ssl_protocols = ["TLSv1.2"] - } - } - - enabled = true - is_ipv6_enabled = true - comment = "CDN for ${var.domain_name}" - default_root_object = "index.html" - - aliases = ["${var.domain_name}", "www.${var.domain_name}"] - - default_cache_behavior { - allowed_methods = ["GET", "HEAD"] - cached_methods = ["GET", "HEAD"] - target_origin_id = "S3-VueApp" - - forwarded_values { - query_string = false - cookies { - forward = "none" - } - } - - viewer_protocol_policy = "redirect-to-https" - min_ttl = 0 - default_ttl = 3600 - max_ttl = 86400 - } - - restrictions { - geo_restriction { - restriction_type = "none" - } - } - - viewer_certificate { - acm_certificate_arn = aws_acm_certificate_validation.deflock_me_cert_validation.certificate_arn - ssl_support_method = "sni-only" - minimum_protocol_version = "TLSv1.2_2021" - } - - tags = { - Name = "CloudFront Vue App CDN" - } -} - -# Route 53 Records for Domain -resource "aws_route53_record" "deflock_me_root" { - zone_id = aws_route53_zone.deflock_me.zone_id - name = var.domain_name - type = "A" - - alias { - name = aws_cloudfront_distribution.vue_app_cdn.domain_name - zone_id = aws_cloudfront_distribution.vue_app_cdn.hosted_zone_id - evaluate_target_health = false - } -} - -resource "aws_route53_record" "deflock_me_www" { - zone_id = aws_route53_zone.deflock_me.zone_id - name = "www.${var.domain_name}" - type = "A" - - alias { - name = aws_cloudfront_distribution.vue_app_cdn.domain_name - zone_id = aws_cloudfront_distribution.vue_app_cdn.hosted_zone_id - evaluate_target_health = false - } -} diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 183f590..3b839d6 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -8,7 +8,9 @@ "name": "deflock", "version": "0.0.0", "dependencies": { + "@auth0/auth0-vue": "^2.3.3", "axios": "^1.7.7", + "exifreader": "^4.25.0", "vue": "^3.4.29", "vue-router": "^4.3.3", "vuetify": "^3.7.2" @@ -27,6 +29,28 @@ "vue-tsc": "^2.0.21" } }, + "node_modules/@auth0/auth0-spa-js": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.1.3.tgz", + "integrity": "sha512-NMTBNuuG4g3rame1aCnNS5qFYIzsTUV5qTFPRfTyYFS1feS6jsCBR+eTq9YkxCp1yuoM2UIcjunPaoPl77U9xQ==" + }, + "node_modules/@auth0/auth0-vue": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@auth0/auth0-vue/-/auth0-vue-2.3.3.tgz", + "integrity": "sha512-xL6R2nriWynA4Cp75NqtGjSWNx+JbhPMMffSsfDxf/glYfj7Q9kn1DIqnTp+ZrjzyV3kcUj7x0yCYW0yi3t+rg==", + "dependencies": { + "@auth0/auth0-spa-js": "^2.1.3", + "vue": "^3.2.41" + }, + "peerDependencies": { + "vue-router": "^4.0.12" + }, + "peerDependenciesMeta": { + "vue-router": { + "optional": true + } + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", @@ -870,6 +894,15 @@ "integrity": "sha512-VcZK7MvpjuTPx2w6blwnwZAu5/LgBUtejFOi3pPGQFXQN5Ela03FUtd2Qtg4yWGGissVL0dr6Ro1LfOFh+PCuQ==", "dev": true }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.5.tgz", + "integrity": "sha512-6g1EwSs8cr8JhP1iBxzyVAWM6BIDvx9Y3FZRIQiMDzgG43Pxi8YkWOZ0nQj2NHgNzgXDZbJewFx/n+YAvMZrfg==", + "optional": true, + "engines": { + "node": ">=14.6" + } + }, "node_modules/ansi-styles": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", @@ -1016,6 +1049,15 @@ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" }, + "node_modules/exifreader": { + "version": "4.25.0", + "resolved": "https://registry.npmjs.org/exifreader/-/exifreader-4.25.0.tgz", + "integrity": "sha512-lPyPXWTUuYgoKdKf3rw2EDoE9Zl7xHoy/ehPNeQ4gFVNLzfLyNMP4oEI+sP0/Czp5r/2i7cFhqg5MHsl4FYtyw==", + "hasInstallScript": true, + "optionalDependencies": { + "@xmldom/xmldom": "^0.9.4" + } + }, "node_modules/follow-redirects": { "version": "1.15.9", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", @@ -1543,6 +1585,20 @@ } }, "dependencies": { + "@auth0/auth0-spa-js": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.1.3.tgz", + "integrity": "sha512-NMTBNuuG4g3rame1aCnNS5qFYIzsTUV5qTFPRfTyYFS1feS6jsCBR+eTq9YkxCp1yuoM2UIcjunPaoPl77U9xQ==" + }, + "@auth0/auth0-vue": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@auth0/auth0-vue/-/auth0-vue-2.3.3.tgz", + "integrity": "sha512-xL6R2nriWynA4Cp75NqtGjSWNx+JbhPMMffSsfDxf/glYfj7Q9kn1DIqnTp+ZrjzyV3kcUj7x0yCYW0yi3t+rg==", + "requires": { + "@auth0/auth0-spa-js": "^2.1.3", + "vue": "^3.2.41" + } + }, "@babel/helper-string-parser": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", @@ -2042,6 +2098,12 @@ "integrity": "sha512-VcZK7MvpjuTPx2w6blwnwZAu5/LgBUtejFOi3pPGQFXQN5Ela03FUtd2Qtg4yWGGissVL0dr6Ro1LfOFh+PCuQ==", "dev": true }, + "@xmldom/xmldom": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.5.tgz", + "integrity": "sha512-6g1EwSs8cr8JhP1iBxzyVAWM6BIDvx9Y3FZRIQiMDzgG43Pxi8YkWOZ0nQj2NHgNzgXDZbJewFx/n+YAvMZrfg==", + "optional": true + }, "ansi-styles": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", @@ -2160,6 +2222,14 @@ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" }, + "exifreader": { + "version": "4.25.0", + "resolved": "https://registry.npmjs.org/exifreader/-/exifreader-4.25.0.tgz", + "integrity": "sha512-lPyPXWTUuYgoKdKf3rw2EDoE9Zl7xHoy/ehPNeQ4gFVNLzfLyNMP4oEI+sP0/Czp5r/2i7cFhqg5MHsl4FYtyw==", + "requires": { + "@xmldom/xmldom": "^0.9.4" + } + }, "follow-redirects": { "version": "1.15.9", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", diff --git a/webapp/package.json b/webapp/package.json index 826d2ac..599d2fc 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -11,7 +11,9 @@ "type-check": "vue-tsc --build --force" }, "dependencies": { + "@auth0/auth0-vue": "^2.3.3", "axios": "^1.7.7", + "exifreader": "^4.25.0", "vue": "^3.4.29", "vue-router": "^4.3.3", "vuetify": "^3.7.2" diff --git a/webapp/src/components/OSMTagSelector.vue b/webapp/src/components/OSMTagSelector.vue index ae79bd2..534bf7f 100644 --- a/webapp/src/components/OSMTagSelector.vue +++ b/webapp/src/components/OSMTagSelector.vue @@ -1,15 +1,6 @@