diff --git a/css/app.css b/css/app.css index 4d291387b..5d50672e3 100644 --- a/css/app.css +++ b/css/app.css @@ -2601,12 +2601,17 @@ img.tile-removing { text-align: right; width: 100%; padding: 0px 10px; + color: #eee; } .api-status.offline, .api-status.readonly, .api-status.error { - background: red; + background: #a22; +} + +.api-status-login { + color: #aaf; } /* Modals diff --git a/data/core.yaml b/data/core.yaml index 101440de6..e1e8bf3c8 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -180,6 +180,7 @@ en: localized_translation_language: Choose language localized_translation_name: Name zoom_in_edit: Zoom in to Edit + login: login logout: logout loading_auth: "Connecting to OpenStreetMap..." report_a_bug: Report a bug @@ -191,6 +192,7 @@ en: error: Unable to connect to API. offline: The API is offline. Please try editing later. readonly: The API is read-only. You will need to wait to save your changes. + rateLimit: The API is limiting anonymous connections. You can fix this by logging in. commit: title: Save Changes description_placeholder: Brief description of your contributions (required) diff --git a/dist/locales/en.json b/dist/locales/en.json index 7d70c675e..d566fe4c8 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -227,6 +227,7 @@ "localized_translation_name": "Name" }, "zoom_in_edit": "Zoom in to Edit", + "login": "login", "logout": "logout", "loading_auth": "Connecting to OpenStreetMap...", "report_a_bug": "Report a bug", @@ -238,7 +239,8 @@ "status": { "error": "Unable to connect to API.", "offline": "The API is offline. Please try editing later.", - "readonly": "The API is read-only. You will need to wait to save your changes." + "readonly": "The API is read-only. You will need to wait to save your changes.", + "rateLimit": "The API is limiting anonymous connections. You can fix this by logging in." }, "commit": { "title": "Save Changes", diff --git a/modules/renderer/map.js b/modules/renderer/map.js index 3203713bc..eb1cc327a 100644 --- a/modules/renderer/map.js +++ b/modules/renderer/map.js @@ -64,6 +64,8 @@ export function rendererMap(context) { context .on('change.map', immediateRedraw); + context.connection() + .on('change.map', immediateRedraw); context.history() .on('change.map', immediateRedraw); context.background() diff --git a/modules/services/osm.js b/modules/services/osm.js index b888f42d4..c22db080c 100644 --- a/modules/services/osm.js +++ b/modules/services/osm.js @@ -9,7 +9,7 @@ import { utilDetect } from '../util/detect'; import { utilRebind } from '../util/rebind'; -var dispatch = d3.dispatch('authenticating', 'authenticated', 'auth', 'loading', 'loaded'), +var dispatch = d3.dispatch('authLoading', 'authDone', 'change', 'loading', 'loaded'), useHttps = window.location.protocol === 'https:', protocol = useHttps ? 'https:' : 'http:', urlroot = protocol + '//www.openstreetmap.org', @@ -20,25 +20,28 @@ var dispatch = d3.dispatch('authenticating', 'authenticated', 'auth', 'loading', url: urlroot, oauth_consumer_key: '5A043yRSEugj4DJ5TljuapfnrflWDte8jTOcWLlT', oauth_secret: 'aB3jKq1TRsCOUrfOIZ6oQMEDmv2ptV76PA54NGLL', - loading: authenticating, - done: authenticated + loading: authLoading, + done: authDone }), + rateLimitError, userDetails, off; -function authenticating() { - dispatch.call('authenticating'); +function authLoading() { + dispatch.call('authLoading'); } -function authenticated() { - dispatch.call('authenticated'); +function authDone() { + dispatch.call('authDone'); } function abortRequest(i) { - i.abort(); + if (i) { + i.abort(); + } } @@ -129,10 +132,10 @@ var parsers = { }; -function parse(dom) { - if (!dom || !dom.childNodes) return; +function parse(xml) { + if (!xml || !xml.childNodes) return; - var root = dom.childNodes[0], + var root = xml.childNodes[0], children = root.childNodes, entities = []; @@ -151,7 +154,7 @@ function parse(dom) { export default { init: function() { - this.event = utilRebind(this, dispatch, 'on'); + utilRebind(this, dispatch, 'on'); }, @@ -189,9 +192,34 @@ export default { loadFromAPI: function(path, callback) { - function done(err, dom) { - return callback(err, parse(dom)); + var that = this; + + function done(err, xml) { + var isAuthenticated = that.authenticated(); + + // 400 Bad Request, 401 Unauthorized, 403 Forbidden + // Logout and retry the request.. + if (isAuthenticated && err && + (err.status === 400 || err.status === 401 || err.status === 403)) { + that.logout(); + that.loadFromAPI(path, callback); + + // else, no retry.. + } else { + // 509 Bandwidth Limit Exceeded, 429 Too Many Requests + // Set the rateLimitError flag and trigger a warning.. + if (!isAuthenticated && !rateLimitError && err && + (err.status === 509 || err.status === 429)) { + rateLimitError = err; + dispatch.call('change'); + } + + if (callback) { + callback(err, parse(xml)); + } + } } + if (this.authenticated()) { return oauth.xhr({ method: 'GET', path: path }, done); } else { @@ -402,8 +430,13 @@ export default { status: function(callback) { function done(capabilities) { - var apiStatus = capabilities.getElementsByTagName('status'); - callback(undefined, apiStatus[0].getAttribute('api')); + if (rateLimitError) { + callback(rateLimitError, 'rateLimited'); + } else { + var apiStatus = capabilities.getElementsByTagName('status'), + val = apiStatus[0].getAttribute('api'); + callback(undefined, val); + } } d3.xml(urlroot + '/api/capabilities').get() .on('load', done) @@ -467,8 +500,10 @@ export default { inflight[id] = that.loadFromAPI( '/api/0.6/map?bbox=' + tile.extent.toParam(), function(err, parsed) { - loadedTiles[id] = true; delete inflight[id]; + if (!err) { + loadedTiles[id] = true; + } if (callback) { callback(err, _.extend({ data: parsed }, tile)); @@ -477,7 +512,8 @@ export default { if (_.isEmpty(inflight)) { dispatch.call('loaded'); } - }); + } + ); }); }, @@ -487,10 +523,10 @@ export default { oauth.options(_.extend({ url: urlroot, - loading: authenticating, - done: authenticated + loading: authLoading, + done: authDone }, options)); - dispatch.call('auth'); + dispatch.call('change'); this.reset(); return this; }, @@ -512,7 +548,7 @@ export default { logout: function() { userDetails = undefined; oauth.logout(); - dispatch.call('auth'); + dispatch.call('change'); return this; }, @@ -520,7 +556,8 @@ export default { authenticate: function(callback) { userDetails = undefined; function done(err, res) { - dispatch.call('auth'); + rateLimitError = undefined; + dispatch.call('change'); if (callback) callback(err, res); } return oauth.authenticate(done); diff --git a/modules/ui/account.js b/modules/ui/account.js index e846541e0..13c122b47 100644 --- a/modules/ui/account.js +++ b/modules/ui/account.js @@ -67,7 +67,9 @@ export function uiAccount(context) { .attr('id', 'userLink') .classed('hide', true); - connection.event.on('auth.account', function() { update(selection); }); + connection + .on('change.account', function() { update(selection); }); + update(selection); }; } diff --git a/modules/ui/init.js b/modules/ui/init.js index ffa257209..1b5661b42 100644 --- a/modules/ui/init.js +++ b/modules/ui/init.js @@ -271,14 +271,15 @@ export function uiInit(context) { .call(uiRestore(context)); var authenticating = uiLoading(context) - .message(t('loading_auth')); + .message(t('loading_auth')) + .blocking(true); context.connection() - .on('authenticating.ui', function() { + .on('authLoading.ui', function() { context.container() .call(authenticating); }) - .on('authenticated.ui', function() { + .on('authDone.ui', function() { authenticating.close(); }); } diff --git a/modules/ui/spinner.js b/modules/ui/spinner.js index 1cd8b0d52..9daaf4b92 100644 --- a/modules/ui/spinner.js +++ b/modules/ui/spinner.js @@ -8,13 +8,13 @@ export function uiSpinner(context) { .attr('src', context.imagePath('loader-black.gif')) .style('opacity', 0); - connection.event + connection .on('loading.spinner', function() { img.transition() .style('opacity', 1); }); - connection.event + connection .on('loaded.spinner', function() { img.transition() .style('opacity', 0); diff --git a/modules/ui/status.js b/modules/ui/status.js index 6bdb4e7a8..05cf2bc9d 100644 --- a/modules/ui/status.js +++ b/modules/ui/status.js @@ -1,18 +1,36 @@ +import * as d3 from 'd3'; import { t } from '../util/locale'; +import { svgIcon } from '../svg/index'; + export function uiStatus(context) { - var connection = context.connection(), - errCount = 0; + var connection = context.connection(); return function(selection) { function update() { connection.status(function(err, apiStatus) { selection.html(''); - if (err && errCount++ < 2) return; if (err) { - selection.text(t('status.error')); + if (apiStatus === 'rateLimited') { + selection + .text(t('status.rateLimit')) + .append('a') + .attr('class', 'api-status-login') + .attr('target', '_blank') + .call(svgIcon('#icon-out-link', 'inline')) + .append('span') + .text(t('login')) + .on('click.login', function() { + d3.event.preventDefault(); + connection.authenticate(); + }); + } else { + // TODO: nice messages for different error types + selection.text(t('status.error')); + } + } else if (apiStatus === 'readonly') { selection.text(t('status.readonly')); } else if (apiStatus === 'offline') { @@ -20,12 +38,11 @@ export function uiStatus(context) { } selection.attr('class', 'api-status ' + (err ? 'error' : apiStatus)); - if (!err) errCount = 0; }); } connection - .on('auth', function() { update(selection); }); + .on('change', function() { update(selection); }); window.setInterval(update, 90000); update(selection); diff --git a/test/spec/services/osm.js b/test/spec/services/osm.js index a16adb047..c4bd4950d 100644 --- a/test/spec/services/osm.js +++ b/test/spec/services/osm.js @@ -44,9 +44,9 @@ describe('iD.serviceOsm', function () { expect(connection.changesetURL(1)).to.equal('http://example.com/changeset/1'); }); - it('emits an auth event', function(done) { - connection.on('auth', function() { - connection.on('auth', null); + it('emits a change event', function(done) { + connection.on('change', function() { + connection.on('change', null); done(); }); connection.switch({ urlroot: 'http://example.com' });