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 }} +
+
+ Custom presets + {{ 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: {}, });