tinyweb/handlers/__init__.py
blankie 94f4b2d28a split handlers.py into handlers/ package
- _helpers.py: CSRF, FTS sanitizer, pagination, response helpers, tag helpers
- search.py: BM25 + hybrid search, trusted/remote result rendering
- pages.py: add/edit/delete/bulk/bookmark handlers
- subscriptions.py: sync, share preview, API sites, subscription CRUD
- customize.py: settings form, about page
- tags.py: tag list and tag browse handlers
- data.py: export, import, semantic reindex handlers
- __init__.py: dispatch, re-exports, forum_plugin, _request_local

All 58 external symbols re-exported. No changes to app.py, conftest.py,
or any test file.
2026-06-09 02:34:04 +00:00

200 lines
8 KiB
Python

import json
import secrets
import threading
from urllib.parse import unquote
from db import get_db, return_db, set_setting
import templates as templates_mod
from templates import esc, wrap_page
from rns_client import fetch_remote_sites
from ._helpers import (
_request_local, _get_csrf_token, _csrf_field, _check_csrf,
_sanitize_fts_query, _get_bookmark_token,
_respond, _redirect, _json_response, _text_response, _error,
PER_PAGE, BROWSE_PER_PAGE, _paginate, _page_nav,
_get_page_tags, _set_page_tags, _cleanup_orphaned_tags,
)
from .search import handle_search
from .pages import (
handle_add_form, handle_add_submit, handle_add_manual_submit,
handle_pages, _render_bulk_delete_confirm, handle_bulk_action,
handle_edit_form, handle_edit_submit,
handle_delete_confirm, handle_delete,
handle_bookmark,
)
from .subscriptions import (
_page_is_shared, _shared_sites, _shared_all_urls, _count_shared_pages,
handle_share_preview, handle_api_sites,
handle_subscriptions, handle_subscription_add, handle_subscription_browse,
handle_subscription_pick, _sync_subscription,
handle_subscription_sync, handle_subscription_autosync,
handle_subscription_delete, handle_subscription_syncall,
_sync_threads,
)
from .customize import handle_style_form, handle_style_submit, handle_about
from .tags import handle_tags, handle_tag_browse
from .data import (
handle_export, handle_import_form, handle_import_submit,
handle_reindex_form, handle_reindex_submit, _reindex_thread,
)
forum_plugin = None
def _dispatch_inner(data):
method = data.get("method", "GET")
path = data.get("path", "/")
query = data.get("query", {})
body = data.get("body", {})
gateway_host = data.get("gateway_host", "")
def extract_id(prefix):
try:
return int(path[len(prefix):])
except (ValueError, IndexError):
return None
if method == "GET":
if path == "/":
return handle_search(query)
elif path == "/add":
action_type = query.get("type", ["index"])[0]
prefill_url = query.get("url", [""])[0].strip()
return handle_add_form(
action_type=action_type if action_type == "subscribe" else "index",
prefill_url=prefill_url,
)
elif path == "/pages":
return handle_pages(query)
elif path.startswith("/edit/"):
pid = extract_id("/edit/")
return handle_edit_form(pid) if pid is not None else _error(400)
elif path.startswith("/delete/"):
pid = extract_id("/delete/")
return handle_delete_confirm(pid) if pid is not None else _error(400)
elif path == "/bookmark":
return handle_bookmark(query)
elif path == "/style":
return handle_style_form()
elif path == "/share/preview":
return handle_share_preview()
elif path == "/about":
return handle_about()
elif path == "/export":
return handle_export(query)
elif path == "/import":
return handle_import_form()
elif path == "/tags":
return handle_tags()
elif path.startswith("/tags/"):
tag_name = unquote(path[len("/tags/"):])
return handle_tag_browse(tag_name, query) if tag_name else _error(400)
elif path == "/reindex":
return handle_reindex_form()
elif path == "/api/sites":
return handle_api_sites(query)
elif path == "/subscriptions":
return handle_subscriptions()
elif path.startswith("/subscriptions/browse/"):
sid = extract_id("/subscriptions/browse/")
return handle_subscription_browse(sid) if sid is not None else _error(400)
elif path.startswith("/forum"):
if forum_plugin and forum_plugin.is_enabled():
return forum_plugin.handle(method, path, query, {}, data.get("cookies", {}))
return _error(404)
elif method == "POST":
if path.startswith("/forum"):
if forum_plugin and forum_plugin.is_enabled():
return forum_plugin.handle(method, path, query, body, data.get("cookies", {}))
return _error(404)
if not _check_csrf(body):
return _respond("<h1>403 Forbidden</h1><p>Invalid or missing CSRF token.</p>", status=403)
if path == "/add":
return handle_add_submit(body)
elif path == "/pages/bulk":
return handle_bulk_action(body)
elif path == "/add/manual":
return handle_add_manual_submit(body)
elif path.startswith("/edit/"):
pid = extract_id("/edit/")
return handle_edit_submit(pid, body) if pid is not None else _error(400)
elif path.startswith("/delete/"):
pid = extract_id("/delete/")
return handle_delete(pid) if pid is not None else _error(400)
elif path == "/style":
return handle_style_submit(body)
elif path == "/style/reset":
set_setting("custom_template", "")
return handle_style_form("Template reset to default.")
elif path == "/style/vacuum":
from db import vacuum_db
vacuum_db()
return handle_style_form("Database vacuumed.")
elif path == "/import":
return handle_import_submit(body)
elif path == "/reindex":
return handle_reindex_submit(body)
elif path == "/subscriptions/add":
return handle_subscription_add(body)
elif path == "/subscriptions/pick":
return handle_subscription_pick(body)
elif path.startswith("/subscriptions/sync/"):
sid = extract_id("/subscriptions/sync/")
return handle_subscription_sync(sid) if sid is not None else _error(400)
elif path.startswith("/subscriptions/autosync/"):
sid = extract_id("/subscriptions/autosync/")
return handle_subscription_autosync(sid) if sid is not None else _error(400)
elif path.startswith("/subscriptions/delete/"):
sid = extract_id("/subscriptions/delete/")
return handle_subscription_delete(sid) if sid is not None else _error(400)
elif path == "/subscriptions/syncall":
return handle_subscription_syncall()
return _error(404)
def dispatch_request(data):
path = data.get("path", "/")
cookies = data.get("cookies", {})
if path.startswith("/forum") and forum_plugin and forum_plugin.is_enabled():
resp = _dispatch_inner(data)
resp.setdefault("headers", {})
resp["headers"]["X-Frame-Options"] = "DENY"
resp["headers"]["X-Content-Type-Options"] = "nosniff"
if resp.get("content_type", "").startswith("text/html"):
resp["body"] = wrap_page(resp.get("body", ""))
resp["headers"]["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; "
"img-src * data:; "
"frame-ancestors 'none'; "
"form-action 'self'; "
"base-uri 'self'"
)
return resp
csrf_token = cookies.get("_csrf", "")
if not csrf_token:
csrf_token = secrets.token_hex(32)
_request_local.csrf_token = csrf_token
resp = _dispatch_inner(data)
resp.setdefault("headers", {})
resp["headers"]["Set-Cookie"] = f"_csrf={csrf_token}; SameSite=Strict; HttpOnly; Path=/"
resp["headers"]["X-Frame-Options"] = "DENY"
resp["headers"]["X-Content-Type-Options"] = "nosniff"
if resp.get("content_type", "").startswith("text/html"):
resp["headers"]["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; "
"img-src * data:; "
"frame-ancestors 'none'; "
"form-action 'self'; "
"base-uri 'self'"
)
return resp