diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5e7c0ed --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# Stage 1: Build the frontend +FROM node:18 AS frontend-build +WORKDIR /app/frontend +COPY webapp/package*.json ./ +RUN npm install +COPY webapp ./ +RUN npm run build + +# Stage 2: Build the Scala app +FROM hseeberger/scala-sbt:8u222_1.3.5_2.13.1 AS scala-build +WORKDIR /app +COPY shotgun/project/ ./project +COPY shotgun/build.sbt ./build.sbt +COPY shotgun/ ./ +COPY --from=frontend-build /app/frontend/dist/ ../webapp/dist/ +RUN sbt assembly + +# Stage 3: Run the Scala app +FROM openjdk:11-jre-slim +WORKDIR /app +COPY --from=scala-build /app/target/scala-2.12/shotgun-assembly-0.1.0-SNAPSHOT.jar ./shotgun-assembly-0.1.0-SNAPSHOT.jar +COPY --from=frontend-build /app/frontend/dist/ ../webapp/dist/ +CMD ["java", "-jar", "shotgun-assembly-0.1.0-SNAPSHOT.jar"] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..2cd1ca1 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,31 @@ +version: '3.8' + +services: + deflock: + image: public.ecr.aws/w2o0b9g0/deflock.me:latest + expose: + - 8080 + #restart: always + stdin_open: true + + nginx: + image: nginx:latest + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf + - /etc/letsencrypt:/etc/letsencrypt + environment: + - SSL_CERTIFICATE=/etc/letsencrypt/live/deflock.me/fullchain.pem + - SSL_CERTIFICATE_KEY=/etc/letsencrypt/live/deflock.me/privkey.pem + restart: always + + certbot: + image: certbot/certbot + environment: + - CERTBOT_EMAIL + volumes: + - /etc/letsencrypt:/etc/letsencrypt + - /var/lib/letsencrypt:/var/lib/letsencrypt + command: certonly --webroot -w /var/www/certbot -d deflock.me --email ${CERTBOT_EMAIL} --agree-tos --no-eff-email diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..bfffd29 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,43 @@ +user nginx; +worker_processes 1; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + error_log /var/log/nginx/error.log warn; + sendfile on; + keepalive_timeout 65; + server { + listen 80; + listen [::]:80; + server_name deflock.me www.deflock.me; + + return 301 https://$host$request_uri; + } + + server { + listen 443 ssl; + listen [::]:443 ssl; + server_name deflock.me www.deflock.me; + + ssl_certificate /etc/letsencrypt/live/deflock.me/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/deflock.me/privkey.pem; + + location / { + proxy_pass http://deflock:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } +} diff --git a/shotgun/.bsp/sbt.json b/shotgun/.bsp/sbt.json index b7bca3d..c5b0993 100644 --- a/shotgun/.bsp/sbt.json +++ b/shotgun/.bsp/sbt.json @@ -1 +1 @@ -{"name":"sbt","version":"1.9.1","bspVersion":"2.1.0-M1","languages":["scala"],"argv":["/Users/willfreeman/Library/Caches/Coursier/arc/https/github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.10%252B7/OpenJDK17U-jdk_x64_mac_hotspot_17.0.10_7.tar.gz/jdk-17.0.10+7/Contents/Home/bin/java","-Xms100m","-Xmx100m","-classpath","/Users/willfreeman/Library/Caches/Coursier/arc/https/github.com/sbt/sbt/releases/download/v1.8.2/sbt-1.8.2.zip/sbt/bin/sbt-launch.jar","-Dsbt.script=/Users/willfreeman/Library/Caches/Coursier/arc/https/github.com/sbt/sbt/releases/download/v1.8.2/sbt-1.8.2.zip/sbt/bin/sbt","xsbt.boot.Boot","-bsp"]} \ No newline at end of file +{"name":"sbt","version":"1.9.1","bspVersion":"2.1.0-M1","languages":["scala"],"argv":["/Users/willfreeman/Library/Java/JavaVirtualMachines/corretto-11.0.19/Contents/Home/bin/java","-Xms100m","-Xmx100m","-classpath","/Users/willfreeman/Library/Application Support/JetBrains/IdeaIC2024.1/plugins/Scala/launcher/sbt-launch.jar","-Dsbt.script=/Users/willfreeman/Library/Application%20Support/Coursier/bin/sbt","xsbt.boot.Boot","-bsp"]} \ No newline at end of file diff --git a/shotgun/build.sbt b/shotgun/build.sbt index e7cbb85..4e249dc 100644 --- a/shotgun/build.sbt +++ b/shotgun/build.sbt @@ -17,8 +17,7 @@ libraryDependencies ++= Seq( "org.apache.pekko" %% "pekko-actor-typed" % PekkoVersion, "org.apache.pekko" %% "pekko-stream" % PekkoVersion, "org.apache.pekko" %% "pekko-http" % PekkoHttpVersion, - "io.circe" %% "circe-core" % "0.14.7", - "io.circe" %% "circe-generic" % "0.14.9", - "io.circe" %% "circe-parser" % "0.14.9", - "org.apache.pekko" %% "pekko-http-cors" % PekkoHttpVersion + "org.apache.pekko" %% "pekko-http-spray-json" % PekkoHttpVersion, + "org.apache.pekko" %% "pekko-http-cors" % PekkoHttpVersion, + "org.apache.pekko" %% "pekko-slf4j" % PekkoVersion, ) diff --git a/shotgun/project/plugins.sbt b/shotgun/project/plugins.sbt new file mode 100644 index 0000000..7bc4622 --- /dev/null +++ b/shotgun/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "1.1.0") diff --git a/shotgun/src/main/scala/me/deflock/shotgun/ShotgunServer.scala b/shotgun/src/main/scala/me/deflock/shotgun/ShotgunServer.scala index 094b634..07ef9de 100644 --- a/shotgun/src/main/scala/me/deflock/shotgun/ShotgunServer.scala +++ b/shotgun/src/main/scala/me/deflock/shotgun/ShotgunServer.scala @@ -1,25 +1,65 @@ package me.deflock.shotgun import org.apache.pekko -import org.apache.pekko.http.scaladsl.model.headers.{HttpOrigin, HttpOriginRange, `Access-Control-Allow-Origin`} -import pekko.actor.typed.ActorSystem -import pekko.actor.typed.scaladsl.Behaviors +import org.apache.pekko.actor.ActorSystem +import org.apache.pekko.event.Logging +import org.apache.pekko.http.cors.scaladsl.CorsDirectives.cors +import org.apache.pekko.http.cors.scaladsl.model.HttpOriginMatcher +import org.apache.pekko.http.cors.scaladsl.settings.CorsSettings +import org.apache.pekko.http.scaladsl.model.headers.{HttpOrigin, `Access-Control-Allow-Origin`} import pekko.http.scaladsl.Http import pekko.http.scaladsl.model._ -import pekko.http.scaladsl.server.Directives._ +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 scala.concurrent.ExecutionContextExecutor +import scala.concurrent.{Await, ExecutionContextExecutor, Future} +import scala.concurrent.duration._ import scala.io.StdIn object ShotgunServer { def main(args: Array[String]): Unit = { - implicit val system: ActorSystem[Any] = ActorSystem(Behaviors.empty, "my-system") - implicit val executionContext: ExecutionContextExecutor = system.executionContext + implicit val system: ActorSystem = ActorSystem("my-system") + implicit val executionContext: ExecutionContextExecutor = system.dispatcher + val logging = Logging(system, getClass) - val routes = { - concat { + val client = new services.OverpassClient() + + // CORS + val allowedOrigins = List( + "http://localhost:8080", + "http://localhost:5173", + "https://deflock.me", + "https://www.deflock.me", + ).map(HttpOrigin(_)) // TODO: make this a config setting + val corsSettings = CorsSettings.default + .withAllowedOrigins(HttpOriginMatcher(allowedOrigins: _*)) + .withExposedHeaders(List(`Access-Control-Allow-Origin`.name)) + + val rejectionHandler = RejectionHandler.newBuilder() + .handleNotFound { + complete((StatusCodes.NotFound, "The requested resource could not be found.")) + } + .handle { + case corsRejection: org.apache.pekko.http.cors.scaladsl.CorsRejection => + complete((StatusCodes.Forbidden, "CORS rejection: Invalid origin")) + } + .result() + + val apiRoutes = pathPrefix("api") { + concat ( + path("alpr") { + get { + parameters("minLat".as[Double], "minLng".as[Double], "maxLat".as[Double], "maxLng".as[Double]) { (minLat, minLng, maxLat, maxLng) => + val bBox = services.BoundingBox(minLat, minLng, maxLat, maxLng) + onSuccess(client.getALPRs(bBox)) { json => + complete(json) + } + } + } + }, path("oauth2" / "callback") { get { parameters(Symbol("code").?) { (code) => @@ -27,23 +67,20 @@ object ShotgunServer { } } } - path("alpr") { - get { - parameters("minLat".as[Double], "minLng".as[Double], "maxLat".as[Double], "maxLng".as[Double]) { (minLat, minLng, maxLat, maxLng) => - val client = new services.OverpassClient() // TODO: make this global - val bBox = services.BoundingBox(minLat, minLng, maxLat, maxLng) - onSuccess(client.getALPRs(bBox)) { json => - respondWithHeader(`Access-Control-Allow-Origin`.*) { - complete(HttpEntity(ContentTypes.`application/json`, json.toString())) - } - } - } - } - } + ) + } + + val spaRoutes = pathSingleSlash { + getFromFile("../webapp/dist/index.html") + } ~ getFromDirectory("../webapp/dist") + + val routes = handleRejections(rejectionHandler) { + cors(corsSettings) { + concat(apiRoutes, spaRoutes) } } - val bindingFuture = Http().newServerAt("localhost", 8080).bind(routes) + val bindingFuture = Http().newServerAt("0.0.0.0", 8080).bind(routes) println(s"Server now online. Please navigate to http://localhost:8080\nPress RETURN to stop...") StdIn.readLine() // let it run until user presses return diff --git a/shotgun/src/main/scala/services/OverpassClient.scala b/shotgun/src/main/scala/services/OverpassClient.scala index 892e542..ca27e4f 100644 --- a/shotgun/src/main/scala/services/OverpassClient.scala +++ b/shotgun/src/main/scala/services/OverpassClient.scala @@ -1,25 +1,20 @@ package services import org.apache.pekko -import pekko.actor.typed.ActorSystem -import pekko.actor.typed.scaladsl.Behaviors +import org.apache.pekko.actor.ActorSystem import pekko.http.scaladsl.Http import pekko.http.scaladsl.model._ import pekko.http.scaladsl.unmarshalling.Unmarshal -import io.circe._ -import io.circe.parser._ -import org.apache.pekko.http.scaladsl.client.RequestBuilding._ - -import scala.concurrent.duration.DurationInt +import spray.json._ +import DefaultJsonProtocol._ import scala.concurrent.{ExecutionContextExecutor, Future} -import scala.util.{Failure, Success} case class BoundingBox(minLat: Double, minLng: Double, maxLat: Double, maxLng: Double) -class OverpassClient(implicit val system: ActorSystem[_], implicit val executionContext: ExecutionContextExecutor) { +class OverpassClient(implicit val system: ActorSystem, implicit val executionContext: ExecutionContextExecutor) { val baseUrl = "https://overpass-api.de/api/interpreter" - def getALPRs(bBox: BoundingBox): Future[Json] = { + def getALPRs(bBox: BoundingBox): Future[JsValue] = { val query = s"""[out:json][bbox:${bBox.minLat},${bBox.minLng},${bBox.maxLat},${bBox.maxLng}];node["man_made"="surveillance"]["surveillance:type"="ALPR"];out body;>;out skel qt;""" val formData = FormData("data" -> query).toEntity val request = HttpRequest( @@ -29,8 +24,14 @@ class OverpassClient(implicit val system: ActorSystem[_], implicit val execution ) Http().singleRequest(request).flatMap { response => - response.entity.toStrict(5.seconds).flatMap { entity => - Unmarshal(entity).to[String].map(parse(_).getOrElse(Json.Null)) + response.status match { + case StatusCodes.OK => + Unmarshal(response.entity).to[String].map { jsonString => + jsonString.parseJson + } + case _ => + response.discardEntityBytes() + Future.failed(new Exception(s"Failed to get ALPRs: ${response.status}")) } } } diff --git a/webapp/public/adjust-angle.png b/webapp/public/adjust-angle.png new file mode 100644 index 0000000..d0fb63f Binary files /dev/null and b/webapp/public/adjust-angle.png differ diff --git a/webapp/public/deflock-logo.png b/webapp/public/deflock-logo.png deleted file mode 100644 index 936df29..0000000 Binary files a/webapp/public/deflock-logo.png and /dev/null differ diff --git a/webapp/public/edit-map.png b/webapp/public/edit-map.png new file mode 100644 index 0000000..aef3e9d Binary files /dev/null and b/webapp/public/edit-map.png differ diff --git a/webapp/public/paste-tags.png b/webapp/public/paste-tags.png new file mode 100644 index 0000000..a4ec791 Binary files /dev/null and b/webapp/public/paste-tags.png differ diff --git a/webapp/src/App.vue b/webapp/src/App.vue index 67a6fc8..bed4061 100644 --- a/webapp/src/App.vue +++ b/webapp/src/App.vue @@ -20,16 +20,10 @@ const drawer = ref(false) - + - - +
+ + mdi-content-copy + + + + +
+ + + + + diff --git a/webapp/src/services/apiService.ts b/webapp/src/services/apiService.ts index 3766219..097cec1 100644 --- a/webapp/src/services/apiService.ts +++ b/webapp/src/services/apiService.ts @@ -1,14 +1,44 @@ import axios from "axios"; -export interface BoundingBox { +export interface BoundingBoxLiteral { minLat: number; maxLat: number; minLng: number; maxLng: number; } +export class BoundingBox implements BoundingBoxLiteral { + minLat: number; + maxLat: number; + minLng: number; + maxLng: number; + + constructor({minLat, maxLat, minLng, maxLng}: BoundingBoxLiteral) { + this.minLat = minLat; + this.maxLat = maxLat; + this.minLng = minLng; + this.maxLng = maxLng; + } + + updateFromOther(boundingBoxLiteral: BoundingBoxLiteral) { + this.minLat = boundingBoxLiteral.minLat; + this.maxLat = boundingBoxLiteral.maxLat; + this.minLng = boundingBoxLiteral.minLng; + this.maxLng = boundingBoxLiteral.maxLng; + } + + isSubsetOf(other: BoundingBoxLiteral) { + return ( + this.minLat >= other.minLat && + this.maxLat <= other.maxLat && + this.minLng >= other.minLng && + this.maxLng <= other.maxLng + ); + } +} + const apiService = axios.create({ - baseURL: "http://localhost:8080", + baseURL: window.location.hostname === "localhost" ? "http://localhost:8080/api" : "/api", headers: { "Content-Type": "application/json", }, diff --git a/webapp/src/views/HomeView.vue b/webapp/src/views/HomeView.vue index cb64b69..db0cc4d 100644 --- a/webapp/src/views/HomeView.vue +++ b/webapp/src/views/HomeView.vue @@ -20,11 +20,14 @@ name="OpenStreetMap" > This is an ALPR! More data (such as direction) coming soon. + > +

ALPR

+

Brand: {{ alpr.tags.brand || alpr.tags.operator || 'Unknown' }}

+

Faces: {{ degreesToCardinal(alpr.tags.direction) }}

+
loading... @@ -38,7 +41,7 @@ import { LMap, LTileLayer, LMarker, LPopup } from '@vue-leaflet/vue-leaflet'; import { ref, onMounted, computed } from 'vue'; import { useRouter } from 'vue-router' import type { Ref } from 'vue'; -import type { BoundingBox } from '@/services/apiService'; +import { BoundingBox } from '@/services/apiService'; import { getALPRs } from '@/services/apiService'; const zoom: Ref = ref(12); @@ -48,46 +51,8 @@ const router = useRouter(); const canRefreshMarkers = computed(() => zoom.value >= 10); -const alprsInView: Ref = ref([ - // { - // "type": "node", - // "id": 12187369976, - // "lat": 34.6616103, - // "lon": -86.4870137, - // "tags": { - // "brand": "Flock Safety", - // "brand:wikidata": "Q108485435", - // "camera:mount": "pole", - // "camera:type": "fixed", - // "direction": "335", - // "man_made": "surveillance", - // "operator": "Flock Safety", - // "operator:wikidata": "Q108485435", - // "surveillance": "traffic", - // "surveillance:type": "ALPR", - // "surveillance:zone": "traffic" - // } - // }, - // { - // "type": "node", - // "id": 12187369977, - // "lat": 34.6615727, - // "lon": -86.4881948, - // "tags": { - // "brand": "Flock Safety", - // "brand:wikidata": "Q108485435", - // "camera:mount": "pole", - // "camera:type": "fixed", - // "direction": "295", - // "man_made": "surveillance", - // "operator": "Flock Safety", - // "operator:wikidata": "Q108485435", - // "surveillance": "traffic", - // "surveillance:type": "ALPR", - // "surveillance:zone": "traffic" - // } - // } -]); +const alprsInView: Ref = ref([]); +const bboxForLastRequest: Ref = ref(null); function getUserLocation(): Promise<[number, number]> { return new Promise((resolve, reject) => { @@ -115,18 +80,22 @@ function mapLoaded(map: any) { } function updateBounds(newBounds: any) { - bounds.value = { + updateURL(); + + const newBoundingBox = new BoundingBox({ minLat: newBounds.getSouth(), maxLat: newBounds.getNorth(), minLng: newBounds.getWest(), maxLng: newBounds.getEast(), - }; + }); + bounds.value = newBoundingBox; + + if (bboxForLastRequest.value && newBoundingBox.isSubsetOf(bboxForLastRequest.value)) { + console.debug('new bounds are a subset of the last request, skipping'); + return; + } updateMarkers(); - - if (center.value) { - updateURL(); - } } function updateURL() { @@ -153,14 +122,19 @@ function updateMarkers() { getALPRs(bounds.value) .then((alprs: any) => { alprsInView.value = alprs.elements; + bboxForLastRequest.value = bounds.value; }); } +function degreesToCardinal(degrees: number): string { + const cardinals = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; + return cardinals[Math.round(degrees / 45) % 8]; +} + onMounted(() => { const hash = router.currentRoute.value.hash; if (hash) { const parts = hash.split('/'); - console.log('parts', parts); if (parts.length === 3 && parts[0].startsWith('#map')) { const zoomLevelString = parts[0].replace('#map=', ''); zoom.value = parseInt(zoomLevelString, 10); @@ -168,8 +142,6 @@ onMounted(() => { lat: parseFloat(parts[1]), lng: parseFloat(parts[2]), }; - console.log('center', center.value); - console.log('zoom', zoom.value); } } @@ -196,9 +168,9 @@ onMounted(() => { left: 32px; width: calc(100% - 64px); z-index: 1000; - background-color: rgba(255, 255, 255, 0.8); + background-color: rgba(0, 0, 0, 0.8); border-radius: 4px; padding: 4px; - color: #333; + color: #eee; } diff --git a/webapp/src/views/ReportView.vue b/webapp/src/views/ReportView.vue index c895869..2a88ca6 100644 --- a/webapp/src/views/ReportView.vue +++ b/webapp/src/views/ReportView.vue @@ -2,33 +2,85 @@

How to Report an ALPR

- If you've spotted an ALPR in your area, you can help us track it by reporting it to our database. Here's how you can do it: + 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:

-

Coming Soon

-

- We're working on a way for you to report ALPRs directly from this site. Check back soon for updates! -

-

- Until then, you can report them on OpenStreetMap. Learn how to add ALPRs to OpenStreetMap. -

+ +

+ Before you report an ALPR, please read our guide on what ALPRs look like to make sure you're reporting the right thing. +

+
- +

3. Add the ALPR to OpenStreetMap

+

+ Once you've found the location of the ALPR, click the Edit button in the top left corner of the page. This will open the OpenStreetMap editor, where you can add the ALPR to the map. +

+ + + +

+ To add the ALPR, click the Point button in the top left corner of the editor, then click on the location of the ALPR on the map. In the popup that appears, paste the following tags: +

+ + + man_made=surveillance
+ surveillance:type=ALPR
+ camera:mount=pole
+ camera:type=fixed
+ surveillance=traffic
+ surveillance:zone=traffic
+
+ + + +

+ If you've identified the brand of the ALPR as Flock Safety, then you can also add the following tags: +

+ + operator=Flock Safety
+ operator:wikidata=Q108485435
+ brand=Flock Safety
+ brand:wikidata=Q108485435
+
+ +

4. Adjust the Direction

+ + + +

+ If you know the direction that the ALPR is facing, you can use the up and down arrows to set the direction it faces. +

+ +

5. Submit Your Changes

+

+ Once you've added the ALPR to the map, click the Save button in the top left corner of the editor. You'll be asked to provide a brief description of your changes. Once you've submitted your changes, the ALPR will be added to OpenStreetMap. +

+ +
+ +