mirror of
https://github.com/FoggedLens/deflock.git
synced 2026-02-12 15:02:45 +00:00
works with all browser-native formats
This commit is contained in:
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
99
shotgun/src/main/scala/services/AWSClient.scala
Normal file
99
shotgun/src/main/scala/services/AWSClient.scala
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
70
webapp/package-lock.json
generated
70
webapp/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
<template>
|
||||
<v-row style="align-items: center; margin-top: 1.25rem;">
|
||||
<v-col cols="12" sm="6">
|
||||
<!-- <v-select
|
||||
v-model="selectedBrand"
|
||||
return-object
|
||||
:items="alprBrands"
|
||||
item-title="name"
|
||||
item-value="wikidata"
|
||||
label="Select a company"
|
||||
outlined
|
||||
/> -->
|
||||
<h2 class="text-center mb-4">Choose Brand</h2>
|
||||
<v-row>
|
||||
<v-col v-for="brand in alprBrands" :key="brand.wikidata" cols="6">
|
||||
@@ -26,10 +17,6 @@
|
||||
</v-row>
|
||||
</v-col>
|
||||
|
||||
<!-- <v-col cols="12" sm="4">
|
||||
<v-img rounded cover aspect-ratio="1" width="220" :src="selectedBrand.exampleImage" />
|
||||
</v-col> -->
|
||||
|
||||
<v-col cols="12" sm="6">
|
||||
<h3 class="text-center">{{ selectedBrand.nickname }}</h3>
|
||||
<DFCode>
|
||||
|
||||
121
webapp/src/components/ReviewSubmission.vue
Normal file
121
webapp/src/components/ReviewSubmission.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title class="text-center">Review Submission</v-card-title>
|
||||
<v-card-subtitle class="text-center">by {{ submission.author }}</v-card-subtitle>
|
||||
<v-card-text>
|
||||
|
||||
<v-row class="align-center">
|
||||
<v-col cols="12" sm="6">
|
||||
<v-img cover ref="imageEl" :src="submission.publicUrl" @load="loadExif" />
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6">
|
||||
<div class="d-flex flex-column justify-space-between">
|
||||
<v-btn class="my-4" variant="outlined" target="_blank" :href="googleMapsUrl"><v-icon start>mdi-earth</v-icon>Google Maps</v-btn>
|
||||
<v-btn class="my-4" variant="outlined" target="_blank" :href="osmEditUrl"><v-icon start>mdi-pencil</v-icon>Edit on OSM</v-btn>
|
||||
<v-btn class="my-4" variant="outlined" @click="copyCoordsToClipboard"><v-icon start>mdi-content-copy</v-icon>Copy Coordinates</v-btn>
|
||||
<v-btn class="my-4 text-grey-darken-3" variant="text" @click="showInstructions = !showInstructions"><v-icon start>mdi-eye-outline</v-icon>
|
||||
{{ showInstructions ? 'Hide Instructions' : 'Show Instructions' }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-expand-transition>
|
||||
<div v-show="showInstructions" class="px-4 mt-4">
|
||||
<ol>
|
||||
<li>Verify image has an ALPR</li>
|
||||
<li>Check Google Maps Street View</li>
|
||||
<li>If starting an editing session, click <i>Edit on OSM</i></li>
|
||||
<li>For existing session, copy coordinates and paste into <i>Search Features</i> on OSM</li>
|
||||
</ol>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-btn @click="cancel">Cancel</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn @click="deleteSubmission" color="error">Reject</v-btn>
|
||||
<v-btn @click="deleteSubmission" color="primary">Mark as Added</v-btn>
|
||||
</v-card-actions>
|
||||
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PropType, Ref } from 'vue';
|
||||
import { computed, defineProps, ref, defineEmits } from 'vue';
|
||||
import type { Submission } from '@/types';
|
||||
import ExifReader from 'exifreader';
|
||||
|
||||
const emit = defineEmits(['cancel', 'delete']);
|
||||
|
||||
const lat: Ref<number|null> = ref(null);
|
||||
const lng: Ref<number|null> = ref(null);
|
||||
|
||||
const showInstructions = ref(false);
|
||||
|
||||
function cancel() {
|
||||
emit('cancel');
|
||||
}
|
||||
|
||||
function deleteSubmission() {
|
||||
emit('delete', props.submission);
|
||||
}
|
||||
|
||||
const loadExif = async (e: any) => {
|
||||
const response = await fetch(props.submission.publicUrl);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const tags = ExifReader.load(arrayBuffer);
|
||||
|
||||
if (tags.GPSLatitude && tags.GPSLongitude) {
|
||||
lat.value = parseFloat(tags.GPSLatitude.description);
|
||||
lng.value = parseFloat(tags.GPSLongitude.description);
|
||||
|
||||
// Check the GPSLongitudeRef to determine if it's east or west
|
||||
if (tags.GPSLongitudeRef && tags.GPSLongitudeRef.description.startsWith('W')) {
|
||||
lng.value = -lng.value;
|
||||
}
|
||||
|
||||
// Check the GPSLatitudeRef to determine if it's north or south
|
||||
if (tags.GPSLatitudeRef && tags.GPSLatitudeRef.description.startsWith('S')) {
|
||||
lat.value = -lat.value;
|
||||
}
|
||||
} else {
|
||||
console.error('No GPS data found in the image.');
|
||||
}
|
||||
|
||||
// const hasCoordinates = !!(tags.GPSLatitude && tags.GPSLongitude);
|
||||
// if (!hasCoordinates) {
|
||||
// allGeotagged = false;
|
||||
// errorMessage.value = 'One or more images do not have GPS coordinates';
|
||||
// break;
|
||||
// }
|
||||
};
|
||||
|
||||
const googleMapsUrl = computed(() =>
|
||||
lat.value && lng.value ?
|
||||
`https://www.google.com/maps/place/${lat.value},${lng.value}/@${lat.value},${lng.value},17z/data=!3m1!1e3` :
|
||||
''
|
||||
);
|
||||
|
||||
const osmEditUrl = computed(() =>
|
||||
lat.value && lng.value ?
|
||||
`https://www.openstreetmap.org/edit?editor=id&lat=${lat.value}&lon=${lng.value}&zoom=17` :
|
||||
''
|
||||
);
|
||||
|
||||
const copyCoordsToClipboard = () => {
|
||||
navigator.clipboard.writeText(`${lat.value}, ${lng.value}`);
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
submission: {
|
||||
required: true,
|
||||
type: Object as PropType<Submission>,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
@@ -9,6 +9,16 @@ import { createVuetify } from 'vuetify'
|
||||
import * as components from 'vuetify/components'
|
||||
import * as directives from 'vuetify/directives'
|
||||
import '@mdi/font/css/materialdesignicons.css' // Ensure you are using css-loader
|
||||
import { createAuth0 } from '@auth0/auth0-vue';
|
||||
|
||||
const auth0 = createAuth0({
|
||||
domain: "deflock.us.auth0.com",
|
||||
clientId: "IEBa7ckgWrMGErTWXA8Z9q91hre7uII2",
|
||||
authorizationParams: {
|
||||
redirect_uri: 'http://localhost:5173/upload'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const vuetify = createVuetify({
|
||||
components,
|
||||
@@ -22,5 +32,6 @@ const app = createApp(App)
|
||||
|
||||
app.use(router)
|
||||
app.use(vuetify)
|
||||
app.use(auth0)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
@@ -55,8 +55,23 @@ const router = createRouter({
|
||||
path: '/legal',
|
||||
name: 'legal',
|
||||
component: () => import('../views/LegalView.vue')
|
||||
},
|
||||
{
|
||||
path: '/upload',
|
||||
name: 'upload',
|
||||
component: () => import('../views/ReportPhoto.vue')
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'review',
|
||||
component: () => import('../views/Dashboard.vue')
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
console.log(`Navigating to ${to.fullPath} from ${from.fullPath}`);
|
||||
next();
|
||||
});
|
||||
|
||||
export default router
|
||||
|
||||
@@ -54,6 +54,21 @@ const apiService = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
export const getUserSubmissions = async () => {
|
||||
const response = await apiService.get("/user-submissions");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const getPresignedUrls = async (count: number, contentType: string, author: string) => {
|
||||
const response = await apiService.get(`/presigned-urls?count=${encodeURIComponent(count)}&contentType=${encodeURIComponent(contentType)}&author=${encodeURIComponent(author)}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const deleteObject = async (objectKey: string) => {
|
||||
console.log("deleting object", objectKey);
|
||||
await apiService.post(`/delete-object`, { objectKey });
|
||||
}
|
||||
|
||||
export const getALPRs = async (boundingBox: BoundingBox) => {
|
||||
const queryParams = new URLSearchParams({
|
||||
minLat: boundingBox.minLat.toString(),
|
||||
|
||||
@@ -12,3 +12,10 @@ export interface WikidataItem {
|
||||
wikidata: string;
|
||||
exampleImage: string|undefined;
|
||||
}
|
||||
|
||||
export interface Submission {
|
||||
author: string,
|
||||
bucketName: string,
|
||||
objectKey: string,
|
||||
publicUrl: string,
|
||||
}
|
||||
|
||||
82
webapp/src/views/Dashboard.vue
Normal file
82
webapp/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<v-container>
|
||||
<h1>Review Submissions</h1>
|
||||
|
||||
<v-data-table
|
||||
:loading="isLoading"
|
||||
:headers="headers"
|
||||
:items="submissions"
|
||||
:items-per-page="10"
|
||||
class="elevation-1"
|
||||
>
|
||||
<template #item.review="{ item }: { item: Submission }">
|
||||
<v-btn @click="reviewSubmission(item)" color="primary" variant="text">Review</v-btn>
|
||||
</template>
|
||||
|
||||
<template #item.objectKey="{ item }: { item: Submission }">
|
||||
<span style="text-transform: uppercase">{{ getExtension(item.objectKey) }}</span>
|
||||
</template>
|
||||
</v-data-table>
|
||||
|
||||
<v-dialog
|
||||
v-model="isDialogOpen"
|
||||
v-if="selectedSubmission"
|
||||
>
|
||||
<ReviewSubmission
|
||||
@cancel="isDialogOpen = false"
|
||||
@delete="handleDelete"
|
||||
:submission="selectedSubmission"
|
||||
/>
|
||||
</v-dialog>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, type Ref } from 'vue'
|
||||
import { getUserSubmissions, deleteObject } from '@/services/apiService';
|
||||
import type { Submission } from '@/types';
|
||||
import ReviewSubmission from '@/components/ReviewSubmission.vue';
|
||||
|
||||
const headers = [
|
||||
{ title: 'Author', value: 'author' },
|
||||
{ title: 'Type', value: 'objectKey' },
|
||||
{ title: 'Review', value: 'review' }
|
||||
];
|
||||
|
||||
const isLoading = ref(true);
|
||||
const submissions: Ref<Submission[]> = ref([]);
|
||||
const isDialogOpen = ref(false);
|
||||
const selectedSubmission: Ref<Submission|null> = ref(null);
|
||||
|
||||
function reviewSubmission(submission: Submission) {
|
||||
selectedSubmission.value = submission;
|
||||
isDialogOpen.value = true;
|
||||
}
|
||||
|
||||
function getExtension(filename: string) {
|
||||
return filename.split('.').pop();
|
||||
}
|
||||
|
||||
function handleDelete(submission: Submission) {
|
||||
isDialogOpen.value = false;
|
||||
const fullObjectKey = submission.author + '/' + submission.objectKey;
|
||||
deleteObject(fullObjectKey)
|
||||
.then(() => {
|
||||
submissions.value = submissions.value.filter((s) => s.objectKey !== submission.objectKey);
|
||||
console.log('Object deleted successfully', fullObjectKey);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error deleting object', error);
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getUserSubmissions()
|
||||
.then((response) => {
|
||||
submissions.value = response;
|
||||
})
|
||||
.finally(() => {
|
||||
isLoading.value = false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
190
webapp/src/views/ReportPhoto.vue
Normal file
190
webapp/src/views/ReportPhoto.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<v-container>
|
||||
<v-dialog
|
||||
v-model="showLoginDialog"
|
||||
persistent
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="text-center">Account Required</v-card-title>
|
||||
<v-card-text>
|
||||
<p>
|
||||
To report an ALPR, you must be logged in. Please log in to continue.
|
||||
</p>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn color="primary" @click="loginWithPopup">Log In/Sign Up</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<div>
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
Report with a Geotagged Photo
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<p>
|
||||
If you snapped a picture of an ALPR your phone, you can upload it here where it will be reviewed and added to the map.
|
||||
</p>
|
||||
<div class="mt-8">
|
||||
<v-file-input
|
||||
v-model="files"
|
||||
accept="image/*,.heif,.heic"
|
||||
label="Upload Photos"
|
||||
prepend-icon="mdi-camera"
|
||||
multiple
|
||||
show-size
|
||||
counter
|
||||
@update:model-value="checkGeotagging"
|
||||
></v-file-input>
|
||||
|
||||
<v-alert
|
||||
v-if="errorMessage"
|
||||
type="error"
|
||||
dismissible
|
||||
>
|
||||
<span>{{ errorMessage }}</span>
|
||||
<p>If you continue to experience issues, try <router-link to="/report">manually reporting</router-link>.</p>
|
||||
</v-alert>
|
||||
|
||||
<v-alert
|
||||
v-else-if="areAllImagesGeotagged"
|
||||
type="success"
|
||||
dismissible
|
||||
>
|
||||
Found Geotags!
|
||||
</v-alert>
|
||||
|
||||
<v-checkbox
|
||||
v-model="agree"
|
||||
label="I agree this information is accurate"
|
||||
></v-checkbox>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<span class="pl-4 text-grey-darken-1">Submitting as {{ user?.name }}</span>
|
||||
<v-spacer/>
|
||||
<v-btn color="primary" @click="upload" :disabled="!canSubmit">Submit</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
|
||||
<v-divider class="my-8">OR</v-divider>
|
||||
|
||||
<v-card>
|
||||
<v-card-title>Report Manually</v-card-title>
|
||||
<v-card-text>
|
||||
<p class=mb-4>
|
||||
If you don't have a geotagged photo, you can report manually by providing the location and a description of the ALPR.
|
||||
</p>
|
||||
<v-btn color="primary" to="/report">Report Manually</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ExifReader from 'exifreader';
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useAuth0 } from '@auth0/auth0-vue';
|
||||
import { getPresignedUrls } from '@/services/apiService';
|
||||
|
||||
const { loginWithPopup, user, isAuthenticated } = useAuth0();
|
||||
|
||||
const agree = ref(false);
|
||||
const files = ref<File[]>([]);
|
||||
const errorMessage = ref('');
|
||||
const areAllImagesGeotagged = ref(false);
|
||||
const showLoginDialog = ref(false); // TODO: changeme
|
||||
const presignedUrls = ref<string[]>([]);
|
||||
|
||||
// watch(isAuthenticated, async (isAuthenticated) => {
|
||||
// if (isAuthenticated) {
|
||||
// showLoginDialog.value = false;
|
||||
// } else {
|
||||
// showLoginDialog.value = true;
|
||||
// }
|
||||
// });
|
||||
|
||||
const MAX_FILE_SIZE = 8 * 1024 * 1024; // 8 MB
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
return agree.value && files.value.length > 0 && areAllImagesGeotagged.value;
|
||||
});
|
||||
|
||||
const checkGeotagging = async () => {
|
||||
if (!files.value.length) {
|
||||
areAllImagesGeotagged.value = false;
|
||||
errorMessage.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.value.some(file => file.size > MAX_FILE_SIZE)) {
|
||||
errorMessage.value = `Each file must be smaller than ${MAX_FILE_SIZE / (1024 * 1024)} MB.`;
|
||||
areAllImagesGeotagged.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.value.length > 5) {
|
||||
errorMessage.value = 'You can only upload up to 5 files at a time.';
|
||||
areAllImagesGeotagged.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.value.map(f => f.type).some(type => type !== files.value[0].type)) {
|
||||
errorMessage.value = 'All files must be of the same type. Temporary technical limitation.';
|
||||
areAllImagesGeotagged.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// fetch presigned urls ahead of time to save time
|
||||
getPresignedUrls(files.value.length, files.value[0].type, 'CHANGE_ME_PLEASE!!').then((urls) => {
|
||||
presignedUrls.value = urls;
|
||||
});
|
||||
|
||||
let allGeotagged = true;
|
||||
for (const file of files.value) {
|
||||
try {
|
||||
const tags = await ExifReader.load(file);
|
||||
const hasCoordinates = !!(tags.GPSLatitude && tags.GPSLongitude);
|
||||
if (!hasCoordinates) {
|
||||
allGeotagged = false;
|
||||
errorMessage.value = 'One or more images do not have GPS coordinates';
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
allGeotagged = false;
|
||||
errorMessage.value = 'Error reading EXIF data from one or more images';
|
||||
break;
|
||||
}
|
||||
}
|
||||
areAllImagesGeotagged.value = allGeotagged;
|
||||
if (allGeotagged) {
|
||||
errorMessage.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
async function upload() {
|
||||
if (presignedUrls.value.length !== files.value.length) {
|
||||
console.error('Presigned URLs not fetched yet');
|
||||
return;
|
||||
}
|
||||
|
||||
files.value.forEach(async (file, index) => {
|
||||
const presignedUrl = presignedUrls.value[index];
|
||||
const response = await fetch(presignedUrl, {
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
});
|
||||
|
||||
console.log(response);
|
||||
|
||||
if (response.ok) {
|
||||
console.log('File uploaded successfully');
|
||||
} else {
|
||||
console.error('Failed to upload file');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -1,15 +1,5 @@
|
||||
<template>
|
||||
<v-container max-width="1000">
|
||||
<v-alert
|
||||
variant="tonal"
|
||||
type="info"
|
||||
class="my-6"
|
||||
title="Reporting Feature Coming Soon"
|
||||
>
|
||||
<p>
|
||||
We're working on a feature that will allow you to report ALPRs directly on this site. In the meantime, you can follow the steps below to add the ALPR to OpenStreetMap.
|
||||
</p>
|
||||
</v-alert>
|
||||
<h2>How to Report an ALPR</h2>
|
||||
<p>
|
||||
If you've spotted an ALPR in your area, you can help us track it by reporting it to OpenStreetMap, where we source our information. Here's how you can do it:
|
||||
|
||||
Reference in New Issue
Block a user