from flask import Flask, Response, request, redirect, abort from appdirs import user_data_dir from pathlib import Path import urllib from mimetypes import guess_type import sys import yaml import csv from .pyfav import get_favicon_url from .rendering import * app = Flask("utab") # locate page template at e.g. $XDG_CONFIG_HOME/utab/index.html # and sites json file at utab/sites.json data_dir = user_data_dir(appname="utab") template_fp = Path(data_dir) / "index.html" site_form_fp = Path(data_dir) / "site.html" css_dir = Path(data_dir) / "css" icons_dir = Path(data_dir) / "icons" try: with open(template_fp) as f: template: str = f.read() f.close() with open(site_form_fp) as f: site_form: str = f.read() f.close() except FileNotFoundError: print("One or more template files are not found.") sys.exit(1) sites_fp = Path(data_dir) / "sites.csv" config_fp = Path(data_dir) / "config.yml" with open(config_fp) as f: config: dict = yaml.load(f.read(), Loader=yaml.FullLoader) f.close() sites_grid_dimensions = {"columns": config["columns"], "rows": config["rows"]} def read_sites() -> list: with open(sites_fp) as f: sites = list(csv.reader(f)) f.close() return sites def write_sites(data: list): with open(sites_fp, "w") as f: csv.writer(f).writerows(data) f.close() def append_site(site: list): with open(sites_fp, "a") as f: csv.writer(f).writerow(site) f.close() @app.route("/") def index(): # render sites in a grid. # each cell links to /go/ return render_page( template, site_heading="Top Sites", sites=render_sites(read_sites(), **sites_grid_dimensions), ) @app.route("/go/") def visit_site(url: str): # log this visit, then redirect to the unescaped url sites = read_sites() for i, s in enumerate(sites): if s[URL] == url: sites[i][VISITS] = str(int(sites[i][VISITS]) + 1) write_sites(sites) return redirect(url, 302) return abort(404) @app.route("/new") def add_site(): # /new => a page asking for url, title, etc. # /new?url=&title=&... => add this site if it DNE, else abort url, title, favicon = ( request.args.get("url"), request.args.get("title"), request.args.get("favicon"), ) if url: sites = read_sites() for s in sites: if s[URL] == url: return "A site with the same URL already exists.", 403 # now we have ensured there isn't such URL in sites if favicon: favicon_src: str = favicon else: # get its favicon url # fetch favicon url from the root page (w/o path): url_split = urllib.parse.urlsplit(url) root = url_split.scheme + "://" + url_split.netloc favicon_src: str = get_favicon_url(root) append_site([url, title, favicon_src, 0]) return redirect("/", 302) else: # no request params; let user fill form return render_page( site_form, site_heading="Add site", url="https://", url_esc="", title="", favicon_src="", favicon_placeholder="Leave blank to auto-retrieve favicon. Base64 is allowed.", delete_button_visibility="hidden", action="new", ) @app.route("/edit/<path:url>") def edit_site(url: str): # /edit/ => /edit # /edit/<escaped_url> => form with original data pre-filled for user to modify # /edit/<escaped_url>?url=<escaped_new_url>&... => edit this site in database if not url: return redirect("/edit", 301) sites = read_sites() for i, s in enumerate(sites): if s[URL] == url: new_url, new_title, new_favicon = ( request.args.get("url"), request.args.get("title"), request.args.get("favicon"), ) if not new_url: return render_page( site_form, site_heading="Edit site", url=s[URL], url_esc=urllib.parse.quote(s[URL], safe=""), title=s[TITLE], favicon_src=s[FAVICON], favicon_placeholder="Base64 is allowed.", delete_button_visibility="visible", action="edit/" + urllib.parse.quote(s[URL], safe=""), ) sites[i][URL] = new_url sites[i][TITLE] = new_title sites[i][FAVICON] = new_favicon write_sites(sites) return redirect("/", 302) return abort(404) @app.route("/edit") def select_site_to_edit(): # render sites in a grid. # each cell links to /edit/<escaped_url> return render_page( template, site_heading="Select site to edit", sites=render_sites(read_sites(), action="edit", **sites_grid_dimensions), ) @app.route("/delete/<path:url>") def delete_site(url: str): sites = read_sites() for i, s in enumerate(sites): if s[URL] == url: sites = sites[:i] + sites[i + 1 :] write_sites(sites) return redirect("/", 302) return "No site with such URL exists", 403 @app.route("/search") def search(): # [/engine_keyword ]query query: str = request.args.get("q").strip() words = query.split(" ") if not words: return abort(400) if words[0].startswith("/"): try: engine: str = config["engines"][words[0][1:]] # get engine from keyword query = " ".join(words[1:]) # strip engine from query except KeyError: engine: str = config["engines"][config["default_engine"]] else: try: engine: str = config["engines"][config["default_engine"]] except (KeyError, IndexError): return "No engines defined", 403 return redirect(engine["url"].replace("{{query}}", query), 302) @app.route("/css/<string:filename>") def serve_css(filename: str): # serve static CSS because browsers forbid file:// scheme try: with open(css_dir / filename) as f: resp = Response(f.read(), 200, {"Content-Type": "text/css"}) f.close() return resp except FileNotFoundError: return abort(404) except: return abort(500) @app.route("/icons/<string:filename>") def serve_icon(filename: str): try: fp = icons_dir / filename with open(fp, "rb") as f: resp = Response(f.read(), 200, {"Content-Type": guess_type(fp)[0]}) f.close() return resp except FileNotFoundError: return abort(404) except: return abort(500) # run on localhost only app.run("127.0.0.1", 64366)