works with all browser-native formats

This commit is contained in:
Will Freeman
2024-11-13 23:17:18 -07:00
parent fb149b4580
commit 09a6e6f9ad
16 changed files with 648 additions and 198 deletions

View File

@@ -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",
)

View File

@@ -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) =>

View 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"
}
}

View File

@@ -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"
}

View File

@@ -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
}
}

View File

@@ -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",

View File

@@ -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"

View File

@@ -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>

View 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>

View File

@@ -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')

View File

@@ -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

View File

@@ -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(),

View File

@@ -12,3 +12,10 @@ export interface WikidataItem {
wikidata: string;
exampleImage: string|undefined;
}
export interface Submission {
author: string,
bucketName: string,
objectKey: string,
publicUrl: string,
}

View 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>

View 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>

View File

@@ -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: