diff --git a/.gitignore b/.gitignore
index 39dd5af..2340990 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,3 +22,6 @@ pnpm-debug.log*
*.njsproj
*.sln
*.sw?
+
+firebaseConfig.js
+
diff --git a/api/Pipfile b/api/Pipfile
index fe16757..5c7179b 100644
--- a/api/Pipfile
+++ b/api/Pipfile
@@ -7,8 +7,6 @@ name = "pypi"
flask = "*"
psycopg2 = "*"
flask-cors = "*"
-google-oauth = "*"
-google-auth = "*"
gunicorn = "*"
loguru = "*"
diff --git a/api/api.py b/api/api.py
index 10ed159..8ff53ef 100644
--- a/api/api.py
+++ b/api/api.py
@@ -4,15 +4,20 @@ from psycopg2.extras import RealDictCursor
from flask import Flask, request, jsonify, abort, Response
from flask_cors import CORS
import json
-from google.oauth2 import id_token
-import google.auth.transport
from functools import wraps
import os
from loguru import logger
from datetime import datetime
import math
+import firebase_admin
+from firebase_admin import credentials
+from firebase_admin import auth
+from firebase_admin import firestore
+import time
-GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", None)
+cred = credentials.Certificate("service_account.json")
+firebase_app = firebase_admin.initialize_app(cred)
+db = firestore.client()
app = Flask(__name__)
app.config["TEMPLATES_AUTO_RELOAD"] = True
@@ -20,39 +25,39 @@ app.config["TEMPLATES_AUTO_RELOAD"] = True
CORS(app)
ALLOWED_COMPARISONS = [
- '=',
- '!=',
- '>',
- '<',
- '>=',
- '<=',
- 'starts with',
- 'ends with',
- 'contains',
- 'does not contain',
- 'is null',
- 'is not null',
- ]
-
-ALLOWED_METHODS = [
- "OR",
- "AND"
+ "=",
+ "!=",
+ ">",
+ "<",
+ ">=",
+ "<=",
+ "starts with",
+ "ends with",
+ "contains",
+ "does not contain",
+ "is null",
+ "is not null",
]
-ALLOWED_CASTS = [
- "integer",
- "float",
- "cast_to_int",
- "cast_to_float"
-]
+ALLOWED_METHODS = ["OR", "AND"]
+
+ALLOWED_CASTS = ["integer", "float", "cast_to_int", "cast_to_float"]
+
def get_db_connection():
- conn = psycopg2.connect(database=os.environ.get("PG_DB"), host=os.environ.get("PG_HOST"), port=os.environ.get("PG_PORT"), user=os.environ.get("PG_USER"), password=os.environ.get("PG_PASSWORD"))
+ conn = psycopg2.connect(
+ database=os.environ.get("PG_DB"),
+ host=os.environ.get("PG_HOST"),
+ port=os.environ.get("PG_PORT"),
+ user=os.environ.get("PG_USER"),
+ password=os.environ.get("PG_PASSWORD"),
+ )
return conn
-def json_query(query, conn=None):
+
+def query_with_timing(query, conn=None):
if conn is None:
- conn = get_db_connection()
+ conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
@@ -71,8 +76,23 @@ def json_query(query, conn=None):
t2 = datetime.now()
- logger.info(f"Found {len(data)} results in {t2 - t1} seconds")
- return jsonify(data)
+ for d in data:
+ d.pop('point_geom')
+
+ return (data, t2 - t1)
+
+def get_user(request):
+ token = None
+
+ if "Authorization" in request.headers:
+ token = request.headers["Authorization"].split(" ")[1]
+
+ if not token:
+ return None
+
+ idinfo = auth.verify_id_token(token)
+ return idinfo
+
def token_required(f):
@wraps(f)
@@ -86,24 +106,24 @@ def token_required(f):
return {
"message": "Authentication Token is missing!",
"data": None,
- "error": "Unauthorized"
+ "error": "Unauthorized",
}, 401
try:
- idinfo = id_token.verify_oauth2_token(token, google.auth.transport.requests.Request(), GOOGLE_CLIENT_ID)
-
+ idinfo = auth.verify_id_token(token)
+
if idinfo is None:
logger.warning(f"Invalid authentication token {token}")
return {
- "message": "Invalid Authentication token!",
- "data": None,
- "error": "Unauthorized"
- }, 403
+ "message": "Invalid Authentication token!",
+ "data": None,
+ "error": "Unauthorized",
+ }, 403
except Exception as e:
logger.warning(f"Other error {e}")
return {
"message": "Something went wrong",
"data": None,
- "error": str(e)
+ "error": str(e),
}, 403
logger.info(f"Authenticated request by {idinfo['email']}")
@@ -111,59 +131,87 @@ def token_required(f):
return decorated
+
def make_filter_query(filter):
filter_query = sql.SQL("")
-
+
i = 0
- for subfilter in filter['filters']:
- if subfilter['comparison'] not in ALLOWED_COMPARISONS:
+ for subfilter in filter["filters"]:
+ if subfilter["comparison"] not in ALLOWED_COMPARISONS:
logger.error(f"Invalid comparison {subfilter['comparison']}")
break
- if subfilter['comparison'] == '=':
- filter_query = sql.SQL("{filter_query} (tags @> {match})").format(filter_query=filter_query, match=sql.Literal(subfilter['parameter'] + '=>' + subfilter['value']))
- elif subfilter['comparison'] == '!=':
- filter_query = sql.SQL("{filter_query} NOT(tags @> {match})").format(filter_query=filter_query, match=sql.Literal(subfilter['parameter'] + '=>' + subfilter['value']))
- elif subfilter['comparison'] == 'is null':
- filter_query = sql.SQL("{filter_query} NOT(tags?{parameter})").format(filter_query=filter_query, parameter=sql.Literal(subfilter['parameter']))
- elif subfilter['comparison'] == 'is not null':
- filter_query = sql.SQL("{filter_query} (tags?{parameter})").format(filter_query=filter_query, parameter=sql.Literal(subfilter['parameter']))
+ if subfilter["comparison"] == "=":
+ filter_query = sql.SQL("{filter_query} (tags @> {match})").format(
+ filter_query=filter_query,
+ match=sql.Literal(subfilter["parameter"] + "=>" + subfilter["value"]),
+ )
+ elif subfilter["comparison"] == "!=":
+ filter_query = sql.SQL("{filter_query} NOT(tags @> {match})").format(
+ filter_query=filter_query,
+ match=sql.Literal(subfilter["parameter"] + "=>" + subfilter["value"]),
+ )
+ elif subfilter["comparison"] == "is null":
+ filter_query = sql.SQL("{filter_query} NOT(tags?{parameter})").format(
+ filter_query=filter_query, parameter=sql.Literal(subfilter["parameter"])
+ )
+ elif subfilter["comparison"] == "is not null":
+ filter_query = sql.SQL("{filter_query} (tags?{parameter})").format(
+ filter_query=filter_query, parameter=sql.Literal(subfilter["parameter"])
+ )
else:
- parameter = sql.SQL("tags->{parameter}").format(parameter=sql.Literal(subfilter['parameter']))
+ parameter = sql.SQL("tags->{parameter}").format(
+ parameter=sql.Literal(subfilter["parameter"])
+ )
- if 'cast' in subfilter and subfilter['cast'] in ALLOWED_CASTS:
- if subfilter['cast'] == 'cast_to_float':
- parameter = sql.SQL("cast_to_float({parameter}, 0.0)").format(parameter=parameter)
- elif subfilter['cast'] == 'cast_to_int':
- parameter = sql.SQL("cast_to_int({parameter}, 0)").format(parameter=parameter)
+ if "cast" in subfilter and subfilter["cast"] in ALLOWED_CASTS:
+ if subfilter["cast"] == "cast_to_float":
+ parameter = sql.SQL("cast_to_float({parameter}, 0.0)").format(
+ parameter=parameter
+ )
+ elif subfilter["cast"] == "cast_to_int":
+ parameter = sql.SQL("cast_to_int({parameter}, 0)").format(
+ parameter=parameter
+ )
else:
- parameter = sql.SQL("CAST({parameter} AS {cast})").format(parameter=parameter, cast=sql.SQL(subfilter['cast']))
+ parameter = sql.SQL("CAST({parameter} AS {cast})").format(
+ parameter=parameter, cast=sql.SQL(subfilter["cast"])
+ )
- if subfilter['comparison'] == 'starts with':
- subfilter['value'] = f"{subfilter['value']}%"
- subfilter['comparison'] = 'ILIKE'
- elif subfilter['comparison'] == 'ends with':
- subfilter['value'] = f"%{subfilter['value']}"
- subfilter['comparison'] = 'ILIKE'
- elif subfilter['comparison'] == 'contains':
- subfilter['value'] = f"%{subfilter['value']}%"
- subfilter['comparison'] = 'ILIKE'
- elif subfilter['comparison'] == 'does not contain':
- subfilter['value'] = f"%{subfilter['value']}%"
- subfilter['comparison'] = 'NOT ILIKE'
+ if subfilter["comparison"] == "starts with":
+ subfilter["value"] = f"{subfilter['value']}%"
+ subfilter["comparison"] = "ILIKE"
+ elif subfilter["comparison"] == "ends with":
+ subfilter["value"] = f"%{subfilter['value']}"
+ subfilter["comparison"] = "ILIKE"
+ elif subfilter["comparison"] == "contains":
+ subfilter["value"] = f"%{subfilter['value']}%"
+ subfilter["comparison"] = "ILIKE"
+ elif subfilter["comparison"] == "does not contain":
+ subfilter["value"] = f"%{subfilter['value']}%"
+ subfilter["comparison"] = "NOT ILIKE"
- filter_query = sql.SQL("{filter_query} ({parameter} {comparison} {value})").format(filter_query=filter_query, parameter=parameter, comparison=sql.SQL(subfilter['comparison']), value=sql.Literal(subfilter['value']))
+ filter_query = sql.SQL(
+ "{filter_query} ({parameter} {comparison} {value})"
+ ).format(
+ filter_query=filter_query,
+ parameter=parameter,
+ comparison=sql.SQL(subfilter["comparison"]),
+ value=sql.Literal(subfilter["value"]),
+ )
- if i != len(filter['filters']) - 1:
- filter_query = sql.SQL("{filter_query} {method}").format(filter_query=filter_query, method=sql.SQL(filter['method']))
+ if i != len(filter["filters"]) - 1:
+ filter_query = sql.SQL("{filter_query} {method}").format(
+ filter_query=filter_query, method=sql.SQL(filter["method"])
+ )
i += 1
return filter_query
-@app.route('/intersection')
+@app.route("/intersection")
@token_required
def get_intersection():
args = request.args
@@ -178,23 +226,44 @@ def get_intersection():
bbox = [l, b, r, t]
- area = math.pow(6371,2) * math.pi * abs(math.sin(math.radians(t)) - math.sin(math.radians(b))) * abs(r - l) / 180
-
+ area = (
+ math.pow(6371, 2)
+ * math.pi
+ * abs(math.sin(math.radians(t)) - math.sin(math.radians(b)))
+ * abs(r - l)
+ / 180
+ )
+
# reject queries that are too large
if area > 4e6:
return Response(status=400)
- bbox_filter = sql.SQL("AND (way && ST_Transform(ST_MakeEnvelope({left}, {bottom}, {right}, {top}, 4326), 3857))").format(left=sql.Literal(bbox[0]), bottom=sql.Literal(bbox[1]), right=sql.Literal(bbox[2]), top=sql.Literal(bbox[3]))
+ bbox_filter = sql.SQL(
+ "AND (way && ST_Transform(ST_MakeEnvelope({left}, {bottom}, {right}, {top}, 4326), 3857))"
+ ).format(
+ left=sql.Literal(bbox[0]),
+ bottom=sql.Literal(bbox[1]),
+ right=sql.Literal(bbox[2]),
+ top=sql.Literal(bbox[3]),
+ )
first = filters[0]
- first_query = sql.SQL("SELECT tags->'name' AS name, ST_Centroid(way) AS point_geom, way AS geom FROM {table}").format(table=
- sql.SQL('planet_osm_line') if first['type'] == 'line' else
- sql.SQL('planet_osm_polygon') if first['type'] == 'polygon' else
- sql.SQL('planet_osm_point') if first['type'] == 'point' else
- sql.SQL('planet_osm'))
+ first_query = sql.SQL(
+ "SELECT tags->'name' AS name, ST_Centroid(way) AS point_geom, way AS geom FROM {table}"
+ ).format(
+ table=sql.SQL("planet_osm_line")
+ if first["type"] == "line"
+ else sql.SQL("planet_osm_polygon")
+ if first["type"] == "polygon"
+ else sql.SQL("planet_osm_point")
+ if first["type"] == "point"
+ else sql.SQL("planet_osm")
+ )
first_filter = make_filter_query(first)
- first_assembled = sql.SQL("{query} WHERE ({filter}) {bbox}").format(query=first_query, filter=first_filter, bbox=bbox_filter)
+ first_assembled = sql.SQL("{query} WHERE ({filter}) {bbox}").format(
+ query=first_query, filter=first_filter, bbox=bbox_filter
+ )
logger.info(f"Buffer: {buffer}\tFilters: {filters}\tBbox: [{l},{b},{r},{t}]")
@@ -202,40 +271,74 @@ def get_intersection():
for f in filters[1:]:
filter = make_filter_query(f)
- if f['type'] == 'point':
+ if f["type"] == "point":
query = sql.SQL("SELECT way AS geom FROM planet_osm_point")
- elif f['type'] == 'line':
+ elif f["type"] == "line":
query = sql.SQL("SELECT way AS geom FROM planet_osm_line")
- elif f['type'] == 'polygon':
+ elif f["type"] == "polygon":
query = sql.SQL("SELECT way AS geom FROM planet_osm_polygon")
- elif f['type'] == 'any':
+ elif f["type"] == "any":
query = sql.SQL("SELECT way AS geom FROM planet_osm")
- assembled = sql.SQL("{query} WHERE ({filter}) {bbox}").format(query=query, filter=filter, bbox=bbox_filter)
+ assembled = sql.SQL("{query} WHERE ({filter}) {bbox}").format(
+ query=query, filter=filter, bbox=bbox_filter
+ )
subqueries.append(assembled)
- join_query = sql.SQL("SELECT DISTINCT point_geom, name, ST_Y(ST_Transform(point_geom, 4326)) AS lat, ST_X(ST_Transform(point_geom, 4326)) as lng FROM ({point}) point ").format(point=first_assembled)
-
+ join_query = sql.SQL(
+ "SELECT DISTINCT point_geom, name, ST_Y(ST_Transform(point_geom, 4326)) AS lat, ST_X(ST_Transform(point_geom, 4326)) as lng FROM ({point}) point "
+ ).format(point=first_assembled)
+
i = 0
for q in subqueries:
- join_query = sql.SQL("{join_query} JOIN ({q}) {subindex} ON ST_DWithin(point.geom, {subindex}.geom, {buffer})").format(join_query=join_query, q=q, subindex=sql.SQL('subquery' + str(i)), buffer=sql.Literal(buffer))
+ join_query = sql.SQL(
+ "{join_query} JOIN ({q}) {subindex} ON ST_DWithin(point.geom, {subindex}.geom, {buffer})"
+ ).format(
+ join_query=join_query,
+ q=q,
+ subindex=sql.SQL("subquery" + str(i)),
+ buffer=sql.Literal(buffer),
+ )
i += 1
-
+
join_query = sql.SQL("{join_query} LIMIT 100").format(join_query=join_query)
conn = get_db_connection()
logger.info(f"Executing query: {join_query.as_string(conn)}")
- return json_query(join_query)
-
+ timestamp = time.time_ns()
+ user = get_user(request)
-@app.route('/robots.txt')
+ data, tdiff = query_with_timing(join_query)
+
+ log_data = {
+ "timestamp": firestore.SERVER_TIMESTAMP,
+ "query": join_query.as_string(conn),
+ "user_uid": user["uid"],
+ "user_email": user["email"],
+ "bbox": bbox,
+ "filters": filters,
+ "query_time": tdiff.total_seconds(),
+ "query_nresults": len(data),
+ "query_results": data,
+ }
+
+ db.collection("searches").document(str(timestamp)).set(log_data)
+
+ logger.info(f"Found {len(data)} results in {tdiff} seconds")
+
+ return jsonify(data)
+
+
+@app.route("/robots.txt")
def robots():
- return Response("User-agent: *\nDisallow: /", mimetype='text/plain')
+ return Response("User-agent: *\nDisallow: /", mimetype="text/plain")
+
def start():
app.run(port=5050)
-if __name__ == '__main__':
+
+if __name__ == "__main__":
start()
diff --git a/frontend/src/components/queries.js b/frontend/src/assets/queries.js
similarity index 100%
rename from frontend/src/components/queries.js
rename to frontend/src/assets/queries.js
diff --git a/frontend/src/components/FeatureCustom.vue b/frontend/src/components/FeatureCustom.vue
index 4581edd..043fab3 100644
--- a/frontend/src/components/FeatureCustom.vue
+++ b/frontend/src/components/FeatureCustom.vue
@@ -114,6 +114,7 @@ export default {
type: this.selectedQueryType,
filters: this.filters.filter((v) => v.parameter != ""),
method: this.method,
+ unsavedCustomFeature: true,
},
]);
diff --git a/frontend/src/components/FeatureSelector.vue b/frontend/src/components/FeatureSelector.vue
index 20299f0..ef423eb 100644
--- a/frontend/src/components/FeatureSelector.vue
+++ b/frontend/src/components/FeatureSelector.vue
@@ -30,24 +30,76 @@
Feature presets
- {{ query.name }}
+
+ {{ query.name }}
+
+
+
+ {{ query.name }}
+
+
+
+ Delete custom preset
+
+ Are you sure you want to delete the custom preset "{{
+ deleteDialog.name
+ }}" from your account?
+
+
+ Delete
+
+ Keep preset
+
+
+
@@ -56,7 +108,6 @@
@@ -106,4 +160,11 @@ export default {
.type {
font-style: italic;
}
+
+.custom-header {
+ font-weight: bold;
+ color: black;
+ margin-left: 0.5em;
+ margin-right: 0.5em;
+}
diff --git a/frontend/src/components/FeatureView.vue b/frontend/src/components/FeatureView.vue
index e943158..6677b31 100644
--- a/frontend/src/components/FeatureView.vue
+++ b/frontend/src/components/FeatureView.vue
@@ -26,7 +26,45 @@
Remove
+
+ Save feature preset
+
+
+
+ Save this feature as a preset
+
+ Your saved presets are only visible to you.
+
+
+
+ Cancel
+ Save preset
+
+
+
@@ -37,10 +75,37 @@ export default {
query: Object,
index: Number,
},
+ data() {
+ return {
+ dialog: false,
+ name: "",
+ error: false,
+ };
+ },
methods: {
remove(index) {
this.$store.commit("removeSelected", index);
},
+ tryToSave(index) {
+ if (this.name.length == 0) {
+ this.error = true;
+ return;
+ }
+
+ let oldSelected = this.$store.state.selected;
+ oldSelected[index].unsavedCustomFeature = false;
+ oldSelected[index].name = this.name;
+ this.$store.commit("updateSelected", oldSelected);
+ this.dialog = false;
+ this.$store.dispatch("savePreset", { index, name: this.name });
+ },
+ },
+ watch: {
+ name(newName) {
+ if (newName.length > 0) {
+ this.error = false;
+ }
+ },
},
};
diff --git a/frontend/src/components/FirebaseLogin.vue b/frontend/src/components/FirebaseLogin.vue
index f492b11..91dfda8 100644
--- a/frontend/src/components/FirebaseLogin.vue
+++ b/frontend/src/components/FirebaseLogin.vue
@@ -28,6 +28,13 @@ export default {
firebase.auth().onAuthStateChanged((user) => {
this.$store.commit("setUser", user);
+ if (user) {
+ user.getIdToken().then((token) => {
+ this.$store.commit("setToken", token);
+ });
+
+ this.$store.dispatch("getCustomPresets");
+ }
});
ui.start("#firebaseui-auth-container", uiConfig);
diff --git a/frontend/src/components/SearchControls.vue b/frontend/src/components/SearchControls.vue
index 42a9bbb..6a8aca8 100644
--- a/frontend/src/components/SearchControls.vue
+++ b/frontend/src/components/SearchControls.vue
@@ -3,7 +3,7 @@
-
+
({
+ ...d.data(),
+ id: d.id,
+ }));
+ commit("setCustomPresets", customPresets);
+ },
+
+ async removePreset({ dispatch }, id) {
+ await deleteDoc(doc(firebaseFirestore, "presets", id));
+ dispatch("getCustomPresets");
+ },
},
modules: {},
});