initial commit
This commit is contained in:
parent
269799c80f
commit
d10add04ff
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,3 +1,5 @@
|
||||||
|
config.py
|
||||||
|
|
||||||
# ---> Python
|
# ---> Python
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
# landoc
|
# 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
|
||||||
|
|
15
config.py
Normal file
15
config.py
Normal file
|
@ -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
|
270
landoc.py
Normal file
270
landoc.py
Normal file
|
@ -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
Block a user