dockerize

This commit is contained in:
Will Freeman
2024-10-02 18:27:02 -05:00
parent ff0aff59f4
commit fd94bd3cee
18 changed files with 359 additions and 133 deletions

23
Dockerfile Normal file
View File

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

31
docker-compose.prod.yml Normal file
View File

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

43
nginx.conf Normal file
View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "1.1.0")

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

BIN
webapp/public/edit-map.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

View File

@@ -20,16 +20,10 @@ const drawer = ref(false)
<v-app-bar-nav-icon variant="text" @click.stop="drawer = !drawer"></v-app-bar-nav-icon>
<v-toolbar-title>
<v-img height="36" width="200" src="/deflock-logo.svg" />
<v-img height="36" width="130" src="/deflock-logo.svg" />
</v-toolbar-title>
<v-spacer></v-spacer>
<template v-if="$vuetify.display.mdAndUp">
<v-btn disabled icon="mdi-magnify" variant="text"></v-btn>
<v-btn disabled icon="mdi-filter" variant="text"></v-btn>
</template>
</v-app-bar>
<v-navigation-drawer

View File

@@ -0,0 +1,9 @@
a {
font-weight: bold;
color: rgb(18, 151, 195);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}

View File

@@ -0,0 +1,39 @@
<template>
<div style="position: relative">
<v-btn @click="copyToClipboard" variant="plain" flat class="copy-button">
<v-icon>mdi-content-copy</v-icon>
</v-btn>
<code ref="codeContent">
<slot></slot>
</code>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const codeContent = ref<HTMLElement | null>(null);
function copyToClipboard() {
if (codeContent.value) {
navigator.clipboard.writeText(codeContent.value.innerText);
}
}
</script>
<style scoped>
code {
background-color: #f4f4f4;
padding: 0.5rem;
border-radius: 0.25rem;
display: block;
margin-top: 0.5rem;
}
.copy-button {
position: absolute;
right: 0;
top: 0;
z-index: 1000;
}
</style>

View File

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

View File

@@ -20,11 +20,14 @@
name="OpenStreetMap"
></l-tile-layer>
<l-marker
@click="console.log('marker clicked')"
v-for="alpr in alprsInView"
:key="alpr.id"
:lat-lng="[alpr.lat, alpr.lon]"
><l-popup>This is an ALPR! More data (such as direction) coming soon.</l-popup></l-marker>
><l-popup>
<h2>ALPR</h2>
<p v-if="alpr.tags.brand || alpr.tags.operator"><strong>Brand: </strong><a target="_blank" :href="`https://www.wikidata.org/wiki/${alpr.tags['brand:wikidata'] || alpr.tags['operator:wikidata']}`">{{ alpr.tags.brand || alpr.tags.operator || 'Unknown' }}</a></p>
<p v-if="alpr.tags.direction"><strong>Faces: {{ degreesToCardinal(alpr.tags.direction) }}</strong></p>
</l-popup></l-marker>
</l-map>
<div v-else>
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<number> = ref(12);
@@ -48,46 +51,8 @@ const router = useRouter();
const canRefreshMarkers = computed(() => zoom.value >= 10);
const alprsInView: Ref<any[]> = 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<any[]> = ref([]);
const bboxForLastRequest: Ref<BoundingBox|null> = 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;
}
</style>

View File

@@ -2,33 +2,85 @@
<v-container>
<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 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:
</p>
<h3>Coming Soon</h3>
<p>
We're working on a way for you to report ALPRs directly from this site. Check back soon for updates!
</p>
<p>
Until then, you can report them on OpenStreetMap. <a href="https://wiki.openstreetmap.org/wiki/Tag:man_made%3Dsurveillance" target="_blank">Learn how to add ALPRs to OpenStreetMap</a>.
</p>
<v-alert
variant="tonal"
type="warning"
class="my-6"
title="Are you sure it's an ALPR?"
>
<p>
Before you report an ALPR, please read our <router-link style="color: unset !important" to="/what-is-an-alpr">guide on what ALPRs look like</router-link> to make sure you're reporting the right thing.
</p>
</v-alert>
<!-- <p>
If you can do so safely, take a picture of the ALPR camera. Make sure the camera is visible in the image, and try to capture any identifying information, such as the brand name or any logos.
</p>
<div class="ml-4 mt-4">
<h3>1. Create an OpenStreetMap Account</h3>
<p>
<a href="https://www.openstreetmap.org/user/new" target="_blank">Sign up for an OpenStreetMap account</a> in order to submit changes.
</p>
<h3>Step 2: Note the Location</h3>
<p>
Record the location of the ALPR camera. You can use your phone's GPS to get the coordinates, or note the nearest address or intersection.
</p>
<h3>2. Find the ALPR's Location on OpenStreetMap</h3>
<p>
<a href="https://www.openstreetmap.org" target="_blank">Launch OpenStreetMap</a> and search for the location of the ALPR. You can use the search bar at the top of the page to find the location.
</p>
<h3>Step 3: Submit the Report</h3>
<p>
Click the "Report" button below to submit your report. You'll be asked to upload the picture you took and provide the location information. Your report will help us build a more comprehensive database of ALPRs in the United States.
</p> -->
<h3>3. Add the ALPR to OpenStreetMap</h3>
<p>
Once you've found the location of the ALPR, click the <strong>Edit</strong> button in the top left corner of the page. This will open the OpenStreetMap editor, where you can add the ALPR to the map.
</p>
<v-img src="/edit-map.png" />
<p>
To add the ALPR, click the <strong>Point</strong> 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:
</p>
<DFCode>
man_made=surveillance<br>
surveillance:type=ALPR<br>
camera:mount=pole<br>
camera:type=fixed<br>
surveillance=traffic<br>
surveillance:zone=traffic<br>
</DFCode>
<v-img class="my-4" src="/paste-tags.png" />
<p>
If you've identified the brand of the ALPR as Flock Safety, then you can also add the following tags:
</p>
<DFCode>
operator=Flock Safety<br>
operator:wikidata=Q108485435<br>
brand=Flock Safety<br>
brand:wikidata=Q108485435<br>
</DFCode>
<h3>4. Adjust the Direction</h3>
<v-img src="/adjust-angle.png" />
<p>
If you know the direction that the ALPR is facing, you can use the up and down arrows to set the direction it faces.
</p>
<h3>5. Submit Your Changes</h3>
<p>
Once you've added the ALPR to the map, click the <strong>Save</strong> 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.
</p>
</div>
</v-container>
</template>
<script setup lang="ts">
import DFCode from '@/components/DFCode.vue';
</script>
<style scoped>
/* TODO: put this all in one place, also in what-is view */
h2 {
@@ -50,16 +102,11 @@ p {
margin-top: 0.5rem;
}
a {
color: var(--v-primary-base);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
a:visited {
color: var(--v-primary-base);
code {
background-color: #f4f4f4;
padding: 0.5rem;
border-radius: 0.25rem;
display: block;
margin-top: 0.5rem;
}
</style>