diff --git a/setup.py b/setup.py index 607e8a3bc..627593f5f 100644 --- a/setup.py +++ b/setup.py @@ -203,6 +203,7 @@ def run(self): 'xlrd==2.0.1', 'werkzeug==2.0.3', 'Flask-Caching==1.10.1', + 'Flask-CORS>=3.0.10', 'pytz' ], tests_require=[ diff --git a/tests/api/test_cors.py b/tests/api/test_cors.py new file mode 100644 index 000000000..6d2420ac1 --- /dev/null +++ b/tests/api/test_cors.py @@ -0,0 +1,78 @@ +""" +Test CORS (Cross-Origin Resource Sharing) headers. + +Verifies that CORS headers are properly set for API endpoints. +""" + +import pytest + + +def test_cors_headers_on_root(client): + """Test that CORS headers are present on root endpoint.""" + response = client.get('/') + + # Check for CORS headers + assert 'Access-Control-Allow-Origin' in response.headers + assert response.headers['Access-Control-Allow-Origin'] == '*' + + +def test_cors_preflight_request(client): + """Test CORS preflight (OPTIONS) request with detailed header validation.""" + response = client.options( + '/sparql', + headers={ + 'Origin': 'http://example.com', + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Headers': 'Content-Type' + } + ) + + # Check CORS preflight response headers + assert 'Access-Control-Allow-Origin' in response.headers + assert 'Access-Control-Allow-Methods' in response.headers + assert 'Access-Control-Allow-Headers' in response.headers + + # Validate allowed methods include expected values + allowed_methods = response.headers.get('Access-Control-Allow-Methods', '') + expected_methods = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'] + for method in expected_methods: + assert method in allowed_methods, f"Expected method {method} not in allowed methods" + + # Validate allowed headers include expected values + allowed_headers = response.headers.get('Access-Control-Allow-Headers', '').lower() + expected_headers = ['content-type', 'authorization', 'accept'] + for header in expected_headers: + assert header in allowed_headers, f"Expected header {header} not in allowed headers" + + +def test_cors_headers_on_sparql_endpoint(client): + """Test that CORS headers are present on SPARQL endpoint.""" + response = client.get('/sparql') + + # Check for CORS headers + assert 'Access-Control-Allow-Origin' in response.headers + + +def test_cors_headers_on_api_endpoint(client): + """Test that CORS headers are present on API endpoints.""" + # Test with the nanopub list endpoint + response = client.get('/pub/') + + # Check for CORS headers + assert 'Access-Control-Allow-Origin' in response.headers + + +def test_cors_max_age_header(client): + """Test that CORS max age header is set correctly.""" + response = client.options( + '/sparql', + headers={ + 'Origin': 'http://example.com', + 'Access-Control-Request-Method': 'GET' + } + ) + + # Check for max age header + assert 'Access-Control-Max-Age' in response.headers + # Verify it's set to 3600 (1 hour) + assert response.headers['Access-Control-Max-Age'] == '3600' diff --git a/whyis/app.py b/whyis/app.py index bc71c8152..7a707344e 100644 --- a/whyis/app.py +++ b/whyis/app.py @@ -22,6 +22,7 @@ from depot.middleware import FileServeApp from depot.io.utils import FileIntent from flask import render_template, g, redirect, url_for, request, flash, send_from_directory, abort +from flask_cors import CORS from flask_security import Security from flask_security.core import current_user from flask_security.forms import RegisterForm @@ -97,6 +98,43 @@ def configure_extensions(self): Empty.configure_extensions(self) + # Configure CORS to allow cross-origin requests from any origin + # This enables external applications to access Whyis APIs and data + # Note: supports_credentials should be False with wildcard origins for security + # Configuration options: + # CORS_ORIGINS: "*" (default), single origin, or comma-separated list + # CORS_SUPPORTS_CREDENTIALS: False (default), True (only with specific origins) + # CORS_MAX_AGE: 3600 (default), preflight cache duration in seconds + cors_max_age = self.config.get('CORS_MAX_AGE', 3600) + cors_origins = self.config.get('CORS_ORIGINS', '*') + + # Parse CORS_ORIGINS: wildcard, single origin, or comma-separated list + if cors_origins != '*': + if ',' in cors_origins: + # Multiple origins separated by commas + cors_origins = [origin.strip() for origin in cors_origins.split(',')] + else: + # Single origin - wrap in list for Flask-CORS + cors_origins = [cors_origins.strip()] + + # supports_credentials can only be True with specific origins (not wildcard) + supports_credentials = self.config.get('CORS_SUPPORTS_CREDENTIALS', False) + if supports_credentials and cors_origins == '*': + # Warn and disable credentials if wildcard is used + self.logger.warning("CORS: CORS_SUPPORTS_CREDENTIALS cannot be True with wildcard origins. Disabling credentials.") + supports_credentials = False + + CORS(self, resources={ + r"/*": { + "origins": cors_origins, + "methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"], + "allow_headers": ["Content-Type", "Authorization", "Accept"], + "expose_headers": ["Content-Type", "Authorization"], + "supports_credentials": supports_credentials, + "max_age": cors_max_age + } + }) + if self.config.get('EMBEDDED_CELERY',False): # self.config['CELERY_BROKER_URL'] = 'redis://localhost:6379' # self.config['CELERY_RESULT_BACKEND'] = 'redis://localhost:6379' diff --git a/whyis/blueprint/sparql/sparql_view.py b/whyis/blueprint/sparql/sparql_view.py index 8336ecccb..ee1587ded 100644 --- a/whyis/blueprint/sparql/sparql_view.py +++ b/whyis/blueprint/sparql/sparql_view.py @@ -6,6 +6,35 @@ from setlr import FileLikeFromIter +# HTTP headers that should not be forwarded when proxying requests +# These are hop-by-hop headers or headers that will be set by the requests library +HOP_BY_HOP_HEADERS = [ + 'Host', 'Content-Length', 'Connection', 'Keep-Alive', + 'Proxy-Authenticate', 'Proxy-Authorization', 'TE', 'Trailers', + 'Transfer-Encoding', 'Upgrade' +] + +# Lowercase version for efficient case-insensitive lookups +HOP_BY_HOP_HEADERS_LOWER = {h.lower() for h in HOP_BY_HOP_HEADERS} + + +def filter_headers_for_proxying(headers): + """ + Filter out hop-by-hop headers that should not be forwarded when proxying. + + Performs case-insensitive header matching to comply with HTTP standards, + which specify that header names are case-insensitive. + + Args: + headers: Flask headers object or dict of headers + + Returns: + dict: Filtered headers suitable for forwarding (with hop-by-hop headers removed) + """ + # Filter headers in a single pass with case-insensitive comparison + return {k: v for k, v in headers.items() if k.lower() not in HOP_BY_HOP_HEADERS_LOWER} + + @sparql_blueprint.route('/sparql', methods=['GET', 'POST']) @conditional_login_required def sparql_view(): @@ -36,13 +65,23 @@ def sparql_view(): elif request.method == 'POST': if 'application/sparql-update' in request.headers.get('content-type', ''): return "Update not allowed.", 403 + + # Get the raw data BEFORE accessing request.values. + # Flask's request.values consumes the input stream, making the body + # unavailable for get_data(). By calling get_data() first, we preserve + # the raw body, and can then safely access request.values. + raw_data = request.get_data() + if 'update' in request.values: return "Update not allowed.", 403 + # Filter headers for proxying + headers = filter_headers_for_proxying(request.headers) + print (raw_data) req = current_app.db.store.raw_sparql_request( method='POST', - headers=dict(request.headers), - data=request.get_data() + headers=headers, + data=raw_data ) except NotImplementedError as e: # Local stores don't support proxying - return error @@ -52,8 +91,9 @@ def sparql_view(): current_app.logger.error(f"SPARQL request failed: {str(e)}") return f"SPARQL request failed: {str(e)}", 500 else: - # Fallback for stores without raw_sparql_request (should not happen) - # This is the old behavior - direct HTTP request without authentication + # Fallback for stores without raw_sparql_request (should not happen in practice) + # This path uses requests library directly without authentication support + # Modern stores should implement raw_sparql_request for proper auth handling if request.method == 'GET': headers = {} headers.update(request.headers) @@ -64,10 +104,23 @@ def sparql_view(): elif request.method == 'POST': if 'application/sparql-update' in request.headers.get('content-type', ''): return "Update not allowed.", 403 + + # Get raw data before accessing request.values to preserve request body. + # Flask's request.values consumes the input stream, making the body + # unavailable for get_data(). By calling get_data() first, we preserve + # the raw body, and can then safely access request.values. + raw_data = request.get_data() + if 'update' in request.values: return "Update not allowed.", 403 + + # Filter headers for proxying + headers = filter_headers_for_proxying(request.headers) + + print(raw_data) + # Send raw_data (bytes) not request.values (dict) to preserve exact form encoding req = requests.post(current_app.db.store.query_endpoint, - headers=request.headers, data=request.values, stream=True) + headers=headers, data=raw_data, stream=True) # Return the response response = Response(FileLikeFromIter(req.iter_content()), diff --git a/whyis/filters.py b/whyis/filters.py index 695966ff1..e9902c86d 100644 --- a/whyis/filters.py +++ b/whyis/filters.py @@ -59,6 +59,12 @@ def labelize(entry, key='about', label_key='label', fetch=False): entry[label_key] = app.get_label(resource) return entry + @app.template_filter('label') + def label(entity): + key_uri = rdflib.URIRef(entity) + resource = app.db.resource(key_uri) + return app.get_label(resource) + @app.template_filter('iter_labelize') def iter_labelize(entries, *args, **kw): for entry in entries: diff --git a/whyis/static/js/whyis_vue/components/pages/search/search-page.vue b/whyis/static/js/whyis_vue/components/pages/search/search-page.vue index 3af583ea2..3be80ef29 100644 --- a/whyis/static/js/whyis_vue/components/pages/search/search-page.vue +++ b/whyis/static/js/whyis_vue/components/pages/search/search-page.vue @@ -11,7 +11,7 @@ v-model="searchQuery" type="text" class="form-control" - placeholder="Search knowledge base..." + placeholder="Search..." @keydown.enter="performSearch" /> - diff --git a/whyis/static/js/whyis_vue/components/pages/vega/editor/vega-editor.vue b/whyis/static/js/whyis_vue/components/pages/vega/editor/vega-editor.vue index b01b7d975..619a86717 100644 --- a/whyis/static/js/whyis_vue/components/pages/vega/editor/vega-editor.vue +++ b/whyis/static/js/whyis_vue/components/pages/vega/editor/vega-editor.vue @@ -1,3 +1,9 @@ + diff --git a/whyis/static/js/whyis_vue/components/search-autocomplete.vue b/whyis/static/js/whyis_vue/components/search-autocomplete.vue index cdbea42c8..651775be0 100644 --- a/whyis/static/js/whyis_vue/components/search-autocomplete.vue +++ b/whyis/static/js/whyis_vue/components/search-autocomplete.vue @@ -8,7 +8,7 @@ v-model="searchQuery" type="text" class="form-control form-control-lg" - placeholder="Search knowledge base..." + placeholder="Search..." @input="onInput" @focus="onFocus" @blur="onBlur" diff --git a/whyis/static/js/whyis_vue/components/vega-lite-wrapper.vue b/whyis/static/js/whyis_vue/components/vega-lite-wrapper.vue index ddef7c204..4eb645de6 100644 --- a/whyis/static/js/whyis_vue/components/vega-lite-wrapper.vue +++ b/whyis/static/js/whyis_vue/components/vega-lite-wrapper.vue @@ -1,7 +1,7 @@ @@ -19,7 +19,7 @@ export default { data () { return { id: 'vega-lite', - specValidation: {} + specValidation: {"valid": true} } }, props: { @@ -54,7 +54,14 @@ export default { this.$emit('new-vega-view', result.view) }, validateSpec () { - const validation = jsonValidate(this.spec, vegaLiteSchema) + // Create a shallow copy of the spec to remove $schema property + // Shallow copy is sufficient since we only delete top-level $schema + // and don't modify nested properties. The $schema property can cause + // jsonValidate to attempt URL construction, which fails + const specToValidate = Object.assign({}, this.spec) + delete specToValidate.$schema + + const validation = jsonValidate(specToValidate, vegaLiteSchema) if (!validation.valid) { console.warn('Invalid spec', validation) } else { @@ -63,10 +70,10 @@ export default { this.specValidation = validation }, processSpec () { - this.validateSpec() - if (this.specValidation.valid) { + //this.validateSpec() + //if (this.specValidation.valid) { this.plotSpec() - } + //} } }, watch: { diff --git a/whyis/static/js/whyis_vue/utilities/vega-chart.js b/whyis/static/js/whyis_vue/utilities/vega-chart.js index a3a1871dc..41044991c 100644 --- a/whyis/static/js/whyis_vue/utilities/vega-chart.js +++ b/whyis/static/js/whyis_vue/utilities/vega-chart.js @@ -15,10 +15,10 @@ import { querySparql } from './sparql' const defaultQuery = ` PREFIX rdfs: -SELECT DISTINCT ?c (MIN(?class) AS ?class) (COUNT(?x) AS ?count) +SELECT DISTINCT ?c (MIN(?clabel) AS ?class) (COUNT(?x) AS ?count) WHERE { ?x a ?c. - ?c rdfs:label ?class. + ?c rdfs:label ?clabel. } GROUP BY ?c ORDER BY DESC(?count) diff --git a/whyis/static/vite.config.js b/whyis/static/vite.config.js index c8cab33b7..143222666 100644 --- a/whyis/static/vite.config.js +++ b/whyis/static/vite.config.js @@ -16,13 +16,14 @@ export default defineConfig({ "process.env": {}, }, build: { - lib: { - // Could also be a dictionary or array of multiple entry points - entry: resolve(__dirname, 'js/whyis_vue/main.js'), - name: 'whyis', - // the proper extensions will be added - fileName: 'whyis', - }, + sourcemap: true, + lib: { + // Could also be a dictionary or array of multiple entry points + entry: resolve(__dirname, 'js/whyis_vue/main.js'), + name: 'whyis', + // the proper extensions will be added + fileName: 'whyis', + }, }, plugins: [ vue() diff --git a/whyis/templates/base_vue.html b/whyis/templates/base_vue.html index 2d518d32d..447dffcd9 100644 --- a/whyis/templates/base_vue.html +++ b/whyis/templates/base_vue.html @@ -82,7 +82,6 @@ {% endif %}
  • Query Data
  • Create Visualization
  • -
  • Upload Dataset
  • diff --git a/whyis/templates/class_view.html b/whyis/templates/class_view.html index 070890df1..7e2883ad0 100644 --- a/whyis/templates/class_view.html +++ b/whyis/templates/class_view.html @@ -7,37 +7,37 @@ {% set incoming = this | include("incoming") | fromjson %} {% set outgoing = this | include("outgoing") | fromjson %} -
    + +
    -

    {{attributes.label}}

    @@ -77,33 +77,41 @@

    {{attributes.label}}

    {% for link_name, items in outgoing | groupby("link_label") %}

    {{link_name.title() }}

    -
    +
    {% for item in items %} -
    -
    -
    -
    {{item.target_label}}
    - {% if this.graph.value(rdflib.URIRef(item.target), ns.foaf.depiction) %} +
    + {% if this.graph.value(rdflib.URIRef(item.target), ns.foaf.depiction) %} {% set depiction = this.graph.value(rdflib.URIRef(item.target), ns.foaf.depiction) %} -
    - {% if depiction.startswith(config['LOD_PREFIX']) %} + {% if depiction.startswith(config['LOD_PREFIX']) %} {{item.target_label}} - {% else %} + {% else %} {{item.target_label}} - {% endif %} -
    {% endif %} + {% endif %} +
    +
    +
    +
    {{item.target_label}}
    + {% if item.target_types | length > 0 %}

    + {% for type in item.target_types %}{{type | label }}{% if not loop.last %}, {% endif %} {% endfor %} +

    {% endif %} +
    +
    + + +
    +
    - -
    + {% endfor %}
    {% endfor %} @@ -115,32 +123,39 @@

    {{link_name.title()}} Of {% endif %}

    -
    +
    {% for item in items %} -
    -
    -
    -
    {{item.source_label}}
    - {% if this.graph.value(rdflib.URIRef(item.source), ns.foaf.depiction) %} +
    + {% if this.graph.value(rdflib.URIRef(item.source), ns.foaf.depiction) %} {% set depiction = this.graph.value(rdflib.URIRef(item.source), ns.foaf.depiction) %} -
    - {% if depiction.startswith(config['LOD_PREFIX']) %} + {% if depiction.startswith(config['LOD_PREFIX']) %} {{item.source_label}} - {% else %} + {% else %} {{item.source_label}} - {% endif %} -
    {% endif %} + {% endif %} +
    +
    +
    +
    {{item.source_label}}
    + {% if item.source_types | length > 0 %}

    + {% for type in item.source_types %}{{type | label}}{% if not loop.last %}, {% endif %} {% endfor %} +

    {% endif %} +
    +
    + + +
    +
    - -
    {% endfor %}
    diff --git a/whyis/templates/elements/upload.html b/whyis/templates/elements/upload.html index 561235d90..54234ab71 100644 --- a/whyis/templates/elements/upload.html +++ b/whyis/templates/elements/upload.html @@ -85,11 +85,13 @@