fixed SSRF bypass, tightened error handling
- 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
This commit is contained in:
parent
4899819597
commit
449174b0ca
3 changed files with 310 additions and 256 deletions
5
app.py
5
app.py
|
|
@ -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
13
db.py
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
548
handlers.py
548
handlers.py
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue