Fix SSRF redirect bypass, identity permissions, error leakage, and DB connection leaks

- SSRF: disable automatic redirects, manually follow up to 5 hops with
  IP re-validation at each step to prevent redirect-to-localhost bypass
- Identity file: enforce 0600 permissions on tinyweb_identity at load
  and creation to prevent other users from reading the private key
- Error messages: replace raw exception strings with generic messages
  to avoid leaking internal paths/hostnames to the UI
- DB connections: wrap all get_db() usage in try/finally to guarantee
  close() even when handlers throw mid-operation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Derick Phan 2026-03-26 11:18:47 -07:00
parent d5f2d01651
commit c10aa7955c
No known key found for this signature in database
3 changed files with 310 additions and 256 deletions

5
app.py
View file

@ -15,9 +15,14 @@ IDENTITY_FILE = "tinyweb_identity"
def load_or_create_identity(): def load_or_create_identity():
if os.path.isfile(IDENTITY_FILE): if os.path.isfile(IDENTITY_FILE):
# Ensure identity file is only readable by owner
current = os.stat(IDENTITY_FILE).st_mode & 0o777
if current != 0o600:
os.chmod(IDENTITY_FILE, 0o600)
return RNS.Identity.from_file(IDENTITY_FILE) return RNS.Identity.from_file(IDENTITY_FILE)
identity = RNS.Identity() identity = RNS.Identity()
identity.to_file(IDENTITY_FILE) identity.to_file(IDENTITY_FILE)
os.chmod(IDENTITY_FILE, 0o600)
return identity return identity

13
db.py
View file

@ -201,7 +201,18 @@ def get_site_name():
def fetch_page(url): def fetch_page(url):
_validate_url_target(url) _validate_url_target(url)
resp = requests.get(url, timeout=10, headers={"User-Agent": "TinyWeb/1.0"}) resp = requests.get(url, timeout=10, headers={"User-Agent": "TinyWeb/1.0"}, allow_redirects=False)
# Follow redirects manually, re-validating each target
max_redirects = 5
while resp.is_redirect and max_redirects > 0:
redirect_url = resp.headers.get("Location")
if not redirect_url:
break
redirect_url = urljoin(url, redirect_url)
_validate_url_target(redirect_url)
url = redirect_url
resp = requests.get(url, timeout=10, headers={"User-Agent": "TinyWeb/1.0"}, allow_redirects=False)
max_redirects -= 1
resp.raise_for_status() resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser") soup = BeautifulSoup(resp.text, "html.parser")

View file

