From 06517bfd22a1eb1a9628e3a1e72ae230b1c76d56 Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Tue, 26 Apr 2022 10:52:08 +0200 Subject: [PATCH] Ability to 'Share' a watch by a generated link, this will include all filters and triggers - see Wiki (#563) --- changedetectionio/__init__.py | 96 +++++++++++++++---- changedetectionio/static/images/copy.svg | 40 ++++++++ changedetectionio/static/images/spread.svg | 46 +++++++++ changedetectionio/static/js/watch-overview.js | 18 ++++ changedetectionio/static/styles/styles.css | 3 + changedetectionio/static/styles/styles.scss | 5 + changedetectionio/store.py | 34 ++++++- changedetectionio/templates/base.html | 7 ++ .../templates/watch-overview.html | 5 +- changedetectionio/tests/test_request.py | 1 + changedetectionio/tests/test_share_watch.py | 76 +++++++++++++++ 11 files changed, 308 insertions(+), 23 deletions(-) create mode 100644 changedetectionio/static/images/copy.svg create mode 100644 changedetectionio/static/images/spread.svg create mode 100644 changedetectionio/tests/test_share_watch.py diff --git a/changedetectionio/__init__.py b/changedetectionio/__init__.py index 4baa923e..f8794e80 100644 --- a/changedetectionio/__init__.py +++ b/changedetectionio/__init__.py @@ -32,6 +32,7 @@ from flask import ( render_template, request, send_from_directory, + session, url_for, ) from flask_login import login_required @@ -393,7 +394,8 @@ def changedetection_app(config=None, datastore_o=None): hosted_sticky=os.getenv("SALTED_PASS", False) == False, guid=datastore.data['app_guid'], queued_uuids=update_q.queue) - + if session.get('share-link'): + del(session['share-link']) return output @@ -688,12 +690,14 @@ def changedetection_app(config=None, datastore_o=None): # Up to 5000 per batch so we dont flood the server if len(url) and validators.url(url.replace('source:', '')) and good < 5000: new_uuid = datastore.add_watch(url=url.strip(), tag=" ".join(tags), write_to_disk_now=False) - # Straight into the queue. - update_q.put(new_uuid) - good += 1 - else: - if len(url): - remaining_urls.append(url) + if new_uuid: + # Straight into the queue. + update_q.put(new_uuid) + good += 1 + continue + + if len(url.strip()): + remaining_urls.append(url) flash("{} Imported in {:.2f}s, {} Skipped.".format(good, time.time()-now,len(remaining_urls))) datastore.needs_write = True @@ -1000,23 +1004,24 @@ def changedetection_app(config=None, datastore_o=None): from changedetectionio import forms form = forms.quickWatchForm(request.form) - if form.validate(): + if not form.validate(): + flash("Error") + return redirect(url_for('index')) - url = request.form.get('url').strip() - if datastore.url_exists(url): - flash('The URL {} already exists'.format(url), "error") - return redirect(url_for('index')) + url = request.form.get('url').strip() + if datastore.url_exists(url): + flash('The URL {} already exists'.format(url), "error") + return redirect(url_for('index')) - # @todo add_watch should throw a custom Exception for validation etc - new_uuid = datastore.add_watch(url=url, tag=request.form.get('tag').strip()) + # @todo add_watch should throw a custom Exception for validation etc + new_uuid = datastore.add_watch(url=url, tag=request.form.get('tag').strip()) + if new_uuid: # Straight into the queue. update_q.put(new_uuid) - flash("Watch added.") - return redirect(url_for('index')) - else: - flash("Error") - return redirect(url_for('index')) + + return redirect(url_for('index')) + @app.route("/api/delete", methods=['GET']) @@ -1082,6 +1087,59 @@ def changedetection_app(config=None, datastore_o=None): flash("{} watches are queued for rechecking.".format(i)) return redirect(url_for('index', tag=tag)) + @app.route("/api/share-url", methods=['GET']) + @login_required + def api_share_put_watch(): + """Given a watch UUID, upload the info and return a share-link + the share-link can be imported/added""" + import requests + import json + tag = request.args.get('tag') + uuid = request.args.get('uuid') + + # more for testing + if uuid == 'first': + uuid = list(datastore.data['watching'].keys()).pop() + + # copy it to memory as trim off what we dont need (history) + watch = deepcopy(datastore.data['watching'][uuid]) + if (watch.get('history')): + del (watch['history']) + + # for safety/privacy + for k in list(watch.keys()): + if k.startswith('notification_'): + del watch[k] + + for r in['uuid', 'last_checked', 'last_changed']: + if watch.get(r): + del (watch[r]) + + # Add the global stuff which may have an impact + watch['ignore_text'] += datastore.data['settings']['application']['global_ignore_text'] + watch['subtractive_selectors'] += datastore.data['settings']['application']['global_subtractive_selectors'] + + watch_json = json.dumps(watch) + + try: + r = requests.request(method="POST", + data={'watch': watch_json}, + url="https://changedetection.io/share/share", + headers={'App-Guid': datastore.data['app_guid']}) + res = r.json() + + session['share-link'] = "https://changedetection.io/share/{}".format(res['share_key']) + + + except Exception as e: + flash("Could not share, something went wrong while communicating with the share server.", 'error') + + # https://changedetection.io/share/VrMv05wpXyQa + # in the browser - should give you a nice info page - wtf + # paste in etc + return redirect(url_for('index')) + + # @todo handle ctrl break ticker_thread = threading.Thread(target=ticker_thread_check_time_launch_checks).start() diff --git a/changedetectionio/static/images/copy.svg b/changedetectionio/static/images/copy.svg new file mode 100644 index 00000000..b14994ab --- /dev/null +++ b/changedetectionio/static/images/copy.svg @@ -0,0 +1,40 @@ + + diff --git a/changedetectionio/static/images/spread.svg b/changedetectionio/static/images/spread.svg new file mode 100644 index 00000000..757cb631 --- /dev/null +++ b/changedetectionio/static/images/spread.svg @@ -0,0 +1,46 @@ + + + + + + + diff --git a/changedetectionio/static/js/watch-overview.js b/changedetectionio/static/js/watch-overview.js index acee1e35..1431b1b9 100644 --- a/changedetectionio/static/js/watch-overview.js +++ b/changedetectionio/static/js/watch-overview.js @@ -3,4 +3,22 @@ $(function () { $('.diff-link').click(function () { $(this).closest('.unviewed').removeClass('unviewed'); }); + + $('.with-share-link > *').click(function () { + $("#copied-clipboard").remove(); + + var range = document.createRange(); + var n=$("#share-link")[0]; + range.selectNode(n); + window.getSelection().removeAllRanges(); + window.getSelection().addRange(range); + document.execCommand("copy"); + window.getSelection().removeAllRanges(); + + $('.with-share-link').append('Copied to clipboard'); + $("#copied-clipboard").fadeOut(2500, function() { + $(this).remove(); + }); + }); }); + diff --git a/changedetectionio/static/styles/styles.css b/changedetectionio/static/styles/styles.css index 174c9aea..71ff2f5e 100644 --- a/changedetectionio/static/styles/styles.css +++ b/changedetectionio/static/styles/styles.css @@ -180,6 +180,9 @@ body:after, body:before { .messages li.notice { background: rgba(255, 255, 255, 0.5); } +.messages.with-share-link > *:hover { + cursor: pointer; } + #notification-customisation { border: 1px solid #ccc; padding: 0.5rem; diff --git a/changedetectionio/static/styles/styles.scss b/changedetectionio/static/styles/styles.scss index 3b305b45..a79c051b 100644 --- a/changedetectionio/static/styles/styles.scss +++ b/changedetectionio/static/styles/styles.scss @@ -237,6 +237,11 @@ body:after, body:before { background: rgba(255, 255, 255, .5); } } + &.with-share-link { + > *:hover { + cursor:pointer; + } + } } #notification-customisation { diff --git a/changedetectionio/store.py b/changedetectionio/store.py index 351f2b4c..bb4bab11 100644 --- a/changedetectionio/store.py +++ b/changedetectionio/store.py @@ -1,3 +1,6 @@ +from flask import ( + flash +) import json import logging import os @@ -8,6 +11,7 @@ from copy import deepcopy from os import mkdir, path, unlink from threading import Lock import re +import requests from changedetectionio.model import Watch, App @@ -295,6 +299,33 @@ class ChangeDetectionStore: def add_watch(self, url, tag="", extras=None, write_to_disk_now=True): if extras is None: extras = {} + # Incase these are copied across, assume it's a reference and deepcopy() + apply_extras = deepcopy(extras) + + # Was it a share link? try to fetch the data + if (url.startswith("https://changedetection.io/share/")): + try: + r = requests.request(method="GET", + url=url, + # So we know to return the JSON instead of the human-friendly "help" page + headers={'App-Guid': self.__data['app_guid']}) + res = r.json() + + # List of permisable stuff we accept from the wild internet + for k in ['url', 'tag', + 'paused', 'title', + 'previous_md5', 'headers', + 'body', 'method', + 'ignore_text', 'css_filter', + 'subtractive_selectors', 'trigger_text', + 'extract_title_as_title']: + if res.get(k): + apply_extras[k] = res[k] + + except Exception as e: + logging.error("Error fetching metadata for shared watch link", url, str(e)) + flash("Error fetching metadata for {}".format(url), 'error') + return False with self.lock: # @todo use a common generic version of this @@ -304,8 +335,7 @@ class ChangeDetectionStore: 'tag': tag }) - # Incase these are copied across, assume it's a reference and deepcopy() - apply_extras = deepcopy(extras) + for k in ['uuid', 'history', 'last_checked', 'last_changed', 'newest_history_key', 'previous_md5', 'viewed']: if k in apply_extras: del apply_extras[k] diff --git a/changedetectionio/templates/base.html b/changedetectionio/templates/base.html index 41105f16..9a9c6541 100644 --- a/changedetectionio/templates/base.html +++ b/changedetectionio/templates/base.html @@ -94,6 +94,13 @@ {% endif %} {% endwith %} + + {% if session['share-link'] %} + + {% endif %} + {% block content %} {% endblock %} diff --git a/changedetectionio/templates/watch-overview.html b/changedetectionio/templates/watch-overview.html index 02bb20fb..fc5ee1d0 100644 --- a/changedetectionio/templates/watch-overview.html +++ b/changedetectionio/templates/watch-overview.html @@ -13,8 +13,7 @@ {{ render_simple_field(form.tag, value=active_tag if active_tag else '', placeholder="watch group") }} - - + Tip: You can also add 'shared' watches. More info
All @@ -53,6 +52,8 @@ {{watch.title if watch.title is not none and watch.title|length > 0 else watch.url}} + + {%if watch.fetch_backend == "html_webdriver" %}{% endif %} {% if watch.last_error is defined and watch.last_error != False %} diff --git a/changedetectionio/tests/test_request.py b/changedetectionio/tests/test_request.py index 4959ef89..6d9b28d6 100644 --- a/changedetectionio/tests/test_request.py +++ b/changedetectionio/tests/test_request.py @@ -27,6 +27,7 @@ def test_headers_in_request(client, live_server): ) assert b"1 Imported" in res.data + time.sleep(3) cookie_header = '_ga=GA1.2.1022228332; cookie-preferences=analytics:accepted;' diff --git a/changedetectionio/tests/test_share_watch.py b/changedetectionio/tests/test_share_watch.py new file mode 100644 index 00000000..4ffa0d2d --- /dev/null +++ b/changedetectionio/tests/test_share_watch.py @@ -0,0 +1,76 @@ +#!/usr/bin/python3 + +import time +from flask import url_for +from urllib.request import urlopen +from .util import set_original_response, set_modified_response, live_server_setup +import re + +sleep_time_for_fetch_thread = 3 + + +def test_share_watch(client, live_server): + set_original_response() + live_server_setup(live_server) + + test_url = url_for('test_endpoint', _external=True) + css_filter = ".nice-filter" + + # Add our URL to the import page + res = client.post( + url_for("import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + + assert b"1 Imported" in res.data + + # Goto the edit page, add our ignore text + # Add our URL to the import page + res = client.post( + url_for("edit_page", uuid="first"), + data={"css_filter": css_filter, "url": test_url, "tag": "", "headers": "", 'fetch_backend': "html_requests"}, + follow_redirects=True + ) + assert b"Updated watch." in res.data + # Check it saved + res = client.get( + url_for("edit_page", uuid="first"), + ) + assert bytes(css_filter.encode('utf-8')) in res.data + + # click share the link + res = client.get( + url_for("api_share_put_watch", uuid="first"), + follow_redirects=True + ) + + assert b"Share this link:" in res.data + assert b"https://changedetection.io/share/" in res.data + + html = res.data.decode() + share_link_search = re.search('(.*)', html, re.IGNORECASE) + assert share_link_search + + # Now delete what we have, we will try to re-import it + # Cleanup everything + res = client.get(url_for("api_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + + # Add our URL to the import page + res = client.post( + url_for("import_page"), + data={"urls": share_link_search.group(1)}, + follow_redirects=True + ) + + assert b"1 Imported" in res.data + + # Now hit edit, we should see what we expect + # that the import fetched the meta-data + + # Check it saved + res = client.get( + url_for("edit_page", uuid="first"), + ) + assert bytes(css_filter.encode('utf-8')) in res.data