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/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 4d1426f..96576b3 100644 --- a/webapp/src/components/OSMTagSelector.vue +++ b/webapp/src/components/OSMTagSelector.vue @@ -1,15 +1,6 @@