@ -121,110 +121,111 @@ def _set_page_tags(page_id, tag_string, db=None):
def handle_search(query): def handle_search(query):
q = query.get("q", [""])[0].strip() q = query.get("q", [""])[0].strip()
db = get_db() db = get_db()
count = db.execute("SELECT count(*) FROM pages").fetchone()[0] try:
name = get_site_name() count = db.execute("SELECT count(*) FROM pages").fetchone()[0]
name = get_site_name()
result_html = "" result_html = ""
trusted_html = "" trusted_html = ""
if q: if q:
try: try:
rows = db.execute( rows = db.execute(
"SELECT p.id, p.url, p.title, p.body, p.note " "SELECT p.id, p.url, p.title, p.body, p.note "
"FROM pages_fts f JOIN pages p ON f.rowid = p.id " "FROM pages_fts f JOIN pages p ON f.rowid = p.id "
"WHERE pages_fts MATCH ? ORDER BY rank LIMIT 50", "WHERE pages_fts MATCH ? ORDER BY rank LIMIT 50",
(_sanitize_fts_query(q),), (_sanitize_fts_query(q),),
).fetchall()
except Exception:
rows = []
if rows:
for r in rows:
note_html = ""
if r["note"]:
note_html = f'<div class="note"><em>{esc(r["note"])}</em></div>'
tags = _get_page_tags(r["id"], db)
tags_html = ""
if tags:
tag_links = " ".join(f'<a href="/tags/{esc(t)}" class="tag">[{esc(t)}]</a>' for t in tags)
tags_html = f'<div class="tags">{tag_links}</div>'
result_html += (
f'<div class="result">'
f'<a href="{esc(r["url"])}">{esc(r["title"])}</a><br>'
f'<small>{esc(r["url"])}</small><br>'
f'{esc(snippet(r["body"], q))}'
f'{note_html}{tags_html}'
f'</div>'
)
else:
result_html = "<p>No results in your index.</p>"
# search all linked pages from trusted sites
words = q.lower().split()
all_links = db.execute(
"SELECT l.url, l.label, p.title AS source_title "
"FROM links l JOIN pages p ON l.page_id = p.id",
).fetchall() ).fetchall()
except Exception: indexed_urls = set(r["url"] for r in rows) if rows else set()
rows = [] seen = set()
if rows: trusted = []
for r in rows: for l in all_links:
note_html = "" if l["url"] in indexed_urls or l["url"] in seen:
if r["note"]: continue
note_html = f'<div class="note"><em>{esc(r["note"])}</em></div>' if any(w in l["label"].lower() for w in words):
tags = _get_page_tags(r["id"], db) seen.add(l["url"])
tags_html = "" trusted.append(l)
if tags: if len(trusted) >= 20:
tag_links = " ".join(f'<a href="/tags/{esc(t)}" class="tag">[{esc(t)}]</a>' for t in tags) break
tags_html = f'<div class="tags">{tag_links}</div>'
result_html += ( if trusted:
f'<div class="result">' items = ""
f'<a href="{esc(r["url"])}">{esc(r["title"])}</a><br>' for l in trusted:
f'<small>{esc(r["url"])}</small><br>' items += (
f'{esc(snippet(r["body"], q))}' f'<li><a href="{esc(l["url"])}">{esc(l["label"])}</a> '
f'{note_html}{tags_html}' f'<small>— from {esc(l["source_title"])}</small></li>'
f'</div>' )
trusted_html = (
f'<details class="trusted">'
f'<summary>from your trusted sites ({len(trusted)})</summary>'
f'<ul>{items}</ul>'
f'</details>'
) )
else:
result_html = "<p>No results in your index.</p>"
# search all linked pages from trusted sites # search synced pages from subscriptions
words = q.lower().split() try:
all_links = db.execute( remote_rows = db.execute(
"SELECT l.url, l.label, p.title AS source_title " "SELECT rp.url, rp.title, rp.note, s.name AS source_name "
"FROM links l JOIN pages p ON l.page_id = p.id", "FROM remote_pages_fts rpf "
).fetchall() "JOIN remote_pages rp ON rpf.rowid = rp.id "
indexed_urls = set(r["url"] for r in rows) if rows else set() "JOIN subscriptions s ON rp.subscription_id = s.id "
seen = set() "WHERE remote_pages_fts MATCH ? ORDER BY rank LIMIT 50",
trusted = [] (_sanitize_fts_query(q),),
for l in all_links: ).fetchall()
if l["url"] in indexed_urls or l["url"] in seen: except Exception:
continue remote_rows = []
if any(w in l["label"].lower() for w in words):
seen.add(l["url"])
trusted.append(l)
if len(trusted) >= 20:
break
if trusted: remote_html = ""
items = "" if q and remote_rows:
for l in trusted: # group by source
items += ( by_source = {}
f'<li><a href="{esc(l["url"])}">{esc(l["label"])}</a> ' for r in remote_rows:
f'<small>— from {esc(l["source_title"])}</small></li>' source = r["source_name"] or "unknown"
by_source.setdefault(source, []).append(r)
for source, items in by_source.items():
source_items = ""
for r in items:
note_html = f' — <em>{esc(r["note"])}</em>' if r["note"] else ""
source_items += (
f'<li><a href="{esc(r["url"])}">{esc(r["title"])}</a>'
f'{note_html} <small>({esc(r["url"])})</small></li>'
)
remote_html += (
f'<details class="remote" open>'
f'<summary>from {esc(source)} ({len(items)})</summary>'
f'<ul>{source_items}</ul>'
f'</details>'
) )
trusted_html = ( finally:
f'<details class="trusted">' db.close()
f'<summary>from your trusted sites ({len(trusted)})</summary>'
f'<ul>{items}</ul>'
f'</details>'
)
# search synced pages from subscriptions
try:
remote_rows = db.execute(
"SELECT rp.url, rp.title, rp.note, s.name AS source_name "
"FROM remote_pages_fts rpf "
"JOIN remote_pages rp ON rpf.rowid = rp.id "
"JOIN subscriptions s ON rp.subscription_id = s.id "
"WHERE remote_pages_fts MATCH ? ORDER BY rank LIMIT 50",
(_sanitize_fts_query(q),),
).fetchall()
except Exception:
remote_rows = []
remote_html = ""
if q and remote_rows:
# group by source
by_source = {}
for r in remote_rows:
source = r["source_name"] or "unknown"
by_source.setdefault(source, []).append(r)
for source, items in by_source.items():
source_items = ""
for r in items:
note_html = f' — <em>{esc(r["note"])}</em>' if r["note"] else ""
source_items += (
f'<li><a href="{esc(r["url"])}">{esc(r["title"])}</a>'
f'{note_html} <small>({esc(r["url"])})</small></li>'
)
remote_html += (
f'<details class="remote" open>'
f'<summary>from {esc(source)} ({len(items)})</summary>'
f'<ul>{source_items}</ul>'
f'</details>'
)
db.close()
sub_count = "" sub_count = ""
if q and remote_rows: if q and remote_rows:
sub_count = f" + {len(remote_rows)} from subscriptions" sub_count = f" + {len(remote_rows)} from subscriptions"
@ -266,34 +267,40 @@ def handle_add_submit(body):
title = index_url(url, note) title = index_url(url, note)
if tags: if tags:
db = get_db() db = get_db()
row = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone() try:
if row: row = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone()
_set_page_tags(row["id"], tags, db) if row:
db.commit() _set_page_tags(row["id"], tags, db)
db.close() db.commit()
finally:
db.close()
return handle_add_form(f'Indexed: <a href="{esc(url)}">{esc(title)}</a>') return handle_add_form(f'Indexed: <a href="{esc(url)}">{esc(title)}</a>')
except Exception as e: except ValueError as e:
return handle_add_form(f"Error: {esc(str(e))}") return handle_add_form(f"Error: {esc(str(e))}")
except Exception:
return handle_add_form("Error: could not fetch or index that URL.")
def handle_pages(): def handle_pages():
db = get_db() db = get_db()
rows = db.execute("SELECT id, url, title, note FROM pages ORDER BY id DESC").fetchall() try:
items = "" rows = db.execute("SELECT id, url, title, note FROM pages ORDER BY id DESC").fetchall()
for r in rows: items = ""
note_html = f' — <em>{esc(r["note"])}</em>' if r["note"] else "" for r in rows:
tags = _get_page_tags(r["id"], db) note_html = f' — <em>{esc(r["note"])}</em>' if r["note"] else ""
tags_html = "" tags = _get_page_tags(r["id"], db)
if tags: tags_html = ""
tag_links = " ".join(f'<a href="/tags/{esc(t)}">[{esc(t)}]</a>' for t in tags) if tags:
tags_html = f' {tag_links}' tag_links = " ".join(f'<a href="/tags/{esc(t)}">[{esc(t)}]</a>' for t in tags)
items += ( tags_html = f' {tag_links}'
f'<li>{esc(r["title"])}{note_html}{tags_html} ' items += (
f'<small>(<a href="{esc(r["url"])}">{esc(r["url"])}</a>)</small> ' f'<li>{esc(r["title"])}{note_html}{tags_html} '
f'<a href="/edit/{r["id"]}">edit</a> ' f'<small>(<a href="{esc(r["url"])}">{esc(r["url"])}</a>)</small> '
f'<a href="/delete/{r["id"]}">remove</a></li>' f'<a href="/edit/{r["id"]}">edit</a> '
) f'<a href="/delete/{r["id"]}">remove</a></li>'
db.close() )
finally:
db.close()
return _respond( return _respond(
f"<h1>indexed pages ({len(rows)})</h1>" f"<h1>indexed pages ({len(rows)})</h1>"
f"<ul>{items}</ul>" f"<ul>{items}</ul>"
@ -304,12 +311,13 @@ def handle_pages():
def handle_edit_form(page_id, msg=""): def handle_edit_form(page_id, msg=""):
db = get_db() db = get_db()
row = db.execute("SELECT id, url, title, note FROM pages WHERE id = ?", (page_id,)).fetchone() try:
if not row: row = db.execute("SELECT id, url, title, note FROM pages WHERE id = ?", (page_id,)).fetchone()
if not row:
return _error(404)
tags = ", ".join(_get_page_tags(page_id, db))
finally:
db.close() db.close()
return _error(404)
tags = ", ".join(_get_page_tags(page_id, db))
db.close()
return _respond( return _respond(
f"<h1>edit page</h1>" f"<h1>edit page</h1>"
f"<p><b>{esc(row['title'])}</b><br>" f"<p><b>{esc(row['title'])}</b><br>"
@ -329,17 +337,21 @@ def handle_edit_submit(page_id, body):
note = body.get("note", [""])[0].strip() note = body.get("note", [""])[0].strip()
tags = body.get("tags", [""])[0].strip() tags = body.get("tags", [""])[0].strip()
db = get_db() db = get_db()
db.execute("UPDATE pages SET note = ? WHERE id = ?", (note, page_id)) try:
_set_page_tags(page_id, tags, db) db.execute("UPDATE pages SET note = ? WHERE id = ?", (note, page_id))
db.commit() _set_page_tags(page_id, tags, db)
db.close() db.commit()
finally:
db.close()
return _redirect("/pages") return _redirect("/pages")
def handle_delete_confirm(page_id): def handle_delete_confirm(page_id):
db = get_db() db = get_db()
row = db.execute("SELECT id, url, title FROM pages WHERE id = ?", (page_id,)).fetchone() try:
db.close() row = db.execute("SELECT id, url, title FROM pages WHERE id = ?", (page_id,)).fetchone()
finally:
db.close()
if not row: if not row:
return _error(404) return _error(404)
return _respond( return _respond(
@ -356,11 +368,13 @@ def handle_delete_confirm(page_id):
def handle_delete(page_id): def handle_delete(page_id):
db = get_db() db = get_db()
db.execute("DELETE FROM page_tags WHERE page_id = ?", (page_id,)) try:
db.execute("DELETE FROM links WHERE page_id = ?", (page_id,)) db.execute("DELETE FROM page_tags WHERE page_id = ?", (page_id,))
db.execute("DELETE FROM pages WHERE id = ?", (page_id,)) db.execute("DELETE FROM links WHERE page_id = ?", (page_id,))
db.commit() db.execute("DELETE FROM pages WHERE id = ?", (page_id,))
db.close() db.commit()
finally:
db.close()
return _redirect("/pages") return _redirect("/pages")
@ -382,8 +396,10 @@ def handle_bookmark(query):
def handle_export(): def handle_export():
db = get_db() db = get_db()
rows = db.execute("SELECT url, title, note FROM pages ORDER BY id").fetchall() try:
db.close() rows = db.execute("SELECT url, title, note FROM pages ORDER BY id").fetchall()
finally:
db.close()
data = [{"url": r["url"], "title": r["title"], "note": r["note"]} for r in rows] data = [{"url": r["url"], "title": r["title"], "note": r["note"]} for r in rows]
return _json_response(data, headers={"Content-Disposition": "attachment; filename=tinyweb-export.json"}) return _json_response(data, headers={"Content-Disposition": "attachment; filename=tinyweb-export.json"})
@ -482,10 +498,12 @@ def handle_about():
dest_hash = get_setting("dest_hash") dest_hash = get_setting("dest_hash")
sharing = get_setting("sharing_enabled", "0") == "1" sharing = get_setting("sharing_enabled", "0") == "1"
db = get_db() db = get_db()
page_count = db.execute("SELECT count(*) FROM pages").fetchone()[0] try:
tag_count = db.execute("SELECT count(DISTINCT tag_id) FROM page_tags").fetchone()[0] page_count = db.execute("SELECT count(*) FROM pages").fetchone()[0]
sub_count = db.execute("SELECT count(*) FROM subscriptions").fetchone()[0] tag_count = db.execute("SELECT count(DISTINCT tag_id) FROM page_tags").fetchone()[0]
db.close() sub_count = db.execute("SELECT count(*) FROM subscriptions").fetchone()[0]
finally:
db.close()
sharing_html = ( sharing_html = (
'<p>This instance shares its index publicly. Subscribe to join the network.</p>' '<p>This instance shares its index publicly. Subscribe to join the network.</p>'
@ -531,12 +549,14 @@ def handle_about():
def handle_tags(): def handle_tags():
db = get_db() db = get_db()
rows = db.execute( try:
"SELECT t.name, COUNT(pt.page_id) AS cnt FROM tags t " rows = db.execute(
"JOIN page_tags pt ON t.id = pt.tag_id " "SELECT t.name, COUNT(pt.page_id) AS cnt FROM tags t "
"GROUP BY t.id ORDER BY t.name" "JOIN page_tags pt ON t.id = pt.tag_id "
).fetchall() "GROUP BY t.id ORDER BY t.name"
db.close() ).fetchall()
finally:
db.close()
items = "" items = ""
for r in rows: for r in rows:
items += f'<li><a href="/tags/{esc(r["name"])}">{esc(r["name"])}</a> ({r["cnt"]})</li>' items += f'<li><a href="/tags/{esc(r["name"])}">{esc(r["name"])}</a> ({r["cnt"]})</li>'
@ -549,23 +569,25 @@ def handle_tags():
def handle_tag_browse(tag_name): def handle_tag_browse(tag_name):
db = get_db() db = get_db()
rows = db.execute( try:
"SELECT p.id, p.url, p.title, p.note FROM pages p " rows = db.execute(
"JOIN page_tags pt ON p.id = pt.page_id " "SELECT p.id, p.url, p.title, p.note FROM pages p "
"JOIN tags t ON t.id = pt.tag_id " "JOIN page_tags pt ON p.id = pt.page_id "
"WHERE t.name = ? ORDER BY p.id DESC", "JOIN tags t ON t.id = pt.tag_id "
(tag_name,), "WHERE t.name = ? ORDER BY p.id DESC",
).fetchall() (tag_name,),
items = "" ).fetchall()
for r in rows: items = ""
note_html = f' — <em>{esc(r["note"])}</em>' if r["note"] else "" for r in rows:
tags = _get_page_tags(r["id"], db) note_html = f' — <em>{esc(r["note"])}</em>' if r["note"] else ""
tag_links = " ".join(f'<a href="/tags/{esc(t)}">[{esc(t)}]</a>' for t in tags) tags = _get_page_tags(r["id"], db)
items += ( tag_links = " ".join(f'<a href="/tags/{esc(t)}">[{esc(t)}]</a>' for t in tags)
f'<li>{esc(r["title"])}{note_html} {tag_links} ' items += (
f'<small>(<a href="{esc(r["url"])}">{esc(r["url"])}</a>)</small></li>' f'<li>{esc(r["title"])}{note_html} {tag_links} '
) f'<small>(<a href="{esc(r["url"])}">{esc(r["url"])}</a>)</small></li>'
db.close() )
finally:
db.close()
return _respond( return _respond(
f'<h1>tag: {esc(tag_name)}</h1>' f'<h1>tag: {esc(tag_name)}</h1>'
f'<p>{len(rows)} page(s)</p>' f'<p>{len(rows)} page(s)</p>'
@ -582,20 +604,24 @@ def handle_api_sites():
headers={"Access-Control-Allow-Origin": "*"}, headers={"Access-Control-Allow-Origin": "*"},
) )
db = get_db() db = get_db()
rows = db.execute("SELECT id, url, title, note FROM pages ORDER BY id DESC").fetchall() try:
sites = [] rows = db.execute("SELECT id, url, title, note FROM pages ORDER BY id DESC").fetchall()
for r in rows: sites = []
tags = _get_page_tags(r["id"], db) for r in rows:
sites.append({"url": r["url"], "title": r["title"], "note": r["note"], "tags": tags}) tags = _get_page_tags(r["id"], db)
db.close() sites.append({"url": r["url"], "title": r["title"], "note": r["note"], "tags": tags})
finally:
db.close()
data = {"name": get_site_name(), "sites": sites} data = {"name": get_site_name(), "sites": sites}
return _json_response(data, headers={"Access-Control-Allow-Origin": "*"}) return _json_response(data, headers={"Access-Control-Allow-Origin": "*"})
def handle_subscriptions(msg=""): def handle_subscriptions(msg=""):
db = get_db() db = get_db()
subs = db.execute("SELECT * FROM subscriptions ORDER BY id DESC").fetchall() try:
db.close() subs = db.execute("SELECT * FROM subscriptions ORDER BY id DESC").fetchall()
finally:
db.close()
items = "" items = ""
for s in subs: for s in subs:
auto_label = "on" if s["auto_sync"] else "off" auto_label = "on" if s["auto_sync"] else "off"
@ -651,8 +677,8 @@ def handle_subscription_add(body):
name = data.get("name", "") name = data.get("name", "")
except PermissionError: except PermissionError:
return handle_subscriptions("That instance has sharing disabled.") return handle_subscriptions("That instance has sharing disabled.")
except Exception as e: except Exception:
return handle_subscriptions(f"Could not reach that instance: {esc(str(e))}") return handle_subscriptions("Could not reach that instance.")
db = get_db() db = get_db()
try: try:
db.execute( db.execute(
@ -668,18 +694,19 @@ def handle_subscription_add(body):
def handle_subscription_browse(sub_id): def handle_subscription_browse(sub_id):
db = get_db() db = get_db()
sub = db.execute("SELECT * FROM subscriptions WHERE id = ?", (sub_id,)).fetchone() try:
if not sub: sub = db.execute("SELECT * FROM subscriptions WHERE id = ?", (sub_id,)).fetchone()
db.close() if not sub:
return _error(404) return _error(404)
local_urls = set(r["url"] for r in db.execute("SELECT url FROM pages").fetchall()) local_urls = set(r["url"] for r in db.execute("SELECT url FROM pages").fetchall())
# Use locally synced data if available, otherwise fetch live # Use locally synced data if available, otherwise fetch live
remote_rows = db.execute( remote_rows = db.execute(
"SELECT url, title, note, tags FROM remote_pages WHERE subscription_id = ?", "SELECT url, title, note, tags FROM remote_pages WHERE subscription_id = ?",
(sub_id,), (sub_id,),
).fetchall() ).fetchall()
db.close() finally:
db.close()
if remote_rows: if remote_rows:
sites = [] sites = []
@ -692,8 +719,8 @@ def handle_subscription_browse(sub_id):
sites = data.get("sites", []) sites = data.get("sites", [])
except PermissionError: except PermissionError:
return handle_subscriptions("That instance has sharing disabled.") return handle_subscriptions("That instance has sharing disabled.")
except Exception as e: except Exception:
return handle_subscriptions(f"Could not fetch sites: {esc(str(e))}") return handle_subscriptions("Could not fetch sites from that instance.")
new_items = "" new_items = ""
existing_items = "" existing_items = ""
@ -739,17 +766,19 @@ def handle_subscription_pick(body):
# Build a url->tags map from remote_pages for this subscription # Build a url->tags map from remote_pages for this subscription
db = get_db() db = get_db()
remote_rows = db.execute( try:
"SELECT url, tags FROM remote_pages WHERE subscription_id = ?", (sub_id,) remote_rows = db.execute(
).fetchall() "SELECT url, tags FROM remote_pages WHERE subscription_id = ?", (sub_id,)
remote_tags = {r["url"]: r["tags"] for r in remote_rows} ).fetchall()
remote_tags = {r["url"]: r["tags"] for r in remote_rows}
if import_all: if import_all:
local_urls = set(r["url"] for r in db.execute("SELECT url FROM pages").fetchall()) local_urls = set(r["url"] for r in db.execute("SELECT url FROM pages").fetchall())
urls = [r["url"] for r in remote_rows if r["url"] not in local_urls] urls = [r["url"] for r in remote_rows if r["url"] not in local_urls]
else: else:
urls = body.get("urls", []) urls = body.get("urls", [])
db.close() finally:
db.close()
if not urls: if not urls:
return handle_subscriptions("No sites selected.") return handle_subscriptions("No sites selected.")
@ -763,11 +792,13 @@ def handle_subscription_pick(body):
tags_str = remote_tags.get(url, "") tags_str = remote_tags.get(url, "")
if tags_str: if tags_str:
db = get_db() db = get_db()
row = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone() try:
if row: row = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone()
_set_page_tags(row["id"], tags_str, db) if row:
db.commit() _set_page_tags(row["id"], tags_str, db)
db.close() db.commit()
finally:
db.close()
imported += 1 imported += 1
except Exception: except Exception:
errors += 1 errors += 1
@ -776,62 +807,67 @@ def handle_subscription_pick(body):
def handle_subscription_sync(sub_id): def handle_subscription_sync(sub_id):
db = get_db() db = get_db()
sub = db.execute("SELECT * FROM subscriptions WHERE id = ?", (sub_id,)).fetchone()
if not sub:
db.close()
return handle_subscriptions("Subscription not found.")
try: try:
data = fetch_remote_sites(sub["dest_hash"]) sub = db.execute("SELECT * FROM subscriptions WHERE id = ?", (sub_id,)).fetchone()
sites = data.get("sites", []) if not sub:
remote_name = data.get("name", sub["name"]) return handle_subscriptions("Subscription not found.")
except PermissionError:
db.close()
return handle_subscriptions("That instance has sharing disabled.")
except Exception as e:
db.close()
return handle_subscriptions(f"Could not sync: {esc(str(e))}")
# Clear old remote pages for this subscription and re-insert
db.execute("DELETE FROM remote_pages WHERE subscription_id = ?", (sub_id,))
synced = 0
for s in sites:
try: try:
tags_str = ",".join(s.get("tags", [])) data = fetch_remote_sites(sub["dest_hash"])
db.execute( sites = data.get("sites", [])
"INSERT INTO remote_pages (subscription_id, url, title, note, tags) VALUES (?, ?, ?, ?, ?)", remote_name = data.get("name", sub["name"])
(sub_id, s["url"], s["title"], s.get("note", ""), tags_str), except PermissionError:
) return handle_subscriptions("That instance has sharing disabled.")
synced += 1
except Exception: except Exception:
pass return handle_subscriptions("Could not sync with that instance.")
now = datetime.now().strftime("%Y-%m-%d %H:%M")
db.execute("UPDATE subscriptions SET last_sync = ?, name = ? WHERE id = ?", (now, remote_name, sub_id)) # Clear old remote pages for this subscription and re-insert
db.commit() db.execute("DELETE FROM remote_pages WHERE subscription_id = ?", (sub_id,))
db.close() synced = 0
for s in sites:
try:
tags_str = ",".join(s.get("tags", []))
db.execute(
"INSERT INTO remote_pages (subscription_id, url, title, note, tags) VALUES (?, ?, ?, ?, ?)",
(sub_id, s["url"], s["title"], s.get("note", ""), tags_str),
)
synced += 1
except Exception:
pass
now = datetime.now().strftime("%Y-%m-%d %H:%M")
db.execute("UPDATE subscriptions SET last_sync = ?, name = ? WHERE id = ?", (now, remote_name, sub_id))
db.commit()
finally:
db.close()
return handle_subscriptions(f"Synced {synced} site(s) from {esc(remote_name)}.") return handle_subscriptions(f"Synced {synced} site(s) from {esc(remote_name)}.")
def handle_subscription_autosync(sub_id): def handle_subscription_autosync(sub_id):
db = get_db() db = get_db()
db.execute("UPDATE subscriptions SET auto_sync = 1 - auto_sync WHERE id = ?", (sub_id,)) try:
db.commit() db.execute("UPDATE subscriptions SET auto_sync = 1 - auto_sync WHERE id = ?", (sub_id,))
db.close() db.commit()
finally:
db.close()
return _redirect("/subscriptions") return _redirect("/subscriptions")
def handle_subscription_delete(sub_id): def handle_subscription_delete(sub_id):
db = get_db() db = get_db()
db.execute("DELETE FROM remote_pages WHERE subscription_id = ?", (sub_id,)) try:
db.execute("DELETE FROM subscriptions WHERE id = ?", (sub_id,)) db.execute("DELETE FROM remote_pages WHERE subscription_id = ?", (sub_id,))
db.commit() db.execute("DELETE FROM subscriptions WHERE id = ?", (sub_id,))
db.close() db.commit()
finally:
db.close()
return _redirect("/subscriptions") return _redirect("/subscriptions")
def handle_subscription_syncall(): def handle_subscription_syncall():
db = get_db() db = get_db()
subs = db.execute("SELECT * FROM subscriptions WHERE auto_sync = 1").fetchall() try:
db.close() subs = db.execute("SELECT * FROM subscriptions WHERE auto_sync = 1").fetchall()
finally:
db.close()
if not subs: if not subs:
return handle_subscriptions("No subscriptions have auto-sync enabled.") return handle_subscriptions("No subscriptions have auto-sync enabled.")
total = 0 total = 0
@ -841,20 +877,22 @@ def handle_subscription_syncall():
sites = data.get("sites", []) sites = data.get("sites", [])
remote_name = data.get("name", sub["name"]) remote_name = data.get("name", sub["name"])
db = get_db() db = get_db()
db.execute("DELETE FROM remote_pages WHERE subscription_id = ?", (sub["id"],)) try:
for s in sites: db.execute("DELETE FROM remote_pages WHERE subscription_id = ?", (sub["id"],))
try: for s in sites:
tags_str = ",".join(s.get("tags", [])) try:
db.execute( tags_str = ",".join(s.get("tags", []))
"INSERT INTO remote_pages (subscription_id, url, title, note, tags) VALUES (?, ?, ?, ?, ?)", db.execute(
(sub["id"], s["url"], s["title"], s.get("note", ""), tags_str), "INSERT INTO remote_pages (subscription_id, url, title, note, tags) VALUES (?, ?, ?, ?, ?)",
) (sub["id"], s["url"], s["title"], s.get("note", ""), tags_str),
except Exception: )
pass except Exception:
now = datetime.now().strftime("%Y-%m-%d %H:%M") pass
db.execute("UPDATE subscriptions SET last_sync = ?, name = ? WHERE id = ?", (now, remote_name, sub["id"])) now = datetime.now().strftime("%Y-%m-%d %H:%M")
db.commit() db.execute("UPDATE subscriptions SET last_sync = ?, name = ? WHERE id = ?", (now, remote_name, sub["id"]))
db.close() db.commit()
finally:
db.close()
total += 1 total += 1
except Exception: except Exception:
pass pass