diff --git a/changedetectionio/__init__.py b/changedetectionio/__init__.py index 49bcc387d..4468cb20b 100644 --- a/changedetectionio/__init__.py +++ b/changedetectionio/__init__.py @@ -36,11 +36,12 @@ from flask import ( url_for, ) from flask_login import login_required -from flask_restful import reqparse, abort, Api, Resource +from flask_restful import abort, Api from flask_wtf import CSRFProtect -from changedetectionio import api_v1, html_tools +from changedetectionio import html_tools +from changedetectionio.api import api_v1 __version__ = '0.39.13.1' @@ -696,6 +697,7 @@ def changedetection_app(config=None, datastore_o=None): form=form, current_base_url = datastore.data['settings']['application']['base_url'], hide_remove_pass=os.getenv("SALTED_PASS", False), + api_key=datastore.data['settings']['application'].get('api_access_token'), emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False)) return output diff --git a/changedetectionio/api/__init__.py b/changedetectionio/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/changedetectionio/api_v1.py b/changedetectionio/api/api_v1.py similarity index 97% rename from changedetectionio/api_v1.py rename to changedetectionio/api/api_v1.py index 5b6659cfa..4f1781952 100644 --- a/changedetectionio/api_v1.py +++ b/changedetectionio/api/api_v1.py @@ -1,6 +1,8 @@ from flask_restful import abort, Resource from flask import request, make_response import validators +from . import auth + # https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html @@ -14,6 +16,7 @@ class Watch(Resource): # Get information about a single watch, excluding the history list (can be large) # curl http://localhost:4000/api/v1/watch/ # ?recheck=true + @auth.check_token def get(self, uuid): from copy import deepcopy watch = deepcopy(self.datastore.data['watching'].get(uuid)) @@ -29,6 +32,7 @@ class Watch(Resource): del (watch['history']) return watch + @auth.check_token def delete(self, uuid): if not self.datastore.data['watching'].get(uuid): abort(400, message='No watch exists with the UUID of {}'.format(uuid)) @@ -59,6 +63,7 @@ class WatchSingleHistory(Resource): # Read a given history snapshot and return its content # or "latest" # curl http://localhost:4000/api/v1/watch//history/ + @auth.check_token def get(self, uuid, timestamp): watch = self.datastore.data['watching'].get(uuid) if not watch: @@ -84,6 +89,7 @@ class CreateWatch(Resource): self.datastore = kwargs['datastore'] self.update_q = kwargs['update_q'] + @auth.check_token def post(self): # curl http://localhost:4000/api/v1/watch -H "Content-Type: application/json" -d '{"url": "https://my-nice.com", "tag": "one, two" }' json_data = request.get_json() @@ -101,6 +107,7 @@ class CreateWatch(Resource): # Return concise list of available watches and some very basic info # curl http://localhost:4000/api/v1/watch|python -mjson.tool # ?recheck_all=1 to recheck all + @auth.check_token def get(self): list = {} for k, v in self.datastore.data['watching'].items(): diff --git a/changedetectionio/api/auth.py b/changedetectionio/api/auth.py new file mode 100644 index 000000000..67e0662b2 --- /dev/null +++ b/changedetectionio/api/auth.py @@ -0,0 +1,30 @@ +from flask import request, make_response, jsonify +from functools import wraps + + +# Simple API auth key comparison +# @todo - Maybe short lived token in the future? + +def check_token(f): + @wraps(f) + def decorated(*args, **kwargs): + datastore = args[0].datastore + + try: + api_key_header = request.headers['x-api-key'] + except KeyError: + return make_response( + jsonify("No authorization x-api-key header."), 403 + ) + + config_api_token = datastore.data['settings']['application'].get('api_access_token') + config_api_token_enabled = datastore.data['settings']['application'].get('api_access_token_enabled') + + if config_api_token_enabled and api_key_header != config_api_token: + return make_response( + jsonify("Invalid access - API key invalid.", 403) + ) + + return f(*args, **kwargs) + + return decorated diff --git a/changedetectionio/forms.py b/changedetectionio/forms.py index d99060f57..145deca8d 100644 --- a/changedetectionio/forms.py +++ b/changedetectionio/forms.py @@ -374,6 +374,8 @@ class globalSettingsApplicationForm(commonSettingsForm): empty_pages_are_a_change = BooleanField('Treat empty pages as a change?', default=False) render_anchor_tag_content = BooleanField('Render anchor tag content', default=False) fetch_backend = RadioField('Fetch Method', default="html_requests", choices=content_fetcher.available_fetchers(), validators=[ValidateContentFetcherIsReady()]) + + api_access_token_enabled = BooleanField('API access token security check enabled', default=False) password = SaltyPasswordField() diff --git a/changedetectionio/model/App.py b/changedetectionio/model/App.py index cb1af56be..4aa664356 100644 --- a/changedetectionio/model/App.py +++ b/changedetectionio/model/App.py @@ -27,6 +27,7 @@ class model(dict): 'proxy': None # Preferred proxy connection }, 'application': { + 'api_access_token_enabled': True, 'password': False, 'base_url' : None, 'extract_title_as_title': False, diff --git a/changedetectionio/static/js/global-settings.js b/changedetectionio/static/js/global-settings.js index 9ef7c3071..dc7818bb5 100644 --- a/changedetectionio/static/js/global-settings.js +++ b/changedetectionio/static/js/global-settings.js @@ -1,4 +1,4 @@ -$(document).ready(function() { +$(document).ready(function () { function toggle() { if ($('input[name="application-fetch_backend"]:checked').val() != 'html_requests') { $('#requests-override-options').hide(); @@ -8,9 +8,29 @@ $(document).ready(function() { $('#webdriver-override-options').hide(); } } + $('input[name="application-fetch_backend"]').click(function (e) { toggle(); }); toggle(); + $("#api-key").hover( + function () { + $("#api-key-copy").html('copy').fadeIn(); + }, + function () { + $("#api-key-copy").hide(); + } + ).click(function (e) { + $("#api-key-copy").html('copied'); + var range = document.createRange(); + var n = $("#api-key")[0]; + range.selectNode(n); + window.getSelection().removeAllRanges(); + window.getSelection().addRange(range); + document.execCommand("copy"); + window.getSelection().removeAllRanges(); + + }); }); + diff --git a/changedetectionio/static/styles/styles.css b/changedetectionio/static/styles/styles.css index 2c77d9f35..26300bea8 100644 --- a/changedetectionio/static/styles/styles.css +++ b/changedetectionio/static/styles/styles.css @@ -456,3 +456,9 @@ ul { #webdriver-override-options input[type="number"] { width: 5em; } + +#api-key:hover { + cursor: pointer; } + +#api-key-copy { + color: #0078e7; } diff --git a/changedetectionio/static/styles/styles.scss b/changedetectionio/static/styles/styles.scss index a01f22520..6066bcdee 100644 --- a/changedetectionio/static/styles/styles.scss +++ b/changedetectionio/static/styles/styles.scss @@ -653,4 +653,14 @@ ul { input[type="number"] { width: 5em; } -} \ No newline at end of file +} + +#api-key { + &:hover { + cursor: pointer; + } +} + +#api-key-copy { + color: #0078e7; +} diff --git a/changedetectionio/store.py b/changedetectionio/store.py index 848ff490d..2ef09c549 100644 --- a/changedetectionio/store.py +++ b/changedetectionio/store.py @@ -12,6 +12,7 @@ from os import mkdir, path, unlink from threading import Lock import re import requests +import secrets from . model import App, Watch @@ -107,10 +108,13 @@ class ChangeDetectionStore: # Generate the URL access token for RSS feeds if not 'rss_access_token' in self.__data['settings']['application']: - import secrets secret = secrets.token_hex(16) self.__data['settings']['application']['rss_access_token'] = secret + # Generate the API access token + if not 'api_access_token' in self.__data['settings']['application']: + secret = secrets.token_hex(16) + self.__data['settings']['application']['api_access_token'] = secret # Proxy list support - available as a selection in settings when text file is imported # CSV list diff --git a/changedetectionio/templates/settings.html b/changedetectionio/templates/settings.html index 4cca1da22..47ef0418b 100644 --- a/changedetectionio/templates/settings.html +++ b/changedetectionio/templates/settings.html @@ -20,6 +20,7 @@
  • Notifications
  • Fetching
  • Global Filters
  • +
  • API
  • @@ -43,6 +44,7 @@ Password is locked. {% endif %}
    +
    {{ render_field(form.application.form.base_url, placeholder="http://yoursite.com:5000/", class="m-d") }} @@ -105,7 +107,6 @@
    -
    @@ -150,12 +151,26 @@ nav
    +
    + +

    Drive your changedetection.io via API, More about API access here

    + +
    + {{ render_checkbox_field(form.application.form.api_access_token_enabled) }} +
    Restrict API access limit by using x-api-key header

    +

    API Key {{api_key}} + +
    +
    +
    +
    {{ render_button(form.save_button) }} Back Delete History Snapshot Data
    +