Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
23a76cb
Initial plan
Copilot Feb 3, 2026
9f04abe
Fix footer positioning and query parameter passing in vega-editor
Copilot Feb 3, 2026
016baf4
Move inline style to scoped CSS class for maintainability
Copilot Feb 3, 2026
278bea5
Fix SPARQL proxy to preserve form data when forwarding to Fuseki
Copilot Feb 3, 2026
2d6a0b2
Address code review feedback: improve header handling consistency
Copilot Feb 3, 2026
0e3db50
Extract hop-by-hop headers list to module constant for maintainability
Copilot Feb 3, 2026
88481e3
Add case-insensitive header filtering helper function
Copilot Feb 3, 2026
1f4e1b4
Improve documentation for filter_headers_for_proxying function
Copilot Feb 3, 2026
57474bd
Add clarifying comments for request handling and streaming behavior
Copilot Feb 3, 2026
30ba360
Clarify comments about request.values access ordering
Copilot Feb 3, 2026
c974410
Add comment explaining why raw_data is used instead of request.values
Copilot Feb 3, 2026
ad6620d
Optimize filter_headers_for_proxying with single-pass dictionary comp…
Copilot Feb 3, 2026
db90f62
Remove redundant comment about streaming behavior
Copilot Feb 3, 2026
9a017d4
Optimize with module-level constant and improve comment clarity
Copilot Feb 3, 2026
e44b19f
Clarify architectural reason for dual proxying approaches
Copilot Feb 3, 2026
17b77c5
Remove args configuration from YASQE to fix duplicate query parameter
Copilot Feb 3, 2026
7d0ef71
Fix Vega-Lite URL construction error by removing $schema before valid…
Copilot Feb 3, 2026
efdc860
Add clarifying comment about shallow copy sufficiency
Copilot Feb 3, 2026
c1392a6
Add CORS support to enable cross-origin API access
Copilot Feb 3, 2026
e38d1b3
Fix CORS security issue by disabling credentials with wildcard origins
Copilot Feb 3, 2026
c46963d
Extract CORS max_age to named constant for better maintainability
Copilot Feb 3, 2026
40b0169
Make CORS origins configurable via CORS_ORIGINS setting
Copilot Feb 3, 2026
ddde851
Fix CORS origins parsing to handle single origin correctly
Copilot Feb 3, 2026
c005b12
Make CORS credentials configurable and enhance test coverage
Copilot Feb 3, 2026
36fa4cd
Make CORS max_age configurable and use proper logging
Copilot Feb 3, 2026
18b47c1
Fixed additional layout issues.
Feb 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=[
Expand Down
78 changes: 78 additions & 0 deletions tests/api/test_cors.py
Original file line number Diff line number Diff line change
@@ -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'
38 changes: 38 additions & 0 deletions whyis/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down
63 changes: 58 additions & 5 deletions whyis/blueprint/sparql/sparql_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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()),
Expand Down
6 changes: 6 additions & 0 deletions whyis/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
v-model="searchQuery"
type="text"
class="form-control"
placeholder="Search knowledge base..."
placeholder="Search..."
@keydown.enter="performSearch"
/>
<button
Expand Down
Loading
Loading