diff --git a/backend/__init__.py b/backend/__init__.py index 02e4de23..2909c305 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -6,7 +6,6 @@ # @todo enable https://urllib3.readthedocs.io/en/latest/user-guide.html#ssl as option? # @todo option for interval day/6 hour/etc # @todo on change detected, config for calling some API -# @todo make tables responsive! # @todo fetch title into json # https://distill.io/features # proxy per check @@ -53,6 +52,8 @@ app.config['NEW_VERSION_AVAILABLE'] = False app.config['LOGIN_DISABLED'] = False +#app.config["EXPLAIN_TEMPLATE_LOADING"] = True + # Disables caching of the templates app.config['TEMPLATES_AUTO_RELOAD'] = True @@ -74,6 +75,17 @@ def init_app_secret(datastore_path): return secret +# Remember python is by reference +# populate_form in wtfors didnt work for me. (try using a setattr() obj type on datastore.watch?) +def populate_form_from_watch(form, watch): + for i in form.__dict__.keys(): + if i[0] != '_': + p = getattr(form, i) + if hasattr(p, 'data') and i in watch: + if not p.data: + setattr(p, "data", watch[i]) + + # We use the whole watch object from the store/JSON so we can see if there's some related status in terms of a thread # running or something similar. @app.template_filter('format_last_checked_time') @@ -345,82 +357,47 @@ def changedetection_app(config=None, datastore_o=None): return datastore.data['watching'][uuid]['previous_md5'] + @app.route("/edit/", methods=['GET', 'POST']) @login_required def edit_page(uuid): - import validators + from backend import forms + form = forms.watchForm(request.form) # More for testing, possible to return the first/only if uuid == 'first': uuid = list(datastore.data['watching'].keys()).pop() - if request.method == 'POST': - - url = request.form.get('url').strip() - tag = request.form.get('tag').strip() + if request.method == 'GET': + populate_form_from_watch(form, datastore.data['watching'][uuid]) - minutes_recheck = request.form.get('minutes') - if minutes_recheck: - minutes = int(minutes_recheck.strip()) - if minutes >= 1: - datastore.data['watching'][uuid]['minutes_between_check'] = minutes - else: - flash("Must be atleast 1 minute between checks.", 'error') - return redirect(url_for('edit_page', uuid=uuid)) - - # Extra headers - form_headers = request.form.get('headers').strip().split("\n") - extra_headers = {} - if form_headers: - for header in form_headers: - if len(header): - parts = header.split(':', 1) - if len(parts) == 2: - extra_headers.update({parts[0].strip(): parts[1].strip()}) - - update_obj = {'url': url, - 'tag': tag, - 'headers': extra_headers + if request.method == 'POST' and form.validate(): + update_obj = {'url': form.url.data.strip(), + 'tag': form.tag.data.strip(), + 'headers': form.headers.data } # Notification URLs - form_notification_text = request.form.get('notification_urls') - notification_urls = [] - if form_notification_text: - for text in form_notification_text.strip().split("\n"): - text = text.strip() - if len(text): - notification_urls.append(text) - - datastore.data['watching'][uuid]['notification_urls'] = notification_urls + datastore.data['watching'][uuid]['notification_urls'] = form.notification_urls.data # Ignore text - form_ignore_text = request.form.get('ignore-text') - ignore_text = [] - if form_ignore_text: - for text in form_ignore_text.strip().split("\n"): - text = text.strip() - if len(text): - ignore_text.append(text) - - datastore.data['watching'][uuid]['ignore_text'] = ignore_text + form_ignore_text = form.ignore_text.data + datastore.data['watching'][uuid]['ignore_text'] = form_ignore_text - # Reset the previous_md5 so we process a new snapshot including stripping ignore text. + # Reset the previous_md5 so we process a new snapshot including stripping ignore text. + if form_ignore_text: if len(datastore.data['watching'][uuid]['history']): update_obj['previous_md5'] = get_current_checksum_include_ignore_text(uuid=uuid) - # CSS Filter - css_filter = request.form.get('css_filter') - if css_filter: - datastore.data['watching'][uuid]['css_filter'] = css_filter.strip() + datastore.data['watching'][uuid]['css_filter'] = form.css_filter.data.strip() - # Reset the previous_md5 so we process a new snapshot including stripping ignore text. + # Reset the previous_md5 so we process a new snapshot including stripping ignore text. + if form.css_filter.data.strip() != datastore.data['watching'][uuid]['css_filter']: if len(datastore.data['watching'][uuid]['history']): update_obj['previous_md5'] = get_current_checksum_include_ignore_text(uuid=uuid) - validators.url(url) # @todo switch to prop/attr/observer datastore.data['watching'][uuid].update(update_obj) datastore.needs_write = True flash("Updated watch.") @@ -428,10 +405,9 @@ def changedetection_app(config=None, datastore_o=None): # Queue the watch for immediate recheck update_q.put(uuid) - trigger_n = request.form.get('trigger-test-notification') - if trigger_n: - n_object = {'watch_url': url, - 'notification_urls': notification_urls} + if form.trigger_check.data: + n_object = {'watch_url': form.url.data.strip(), + 'notification_urls': form.notification_urls.data} notification_q.put(n_object) flash('Notifications queued.') @@ -439,7 +415,7 @@ def changedetection_app(config=None, datastore_o=None): return redirect(url_for('index')) else: - output = render_template("edit.html", uuid=uuid, watch=datastore.data['watching'][uuid]) + output = render_template("edit.html", uuid=uuid, watch=datastore.data['watching'][uuid], form=form) return output @@ -447,92 +423,62 @@ def changedetection_app(config=None, datastore_o=None): @login_required def settings_page(): + from backend import forms + form = forms.globalSettingsForm(request.form) if request.method == 'GET': - if request.values.get('notification-test'): - url_count = len(datastore.data['settings']['application']['notification_urls']) - if url_count: - import apprise - apobj = apprise.Apprise() - apobj.debug = True - - # Add each notification - for n in datastore.data['settings']['application']['notification_urls']: - apobj.add(n) - outcome = apobj.notify( - body='Hello from the worlds best and simplest web page change detection and monitoring service!', - title='Changedetection.io Notification Test', - ) - - if outcome: - flash("{} Notification URLs reached.".format(url_count), "notice") - else: - flash("One or more Notification URLs failed", 'error') + form.minutes_between_check.data = int(datastore.data['settings']['requests']['minutes_between_check'] / 60) + form.notification_urls.data = datastore.data['settings']['application']['notification_urls'] - return redirect(url_for('settings_page')) - - if request.values.get('removepassword'): + # Password unset is a GET + if request.values.get('removepassword') == 'true': from pathlib import Path - datastore.data['settings']['application']['password'] = False flash("Password protection removed.", 'notice') flask_login.logout_user() - return redirect(url_for('settings_page')) - - if request.method == 'POST': - - password = request.values.get('password') - if password: - import hashlib - import base64 - import secrets - - # Make a new salt on every new password and store it with the password - salt = secrets.token_bytes(32) + if request.method == 'POST' and form.validate(): - key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000) - store = base64.b64encode(salt + key).decode('ascii') - datastore.data['settings']['application']['password'] = store + datastore.data['settings']['application']['notification_urls'] = form.notification_urls.data + datastore.data['settings']['requests']['minutes_between_check'] = form.minutes_between_check.data * 60 - flash("Password protection enabled.", 'notice') - flask_login.logout_user() - return redirect(url_for('index')) - - try: - minutes = int(request.values.get('minutes').strip()) - except ValueError: - flash("Invalid value given, use an integer.", "error") + if len(form.notification_urls.data): + import apprise + apobj = apprise.Apprise() + apobj.debug = True + + # Add each notification + for n in datastore.data['settings']['application']['notification_urls']: + apobj.add(n) + outcome = apobj.notify( + body='Hello from the worlds best and simplest web page change detection and monitoring service!', + title='Changedetection.io Notification Test', + ) - else: - if minutes >= 1: - datastore.data['settings']['requests']['minutes_between_check'] = minutes - datastore.needs_write = True + if outcome: + flash("{} Notification URLs reached.".format(len(form.notification_urls.data)), "notice") else: - flash("Must be atleast 1 minute.", 'error') + flash("One or more Notification URLs failed", 'error') - # 'validators' package doesnt work because its often a non-stanadard protocol. :( - datastore.data['settings']['application']['notification_urls'] = [] - trigger_n = request.form.get('trigger-test-notification') - for n in request.values.get('notification_urls').strip().split("\n"): - url = n.strip() - datastore.data['settings']['application']['notification_urls'].append(url) - datastore.needs_write = True + datastore.data['settings']['application']['notification_urls'] = form.notification_urls.data + datastore.needs_write = True - if trigger_n: + if form.trigger_check.data: n_object = {'watch_url': "Test from changedetection.io!", - 'notification_urls': datastore.data['settings']['application']['notification_urls']} + 'notification_urls': form.notification_urls.data} notification_q.put(n_object) flash('Notifications queued.') - flash("Settings updated.") + if form.password.encrypted_password: + datastore.data['settings']['application']['password'] = form.password.encrypted_password + flash("Password protection enabled.", 'notice') + flask_login.logout_user() + return redirect(url_for('index')) + flash("Settings updated.") - output = render_template("settings.html", - minutes=datastore.data['settings']['requests']['minutes_between_check'], - notification_urls="\r\n".join( - datastore.data['settings']['application']['notification_urls'])) + output = render_template("settings.html", form=form) return output @app.route("/import", methods=['GET', "POST"]) diff --git a/backend/forms.py b/backend/forms.py new file mode 100644 index 00000000..fa41d9fb --- /dev/null +++ b/backend/forms.py @@ -0,0 +1,109 @@ +from wtforms import Form, BooleanField, StringField, PasswordField, validators, IntegerField, fields, TextAreaField, \ + Field +from wtforms import widgets +from wtforms.fields import html5 + + +class StringListField(StringField): + widget = widgets.TextArea() + + def _value(self): + if self.data: + return "\r\n".join(self.data) + else: + return u'' + + # incoming + def process_formdata(self, valuelist): + if valuelist: + # Remove empty strings + cleaned = list(filter(None, valuelist[0].split("\n"))) + self.data = [x.strip() for x in cleaned] + p = 1 + else: + self.data = [] + + + +class SaltyPasswordField(StringField): + widget = widgets.PasswordInput() + encrypted_password = "" + + def build_password(self, password): + import hashlib + import base64 + import secrets + + # Make a new salt on every new password and store it with the password + salt = secrets.token_bytes(32) + + key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000) + store = base64.b64encode(salt + key).decode('ascii') + + return store + + # incoming + def process_formdata(self, valuelist): + if valuelist: + # Remove empty strings + self.encrypted_password = self.build_password(valuelist[0]) + self.data=[] + else: + self.data = [] + + +# Separated by key:value +class StringDictKeyValue(StringField): + widget = widgets.TextArea() + + def _value(self): + if self.data: + output = u'' + for k in self.data.keys(): + output += "{}: {}\r\n".format(k, self.data[k]) + + return output + else: + return u'' + + # incoming + def process_formdata(self, valuelist): + if valuelist: + self.data = {} + # Remove empty strings + cleaned = list(filter(None, valuelist[0].split("\n"))) + for s in cleaned: + parts = s.strip().split(':') + if len(parts) == 2: + self.data.update({parts[0].strip(): parts[1].strip()}) + + else: + self.data = {} + + +class watchForm(Form): + # https://wtforms.readthedocs.io/en/2.3.x/fields/#module-wtforms.fields.html5 + # `require_tld` = False is needed even for the test harness "http://localhost:5005.." to run + + url = html5.URLField('URL', [validators.URL(require_tld=False)]) + tag = StringField('Tag', [validators.Optional(), validators.Length(max=35)]) + minutes_between_check = html5.IntegerField('Maximum time in minutes until recheck', + [validators.Optional(), validators.NumberRange(min=1)]) + css_filter = StringField('CSS Filter') + + ignore_text = StringListField('Ignore Text') + notification_urls = StringListField('Notification URL List') + headers = StringDictKeyValue('Request Headers') + trigger_check = BooleanField('Send test notification on save') + + +class globalSettingsForm(Form): + + password = SaltyPasswordField() + remove_password = BooleanField('Remove password') + + minutes_between_check = html5.IntegerField('Maximum time in minutes until recheck', + [validators.NumberRange(min=1)]) + + notification_urls = StringListField('Notification URL List') + trigger_check = BooleanField('Send test notification on save') diff --git a/backend/static/styles/package.json b/backend/static/styles/package.json index 9ca29121..f1c3a0e8 100644 --- a/backend/static/styles/package.json +++ b/backend/static/styles/package.json @@ -5,7 +5,7 @@ "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "scss": "node-sass --watch *.scss -o ." + "scss": "node-sass --watch styles.scss diff.scss -o ." }, "author": "", "license": "ISC", diff --git a/backend/static/styles/styles.css b/backend/static/styles/styles.css index 49b75436..1d2bc102 100644 --- a/backend/static/styles/styles.css +++ b/backend/static/styles/styles.css @@ -225,15 +225,39 @@ footer { .paused-state.state-False:hover img { opacity: 0.8; } -.pure-form label { - font-weight: bold; } - -.pure-form input[type=url] { - width: 100%; } - -.pure-form textarea { +.monospaced-textarea textarea { width: 100%; - font-size: 14px; } + font-family: monospace; + white-space: pre; + overflow-wrap: normal; + overflow-x: scroll; } + +.pure-form { + /* The input fields with errors */ + /* The list of errors */ } + .pure-form .pure-control-group, .pure-form .pure-group, .pure-form .pure-controls { + padding-bottom: 1em; } + .pure-form .pure-control-group dd, .pure-form .pure-group dd, .pure-form .pure-controls dd { + margin: 0px; } + .pure-form .error input { + background-color: #ffebeb; } + .pure-form ul.errors { + padding: .5em .6em; + border: 1px solid #dd0000; + border-radius: 4px; + vertical-align: middle; + -webkit-box-sizing: border-box; + box-sizing: border-box; } + .pure-form ul.errors li { + margin-left: 1em; + color: #dd0000; } + .pure-form label { + font-weight: bold; } + .pure-form input[type=url] { + width: 100%; } + .pure-form textarea { + width: 100%; + font-size: 14px; } @media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: 1024px) { .box { diff --git a/backend/static/styles/styles.scss b/backend/static/styles/styles.scss index f55a93c3..4aaa2598 100644 --- a/backend/static/styles/styles.scss +++ b/backend/static/styles/styles.scss @@ -297,8 +297,43 @@ footer { } } +.monospaced-textarea { + textarea { + width: 100%; + font-family: monospace; + white-space: pre; + overflow-wrap: normal; + overflow-x: scroll; + } +} + .pure-form { + .pure-control-group, .pure-group, .pure-controls { + padding-bottom: 1em; + dd { + margin: 0px; + } + } + /* The input fields with errors */ + .error { + input { + background-color: #ffebeb; + } + } + /* The list of errors */ + ul.errors { + padding: .5em .6em; + border: 1px solid #dd0000; + border-radius: 4px; + vertical-align: middle; + -webkit-box-sizing: border-box; + box-sizing: border-box; + li { + margin-left: 1em; + color: #dd0000; + } + } label { font-weight: bold; diff --git a/backend/templates/_helpers.jinja b/backend/templates/_helpers.jinja new file mode 100644 index 00000000..6aeb36eb --- /dev/null +++ b/backend/templates/_helpers.jinja @@ -0,0 +1,12 @@ +{% macro render_field(field) %} +
{{ field.label }} +
{{ field(**kwargs)|safe }} + {% if field.errors %} + + {% endif %} +
+{% endmacro %} \ No newline at end of file diff --git a/backend/templates/edit.html b/backend/templates/edit.html index 472b7ba5..cbc201c7 100644 --- a/backend/templates/edit.html +++ b/backend/templates/edit.html @@ -1,102 +1,58 @@ {% extends 'base.html' %} - {% block content %} -
- - +{% from '_helpers.jinja' import render_field %} +
- - - This is a required field. + {{ render_field(form.url, placeholder="https://...", size=30, required=true) }}
- - - Grouping tags, can be a comma separated list. + {{ render_field(form.tag, size=10) }}
-
- - - Minimum 1 minute between recheck + {{ render_field(form.minutes_between_check, size=5) }}
-
- - + {{ render_field(form.css_filter, size=25, placeholder=".class-name or #some-id, or other CSS selector rule.") }} Limit text to this CSS rule, only text matching this CSS rule is included.
Please be sure that you thoroughly understand how to write CSS selector rules before filing an issue on GitHub!
Go here for more CSS selector help
-
- - - + {{ render_field(form.ignore_text, rows=5) }} Each line will be processed separately as an ignore rule. -
-
- - - -
- +User-Agent: wonderbra 1.0") }}
+
- - - Use AppRise URLs for notification to just about any service! -
-
- + {{ render_field(form.notification_urls, rows=5, placeholder="Gitter - gitter://token/room +Office365 - o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail +AWS SNS - sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo +SMTPS - mailtos://user:pass@mail.domain.com?to=receivingAddress@example.com +") }} + Use AppRise URLs for notification to just about any service! +
-
+
+ {{ render_field(form.trigger_check, rows=5) }}
-

- - -
diff --git a/backend/templates/settings.html b/backend/templates/settings.html index 48eeeb81..e136ad93 100644 --- a/backend/templates/settings.html +++ b/backend/templates/settings.html @@ -1,42 +1,28 @@ {% extends 'base.html' %} {% block content %} -
- +{% from '_helpers.jinja' import render_field %} +
- - - This is a required field.
- Minimum 1 minute between recheck + {{ render_field(form.minutes_between_check, size=5) }}
-
-
- - {% if current_user.is_authenticated %} Remove password + {% else %} + {{ render_field(form.password, size=10) }} {% endif %}
- -
-
-
-
- Notification URLs see Apprise examples. - +") }} + Use AppRise URLs for notification to just about any service!