Use If-Unmodified-Since istead of If-Match

If-Match relies on ETag, which depends on the Content-Encoding,
which is more fragile given we updated the etag on save, while normal
files are served by nginx. So this may occurs false mismatch.
This commit is contained in:
Yohan Boniface 2023-02-27 13:45:15 +01:00
parent 894facc7e3
commit 6694306660
3 changed files with 26 additions and 36 deletions

View file

@ -274,7 +274,7 @@ L.U.DataLayer = L.Evented.extend({
if (!this.umap_id) return; if (!this.umap_id) return;
this.map.get(this._dataUrl(), { this.map.get(this._dataUrl(), {
callback: function (geojson, response) { callback: function (geojson, response) {
this._etag = response.getResponseHeader('ETag'); this._last_modified = response.getResponseHeader('Last-Modified');
this.fromUmapGeoJSON(geojson); this.fromUmapGeoJSON(geojson);
this.backupOptions(); this.backupOptions();
this.fire('loaded'); this.fire('loaded');
@ -1017,7 +1017,7 @@ L.U.DataLayer = L.Evented.extend({
data: formData, data: formData,
callback: function (data, response) { callback: function (data, response) {
this._geojson = geojson; this._geojson = geojson;
this._etag = response.getResponseHeader('ETag'); this._last_modified = response.getResponseHeader('Last-Modified');
this.setUmapId(data.id); this.setUmapId(data.id);
this.updateOptions(data); this.updateOptions(data);
this.backupOptions(); this.backupOptions();
@ -1028,7 +1028,7 @@ L.U.DataLayer = L.Evented.extend({
this.map.continueSaving(); this.map.continueSaving();
}, },
context: this, context: this,
headers: {'If-Match': this._etag || ''} headers: this._last_modified ? {'If-Unmodified-Since': this._last_modified} : {}
}); });
}, },

View file

@ -24,11 +24,8 @@ def post_data():
def test_get(client, settings, datalayer): def test_get(client, settings, datalayer):
url = reverse('datalayer_view', args=(datalayer.pk, )) url = reverse('datalayer_view', args=(datalayer.pk, ))
response = client.get(url) response = client.get(url)
if getattr(settings, 'UMAP_XSENDFILE_HEADER', None):
assert response['ETag'] is not None
assert response['Last-Modified'] is not None assert response['Last-Modified'] is not None
assert response['Cache-Control'] is not None assert response['Cache-Control'] is not None
assert response['Vary'] == 'Accept-Encoding'
assert 'Content-Encoding' not in response assert 'Content-Encoding' not in response
j = json.loads(response.content.decode()) j = json.loads(response.content.decode())
assert '_umap_options' in j assert '_umap_options' in j
@ -91,46 +88,44 @@ def test_should_not_be_possible_to_delete_with_wrong_map_id_in_url(client, datal
def test_get_gzipped(client, datalayer, settings): def test_get_gzipped(client, datalayer, settings):
url = reverse('datalayer_view', args=(datalayer.pk, )) url = reverse('datalayer_view', args=(datalayer.pk, ))
response = client.get(url, HTTP_ACCEPT_ENCODING='gzip') response = client.get(url, HTTP_ACCEPT_ENCODING='gzip')
if getattr(settings, 'UMAP_XSENDFILE_HEADER', None):
assert response['ETag'] is not None
assert response['Last-Modified'] is not None assert response['Last-Modified'] is not None
assert response['Cache-Control'] is not None assert response['Cache-Control'] is not None
assert response['Content-Encoding'] == 'gzip' assert response['Content-Encoding'] == 'gzip'
def test_optimistic_concurrency_control_with_good_etag(client, datalayer, map, post_data): # noqa def test_optimistic_concurrency_control_with_good_last_modified(client, datalayer, map, post_data): # noqa
# Get Etag # Get Last-Modified
url = reverse('datalayer_view', args=(datalayer.pk, )) url = reverse('datalayer_view', args=(datalayer.pk, ))
response = client.get(url) response = client.get(url)
etag = response['ETag'] last_modified = response['Last-Modified']
url = reverse('datalayer_update', url = reverse('datalayer_update',
args=(map.pk, datalayer.pk)) args=(map.pk, datalayer.pk))
client.login(username=map.owner.username, password="123123") client.login(username=map.owner.username, password="123123")
name = 'new name' name = 'new name'
post_data['name'] = 'new name' post_data['name'] = 'new name'
response = client.post(url, post_data, follow=True, HTTP_IF_MATCH=etag) response = client.post(url, post_data, follow=True, HTTP_IF_UNMODIFIED_SINCE=last_modified)
assert response.status_code == 200 assert response.status_code == 200
modified_datalayer = DataLayer.objects.get(pk=datalayer.pk) modified_datalayer = DataLayer.objects.get(pk=datalayer.pk)
assert modified_datalayer.name == name assert modified_datalayer.name == name
def test_optimistic_concurrency_control_with_bad_etag(client, datalayer, map, post_data): # noqa def test_optimistic_concurrency_control_with_bad_last_modified(client, datalayer, map, post_data): # noqa
url = reverse('datalayer_update', args=(map.pk, datalayer.pk)) url = reverse('datalayer_update', args=(map.pk, datalayer.pk))
client.login(username=map.owner.username, password='123123') client.login(username=map.owner.username, password='123123')
name = 'new name' name = 'new name'
post_data['name'] = name post_data['name'] = name
response = client.post(url, post_data, follow=True, HTTP_IF_MATCH='xxx') response = client.post(url, post_data, follow=True, HTTP_IF_UNMODIFIED_SINCE='xxx')
assert response.status_code == 412 assert response.status_code == 412
modified_datalayer = DataLayer.objects.get(pk=datalayer.pk) modified_datalayer = DataLayer.objects.get(pk=datalayer.pk)
assert modified_datalayer.name != name assert modified_datalayer.name != name
def test_optimistic_concurrency_control_with_empty_etag(client, datalayer, map, post_data): # noqa def test_optimistic_concurrency_control_with_empty_last_modified(client, datalayer, map, post_data): # noqa
url = reverse('datalayer_update', args=(map.pk, datalayer.pk)) url = reverse('datalayer_update', args=(map.pk, datalayer.pk))
client.login(username=map.owner.username, password='123123') client.login(username=map.owner.username, password='123123')
name = 'new name' name = 'new name'
post_data['name'] = name post_data['name'] = name
response = client.post(url, post_data, follow=True, HTTP_IF_MATCH=None) response = client.post(url, post_data, follow=True, HTTP_IF_UNMODIFIED_SINCE=None)
assert response.status_code == 200 assert response.status_code == 200
modified_datalayer = DataLayer.objects.get(pk=datalayer.pk) modified_datalayer = DataLayer.objects.get(pk=datalayer.pk)
assert modified_datalayer.name == name assert modified_datalayer.name == name

View file

@ -693,12 +693,10 @@ class GZipMixin(object):
def path(self): def path(self):
return self.object.geojson.path return self.object.geojson.path
def etag(self): @property
# Align ETag with Nginx one, because when using X-Send-File, If-None-Match def last_modified(self):
# and If-Modified-Since are handled by Nginx. stat = os.stat(self.path)
# https://github.com/nginx/nginx/blob/4ace957c4e08bcbf9ef5e9f83b8e43458bead77f/src/http/ngx_http_core_module.c#L1675-L1709 return http_date(stat.st_mtime)
statobj = os.stat(self.path)
return '"%x-%x"' % (int(statobj.st_mtime), statobj.st_size)
class DataLayerView(GZipMixin, BaseDetailView): class DataLayerView(GZipMixin, BaseDetailView):
@ -727,8 +725,7 @@ class DataLayerView(GZipMixin, BaseDetailView):
with open(path, "rb") as f: with open(path, "rb") as f:
# Should not be used in production! # Should not be used in production!
response = HttpResponse(f.read(), content_type="application/geo+json") response = HttpResponse(f.read(), content_type="application/geo+json")
response["Last-Modified"] = http_date(statobj.st_mtime) response["Last-Modified"] = self.last_modified
response["ETag"] = self.etag()
response["Content-Length"] = statobj.st_size response["Content-Length"] = statobj.st_size
response["Vary"] = "Accept-Encoding" response["Vary"] = "Accept-Encoding"
if accepts_gzip and settings.UMAP_GZIP: if accepts_gzip and settings.UMAP_GZIP:
@ -737,7 +734,6 @@ class DataLayerView(GZipMixin, BaseDetailView):
class DataLayerVersion(DataLayerView): class DataLayerVersion(DataLayerView):
@property @property
def path(self): def path(self):
return "{root}/{path}".format( return "{root}/{path}".format(
@ -755,7 +751,7 @@ class DataLayerCreate(FormLessEditMixin, GZipMixin, CreateView):
self.object = form.save() self.object = form.save()
# Simple response with only metadatas (including new id) # Simple response with only metadatas (including new id)
response = simple_json_response(**self.object.metadata) response = simple_json_response(**self.object.metadata)
response["ETag"] = self.etag() response["Last-Modified"] = self.last_modified
return response return response
@ -768,24 +764,23 @@ class DataLayerUpdate(FormLessEditMixin, GZipMixin, UpdateView):
# Simple response with only metadatas (client should not reload all data # Simple response with only metadatas (client should not reload all data
# on save) # on save)
response = simple_json_response(**self.object.metadata) response = simple_json_response(**self.object.metadata)
response["ETag"] = self.etag() response["Last-Modified"] = self.last_modified
return response return response
def if_match(self): def is_unmodified(self):
"""Optimistic concurrency control.""" """Optimistic concurrency control."""
match = True modified = True
if_match = self.request.META.get("HTTP_IF_MATCH") if_unmodified = self.request.META.get("HTTP_IF_UNMODIFIED_SINCE")
if if_match: if if_unmodified:
etag = self.etag() if self.last_modified != if_unmodified:
if etag != if_match: modified = False
match = False return modified
return match
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
if self.object.map != self.kwargs["map_inst"]: if self.object.map != self.kwargs["map_inst"]:
return HttpResponseForbidden() return HttpResponseForbidden()
if not self.if_match(): if not self.is_unmodified():
return HttpResponse(status=412) return HttpResponse(status=412)
return super(DataLayerUpdate, self).post(request, *args, **kwargs) return super(DataLayerUpdate, self).post(request, *args, **kwargs)