Samuel Sloniker
2 years ago
4 changed files with 290 additions and 1 deletions
@ -1,3 +1,5 @@
|
||||
# landoc |
||||
|
||||
Server for sharing documents over a LAN |
||||
Server for sharing documents over a LAN |
||||
|
||||
Not well-tested; please do not use this over the Internet |
||||
|
@ -0,0 +1,15 @@
|
||||
root = "/path/to/documents/" |
||||
# The path to the direvtory containing the documents |
||||
|
||||
title = "User-Visible Title" |
||||
# A user-visible title for the server (e.g. "School Documents" or "My Notes") |
||||
|
||||
allowed_file_types = ["txt", "odt", "pdf", "md"] |
||||
# File types that LANdoc will serve; all other file types will result in 403 |
||||
# Forbidden |
||||
|
||||
# To disallow one of these file types, remove it; to allow another type, add |
||||
# its extension without the `.` |
||||
|
||||
# `md` and `txt` files will be displayed in the LANdoc viewer; all other files |
||||
# will be sent as-is to the client |
@ -0,0 +1,270 @@
|
||||
import flask |
||||
import jinja2 |
||||
import os |
||||
import mimetypes |
||||
import bleach |
||||
from markdown_it import MarkdownIt |
||||
|
||||
app = flask.Flask(__name__) |
||||
md = MarkdownIt("commonmark", {"typographer": True}).enable( |
||||
["replacements", "smartquotes"] |
||||
) |
||||
|
||||
|
||||
def split_url(url): |
||||
segments = [""] |
||||
for character in url: |
||||
segments[-1] += character |
||||
if character == "/": |
||||
segments.append("") |
||||
|
||||
if not segments[-1]: |
||||
del segments[-1] |
||||
|
||||
paths = [ |
||||
[segment, "".join(segments[: i + 1])] |
||||
for i, segment in enumerate(segments) |
||||
] |
||||
|
||||
paths[0][0] = f"<strong>{title}</strong>: {paths[0][0]}" |
||||
|
||||
return paths |
||||
|
||||
|
||||
def render(path, main, show_print=True, status_code=200): |
||||
path = jinja2.filters.escape(path) |
||||
path_segments = split_url(path) |
||||
path_html = ( |
||||
"".join( |
||||
[ |
||||
f' <li class=crumb><a href="{path}" class=button>{name}</a></li>\n' |
||||
for name, path in path_segments[:-1] |
||||
] |
||||
) |
||||
+ f" <li class=crumb><span class=button>{path_segments[-1][0]}</span></li>" |
||||
) |
||||
actions = ( |
||||
"""<hr> |
||||
<ul class=buttons> |
||||
<li class=crumb> |
||||
<a class=button href="javascript:void print();">Print</a> |
||||
</li> |
||||
</ul>""" |
||||
if show_print |
||||
else "" |
||||
) |
||||
|
||||
body = ( |
||||
f"""<!DOCTYPE html> |
||||
<html> |
||||
<head> |
||||
<title>{path} - {title}</title> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1"> |
||||
<meta charset="UTF-8"> |
||||
<style> |
||||
header {{ |
||||
border: 1px solid black; |
||||
padding: 5px; |
||||
background-color: #F0F0F0; |
||||
border-radius: 20px; |
||||
}} |
||||
|
||||
a.button, span.button {{ |
||||
border: 1px solid #404040; |
||||
background-color: white; |
||||
border-radius: 100px; |
||||
padding: 8px; |
||||
margin: 3px; |
||||
min-width: 10px; |
||||
display: inline-block; |
||||
}} |
||||
|
||||
a.button {{ |
||||
color: blue; |
||||
text-decoration: none; |
||||
}} |
||||
|
||||
* {{ |
||||
font-family: "DejaVu Sans", "DejaVu Sans Book", "DejaVu Sans Regular", "Noto Sans", "Noto Sans Regular", sans-serif; |
||||
}} |
||||
|
||||
pre {{ |
||||
font-family: monospace; |
||||
}} |
||||
|
||||
ul.buttons {{ |
||||
display: inline; |
||||
padding: 0; |
||||
}} |
||||
|
||||
li.crumb {{ |
||||
display: inline; |
||||
}} |
||||
|
||||
button[disabled] {{ |
||||
color: black; |
||||
}} |
||||
|
||||
main {{ |
||||
margin: 1em; |
||||
max-width: 40em; |
||||
}} |
||||
|
||||
p {{ |
||||
line-height: 200%; |
||||
text-indent: 0.5in; |
||||
}} |
||||
|
||||
a.direntry {{ |
||||
color: blue !important; |
||||
}} |
||||
|
||||
@media print {{ |
||||
* {{ |
||||
font-family: "DejaVu Serif", "DejaVu Serif Book", "DejaVu Serif Regular", "Noto Serif", "Noto Serif Regular", serif; |
||||
}} |
||||
|
||||
header {{ |
||||
display: none; |
||||
}} |
||||
|
||||
main {{ |
||||
max-width: 1000em; |
||||
}} |
||||
|
||||
h1, h2, h3, h4, h5, h6 {{ |
||||
break-after: avoid; |
||||
}} |
||||
}} |
||||
</style> |
||||
</head> |
||||
<body> |
||||
<header> |
||||
<nav> |
||||
<ul class=buttons> |
||||
{path_html} |
||||
</ul> |
||||
</nav> |
||||
{actions} |
||||
<noscript> |
||||
<hr> |
||||
<p><strong>Note:</strong> JavaScript appears to be disabled. Some features, such as printing, may not work.<p> |
||||
</noscript> |
||||
</header> |
||||
<main> |
||||
{main} |
||||
</main> |
||||
</body> |
||||
</html> |
||||
""", |
||||
) |
||||
return flask.Response( |
||||
body, |
||||
headers={"Content-Type": "text/html"}, |
||||
status=status_code, |
||||
) |
||||
|
||||
|
||||
def render_txt(text, path): |
||||
text = jinja2.filters.escape(text.decode("utf-8")) |
||||
return render(path, f"<pre>{bleach.clean(text)}</pre>") |
||||
|
||||
|
||||
def render_md(text, path): |
||||
text = bleach.clean( |
||||
md.render(text.decode("utf-8")), |
||||
tags=[ |
||||
"a", |
||||
"abbr", |
||||
"acronym", |
||||
"b", |
||||
"blockquote", |
||||
"code", |
||||
"em", |
||||
"i", |
||||
"li", |
||||
"ol", |
||||
"strong", |
||||
"ul", |
||||
"p", |
||||
"pre", |
||||
"img", |
||||
"h1", |
||||
"h2", |
||||
"h3", |
||||
"h4", |
||||
"h5", |
||||
"h6", |
||||
"hr", |
||||
], |
||||
attributes={ |
||||
"a": ["href", "title"], |
||||
"abbr": ["title"], |
||||
"acronym": ["title"], |
||||
"ol": ["start"], |
||||
"li": ["value"], |
||||
}, |
||||
) |
||||
return render(path, text) |
||||
|
||||
|
||||
def render_directory(contents, path): |
||||
# TODO: strikethrough forbidden files |
||||
if path.endswith("/"): |
||||
contents.sort() |
||||
listing = "".join( |
||||
[ |
||||
f'<li><a href="{os.path.join(path, i)}" class=direntry>{i}{"/" if os.path.isdir(os.path.join(root, path[1:], i)) else ""}</a></li>' |
||||
for i in contents |
||||
] |
||||
) |
||||
return render( |
||||
path, f"<ul class=listing>{listing}</ul>", show_print=False |
||||
) |
||||
else: |
||||
return flask.redirect(path + "/") |
||||
|
||||
|
||||
renderers = {"txt": render_txt, "md": render_md} |
||||
|
||||
|
||||
@app.route("/") |
||||
@app.route("/<path:path>") |
||||
def main(path=""): |
||||
# TODO: remove // and fix .. |
||||
|
||||
full_path = os.path.join(root, path) |
||||
path = "/" + path |
||||
file_type = path.split(".")[-1] |
||||
|
||||
try: |
||||
return render_directory(os.listdir(full_path), path) |
||||
except NotADirectoryError: |
||||
if not file_type in allowed_file_types: |
||||
return render( |
||||
path, |
||||
f"<h1>403 Forbidden</h1><p>{path}</p><p>Disallowed file type: <code>{file_type}</code>", |
||||
show_print=False, |
||||
status_code=403, |
||||
) |
||||
elif file_type in renderers: |
||||
with open(full_path, "rb") as f: |
||||
return renderers[file_type](f.read(), path) |
||||
else: |
||||
if path.endswith("/"): |
||||
return flask.redirect(path[:-1]) |
||||
else: |
||||
with open(full_path, "rb") as f: |
||||
return flask.Response( |
||||
f.read(), |
||||
headers={ |
||||
"Content-Type": mimetypes.guess_type(full_path)[0] |
||||
}, |
||||
) |
||||
except FileNotFoundError: |
||||
return render( |
||||
path, |
||||
f"<h1>404 Not Found</h1><p>{path}</p>", |
||||
show_print=False, |
||||
status_code=404, |
||||
) |
Loading…
Reference in new issue